diff --git a/apps/web/src/components/task-rich-editor.tsx b/apps/web/src/components/task-rich-editor.tsx index 578a30d..cc1bffd 100644 --- a/apps/web/src/components/task-rich-editor.tsx +++ b/apps/web/src/components/task-rich-editor.tsx @@ -11,6 +11,7 @@ import { cn } from "@/lib/utils"; const MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024; const MAX_VIDEO_UPLOAD_BYTES = 10 * 1024 * 1024; +const EDITOR_CHANGE_DEBOUNCE_MS = 120; type TaskRichEditorProps = { valueJson: string | null; @@ -159,6 +160,47 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd const [mediaHint, setMediaHint] = useState(null); const imageInputRef = useRef(null); const videoInputRef = useRef(null); + const changeTimeoutRef = useRef(null); + const latestOnChangeRef = useRef(onChange); + const lastSyncedPayloadRef = useRef<{ + json: string | null; + text: string; + }>({ + json: valueJson, + text: textFallback + }); + + useEffect(() => { + latestOnChangeRef.current = onChange; + }, [onChange]); + + function flushEditorChange(currentEditor: TiptapEditor): void { + const nextPayload = { + json: JSON.stringify(currentEditor.getJSON()), + text: currentEditor.getText() + }; + + if ( + nextPayload.json === lastSyncedPayloadRef.current.json && + nextPayload.text === lastSyncedPayloadRef.current.text + ) { + return; + } + + lastSyncedPayloadRef.current = nextPayload; + latestOnChangeRef.current(nextPayload); + } + + function scheduleEditorChange(currentEditor: TiptapEditor): void { + if (changeTimeoutRef.current !== null) { + window.clearTimeout(changeTimeoutRef.current); + } + + changeTimeoutRef.current = window.setTimeout(() => { + flushEditorChange(currentEditor); + changeTimeoutRef.current = null; + }, EDITOR_CHANGE_DEBOUNCE_MS); + } const editor = useEditor({ extensions: [ @@ -186,9 +228,15 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd } }, onUpdate({ editor: currentEditor }) { - const nextJson = JSON.stringify(currentEditor.getJSON()); - const nextText = currentEditor.getText(); - onChange({ json: nextJson, text: nextText }); + scheduleEditorChange(currentEditor); + }, + onBlur({ editor: currentEditor }) { + if (changeTimeoutRef.current !== null) { + window.clearTimeout(changeTimeoutRef.current); + changeTimeoutRef.current = null; + } + + flushEditorChange(currentEditor); } }); @@ -197,6 +245,18 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd return; } + if ( + valueJson === lastSyncedPayloadRef.current.json && + textFallback === lastSyncedPayloadRef.current.text + ) { + return; + } + + if (changeTimeoutRef.current !== null) { + window.clearTimeout(changeTimeoutRef.current); + changeTimeoutRef.current = null; + } + if (valueJson) { const nextJson = parseEditorJson(valueJson); @@ -207,21 +267,32 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd return; } - if (JSON.stringify(editor.getJSON()) === JSON.stringify(nextJson)) { - return; - } - editor.commands.setContent(nextJson, { emitUpdate: false }); + lastSyncedPayloadRef.current = { + json: valueJson, + text: textFallback + }; return; } - if (editor.getText() === textFallback) { - return; + if (editor.getText() !== textFallback) { + editor.commands.setContent(textFallback, { emitUpdate: false }); } - editor.commands.setContent(textFallback, { emitUpdate: false }); + lastSyncedPayloadRef.current = { + json: valueJson, + text: textFallback + }; }, [editor, textFallback, valueJson]); + useEffect(() => { + return () => { + if (changeTimeoutRef.current !== null) { + window.clearTimeout(changeTimeoutRef.current); + } + }; + }, []); + async function handleImageFileChange(event: ChangeEvent): Promise { const file = event.target.files?.[0]; event.target.value = ""; diff --git a/apps/web/src/pages/todo-shell-page.tsx b/apps/web/src/pages/todo-shell-page.tsx index 8904f43..e60728c 100644 --- a/apps/web/src/pages/todo-shell-page.tsx +++ b/apps/web/src/pages/todo-shell-page.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { startTransition, useCallback, useEffect, useRef, useState } from "react"; import { useLiveQuery } from "dexie-react-hooks"; import { CheckCircle2, @@ -51,6 +51,8 @@ type FeedbackNotice = { tone: "success" | "error"; }; +const DRAFT_PERSIST_DEBOUNCE_MS = 500; + const DEFAULT_FORM_STATE: TaskFormState = { title: "", contentJson: null, @@ -362,7 +364,13 @@ export function TodoShellPage({ session }: TodoShellPageProps) { }); } - void persistDraft(); + const timeoutId = window.setTimeout(() => { + void persistDraft(); + }, DRAFT_PERSIST_DEBOUNCE_MS); + + return () => { + window.clearTimeout(timeoutId); + }; }, [draftReadyTaskId, formState, selectedTask, selectedTaskId, userId]); const showFeedback = useCallback((message: string, tone: FeedbackNotice["tone"]): void => { @@ -490,6 +498,16 @@ export function TodoShellPage({ session }: TodoShellPageProps) { } }, [deleting, selectedTaskId, showFeedback]); + const handleEditorChange = useCallback((payload: { json: string | null; text: string }): void => { + startTransition(() => { + setFormState((previous) => ({ + ...previous, + contentJson: payload.json, + contentText: payload.text + })); + }); + }, []); + useEffect(() => { function handleKeydown(event: KeyboardEvent): void { const isSaveShortcut = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s"; @@ -746,13 +764,7 @@ export function TodoShellPage({ session }: TodoShellPageProps) { - setFormState((previous) => ({ - ...previous, - contentJson: payload.json, - contentText: payload.text - })) - } + onChange={handleEditorChange} />