perf(web-editor): reduce task content typing lag
This commit is contained in:
@@ -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 = "";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user