diff --git a/bun.lockb b/bun.lockb index 9b09d97d87..99f23a24d0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index e530908deb..32805334e7 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-fs": "^2.0.3", "@tauri-apps/plugin-shell": "^2", + "@tiptap/extension-highlight": "^2.10.3", + "@tiptap/extension-typography": "^2.10.3", "@tiptap/pm": "^2.10.3", "@tiptap/react": "^2.10.3", "@tiptap/starter-kit": "^2.10.3", diff --git a/src/components/LiveCaptionDock.tsx b/src/components/note/LiveCaptionDock.tsx similarity index 100% rename from src/components/LiveCaptionDock.tsx rename to src/components/note/LiveCaptionDock.tsx diff --git a/src/components/NoteControl.tsx b/src/components/note/NoteControl.tsx similarity index 98% rename from src/components/NoteControl.tsx rename to src/components/note/NoteControl.tsx index 995184b238..80a943b063 100644 --- a/src/components/NoteControl.tsx +++ b/src/components/note/NoteControl.tsx @@ -1,4 +1,4 @@ -import type { Note } from "../types"; +import type { Note } from "../../types"; interface NoteControlProps { note: Note | null; diff --git a/src/components/note/NoteEditor.tsx b/src/components/note/NoteEditor.tsx new file mode 100644 index 0000000000..b42d0f4f2d --- /dev/null +++ b/src/components/note/NoteEditor.tsx @@ -0,0 +1,72 @@ +import "../../styles/editor.css"; + +import { EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Highlight from "@tiptap/extension-highlight"; +import Typography from "@tiptap/extension-typography"; +import { useEffect } from "react"; + +interface NoteEditorProps { + content: string; + onChange: (content: string) => void; +} + +export default function NoteEditor({ content, onChange }: NoteEditorProps) { + const editor = useEditor({ + extensions: [StarterKit, Highlight, Typography], + content, + onUpdate: ({ editor }) => { + onChange(editor.getHTML()); + }, + editorProps: { + attributes: { + class: "focus:outline-none", + }, + handleDOMEvents: { + keydown: (view, event) => { + // Tab 키 이벤트 방지 + if (event.key === "Tab") { + return true; + } + return false; + }, + }, + // 자동 수정 비활성화 + transformPastedText: (text) => text, + transformPastedHTML: (html) => html, + }, + enableInputRules: false, + enablePasteRules: false, + }); + + useEffect(() => { + if (editor && editor.getHTML() !== content) { + editor.commands.setContent(content); + } + }, [content, editor]); + + return ( +
{ + if (!editor) return; + + // 클릭한 위치의 Y 좌표 + const clickY = e.clientY; + // 에디터의 마지막 위치의 Y 좌표 + const editorRect = editor.view.dom.getBoundingClientRect(); + const lastLineY = editorRect.bottom; + + // 클릭 위치가 마지막 줄보다 아래인 경우 새로운 줄 추가 + if (clickY > lastLineY) { + editor.commands.setTextSelection(editor.state.doc.content.size); + editor.commands.enter(); + } + + editor.commands.focus(); + }} + > + +
+ ); +} diff --git a/src/components/NoteHeader.tsx b/src/components/note/NoteHeader.tsx similarity index 95% rename from src/components/NoteHeader.tsx rename to src/components/note/NoteHeader.tsx index 714375ab94..d2152a4a18 100644 --- a/src/components/NoteHeader.tsx +++ b/src/components/note/NoteHeader.tsx @@ -1,4 +1,4 @@ -import type { Note, CalendarEvent } from "../types"; +import type { Note, CalendarEvent } from "../../types"; import NoteControl from "./NoteControl"; interface NoteHeaderProps { @@ -45,7 +45,7 @@ export default function NoteHeader({ }; return ( -
+
({ + isNew: !id, + note: null, + content: "", + title: "", + recordingTime: 0, + showHypercharge: false, + }); + + const updateState = (updates: Partial) => { + setState((prev) => ({ ...prev, ...updates })); + }; + + useEffect(() => { + if (id && !state.isNew) { + const loadNote = async () => { + try { + const noteData = await fetchNote(id); + updateState({ + note: noteData, + title: noteData.title, + content: noteData.rawMemo, + }); + } catch (error) { + console.error("Failed to load note:", error); + } + }; + loadNote(); + } + }, [id, state.isNew]); + + const shouldStartRecording = (event: CalendarEvent) => { + const now = new Date(); + const startTime = event.start.dateTime + ? new Date(event.start.dateTime) + : event.start.date + ? new Date(event.start.date) + : null; + + return startTime ? now >= startTime : false; + }; + + const updateRecordingTime = () => { + setState((prev) => ({ + ...prev, + recordingTime: prev.recordingTime + 1, + })); + }; + + const handlePauseResume = async ( + isPaused: boolean, + resumeRecording: () => void | Promise, + pauseRecording: () => void | Promise, + ) => { + if (isPaused) { + await Promise.resolve(resumeRecording()); + updateState({ showHypercharge: false }); + } else { + await Promise.resolve(pauseRecording()); + updateState({ showHypercharge: true }); + } + }; + + const handleHypercharge = async () => { + const enhancedNote = await enhanceNoteWithAI( + state.title, + state.content, + [], + ); + updateState({ + content: enhancedNote.content, + title: + !state.title && enhancedNote.suggestedTitle + ? enhancedNote.suggestedTitle + : state.title, + }); + }; + + return { + state, + updateState, + shouldStartRecording, + updateRecordingTime, + handlePauseResume, + handleHypercharge, + }; +} diff --git a/src/pages/NotePage.tsx b/src/pages/NotePage.tsx index 489e54b087..6bed211d1f 100644 --- a/src/pages/NotePage.tsx +++ b/src/pages/NotePage.tsx @@ -1,25 +1,25 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useParams } from "react-router-dom"; -import type { Note, CalendarEvent } from "../types"; -import { fetchNote, enhanceNoteWithAI } from "../api/noteApi"; import { useSpeechRecognition } from "../hooks/useSpeechRecognition"; -import SidePanel from "../components/SidePanel"; +import SidePanel from "../components/note/SidePanel"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { useUI } from "../contexts/UIContext"; -import LiveCaptionDock from "../components/LiveCaptionDock"; -import NoteHeader from "../components/NoteHeader"; -import { EditorContent, useEditor } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; +import LiveCaptionDock from "../components/note/LiveCaptionDock"; +import NoteHeader from "../components/note/NoteHeader"; +import NoteEditor from "../components/note/NoteEditor"; +import { useNoteState } from "../hooks/useNoteState"; export default function NotePage() { const { id } = useParams(); - const [isNew] = useState(!id); - const [note, setNote] = useState(null); - const [noteContent, setNoteContent] = useState(""); - const [noteTitle, setNoteTitle] = useState(""); - const [recordingTime, setRecordingTime] = useState(0); - const [showHypercharge, setShowHypercharge] = useState(false); const { isPanelOpen } = useUI(); + const { + state, + updateState, + shouldStartRecording, + updateRecordingTime, + handlePauseResume, + handleHypercharge, + } = useNoteState(id); const { isRecording, @@ -32,37 +32,22 @@ export default function NotePage() { stopRecording, } = useSpeechRecognition(); - const editor = useEditor({ - extensions: [StarterKit], - content: noteContent, - onUpdate: ({ editor }) => { - setNoteContent(editor.getHTML()); - }, - }); - - useEffect(() => { - if (id && !isNew) { - const loadNote = async () => { - const noteData = await fetchNote(id); - setNote(noteData); - setNoteTitle(noteData.title); - setNoteContent(noteData.rawMemo); - }; - loadNote(); - } - }, [id]); + const handlePauseResumeClick = () => { + handlePauseResume(isPaused, resumeRecording, pauseRecording); + }; useEffect(() => { if ( - isNew || - (note?.calendarEvent && shouldStartRecording(note.calendarEvent)) + state.isNew || + (state.note?.calendarEvent && + shouldStartRecording(state.note.calendarEvent)) ) { startRecording(); } const timer = setInterval(() => { if (isRecording && !isPaused) { - setRecordingTime((prev) => prev + 1); + updateRecordingTime(); } }, 1000); @@ -70,67 +55,40 @@ export default function NotePage() { void stopRecording(); clearInterval(timer); }; - }, [isNew, note]); - - useEffect(() => { - if (editor && editor.getHTML() !== noteContent) { - editor.commands.setContent(noteContent); - } - }, [noteContent, editor]); - - const shouldStartRecording = (event: CalendarEvent) => { - const now = new Date(); - const startTime = event.start.dateTime - ? new Date(event.start.dateTime) - : event.start.date - ? new Date(event.start.date) - : null; - - return startTime ? now >= startTime : false; - }; - - const handlePauseResume = async () => { - if (isPaused) { - await resumeRecording(); - setShowHypercharge(false); - } else { - await pauseRecording(); - setShowHypercharge(true); - } - }; - - const handleHypercharge = async () => { - const enhancedNote = await enhanceNoteWithAI(noteTitle, noteContent, []); - setNoteContent(enhancedNote.content); - if (!noteTitle && enhancedNote.suggestedTitle) { - setNoteTitle(enhancedNote.suggestedTitle); - } - }; + }, [ + state.isNew, + state.note, + isRecording, + isPaused, + startRecording, + stopRecording, + updateRecordingTime, + ]); return (
-
+
updateState({ title })} onHypercharge={handleHypercharge} onStartRecording={startRecording} - onPauseResume={handlePauseResume} + onPauseResume={handlePauseResumeClick} />
- updateState({ content })} />
@@ -141,7 +99,7 @@ export default function NotePage() { <> - + )} diff --git a/src/styles/editor.css b/src/styles/editor.css new file mode 100644 index 0000000000..e98a2825cd --- /dev/null +++ b/src/styles/editor.css @@ -0,0 +1,98 @@ +/* Basic editor styles */ +.tiptap { + :first-child { + margin-top: 0; + } + + /* List styles */ + ul, + ol { + padding: 0 1rem; + margin: 1.25rem 1rem 1.25rem 0.4rem; + + li p { + margin-top: 0.25em; + margin-bottom: 0.25em; + } + } + + /* Heading styles */ + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + margin-top: 2.5rem; + text-wrap: pretty; + } + + h1, + h2 { + margin-top: 3.5rem; + margin-bottom: 1.5rem; + } + + h1 { + font-size: 1.4rem; + } + + h2 { + font-size: 1.2rem; + } + + h3 { + font-size: 1.1rem; + } + + h4, + h5, + h6 { + font-size: 1rem; + } + + /* Code and preformatted text styles */ + code { + background-color: var(--purple-light); + border-radius: 0.4rem; + color: var(--black); + font-size: 0.85rem; + padding: 0.25em 0.3em; + } + + pre { + background: var(--black); + border-radius: 0.5rem; + color: var(--white); + font-family: "JetBrainsMono", monospace; + margin: 1.5rem 0; + padding: 0.75rem 1rem; + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + } + } + + mark { + background-color: #faf594; + border-radius: 0.4rem; + box-decoration-break: clone; + padding: 0.1rem 0.3rem; + } + + blockquote { + border-left: 3px solid var(--gray-3); + margin: 1.5rem 0; + padding-left: 1rem; + } + + hr { + border: none; + border-top: 1px solid var(--gray-2); + margin: 2rem 0; + } +}