perf(web-editor): reduce task content typing lag

This commit is contained in:
2026-04-06 01:38:54 +08:00
parent c98adb3051
commit 5d88ac783b
2 changed files with 102 additions and 19 deletions
+81 -10
View File
@@ -11,6 +11,7 @@ import { cn } from "@/lib/utils";
const MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024; const MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024;
const MAX_VIDEO_UPLOAD_BYTES = 10 * 1024 * 1024; const MAX_VIDEO_UPLOAD_BYTES = 10 * 1024 * 1024;
const EDITOR_CHANGE_DEBOUNCE_MS = 120;
type TaskRichEditorProps = { type TaskRichEditorProps = {
valueJson: string | null; valueJson: string | null;
@@ -159,6 +160,47 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
const [mediaHint, setMediaHint] = useState<string | null>(null); const [mediaHint, setMediaHint] = useState<string | null>(null);
const imageInputRef = useRef<HTMLInputElement | null>(null); const imageInputRef = useRef<HTMLInputElement | null>(null);
const videoInputRef = useRef<HTMLInputElement | null>(null); const videoInputRef = useRef<HTMLInputElement | null>(null);
const changeTimeoutRef = useRef<number | null>(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({ const editor = useEditor({
extensions: [ extensions: [
@@ -186,9 +228,15 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
} }
}, },
onUpdate({ editor: currentEditor }) { onUpdate({ editor: currentEditor }) {
const nextJson = JSON.stringify(currentEditor.getJSON()); scheduleEditorChange(currentEditor);
const nextText = currentEditor.getText(); },
onChange({ json: nextJson, text: nextText }); 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; return;
} }
if (
valueJson === lastSyncedPayloadRef.current.json &&
textFallback === lastSyncedPayloadRef.current.text
) {
return;
}
if (changeTimeoutRef.current !== null) {
window.clearTimeout(changeTimeoutRef.current);
changeTimeoutRef.current = null;
}
if (valueJson) { if (valueJson) {
const nextJson = parseEditorJson(valueJson); const nextJson = parseEditorJson(valueJson);
@@ -207,21 +267,32 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
return; return;
} }
if (JSON.stringify(editor.getJSON()) === JSON.stringify(nextJson)) {
return;
}
editor.commands.setContent(nextJson, { emitUpdate: false }); editor.commands.setContent(nextJson, { emitUpdate: false });
lastSyncedPayloadRef.current = {
json: valueJson,
text: textFallback
};
return; return;
} }
if (editor.getText() === textFallback) { if (editor.getText() !== textFallback) {
return; editor.commands.setContent(textFallback, { emitUpdate: false });
} }
editor.commands.setContent(textFallback, { emitUpdate: false }); lastSyncedPayloadRef.current = {
json: valueJson,
text: textFallback
};
}, [editor, textFallback, valueJson]); }, [editor, textFallback, valueJson]);
useEffect(() => {
return () => {
if (changeTimeoutRef.current !== null) {
window.clearTimeout(changeTimeoutRef.current);
}
};
}, []);
async function handleImageFileChange(event: ChangeEvent<HTMLInputElement>): Promise<void> { async function handleImageFileChange(event: ChangeEvent<HTMLInputElement>): Promise<void> {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ""; event.target.value = "";
+21 -9
View File
@@ -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 { useLiveQuery } from "dexie-react-hooks";
import { import {
CheckCircle2, CheckCircle2,
@@ -51,6 +51,8 @@ type FeedbackNotice = {
tone: "success" | "error"; tone: "success" | "error";
}; };
const DRAFT_PERSIST_DEBOUNCE_MS = 500;
const DEFAULT_FORM_STATE: TaskFormState = { const DEFAULT_FORM_STATE: TaskFormState = {
title: "", title: "",
contentJson: null, 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]); }, [draftReadyTaskId, formState, selectedTask, selectedTaskId, userId]);
const showFeedback = useCallback((message: string, tone: FeedbackNotice["tone"]): void => { const showFeedback = useCallback((message: string, tone: FeedbackNotice["tone"]): void => {
@@ -490,6 +498,16 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
} }
}, [deleting, selectedTaskId, showFeedback]); }, [deleting, selectedTaskId, showFeedback]);
const handleEditorChange = useCallback((payload: { json: string | null; text: string }): void => {
startTransition(() => {
setFormState((previous) => ({
...previous,
contentJson: payload.json,
contentText: payload.text
}));
});
}, []);
useEffect(() => { useEffect(() => {
function handleKeydown(event: KeyboardEvent): void { function handleKeydown(event: KeyboardEvent): void {
const isSaveShortcut = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s"; const isSaveShortcut = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s";
@@ -746,13 +764,7 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
<TaskRichEditor <TaskRichEditor
valueJson={formState.contentJson} valueJson={formState.contentJson}
textFallback={formState.contentText} textFallback={formState.contentText}
onChange={(payload) => onChange={handleEditorChange}
setFormState((previous) => ({
...previous,
contentJson: payload.json,
contentText: payload.text
}))
}
/> />
</div> </div>
</div> </div>