feat(editor): save leptos island and page aggregate alignment progress

- switch main document flow toward leptos tiptap island host and generated runtime assets

- align page aggregate loading, page head single-source updates, and AI tool result recovery

- add tests and smoke scripts for title sync, AI route recovery, and editor host cutover
This commit is contained in:
lix-2026
2026-04-22 05:57:06 +08:00
parent 5d1c94eb9e
commit 8353aea2f9
105 changed files with 14768 additions and 4186 deletions
@@ -20,15 +20,27 @@ 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 { emitDocumentsChanged } from "@/lib/events";
import { extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
import {
deleteDocumentCommand,
embedDocumentCommand,
moveDocumentCommand,
renameDocumentCommand,
updatePageOptionsCommand,
updatePageTitleCommand,
} from "@/lib/documents/tree-command-client";
import { EditorHost } from "@/components/editor/editor-host";
import type { EditorHostKind } from "@/components/editor/editor-host-config";
import {
DEFAULT_EDITOR_HOST_KIND,
isLeptosTiptapHostKind,
type EditorHostKind,
} from "@/components/editor/editor-host-config";
import type {
EditorHostEvent,
EditorHostFallbackReason,
} from "@/components/editor/editor-host-types";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import { usePageHeadTitle } from "@/components/editor/use-page-head-title";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -78,21 +90,9 @@ const DocumentCommentsDrawer = dynamic(
);
export interface DocumentContentProps {
documentId: string;
workspaceId: string;
title: string | null;
updatedAt: string | null;
initialContent: unknown;
initialContentRevision?: number | null;
initialConflictDetectionKey?: string | null;
initialPageSubtree?: PageSubtreeProjection | null;
initialOptions: PageOptionsState;
initialStats: DocumentStats | null;
page: PageAggregateProjection;
openTableId?: string | null;
editorHostKind?: EditorHostKind;
readOnly?: boolean;
disableDownload?: boolean;
disableCopy?: boolean;
}
const defaultOptions: PageOptionsState = {
@@ -112,24 +112,43 @@ const defaultOptions: PageOptionsState = {
};
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
const EDITOR_UNMOUNT_GRACE_MS = 1000;
const FALLBACK_TRIGGER_HISTORY_LIMIT = 20;
export async function persistPageTitleAndNotifyDocumentsChanged(input: {
documentId: string;
workspaceId: string;
title: string;
persistTitleCommand: (payload: { documentId: string; workspaceId: string; title: string }) => Promise<unknown>;
notifyDocumentsChanged: (documentId?: string) => void;
}): Promise<string> {
const payload = input.title.trim() || "无标题";
await input.persistTitleCommand({
documentId: input.documentId,
workspaceId: input.workspaceId,
title: payload,
});
input.notifyDocumentsChanged(input.documentId);
return payload;
}
export function DocumentContent({
documentId,
workspaceId,
title,
updatedAt,
initialContent,
initialContentRevision = null,
initialConflictDetectionKey = null,
initialPageSubtree = null,
initialOptions,
initialStats,
page,
openTableId,
editorHostKind = "blocknote",
readOnly = false,
disableDownload = false,
disableCopy = false,
editorHostKind = DEFAULT_EDITOR_HOST_KIND,
}: DocumentContentProps) {
const documentId = page.identity.documentId;
const workspaceId = page.identity.workspaceId;
const initialTitle = page.head.title;
const updatedAt = page.head.updatedAt;
const readOnly = page.head.permissions.readOnly;
const disableDownload = page.head.permissions.disableDownload;
const disableCopy = page.head.permissions.disableCopy;
const initialOptions = page.layout.pageOptions;
const initialContent = page.body.content;
const initialContentRevision = page.body.revision;
const initialConflictDetectionKey = page.body.conflictDetectionKey;
const initialPageSubtree = page.tree.pageSubtree;
const initialStats = page.stats;
const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent);
const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch);
const canEditDocument = !readOnly;
@@ -143,22 +162,42 @@ export function DocumentContent({
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
const {
displayTitle: pageTitle,
committedTitle: committedPageTitle,
setDraftTitle: setPageTitleDraft,
commitPersistedTitle,
} = usePageHeadTitle({
documentId,
fallbackTitle: initialTitle,
});
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 [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState<string>(initialTitle);
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0);
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
const shouldUseExperimentalHost =
editorHostKind === "leptos_tiptap_inline" ||
editorHostKind === "leptos_tiptap_iframe_debug";
const shouldUseRuntimeHost = isLeptosTiptapHostKind(editorHostKind);
const requestedHostKind = shouldUseRuntimeHost ? editorHostKind : "blocknote";
const [activeHostKind, setActiveHostKind] = useState<"blocknote" | EditorHostKind>(() =>
requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind,
);
const [hostRuntimeLoadFailure, setHostRuntimeLoadFailure] = useState<string | null>(null);
const [hostInitFailure, setHostInitFailure] = useState<string | null>(null);
const [hostCommandFailure, setHostCommandFailure] = useState<string | null>(null);
const [hostSaveFailure, setHostSaveFailure] = useState<string | null>(null);
const [hostFallbackCount, setHostFallbackCount] = useState<number>(0);
const [lastFallbackReason, setLastFallbackReason] = useState<EditorHostFallbackReason | null>(null);
const [lastFallbackAt, setLastFallbackAt] = useState<string | null>(null);
const [hostStatus, setHostStatus] = useState<string>("idle");
const [hostEventAt, setHostEventAt] = useState<string | null>(null);
const fallbackTriggerHistoryRef = useRef<string[]>([]);
const shouldStartEditing = canEditDocument;
const [isEditing, setIsEditing] = useState(() => shouldStartEditing);
const [keepEditorMounted, setKeepEditorMounted] = useState(() => shouldStartEditing);
@@ -170,6 +209,51 @@ export function DocumentContent({
const readViewRootRef = useRef<HTMLDivElement>(null);
const pendingRestoreSnapshotRef = useRef<DocumentSnapshot | null>(null);
const lastCopyBlockedAtRef = useRef<number>(0);
const hasRequestedFallbackRef = useRef(false);
const resetHostObservability = useCallback((nextHost: "blocknote" | EditorHostKind) => {
setHostStatus(nextHost === "blocknote" ? "blocknote_active" : "booting");
setHostEventAt(new Date().toISOString());
setHostRuntimeLoadFailure(null);
setHostInitFailure(null);
setHostCommandFailure(null);
setHostSaveFailure(null);
setHostFallbackCount(0);
setLastFallbackReason(null);
setLastFallbackAt(null);
fallbackTriggerHistoryRef.current = [];
hasRequestedFallbackRef.current = false;
}, []);
const requestFallbackToBlockNote = useCallback(
(reason: EditorHostFallbackReason, error?: string | null) => {
if (hasRequestedFallbackRef.current) {
return;
}
hasRequestedFallbackRef.current = true;
const now = new Date().toISOString();
setActiveHostKind("blocknote");
setHostFallbackCount((prev) => prev + 1);
setLastFallbackReason(reason);
setLastFallbackAt(now);
setHostStatus("blocknote_fallback");
setHostEventAt(now);
fallbackTriggerHistoryRef.current = [now, ...fallbackTriggerHistoryRef.current].slice(
0,
FALLBACK_TRIGGER_HISTORY_LIMIT,
);
if (reason === "runtime_load_failed") {
setHostRuntimeLoadFailure(error ?? "runtime 加载失败");
} else if (reason === "host_init_failed") {
setHostInitFailure(error ?? "host 初始化失败");
} else if (reason === "command_failed") {
setHostCommandFailure(error ?? "命令执行失败");
} else if (reason === "save_failed") {
setHostSaveFailure(error ?? "保存失败");
}
},
[],
);
useEffect(() => {
setCurrentDocument(documentId, readOnly, disableDownload, disableCopy);
@@ -243,6 +327,11 @@ export function DocumentContent({
};
}, [disableCopy]);
useEffect(() => {
setActiveHostKind(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
resetHostObservability(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
}, [requestedHostKind, resetHostObservability]);
useEffect(() => {
const tableId = (openTableId ?? "").trim();
if (!tableId) return;
@@ -264,12 +353,8 @@ export function DocumentContent({
}, [documentId, editorBridge, openTableId, router]);
useEffect(() => {
setPageTitle(title ?? "无标题");
}, [title]);
useEffect(() => {
setServerPageSubtreeTitle(title ?? "无标题");
}, [title]);
setServerPageSubtreeTitle(committedPageTitle);
}, [committedPageTitle]);
useEffect(() => {
setServerPageSubtreeSnapshot(initialPageSubtree);
@@ -393,7 +478,7 @@ export function DocumentContent({
typeof payload.pageSubtree?.rootNode.metadata.title === "string" &&
payload.pageSubtree.rootNode.metadata.title.trim()
? payload.pageSubtree.rootNode.metadata.title
: title ?? "无标题",
: committedPageTitle,
);
} catch (error) {
if (canceled) return;
@@ -421,19 +506,36 @@ export function DocumentContent({
contentLoadingTimerRef.current = null;
}
};
}, [documentId, initialContent, contentReloadKey, title, workspaceId]);
}, [committedPageTitle, contentReloadKey, documentId, initialContent, workspaceId]);
const persistTitle = useCallback(
async (nextTitle: string) => {
if (readOnly) return;
const payload = nextTitle.trim() || "无标题";
try {
await renameDocumentCommand({ documentId, workspaceId, title: payload });
const payload = await persistPageTitleAndNotifyDocumentsChanged({
documentId,
workspaceId,
title: nextTitle,
persistTitleCommand: updatePageTitleCommand,
notifyDocumentsChanged: emitDocumentsChanged,
});
commitPersistedTitle(payload);
setServerPageSubtreeTitle(payload);
} catch (error) {
console.error("更新页面标题失败", error);
}
},
[documentId, readOnly, workspaceId],
[commitPersistedTitle, documentId, readOnly, workspaceId],
);
const handleAiPageHeadTitleChange = useCallback(
(nextTitle: string) => {
setPageTitleDraft(nextTitle);
commitPersistedTitle(nextTitle);
setServerPageSubtreeTitle(nextTitle);
emitDocumentsChanged(documentId);
},
[commitPersistedTitle, documentId, setPageTitleDraft],
);
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
@@ -443,7 +545,7 @@ export function DocumentContent({
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
if (!canEditDocument) return;
const value = event.target.value;
setPageTitle(value);
setPageTitleDraft(value);
debouncedPersistTitle(value);
};
@@ -463,15 +565,11 @@ export function DocumentContent({
async (patch: Partial<PageOptionsState>) => {
if (readOnly) return;
try {
const response = await fetch("/api/documents/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, workspaceId, options: patch }),
await updatePageOptionsCommand({
documentId,
workspaceId,
pageOptions: patch,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
console.error(payload?.error ?? "更新页面选项失败");
}
} catch (error) {
console.error(error);
}
@@ -742,10 +840,10 @@ export function DocumentContent({
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${title ?? "未命名页面"}-${new Date().toISOString()}.json`;
anchor.download = `${pageTitle ?? "未命名页面"}-${new Date().toISOString()}.json`;
anchor.click();
URL.revokeObjectURL(url);
}, [disableDownload, history, title]);
}, [disableDownload, history, pageTitle]);
const pageRootClass = cn(
"flex h-full overflow-hidden bg-wolai-bg",
@@ -784,6 +882,16 @@ export function DocumentContent({
})),
[pageSubtree],
);
const getLatestBlocks = useCallback(() => latestBlocksRef.current, []);
const getLatestPageSubtree = useCallback(() => pageSubtree, [pageSubtree]);
const getLatestPersistedMeta = useCallback(
() => ({
workspaceId,
revision: contentRevision,
conflictDetectionKey,
}),
[conflictDetectionKey, contentRevision, workspaceId],
);
const inspectorCanUseEditorBridge = canEditDocument && isEditing;
const jumpToHeading = useCallback((headingId: string) => {
@@ -861,6 +969,68 @@ export function DocumentContent({
editorBridge.replaceWithSnapshot(pendingSnapshot.blocks);
setHistoryOpen(false);
}, [editorBridge, isEditing]);
const handleHostEvent = useCallback((event: EditorHostEvent) => {
setHostEventAt(event.at);
if (event.kind === "status_changed") {
setHostStatus(event.status);
return;
}
if (event.kind === "runtime_load_failed") {
setHostRuntimeLoadFailure(event.message);
return;
}
if (event.kind === "host_init_failed") {
setHostInitFailure(event.message);
return;
}
if (event.kind === "command_failed") {
setHostCommandFailure(event.message);
return;
}
if (event.kind === "save_failed") {
setHostSaveFailure(event.message);
return;
}
}, []);
const hostObservability = useMemo(
() => ({
requestedHostKind,
activeHostKind,
status: hostStatus,
runtimeLoadFailed: hostRuntimeLoadFailure,
hostInitFailed: hostInitFailure,
commandFailed: hostCommandFailure,
saveFailed: hostSaveFailure,
fallbackCount: hostFallbackCount,
lastFallbackReason,
lastFallbackAt,
lastEventAt: hostEventAt,
fallbackTimestamps: fallbackTriggerHistoryRef.current,
}),
[
activeHostKind,
hostEventAt,
hostFallbackCount,
hostInitFailure,
hostCommandFailure,
hostRuntimeLoadFailure,
hostSaveFailure,
hostStatus,
lastFallbackAt,
lastFallbackReason,
requestedHostKind,
],
);
const activeHostFailureMessage =
hostRuntimeLoadFailure ?? hostInitFailure ?? hostCommandFailure ?? hostSaveFailure;
const showFallbackBanner =
requestedHostKind !== "blocknote" && activeHostKind === "blocknote" && lastFallbackReason != null;
const showFailureBanner =
requestedHostKind !== "blocknote" &&
activeHostKind !== "blocknote" &&
activeHostFailureMessage != null;
return (
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
<div className={pageRootClass} ref={pageRootRef}>
@@ -935,15 +1105,55 @@ export function DocumentContent({
</div>
) : (
<div className="relative">
{showFallbackBanner ? (
<div className="mb-4 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<div>
<div className="font-medium">
island 退 BlockNote
</div>
<div className="mt-1 text-xs text-amber-700">
{lastFallbackReason}
{lastFallbackAt ? `,时间:${lastFallbackAt}` : ""}
</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setActiveHostKind(requestedHostKind);
resetHostObservability(requestedHostKind);
}}
>
island
</Button>
</div>
) : null}
{showFailureBanner ? (
<div className="mb-4 flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<div>
<div className="font-medium">island </div>
<div className="mt-1 text-xs text-red-600">{activeHostFailureMessage}</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => requestFallbackToBlockNote("explicit_fallback", activeHostFailureMessage)}
>
BlockNote
</Button>
</div>
) : null}
{keepEditorMounted && (
<div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}>
{shouldUseExperimentalHost ? (
{activeHostKind !== "blocknote" ? (
<EditorHost
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
title={title}
hostKind={editorHostKind}
title={pageTitle}
hostKind={activeHostKind}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
@@ -955,13 +1165,17 @@ export function DocumentContent({
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
onHostEvent={handleHostEvent}
onRequestFallback={(payload) => {
requestFallbackToBlockNote(payload.reason, payload.error);
}}
/>
) : (
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
title={title}
title={pageTitle}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
@@ -977,6 +1191,21 @@ export function DocumentContent({
)}
</div>
)}
<div
className="sr-only"
data-editor-host-observability={JSON.stringify(hostObservability)}
data-editor-host-active={hostObservability.activeHostKind}
data-editor-host-requested={hostObservability.requestedHostKind}
data-editor-host-status={hostObservability.status}
data-editor-host-runtime-load-failed={
hostObservability.runtimeLoadFailed ? "1" : "0"
}
data-editor-host-init-failed={hostObservability.hostInitFailed ? "1" : "0"}
data-editor-host-command-failed={hostObservability.commandFailed ? "1" : "0"}
data-editor-host-save-failed={hostObservability.saveFailed ? "1" : "0"}
data-editor-host-fallback-count={String(hostObservability.fallbackCount)}
data-editor-host-last-fallback-reason={hostObservability.lastFallbackReason ?? ""}
/>
{!isEditing && (
<div className="relative" ref={readViewRootRef}>
<DocumentReadView
@@ -1037,8 +1266,14 @@ export function DocumentContent({
<DocumentCommentsDrawer />
<DocumentAiAgentPanel
documentId={documentId}
getLatestBlocks={() => latestBlocksRef.current}
getLatestPageSubtree={() => pageSubtree}
getLatestBlocks={getLatestBlocks}
getLatestPageSubtree={getLatestPageSubtree}
getLatestPersistedMeta={getLatestPersistedMeta}
onPersistedMetaChange={(meta) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
onPageHeadTitleChange={handleAiPageHeadTitleChange}
/>
</ImagePickerProvider>
);