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;
+ }
+}