perf(web-editor): decouple rich editor state from page render

This commit is contained in:
2026-04-06 01:50:33 +08:00
parent 5d88ac783b
commit 3a7c67bb88
2 changed files with 130 additions and 61 deletions
+7 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { memo, useEffect, useRef, useState, type ChangeEvent } from "react";
import imageCompression from "browser-image-compression";
import type { Editor as TiptapEditor } from "@tiptap/core";
import Link from "@tiptap/extension-link";
@@ -156,7 +156,11 @@ function removeMediaByUploadToken(editor: TiptapEditor, uploadToken: string): bo
});
}
export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEditorProps) {
export const TaskRichEditor = memo(function TaskRichEditor({
valueJson,
textFallback,
onChange
}: TaskRichEditorProps) {
const [mediaHint, setMediaHint] = useState<string | null>(null);
const imageInputRef = useRef<HTMLInputElement | null>(null);
const videoInputRef = useRef<HTMLInputElement | null>(null);
@@ -530,4 +534,4 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
{mediaHint ? <p className="mt-2 text-xs text-muted-foreground">{mediaHint}</p> : null}
</div>
);
}
});
+123 -58
View File
@@ -1,4 +1,4 @@
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import {
CheckCircle2,
@@ -39,13 +39,16 @@ type TodoShellPageProps = {
type TaskFormState = {
title: string;
contentJson: string | null;
contentText: string;
priority: LocalTaskPriority;
status: LocalTaskStatus;
ddlInput: string;
};
type TaskEditorState = {
contentJson: string | null;
contentText: string;
};
type FeedbackNotice = {
message: string;
tone: "success" | "error";
@@ -55,13 +58,16 @@ const DRAFT_PERSIST_DEBOUNCE_MS = 500;
const DEFAULT_FORM_STATE: TaskFormState = {
title: "",
contentJson: null,
contentText: "",
priority: "MEDIUM",
status: "TODO",
ddlInput: ""
};
const DEFAULT_EDITOR_STATE: TaskEditorState = {
contentJson: null,
contentText: ""
};
const PRIORITY_OPTIONS: Array<{ value: LocalTaskPriority; label: string }> = [
{ value: "LOW", label: "低" },
{ value: "MEDIUM", label: "中" },
@@ -125,27 +131,40 @@ function formatUpdatedAt(timestamp: number): string {
function createFormStateFromTask(task: LocalTaskRecord): TaskFormState {
return {
title: task.title,
contentJson: task.contentJson,
contentText: task.contentText ?? "",
priority: task.priority,
status: task.status,
ddlInput: toDatetimeLocalValue(task.ddlAt)
};
}
function createEditorStateFromTask(task: LocalTaskRecord): TaskEditorState {
return {
contentJson: task.contentJson,
contentText: task.contentText ?? ""
};
}
function createFormStateFromDraft(draft: LocalTaskDraftRecord): TaskFormState {
return {
title: draft.title,
contentJson: draft.contentJson,
contentText: draft.contentText,
priority: draft.priority,
status: draft.status,
ddlInput: draft.ddlInput
};
}
function serializeFormState(formState: TaskFormState): string {
return JSON.stringify(formState);
function createEditorStateFromDraft(draft: LocalTaskDraftRecord): TaskEditorState {
return {
contentJson: draft.contentJson,
contentText: draft.contentText
};
}
function serializeFormState(formState: TaskFormState, editorState: TaskEditorState): string {
return JSON.stringify({
...formState,
...editorState
});
}
function formatSyncTimestamp(timestamp: number | null): string {
@@ -256,7 +275,12 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
const [feedback, setFeedback] = useState<FeedbackNotice | null>(null);
const [feedbackVisible, setFeedbackVisible] = useState(false);
const [draftReadyTaskId, setDraftReadyTaskId] = useState<string | null>(null);
const savedTaskSnapshotRef = useRef(serializeFormState(DEFAULT_FORM_STATE));
const [editorSeedState, setEditorSeedState] = useState<TaskEditorState>(DEFAULT_EDITOR_STATE);
const [editorKey, setEditorKey] = useState("editor-empty");
const savedTaskSnapshotRef = useRef(serializeFormState(DEFAULT_FORM_STATE, DEFAULT_EDITOR_STATE));
const formStateRef = useRef(DEFAULT_FORM_STATE);
const editorStateRef = useRef(DEFAULT_EDITOR_STATE);
const draftPersistTimeoutRef = useRef<number | null>(null);
const { status: syncStatus, triggerSync } = useSyncEngine(session);
const userId = session?.user.id ?? "";
@@ -285,6 +309,49 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
return getLocalTaskById(selectedTaskId);
}, [selectedTaskId]);
useEffect(() => {
formStateRef.current = formState;
}, [formState]);
const scheduleDraftPersist = useCallback((): void => {
if (!selectedTaskId || draftReadyTaskId !== selectedTaskId || !userId) {
return;
}
if (draftPersistTimeoutRef.current !== null) {
window.clearTimeout(draftPersistTimeoutRef.current);
}
const currentTaskId = selectedTaskId;
const currentUserId = userId;
const currentFormState = formStateRef.current;
const currentEditorState = editorStateRef.current;
const currentSnapshot = serializeFormState(currentFormState, currentEditorState);
draftPersistTimeoutRef.current = window.setTimeout(() => {
async function persistDraft(): Promise<void> {
if (currentSnapshot === savedTaskSnapshotRef.current) {
await deleteLocalTaskDraft(currentTaskId);
return;
}
await saveLocalTaskDraft({
taskId: currentTaskId,
userId: currentUserId,
title: currentFormState.title,
contentJson: currentEditorState.contentJson,
contentText: currentEditorState.contentText,
priority: currentFormState.priority,
status: currentFormState.status,
ddlInput: currentFormState.ddlInput
});
}
void persistDraft();
draftPersistTimeoutRef.current = null;
}, DRAFT_PERSIST_DEBOUNCE_MS);
}, [draftReadyTaskId, selectedTaskId, userId]);
useEffect(() => {
if (!tasks || tasks.length === 0) {
setSelectedTaskId(null);
@@ -305,8 +372,12 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
useEffect(() => {
if (!selectedTaskId) {
setFormState(DEFAULT_FORM_STATE);
formStateRef.current = DEFAULT_FORM_STATE;
editorStateRef.current = DEFAULT_EDITOR_STATE;
setEditorSeedState(DEFAULT_EDITOR_STATE);
setEditorKey("editor-empty");
setDraftReadyTaskId(null);
savedTaskSnapshotRef.current = serializeFormState(DEFAULT_FORM_STATE);
savedTaskSnapshotRef.current = serializeFormState(DEFAULT_FORM_STATE, DEFAULT_EDITOR_STATE);
return;
}
@@ -319,14 +390,26 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
async function hydrateFormState(): Promise<void> {
const persistedTaskState = createFormStateFromTask(currentTask);
const persistedEditorState = createEditorStateFromTask(currentTask);
const localDraft = await getLocalTaskDraft(currentTask.id);
if (cancelled) {
return;
}
savedTaskSnapshotRef.current = serializeFormState(persistedTaskState);
setFormState(localDraft ? createFormStateFromDraft(localDraft) : persistedTaskState);
const nextFormState = localDraft ? createFormStateFromDraft(localDraft) : persistedTaskState;
const nextEditorState = localDraft
? createEditorStateFromDraft(localDraft)
: persistedEditorState;
savedTaskSnapshotRef.current = serializeFormState(persistedTaskState, persistedEditorState);
formStateRef.current = nextFormState;
editorStateRef.current = nextEditorState;
setFormState(nextFormState);
setEditorSeedState(nextEditorState);
setEditorKey(
`${currentTask.id}:${currentTask.updatedAt}:${localDraft?.updatedAt ?? currentTask.updatedAt}`
);
setDraftReadyTaskId(currentTask.id);
}
@@ -338,40 +421,16 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
}, [selectedTask, selectedTaskId]);
useEffect(() => {
if (!selectedTaskId || !selectedTask || draftReadyTaskId !== selectedTaskId || !userId) {
return;
}
const currentSnapshot = serializeFormState(formState);
const currentTaskId = selectedTaskId;
const currentUserId = userId;
async function persistDraft(): Promise<void> {
if (currentSnapshot === savedTaskSnapshotRef.current) {
await deleteLocalTaskDraft(currentTaskId);
return;
}
await saveLocalTaskDraft({
taskId: currentTaskId,
userId: currentUserId,
title: formState.title,
contentJson: formState.contentJson,
contentText: formState.contentText,
priority: formState.priority,
status: formState.status,
ddlInput: formState.ddlInput
});
}
const timeoutId = window.setTimeout(() => {
void persistDraft();
}, DRAFT_PERSIST_DEBOUNCE_MS);
scheduleDraftPersist();
}, [formState, scheduleDraftPersist]);
useEffect(() => {
return () => {
window.clearTimeout(timeoutId);
if (draftPersistTimeoutRef.current !== null) {
window.clearTimeout(draftPersistTimeoutRef.current);
}
};
}, [draftReadyTaskId, formState, selectedTask, selectedTaskId, userId]);
}, []);
const showFeedback = useCallback((message: string, tone: FeedbackNotice["tone"]): void => {
setFeedback({ message, tone });
@@ -455,11 +514,12 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
try {
setSaving(true);
const currentEditorState = editorStateRef.current;
const updatedTask = await updateLocalTask({
id: selectedTaskId,
title: formState.title,
contentText: formState.contentText || null,
contentJson: formState.contentJson,
contentText: currentEditorState.contentText || null,
contentJson: currentEditorState.contentJson,
priority: formState.priority,
status: formState.status,
ddlAt: parseDatetimeLocalValue(formState.ddlInput)
@@ -470,7 +530,10 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
return;
}
savedTaskSnapshotRef.current = serializeFormState(createFormStateFromTask(updatedTask));
savedTaskSnapshotRef.current = serializeFormState(
createFormStateFromTask(updatedTask),
createEditorStateFromTask(updatedTask)
);
await deleteLocalTaskDraft(selectedTaskId);
showFeedback("任务已保存。", "success");
} finally {
@@ -498,15 +561,16 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
}
}, [deleting, selectedTaskId, showFeedback]);
const handleEditorChange = useCallback((payload: { json: string | null; text: string }): void => {
startTransition(() => {
setFormState((previous) => ({
...previous,
const handleEditorChange = useCallback(
(payload: { json: string | null; text: string }): void => {
editorStateRef.current = {
contentJson: payload.json,
contentText: payload.text
}));
});
}, []);
};
scheduleDraftPersist();
},
[scheduleDraftPersist]
);
useEffect(() => {
function handleKeydown(event: KeyboardEvent): void {
@@ -762,8 +826,9 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
<p></p>
<div className="mt-1">
<TaskRichEditor
valueJson={formState.contentJson}
textFallback={formState.contentText}
key={editorKey}
valueJson={editorSeedState.contentJson}
textFallback={editorSeedState.contentText}
onChange={handleEditorChange}
/>
</div>