perf(web-editor): decouple rich editor state from page render
This commit is contained in:
@@ -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 imageCompression from "browser-image-compression";
|
||||||
import type { Editor as TiptapEditor } from "@tiptap/core";
|
import type { Editor as TiptapEditor } from "@tiptap/core";
|
||||||
import Link from "@tiptap/extension-link";
|
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 [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);
|
||||||
@@ -530,4 +534,4 @@ export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEd
|
|||||||
{mediaHint ? <p className="mt-2 text-xs text-muted-foreground">{mediaHint}</p> : null}
|
{mediaHint ? <p className="mt-2 text-xs text-muted-foreground">{mediaHint}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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 { useLiveQuery } from "dexie-react-hooks";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -39,13 +39,16 @@ type TodoShellPageProps = {
|
|||||||
|
|
||||||
type TaskFormState = {
|
type TaskFormState = {
|
||||||
title: string;
|
title: string;
|
||||||
contentJson: string | null;
|
|
||||||
contentText: string;
|
|
||||||
priority: LocalTaskPriority;
|
priority: LocalTaskPriority;
|
||||||
status: LocalTaskStatus;
|
status: LocalTaskStatus;
|
||||||
ddlInput: string;
|
ddlInput: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TaskEditorState = {
|
||||||
|
contentJson: string | null;
|
||||||
|
contentText: string;
|
||||||
|
};
|
||||||
|
|
||||||
type FeedbackNotice = {
|
type FeedbackNotice = {
|
||||||
message: string;
|
message: string;
|
||||||
tone: "success" | "error";
|
tone: "success" | "error";
|
||||||
@@ -55,13 +58,16 @@ const DRAFT_PERSIST_DEBOUNCE_MS = 500;
|
|||||||
|
|
||||||
const DEFAULT_FORM_STATE: TaskFormState = {
|
const DEFAULT_FORM_STATE: TaskFormState = {
|
||||||
title: "",
|
title: "",
|
||||||
contentJson: null,
|
|
||||||
contentText: "",
|
|
||||||
priority: "MEDIUM",
|
priority: "MEDIUM",
|
||||||
status: "TODO",
|
status: "TODO",
|
||||||
ddlInput: ""
|
ddlInput: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_EDITOR_STATE: TaskEditorState = {
|
||||||
|
contentJson: null,
|
||||||
|
contentText: ""
|
||||||
|
};
|
||||||
|
|
||||||
const PRIORITY_OPTIONS: Array<{ value: LocalTaskPriority; label: string }> = [
|
const PRIORITY_OPTIONS: Array<{ value: LocalTaskPriority; label: string }> = [
|
||||||
{ value: "LOW", label: "低" },
|
{ value: "LOW", label: "低" },
|
||||||
{ value: "MEDIUM", label: "中" },
|
{ value: "MEDIUM", label: "中" },
|
||||||
@@ -125,27 +131,40 @@ function formatUpdatedAt(timestamp: number): string {
|
|||||||
function createFormStateFromTask(task: LocalTaskRecord): TaskFormState {
|
function createFormStateFromTask(task: LocalTaskRecord): TaskFormState {
|
||||||
return {
|
return {
|
||||||
title: task.title,
|
title: task.title,
|
||||||
contentJson: task.contentJson,
|
|
||||||
contentText: task.contentText ?? "",
|
|
||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
ddlInput: toDatetimeLocalValue(task.ddlAt)
|
ddlInput: toDatetimeLocalValue(task.ddlAt)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createEditorStateFromTask(task: LocalTaskRecord): TaskEditorState {
|
||||||
|
return {
|
||||||
|
contentJson: task.contentJson,
|
||||||
|
contentText: task.contentText ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createFormStateFromDraft(draft: LocalTaskDraftRecord): TaskFormState {
|
function createFormStateFromDraft(draft: LocalTaskDraftRecord): TaskFormState {
|
||||||
return {
|
return {
|
||||||
title: draft.title,
|
title: draft.title,
|
||||||
contentJson: draft.contentJson,
|
|
||||||
contentText: draft.contentText,
|
|
||||||
priority: draft.priority,
|
priority: draft.priority,
|
||||||
status: draft.status,
|
status: draft.status,
|
||||||
ddlInput: draft.ddlInput
|
ddlInput: draft.ddlInput
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeFormState(formState: TaskFormState): string {
|
function createEditorStateFromDraft(draft: LocalTaskDraftRecord): TaskEditorState {
|
||||||
return JSON.stringify(formState);
|
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 {
|
function formatSyncTimestamp(timestamp: number | null): string {
|
||||||
@@ -256,7 +275,12 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
const [feedback, setFeedback] = useState<FeedbackNotice | null>(null);
|
const [feedback, setFeedback] = useState<FeedbackNotice | null>(null);
|
||||||
const [feedbackVisible, setFeedbackVisible] = useState(false);
|
const [feedbackVisible, setFeedbackVisible] = useState(false);
|
||||||
const [draftReadyTaskId, setDraftReadyTaskId] = useState<string | null>(null);
|
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 { status: syncStatus, triggerSync } = useSyncEngine(session);
|
||||||
|
|
||||||
const userId = session?.user.id ?? "";
|
const userId = session?.user.id ?? "";
|
||||||
@@ -285,6 +309,49 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
return getLocalTaskById(selectedTaskId);
|
return getLocalTaskById(selectedTaskId);
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!tasks || tasks.length === 0) {
|
if (!tasks || tasks.length === 0) {
|
||||||
setSelectedTaskId(null);
|
setSelectedTaskId(null);
|
||||||
@@ -305,8 +372,12 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedTaskId) {
|
if (!selectedTaskId) {
|
||||||
setFormState(DEFAULT_FORM_STATE);
|
setFormState(DEFAULT_FORM_STATE);
|
||||||
|
formStateRef.current = DEFAULT_FORM_STATE;
|
||||||
|
editorStateRef.current = DEFAULT_EDITOR_STATE;
|
||||||
|
setEditorSeedState(DEFAULT_EDITOR_STATE);
|
||||||
|
setEditorKey("editor-empty");
|
||||||
setDraftReadyTaskId(null);
|
setDraftReadyTaskId(null);
|
||||||
savedTaskSnapshotRef.current = serializeFormState(DEFAULT_FORM_STATE);
|
savedTaskSnapshotRef.current = serializeFormState(DEFAULT_FORM_STATE, DEFAULT_EDITOR_STATE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,14 +390,26 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
|
|
||||||
async function hydrateFormState(): Promise<void> {
|
async function hydrateFormState(): Promise<void> {
|
||||||
const persistedTaskState = createFormStateFromTask(currentTask);
|
const persistedTaskState = createFormStateFromTask(currentTask);
|
||||||
|
const persistedEditorState = createEditorStateFromTask(currentTask);
|
||||||
const localDraft = await getLocalTaskDraft(currentTask.id);
|
const localDraft = await getLocalTaskDraft(currentTask.id);
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
savedTaskSnapshotRef.current = serializeFormState(persistedTaskState);
|
const nextFormState = localDraft ? createFormStateFromDraft(localDraft) : persistedTaskState;
|
||||||
setFormState(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);
|
setDraftReadyTaskId(currentTask.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,40 +421,16 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
}, [selectedTask, selectedTaskId]);
|
}, [selectedTask, selectedTaskId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedTaskId || !selectedTask || draftReadyTaskId !== selectedTaskId || !userId) {
|
scheduleDraftPersist();
|
||||||
return;
|
}, [formState, scheduleDraftPersist]);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
return () => {
|
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 => {
|
const showFeedback = useCallback((message: string, tone: FeedbackNotice["tone"]): void => {
|
||||||
setFeedback({ message, tone });
|
setFeedback({ message, tone });
|
||||||
@@ -455,11 +514,12 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
const currentEditorState = editorStateRef.current;
|
||||||
const updatedTask = await updateLocalTask({
|
const updatedTask = await updateLocalTask({
|
||||||
id: selectedTaskId,
|
id: selectedTaskId,
|
||||||
title: formState.title,
|
title: formState.title,
|
||||||
contentText: formState.contentText || null,
|
contentText: currentEditorState.contentText || null,
|
||||||
contentJson: formState.contentJson,
|
contentJson: currentEditorState.contentJson,
|
||||||
priority: formState.priority,
|
priority: formState.priority,
|
||||||
status: formState.status,
|
status: formState.status,
|
||||||
ddlAt: parseDatetimeLocalValue(formState.ddlInput)
|
ddlAt: parseDatetimeLocalValue(formState.ddlInput)
|
||||||
@@ -470,7 +530,10 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
savedTaskSnapshotRef.current = serializeFormState(createFormStateFromTask(updatedTask));
|
savedTaskSnapshotRef.current = serializeFormState(
|
||||||
|
createFormStateFromTask(updatedTask),
|
||||||
|
createEditorStateFromTask(updatedTask)
|
||||||
|
);
|
||||||
await deleteLocalTaskDraft(selectedTaskId);
|
await deleteLocalTaskDraft(selectedTaskId);
|
||||||
showFeedback("任务已保存。", "success");
|
showFeedback("任务已保存。", "success");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -498,15 +561,16 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
}
|
}
|
||||||
}, [deleting, selectedTaskId, showFeedback]);
|
}, [deleting, selectedTaskId, showFeedback]);
|
||||||
|
|
||||||
const handleEditorChange = useCallback((payload: { json: string | null; text: string }): void => {
|
const handleEditorChange = useCallback(
|
||||||
startTransition(() => {
|
(payload: { json: string | null; text: string }): void => {
|
||||||
setFormState((previous) => ({
|
editorStateRef.current = {
|
||||||
...previous,
|
|
||||||
contentJson: payload.json,
|
contentJson: payload.json,
|
||||||
contentText: payload.text
|
contentText: payload.text
|
||||||
}));
|
};
|
||||||
});
|
scheduleDraftPersist();
|
||||||
}, []);
|
},
|
||||||
|
[scheduleDraftPersist]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeydown(event: KeyboardEvent): void {
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
@@ -762,8 +826,9 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
<p>任务内容</p>
|
<p>任务内容</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<TaskRichEditor
|
<TaskRichEditor
|
||||||
valueJson={formState.contentJson}
|
key={editorKey}
|
||||||
textFallback={formState.contentText}
|
valueJson={editorSeedState.contentJson}
|
||||||
|
textFallback={editorSeedState.contentText}
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user