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
+82 -11
View File
@@ -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<string | null>(null);
const imageInputRef = 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({
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 });
}
lastSyncedPayloadRef.current = {
json: valueJson,
text: textFallback
};
}, [editor, textFallback, valueJson]);
useEffect(() => {
return () => {
if (changeTimeoutRef.current !== null) {
window.clearTimeout(changeTimeoutRef.current);
}
};
}, []);
async function handleImageFileChange(event: ChangeEvent<HTMLInputElement>): Promise<void> {
const file = event.target.files?.[0];
event.target.value = "";
+20 -8
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 {
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) {
});
}
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) {
<TaskRichEditor
valueJson={formState.contentJson}
textFallback={formState.contentText}
onChange={(payload) =>
setFormState((previous) => ({
...previous,
contentJson: payload.json,
contentText: payload.text
}))
}
onChange={handleEditorChange}
/>
</div>
</div>