Fix tiptap selection sync and toolbar event loop

This commit is contained in:
lix-2026
2026-04-19 21:03:25 +08:00
parent 111a87d4fd
commit 394e2a155c
87 changed files with 17415 additions and 527 deletions
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { DocumentToc } from "@/components/editor/document-toc";
import { DocumentReadView } from "@/components/editor/document-read-view";
import { buildPageSubtreeProjection, extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
import {
deleteDocumentCommand,
embedDocumentCommand,
@@ -143,6 +143,11 @@ export function DocumentContent({
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
const [content, setContent] = useState<unknown>(initialContent);
const [serverContentSnapshot, setServerContentSnapshot] = useState<unknown>(initialContent);
const [serverPageSubtreeSnapshot, setServerPageSubtreeSnapshot] = useState<PageSubtreeProjection | null>(
initialPageSubtree,
);
const [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState<string>(title ?? "无标题");
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
@@ -259,6 +264,14 @@ export function DocumentContent({
setPageTitle(title ?? "无标题");
}, [title]);
useEffect(() => {
setServerPageSubtreeTitle(title ?? "无标题");
}, [title]);
useEffect(() => {
setServerPageSubtreeSnapshot(initialPageSubtree);
}, [initialPageSubtree]);
useEffect(() => {
setOptions(initialOptions ?? defaultOptions);
}, [initialOptions]);
@@ -275,6 +288,10 @@ export function DocumentContent({
setConflictDetectionKey(initialConflictDetectionKey);
}, [initialConflictDetectionKey]);
useEffect(() => {
setServerContentSnapshot(initialContent);
}, [initialContent]);
useEffect(() => {
const nextBlocks = extractPageBlocks(content);
latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null;
@@ -354,9 +371,11 @@ export function DocumentContent({
content?: unknown;
revision?: number | null;
conflictDetectionKey?: string | null;
pageSubtree?: PageSubtreeProjection | null;
};
if (canceled) return;
setContent(payload.content ?? null);
setServerContentSnapshot(payload.content ?? null);
setContentRevision(
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
@@ -367,6 +386,13 @@ export function DocumentContent({
? payload.conflictDetectionKey
: `${documentId}:0`,
);
setServerPageSubtreeSnapshot(payload.pageSubtree ?? null);
setServerPageSubtreeTitle(
typeof payload.pageSubtree?.rootNode.metadata.title === "string" &&
payload.pageSubtree.rootNode.metadata.title.trim()
? payload.pageSubtree.rootNode.metadata.title
: title ?? "无标题",
);
} catch (error) {
if (canceled) return;
if ((error as { name?: string })?.name === "AbortError") return;
@@ -393,7 +419,7 @@ export function DocumentContent({
contentLoadingTimerRef.current = null;
}
};
}, [documentId, initialContent, contentReloadKey, workspaceId]);
}, [documentId, initialContent, contentReloadKey, title, workspaceId]);
const persistTitle = useCallback(
async (nextTitle: string) => {
@@ -730,18 +756,24 @@ export function DocumentContent({
options.hideChildPages && "wolai-hide-child-pages",
);
const pageSubtree = useMemo(() => {
if (initialPageSubtree && content === initialContent && pageTitle === (title ?? "无标题")) {
return initialPageSubtree;
const hasServerPageSubtree = Boolean(serverPageSubtreeSnapshot);
const titleUnchanged = pageTitle === serverPageSubtreeTitle;
const contentUnchanged = content === serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
return serverPageSubtreeSnapshot;
}
return buildPageSubtreeProjection({
documentId,
title: pageTitle,
content,
});
}, [content, documentId, initialContent, initialPageSubtree, pageTitle, title]);
return null;
}, [
content,
pageTitle,
serverContentSnapshot,
serverPageSubtreeSnapshot,
serverPageSubtreeTitle,
]);
const readViewTocEntries = useMemo(
() =>
pageSubtree.outline
(pageSubtree?.outline ?? [])
.filter((entry) => typeof entry.anchorBlockId === "string" && entry.anchorBlockId.trim())
.map(({ anchorBlockId, level, numbering, title: entryTitle }) => ({
id: anchorBlockId as string,
@@ -5,7 +5,6 @@ import type { CSSProperties, ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { TocEntry } from "@/components/editor/document-toc";
import {
buildPageSubtreeProjection,
clampHeadingLevel,
extractPageBlocks,
getInlineText,
@@ -181,11 +180,26 @@ const buildHeadingNumberingMapFromOutline = (outline: PageOutlineEntry[]): Map<s
export const extractReadViewBlocks = (content: unknown): PageSubtreeBlock[] => extractPageBlocks(content);
export const buildReadViewTocEntries = (blocks: PageSubtreeBlock[]): TocEntry[] => {
return buildPageSubtreeProjection({
documentId: "preview",
title: "预览",
content: blocks,
}).outline.map(({ id, level, numbering, title }) => ({
const counters = [0, 0, 0, 0, 0];
return blocks.flatMap((block) => {
if (block.type !== "heading") {
return [];
}
const level = clampHeadingLevel(block.props?.level);
counters[level - 1] += 1;
for (let index = level; index < counters.length; index += 1) {
counters[index] = 0;
}
return [{
id: String(block.id ?? `preview-heading-${counters.join("-")}`),
level,
numbering: counters
.slice(0, level)
.filter((value) => value > 0)
.join("."),
title: getInlineText(block.content) || "未命名标题",
}];
}).map(({ id, level, numbering, title }) => ({
id,
level,
numbering,
@@ -583,22 +597,15 @@ function DocumentReadStructurePanel({ pageSubtree }: { pageSubtree: PageSubtreeP
export function DocumentReadView({ content, documentId, options, pageSubtree, className }: DocumentReadViewProps) {
const blocks = extractReadViewBlocks(content);
const resolvedPageSubtree =
pageSubtree ??
buildPageSubtreeProjection({
documentId,
title: null,
content,
});
const headingNumberingById =
resolvedPageSubtree.outline.length > 0
? buildHeadingNumberingMapFromOutline(resolvedPageSubtree.outline)
(pageSubtree?.outline.length ?? 0) > 0
? buildHeadingNumberingMapFromOutline(pageSubtree?.outline ?? [])
: buildHeadingNumberingMap(blocks);
if (blocks.length === 0) {
return (
<div className={cn("space-y-4", className)}>
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null}
{options.showStructure && pageSubtree ? <DocumentReadStructurePanel pageSubtree={pageSubtree} /> : null}
<div className="flex min-h-[40vh] items-center justify-center rounded-2xl border border-dashed border-[#e4e4e7] bg-[#fafafa] px-6 py-10 text-sm text-[#71717a]">
</div>
@@ -608,7 +615,7 @@ export function DocumentReadView({ content, documentId, options, pageSubtree, cl
return (
<div className={cn("space-y-4", className)}>
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null}
{options.showStructure && pageSubtree ? <DocumentReadStructurePanel pageSubtree={pageSubtree} /> : null}
{renderBlocks(blocks, options, documentId, headingNumberingById, 0)}
</div>
);