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
@@ -0,0 +1,177 @@
import { describe, expect, it, vi } from "vitest";
import type { Json } from "@/types/supabase";
import {
applyDocWriteToolResultToPageBody,
buildAiAgentSessionsStoragePayload,
extractCurrentPageTitleFromSlashToolResult,
shouldSyncActiveSessionSnapshot,
} from "./DocumentAiAgentPanel.runtime";
type TestSession = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messages: Array<{ role: "user" | "assistant"; content: string }>;
toolLogs: Array<{ type: string; id?: string; tool?: string; ok?: boolean; ms?: number; result?: unknown }>;
codexSessionId?: string | null;
codexMode?: "chat" | "test" | "dev" | null;
};
function buildSession(overrides: Partial<TestSession> = {}): TestSession {
return {
id: "session-1",
title: "新会话",
createdAt: 1,
updatedAt: 2,
messages: [{ role: "assistant", content: "欢迎语" }],
toolLogs: [],
codexSessionId: null,
codexMode: null,
...overrides,
};
}
describe("DocumentAiAgentPanel.runtime 会话回写保护", () => {
it("当前会话快照与本地消息和日志一致时,不应触发回写", () => {
const session = buildSession();
expect(shouldSyncActiveSessionSnapshot(session, session.messages, session.toolLogs)).toBe(false);
});
it("当前会话快照与本地消息或日志不同时时,才应触发回写", () => {
const session = buildSession({
messages: [{ role: "assistant", content: "旧内容" }],
toolLogs: [{ type: "info", message: "old" }],
});
expect(
shouldSyncActiveSessionSnapshot(
session,
[{ role: "assistant", content: "新内容" }],
[{ type: "info", message: "new" }],
),
).toBe(true);
});
it("持久化 payload 应稳定包含标准化后的会话列表", () => {
const payload = buildAiAgentSessionsStoragePayload("session-1", [
buildSession({
updatedAt: 999,
messages: [{ role: "assistant", content: "欢迎语" }],
}),
]);
const parsed = JSON.parse(payload) as {
activeSessionId: string;
sessions: Array<{ id: string; title: string; createdAt: number; updatedAt: number; messages: Json; toolLogs: Json }>;
};
expect(parsed.activeSessionId).toBe("session-1");
expect(parsed.sessions).toHaveLength(1);
expect(parsed.sessions[0]?.id).toBe("session-1");
expect(parsed.sessions[0]?.messages).toEqual([{ role: "assistant", content: "欢迎语" }]);
});
});
describe("applyDocWriteToolResultToPageBody", () => {
it("doc 写工具成功时应按最新持久化元信息执行 page body save", async () => {
const applyPageBodyCommandImpl = vi.fn(async () => ({
revision: 6,
conflictDetectionKey: "doc-1:6",
}));
const applyEditorSnapshot = vi.fn();
const onPersistedMetaChange = vi.fn();
const applied = await applyDocWriteToolResultToPageBody({
tool: "doc_insert_blocks",
ok: true,
result: {
data: [{ id: "block_1", type: "paragraph", content: "AI 正文" }],
},
documentId: "doc-1",
getLatestPersistedMeta: () => ({
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
}),
applyEditorSnapshot,
onPersistedMetaChange,
applyPageBodyCommandImpl,
});
expect(applied).toBe(true);
expect(applyPageBodyCommandImpl).toHaveBeenCalledWith({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
blocks: [{ id: "block_1", type: "paragraph", content: "AI 正文" }],
applyEditorSnapshot,
});
expect(onPersistedMetaChange).toHaveBeenCalledWith({
revision: 6,
conflictDetectionKey: "doc-1:6",
});
});
it("非 doc 写工具或无 data 时应直接忽略", async () => {
const applyPageBodyCommandImpl = vi.fn();
await expect(
applyDocWriteToolResultToPageBody({
tool: "docs_read",
ok: true,
result: { data: [] },
documentId: "doc-1",
getLatestPersistedMeta: () => ({
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
}),
applyEditorSnapshot: vi.fn(),
onPersistedMetaChange: vi.fn(),
applyPageBodyCommandImpl,
}),
).resolves.toBe(false);
expect(applyPageBodyCommandImpl).not.toHaveBeenCalled();
});
});
describe("extractCurrentPageTitleFromSlashToolResult", () => {
it("slash_run 成功重命名当前页时应返回新标题", () => {
expect(
extractCurrentPageTitleFromSlashToolResult({
tool: "slash_run",
ok: true,
documentId: "doc-1",
result: {
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 新标题",
},
},
},
}),
).toBe("AI 新标题");
});
it("非当前页或非 rename 结果时应忽略", () => {
expect(
extractCurrentPageTitleFromSlashToolResult({
tool: "slash_run",
ok: true,
documentId: "doc-1",
result: {
parsed: {
command: "rename_doc",
params: {
documentId: "doc-2",
title: "别的页面",
},
},
},
}),
).toBeNull();
});
});
@@ -15,6 +15,12 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import {
applyPageBodyCommand,
type ApplyPageBodyCommandInput,
type PageBodyPersistedMeta,
type PageBodyPersistedState,
} from "@/lib/documents/page-body-command";
type AgentMessage = { role: "user" | "assistant"; content: string };
type CodexMode = "chat" | "test" | "dev";
@@ -113,6 +119,110 @@ const normalizeSessions = (sessions: ChatSession[]) => {
.sort((a, b) => b.updatedAt - a.updatedAt);
};
function buildSessionSnapshotKey(input: {
messages: ChatSession["messages"];
toolLogs: ChatSession["toolLogs"];
}): string {
return JSON.stringify({
messages: input.messages,
toolLogs: input.toolLogs,
});
}
export function shouldSyncActiveSessionSnapshot(
session: Pick<ChatSession, "messages" | "toolLogs"> | null | undefined,
messages: ChatSession["messages"],
toolLogs: ChatSession["toolLogs"],
): boolean {
if (!session) {
return true;
}
return (
buildSessionSnapshotKey({
messages: session.messages,
toolLogs: session.toolLogs,
}) !==
buildSessionSnapshotKey({
messages,
toolLogs,
})
);
}
export function buildAiAgentSessionsStoragePayload(
activeSessionId: string,
sessions: ChatSession[],
): string {
return JSON.stringify({
activeSessionId,
sessions: normalizeSessions(sessions),
});
}
export async function applyDocWriteToolResultToPageBody(input: {
tool: string;
ok: boolean;
result: unknown;
documentId: string;
getLatestPersistedMeta: () => PageBodyPersistedState;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
applyPageBodyCommandImpl?: (input: ApplyPageBodyCommandInput) => Promise<PageBodyPersistedMeta>;
}): Promise<boolean> {
if (!input.ok || (input.tool !== "doc_insert_blocks" && input.tool !== "doc_replace_range")) {
return false;
}
const resultRecord =
input.result && typeof input.result === "object" ? (input.result as Record<string, unknown>) : null;
const dataNode = resultRecord && "data" in resultRecord ? resultRecord.data : null;
if (dataNode == null) {
return false;
}
const latestPersistedMeta = input.getLatestPersistedMeta();
const applyPageBodyCommandImpl = input.applyPageBodyCommandImpl ?? applyPageBodyCommand;
const persistedMeta = await applyPageBodyCommandImpl({
documentId: input.documentId,
workspaceId: latestPersistedMeta.workspaceId,
revision: latestPersistedMeta.revision,
conflictDetectionKey: latestPersistedMeta.conflictDetectionKey,
blocks: dataNode as Json,
applyEditorSnapshot: input.applyEditorSnapshot,
});
input.onPersistedMetaChange?.(persistedMeta);
return true;
}
export function extractCurrentPageTitleFromSlashToolResult(input: {
tool: string;
ok: boolean;
result: unknown;
documentId: string;
}): string | null {
if (!input.ok || input.tool !== "slash_run") {
return null;
}
const resultRecord =
input.result && typeof input.result === "object" ? (input.result as Record<string, unknown>) : null;
const parsed =
resultRecord?.parsed && typeof resultRecord.parsed === "object"
? (resultRecord.parsed as Record<string, unknown>)
: null;
if (!parsed || String(parsed.command ?? "") !== "rename_doc") {
return null;
}
const params =
parsed.params && typeof parsed.params === "object" ? (parsed.params as Record<string, unknown>) : null;
if (!params || String(params.documentId ?? "") !== input.documentId) {
return null;
}
const title = String(params.title ?? "").trim();
return title || null;
}
const safeJsonStringify = (value: unknown) => {
try {
return JSON.stringify(value);
@@ -125,10 +235,16 @@ export function DocumentAiAgentPanelRuntime({
documentId,
getLatestBlocks,
getLatestPageSubtree,
getLatestPersistedMeta,
onPersistedMetaChange,
onPageHeadTitleChange,
}: {
documentId: string;
getLatestBlocks: () => Json | null;
getLatestPageSubtree: () => PageSubtreeProjection | null;
getLatestPersistedMeta: () => PageBodyPersistedState;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
onPageHeadTitleChange?: (title: string) => void;
}) {
const editorBridge = useEditorBridgeStore((s) => s.bridge);
@@ -158,6 +274,7 @@ export function DocumentAiAgentPanelRuntime({
const abortRef = useRef<AbortController | null>(null);
const syncTimerRef = useRef<number | null>(null);
const hydratedSessionsRef = useRef(false);
useEffect(() => {
if (flightMode) {
@@ -207,6 +324,7 @@ export function DocumentAiAgentPanelRuntime({
setActiveSessionId(id);
setMessages(session.messages);
setToolLogs([]);
hydratedSessionsRef.current = true;
return;
}
@@ -237,8 +355,10 @@ export function DocumentAiAgentPanelRuntime({
const cur = loaded.find((s) => s.id === picked) ?? loaded[0]!;
setMessages(cur.messages?.length ? cur.messages : DEFAULT_SESSION_MESSAGES);
setToolLogs(cur.toolLogs ?? []);
hydratedSessionsRef.current = true;
} catch {
// ignore
hydratedSessionsRef.current = true;
}
// 只在 documentId 变化时读取一次
@@ -246,9 +366,14 @@ export function DocumentAiAgentPanelRuntime({
useEffect(() => {
if (!activeSessionId) return;
if (!hydratedSessionsRef.current) return;
if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
syncTimerRef.current = window.setTimeout(() => {
setSessions((prev) => {
const current = prev.find((s) => s.id === activeSessionId) ?? null;
if (shouldSyncActiveSessionSnapshot(current, messages, toolLogs) === false) {
return prev;
}
const now = Date.now();
const next = prev.some((s) => s.id === activeSessionId)
? prev.map((s) =>
@@ -277,9 +402,10 @@ export function DocumentAiAgentPanelRuntime({
useEffect(() => {
if (!documentId) return;
if (!hydratedSessionsRef.current) return;
try {
const key = `doc_ai_sessions:${documentId}`;
const payload = JSON.stringify({ activeSessionId, sessions: normalizeSessions(sessions) });
const payload = buildAiAgentSessionsStoragePayload(activeSessionId, sessions);
if (payload.length <= 900_000) window.localStorage.setItem(key, payload);
} catch {
// ignore
@@ -582,19 +708,32 @@ export function DocumentAiAgentPanelRuntime({
{ type: "tool_result", id: String(obj.id ?? ""), tool, ok: Boolean(obj.ok), ms: Number(obj.ms ?? 0), result },
]);
// doc 写工具返回 data=blocks 时,立即落入编辑器
if ((tool === "doc_insert_blocks" || tool === "doc_replace_range") && obj.ok) {
const r = result as unknown;
const dataNode =
typeof r === "object" && r && "data" in (r as Record<string, unknown>) ? (r as Record<string, unknown>).data : null;
if (dataNode && editorBridge?.replaceWithSnapshot) {
try {
editorBridge.replaceWithSnapshot(dataNode as Json);
} catch {
// ignore
}
}
const nextPageTitle = extractCurrentPageTitleFromSlashToolResult({
tool,
ok: Boolean(obj.ok),
result,
documentId,
});
if (nextPageTitle) {
onPageHeadTitleChange?.(nextPageTitle);
}
void applyDocWriteToolResultToPageBody({
tool,
ok: Boolean(obj.ok),
result,
documentId,
getLatestPersistedMeta,
applyEditorSnapshot: editorBridge?.replaceWithSnapshot
? (blocks) => {
editorBridge.replaceWithSnapshot(blocks);
}
: undefined,
onPersistedMetaChange,
}).catch((error) => {
const message = error instanceof Error ? error.message : "AI 页面正文保存失败";
setToolLogs((prev) => [...prev, { type: "error", message }]);
});
} catch {
// ignore
}
@@ -3,6 +3,7 @@
import dynamic from "next/dynamic";
import { useEffect } from "react";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { PageBodyPersistedMeta, PageBodyPersistedState } from "@/lib/documents/page-body-command";
import type { Json } from "@/types/supabase";
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
@@ -10,6 +11,9 @@ type DocumentAiAgentPanelProps = {
documentId: string;
getLatestBlocks: () => Json | null;
getLatestPageSubtree: () => PageSubtreeProjection | null;
getLatestPersistedMeta: () => PageBodyPersistedState;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
onPageHeadTitleChange?: (title: string) => void;
};
const DocumentAiAgentPanelRuntime = dynamic<DocumentAiAgentPanelProps>(
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";
import { persistPageTitleAndNotifyDocumentsChanged } from "@/components/editor/document-content";
describe("persistPageTitleAndNotifyDocumentsChanged", () => {
it("标题提交成功后应广播 documents-changed", async () => {
const persistTitleCommand = vi.fn(async () => ({ ok: true as const }));
const emitDocumentsChanged = vi.fn();
const result = await persistPageTitleAndNotifyDocumentsChanged({
documentId: "doc-1",
workspaceId: "ws-1",
title: " 新标题 ",
persistTitleCommand,
notifyDocumentsChanged: emitDocumentsChanged,
});
expect(result).toBe("新标题");
expect(persistTitleCommand).toHaveBeenCalledWith({
documentId: "doc-1",
workspaceId: "ws-1",
title: "新标题",
});
expect(emitDocumentsChanged).toHaveBeenCalledWith("doc-1");
});
});
@@ -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>
);
@@ -3,6 +3,8 @@
import type { DocumentContentProps } from "@/components/editor/document-content";
import { DocumentContent } from "@/components/editor/document-content";
export function DocumentShell(props: DocumentContentProps) {
export interface DocumentShellProps extends DocumentContentProps {}
export function DocumentShell(props: DocumentShellProps) {
return <DocumentContent {...props} />;
}
@@ -0,0 +1,37 @@
import {
DEFAULT_EDITOR_HOST_KIND,
normalizeEditorHostKind,
resolveEditorHostKind,
} from "@/components/editor/editor-host-config";
import { describe, expect, it } from "vitest";
describe("editor-host-config", () => {
it("保持 island 为默认正式 host", () => {
expect(DEFAULT_EDITOR_HOST_KIND).toBe("leptos_tiptap_island");
});
it("将 inline 和旧 runtime 兼容别名收敛到正式 island host", () => {
expect(normalizeEditorHostKind("leptos_tiptap_inline")).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("leptos_tiptap_runtime")).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("leptos_tiptap")).toBe("leptos_tiptap_island");
});
it("保留 iframe debug 作为显式调试 host", () => {
expect(normalizeEditorHostKind("leptos_tiptap_iframe_debug")).toBe(
"leptos_tiptap_iframe_debug",
);
expect(normalizeEditorHostKind("iframe_debug")).toBe("leptos_tiptap_iframe_debug");
expect(normalizeEditorHostKind("leptos_tiptap_debug")).toBe(
"leptos_tiptap_iframe_debug",
);
});
it("优先尊重 query override 的 debug host 选择", () => {
expect(
resolveEditorHostKind({
override: "leptos_tiptap_iframe_debug",
runtimeDefault: "leptos_tiptap_island",
}),
).toBe("leptos_tiptap_iframe_debug");
});
});
@@ -1,23 +1,59 @@
export type EditorHostKind =
| "blocknote"
| "leptos_tiptap_inline"
| "leptos_tiptap_island"
| "leptos_tiptap_iframe_debug";
export interface EditorHostConfig {
kind: EditorHostKind;
}
const DEFAULT_EDITOR_HOST_KIND: EditorHostKind = "blocknote";
export const DEFAULT_EDITOR_HOST_KIND: EditorHostKind = "leptos_tiptap_island";
export function normalizeEditorHostKind(value: unknown): EditorHostKind {
export function normalizeEditorHostKind(
value: unknown,
fallback: EditorHostKind = DEFAULT_EDITOR_HOST_KIND,
): EditorHostKind {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "leptos_tiptap_inline") {
return "leptos_tiptap_inline";
if (normalized === "blocknote") {
return "blocknote";
}
if (normalized === "leptos_tiptap_iframe_debug" || normalized === "leptos_tiptap") {
if (
normalized === "leptos_tiptap_runtime" ||
normalized === "leptos_tiptap_inline" ||
normalized === "leptos_tiptap"
) {
return "leptos_tiptap_island";
}
// 说明:iframe 只保留给显式调试桥,不再混入正式 runtime 主链。
if (
normalized === "leptos_tiptap_iframe_debug" ||
normalized === "leptos_tiptap_debug" ||
normalized === "iframe_debug"
) {
return "leptos_tiptap_iframe_debug";
}
return DEFAULT_EDITOR_HOST_KIND;
// 说明:保留历史 query 值兼容;未显式声明 debug 时,一律回到正式 runtime host。
return fallback;
}
export function resolveEditorHostKind(input: {
override?: unknown;
runtimeDefault?: unknown;
}): EditorHostKind {
const runtimeDefault = normalizeEditorHostKind(
input.runtimeDefault,
DEFAULT_EDITOR_HOST_KIND,
);
const hasOverride =
typeof input.override === "string" && input.override.trim().length > 0;
if (hasOverride) {
return normalizeEditorHostKind(input.override, runtimeDefault);
}
return runtimeDefault;
}
export function isLeptosTiptapHostKind(kind: EditorHostKind): boolean {
return kind !== "blocknote";
}
export function getEditorHostKindFromEnv(value?: unknown): EditorHostKind {
@@ -22,10 +22,44 @@ export interface DocumentEditorHostProps {
revision: number | null;
conflictDetectionKey: string | null;
}) => void;
onHostEvent?: (event: EditorHostEvent) => void;
onRequestFallback?: (payload: EditorHostFallbackRequest) => void;
}
export type BlockNoteEditorProps = DocumentEditorHostProps;
export type EditorHostFallbackReason =
| "runtime_load_failed"
| "host_init_failed"
| "command_failed"
| "save_failed"
| "explicit_fallback";
export type EditorHostFallbackRequest = {
reason: EditorHostFallbackReason;
error?: string | null;
at: string;
};
export type EditorHostEvent =
| {
kind: "status_changed";
status: string;
at: string;
message?: string | null;
}
| {
kind: "runtime_load_failed" | "host_init_failed" | "command_failed" | "save_failed";
at: string;
message: string;
}
| {
kind: "fallback_triggered";
at: string;
reason: EditorHostFallbackReason;
message?: string | null;
};
export type LeptosTiptapHostBridgeState = {
ready: boolean;
runtimeName: string;
@@ -52,3 +86,8 @@ export type LeptosTiptapHostBridgeEventDetail = {
error?: string | null;
at?: string;
};
export type LeptosTiptapRuntimePageOptions = Pick<
PageOptionsState,
"wideLayout" | "smallText" | "layoutDensity" | "showHeadingNumbers" | "embedDefaultBlockId"
>;
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_EDITOR_HOST_KIND,
normalizeEditorHostKind,
} from "@/components/editor/editor-host-config";
describe("editor-host", () => {
it("默认正式主链是 island host", () => {
expect(DEFAULT_EDITOR_HOST_KIND).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("leptos_tiptap_runtime")).toBe("leptos_tiptap_island");
});
});
@@ -4,6 +4,19 @@ import dynamic from "next/dynamic";
import type { ComponentType } from "react";
import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types";
const LeptosTiptapIslandEditor = dynamic(
() =>
import("@/components/editor/leptos-tiptap-island-editor-host").then(
(mod) => mod.LeptosTiptapIslandEditorHost,
),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
) as ComponentType<DocumentEditorHostProps>;
const LeptosTiptapIframeDebugEditor = dynamic(
() => import("@/components/editor/leptos-tiptap-editor-host").then((mod) => mod.LeptosTiptapEditorHost),
{
@@ -14,22 +27,9 @@ const LeptosTiptapIframeDebugEditor = dynamic(
},
) as ComponentType<DocumentEditorHostProps>;
const LeptosTiptapInlineEditor = dynamic(
() =>
import("@/components/editor/leptos-tiptap-inline-editor-host").then(
(mod) => mod.LeptosTiptapInlineEditorHost,
),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
) as ComponentType<DocumentEditorHostProps>;
export function EditorHost(props: DocumentEditorHostProps) {
if (props.hostKind === "leptos_tiptap_inline") {
return <LeptosTiptapInlineEditor {...props} />;
if (props.hostKind === "leptos_tiptap_iframe_debug") {
return <LeptosTiptapIframeDebugEditor {...props} />;
}
return <LeptosTiptapIframeDebugEditor {...props} />;
return <LeptosTiptapIslandEditor {...props} />;
}
@@ -193,6 +193,9 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
const revisionRef = useRef<number | null>(props.initialRevision ?? null);
const conflictDetectionKeyRef = useRef<string | null>(props.initialConflictDetectionKey ?? null);
const bootstrapPayloadRef = useRef<BootstrapPayload | null>(null);
const runtimeDocRef = useRef<Json>(
normalizeRuntimeDoc(tiptapDocFromBlocks(props.initialContent as Json) as Json) as Json,
);
const [runtimeDoc, setRuntimeDoc] = useState<Json>(() => tiptapDocFromBlocks(props.initialContent as Json) as Json);
const [reloadKey, setReloadKey] = useState(0);
const [iframeHeight, setIframeHeight] = useState(FALLBACK_IFRAME_MIN_HEIGHT);
@@ -222,6 +225,10 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
conflictDetectionKeyRef.current = props.initialConflictDetectionKey ?? null;
}, [props.initialConflictDetectionKey, props.initialRevision, props.onPersistedMetaChange, props.onSnapshot, props.onStatsChange]);
useEffect(() => {
runtimeDocRef.current = runtimeDoc;
}, [runtimeDoc]);
useEffect(() => {
const nextRuntimeDoc = tiptapDocFromBlocks(props.initialContent as Json) as Json;
setRuntimeDoc(nextRuntimeDoc);
@@ -246,15 +253,6 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
}));
}, [props.documentId, props.initialContent, props.initialConflictDetectionKey, props.initialRevision, props.readOnly, props.title, props.workspaceId]);
useEffect(() => {
const iframeWindow = iframeRef.current?.contentWindow;
if (!iframeWindow) return;
const payload = bootstrapPayloadRef.current;
if (!payload) return;
postBridgeMessage(iframeWindow, REPLACE_DOCUMENT_EVENT, payload);
setBridgeState((prev) => ({ ...prev, status: prev.ready ? "reloading" : prev.status }));
}, [runtimeDoc]);
useEffect(() => {
const bridge: EditorReferenceBridge = {
insertInlineReference: () => ({ blockId: null }),
@@ -304,7 +302,9 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
const publishSnapshot = (payload: HostDocumentPayload) => {
const blocks = blocksFromTiptapDoc(payload.content) as Json;
const stats = buildStats(blocks);
setRuntimeDoc(normalizeRuntimeDoc(payload.content as Json) as Json);
const nextRuntimeDoc = normalizeRuntimeDoc(payload.content as Json) as Json;
runtimeDocRef.current = nextRuntimeDoc;
setRuntimeDoc(nextRuntimeDoc);
onSnapshotRef.current?.({ blocks, stats });
onStatsChangeRef.current?.(stats);
return { blocks, stats };
@@ -423,14 +423,14 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
data-editor-host-kind="leptos_tiptap_iframe_debug"
>
<div className="flex items-center justify-between border-b border-amber-200 px-3 py-2 text-xs text-amber-800">
<span>`iframe + postMessage` prototype</span>
<span>`leptos-tiptap` iframe debug host</span>
<span>{bridgeState.status}</span>
</div>
<iframe
key={`${props.documentId}:${reloadKey}`}
ref={iframeRef}
src={iframeSrc}
title="Leptos Tiptap Editor Debug Bridge"
title="Leptos Tiptap Main Editor Runtime"
className="w-full border-0"
style={{ height: `${iframeHeight}px`, minHeight: `${FALLBACK_IFRAME_MIN_HEIGHT}px` }}
data-bridge-status={bridgeState.status}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,553 @@
import { act, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { LeptosTiptapIslandEditorHost } from "@/components/editor/leptos-tiptap-island-editor-host";
import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types";
const runtimeModuleUrl = `data:text/javascript;base64,${Buffer.from(`
const mounts = new Map();
export default async function init() {
globalThis.__MNOTE_ISLAND_TEST_STATE__.initCalls += 1;
}
export function mount(container, options) {
const state = globalThis.__MNOTE_ISLAND_TEST_STATE__;
const mountId = state.nextMountId++;
const surface = document.createElement("div");
surface.className = "editor-surface";
const editor = document.createElement("div");
editor.className = "ProseMirror";
editor.setAttribute("contenteditable", "true");
editor.textContent = options?.title ?? "untitled";
surface.appendChild(editor);
container.replaceChildren(surface);
container.addEventListener("mnote:leptos-tiptap-spike:command", (event) => {
const detail = event?.detail ?? {};
state.commandCalls.push({
command: detail?.payload?.command ?? null,
payload: detail?.payload ?? null,
});
});
mounts.set(mountId, container);
state.mountCalls.push({
mountId,
title: options?.title ?? null,
pageOptions: options?.pageOptions ?? null,
});
return mountId;
}
export function unmount(mountId) {
const state = globalThis.__MNOTE_ISLAND_TEST_STATE__;
const container = mounts.get(mountId);
if (container) {
container.replaceChildren();
mounts.delete(mountId);
}
state.unmountCalls.push(mountId);
}
`).toString("base64")}`;
vi.mock("@/components/editor/leptos-tiptap-island-loader", () => ({
loadLeptosTiptapIslandAssets: vi.fn(async () => ({
manifest: null,
entryAssetUrl: runtimeModuleUrl,
wasmAssetUrl: null,
})),
}));
type RuntimeTestState = {
initCalls: number;
nextMountId: number;
mountCalls: Array<{ mountId: number; title: string | null; pageOptions?: unknown }>;
unmountCalls: number[];
commandCalls: Array<{ command: string | null; payload?: unknown }>;
};
declare global {
interface Window {
__MNOTE_ISLAND_TEST_STATE__?: RuntimeTestState;
}
}
function flushEffects() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function buildProps(overrides: Partial<DocumentEditorHostProps> = {}): DocumentEditorHostProps {
return {
documentId: "doc-1",
workspaceId: "ws-1",
initialContent: [],
title: "标题 A",
hostKind: "leptos_tiptap_island",
initialRevision: 1,
initialConflictDetectionKey: "doc-1:1",
pageOptions: {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
},
readOnly: false,
onStatsChange: vi.fn(),
onSnapshot: vi.fn(),
onCloseToc: vi.fn(),
onPersistedMetaChange: vi.fn(),
onHostEvent: vi.fn(),
onRequestFallback: vi.fn(),
...overrides,
};
}
function HostEchoHarness({
initialContent,
onSnapshotSpy,
}: {
initialContent: DocumentEditorHostProps["initialContent"];
onSnapshotSpy?: ReturnType<typeof vi.fn>;
}) {
const [content, setContent] = useState(initialContent);
return (
<LeptosTiptapIslandEditorHost
{...buildProps({
initialContent: content,
onSnapshot: (payload) => {
onSnapshotSpy?.(payload);
setContent(payload.blocks);
},
})}
/>
);
}
describe("leptos-tiptap-island-editor-host", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
window.__MNOTE_ISLAND_TEST_STATE__ = {
initCalls: 0,
nextMountId: 1,
mountCalls: [],
unmountCalls: [],
commandCalls: [],
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => {
root.unmount();
await flushEffects();
});
container.remove();
delete window.__MNOTE_ISLAND_TEST_STATE__;
});
it("同一文档仅标题变化时不应重复卸载并重挂 island", async () => {
const initialProps = buildProps();
await act(async () => {
root.render(<LeptosTiptapIslandEditorHost {...initialProps} />);
await flushEffects();
await flushEffects();
});
expect(window.__MNOTE_ISLAND_TEST_STATE__?.mountCalls).toHaveLength(1);
expect(window.__MNOTE_ISLAND_TEST_STATE__?.unmountCalls).toHaveLength(0);
expect(
container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]',
),
).not.toBeNull();
await act(async () => {
root.render(
<LeptosTiptapIslandEditorHost
{...buildProps({
title: "标题 B",
})}
/>,
);
await flushEffects();
await flushEffects();
});
expect(window.__MNOTE_ISLAND_TEST_STATE__?.mountCalls).toHaveLength(1);
expect(window.__MNOTE_ISLAND_TEST_STATE__?.unmountCalls).toHaveLength(0);
});
it("页面内 island 宿主不应再额外施加横向 padding", async () => {
await act(async () => {
root.render(<LeptosTiptapIslandEditorHost {...buildProps()} />);
await flushEffects();
await flushEffects();
});
const host = container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
) as HTMLDivElement | null;
expect(host).not.toBeNull();
expect(host?.className).toContain("min-h-[720px]");
expect(host?.className).toContain("py-6");
expect(host?.className).not.toContain("px-8");
});
it("mount 时应把 pageOptions 传给 island runtime", async () => {
await act(async () => {
root.render(
<LeptosTiptapIslandEditorHost
{...buildProps({
pageOptions: {
...buildProps().pageOptions,
wideLayout: true,
smallText: true,
layoutDensity: "compact",
showHeadingNumbers: false,
embedDefaultBlockId: "block-anchor-1",
},
})}
/>,
);
await flushEffects();
await flushEffects();
});
expect(window.__MNOTE_ISLAND_TEST_STATE__?.mountCalls[0]?.pageOptions).toEqual({
wideLayout: true,
smallText: true,
layoutDensity: "compact",
showHeadingNumbers: false,
embedDefaultBlockId: "block-anchor-1",
});
});
it("pageOptions 变化时应向 runtime 发送 setPageOptions 命令", async () => {
await act(async () => {
root.render(<LeptosTiptapIslandEditorHost {...buildProps()} />);
await flushEffects();
await flushEffects();
});
await act(async () => {
root.render(
<LeptosTiptapIslandEditorHost
{...buildProps({
pageOptions: {
...buildProps().pageOptions,
wideLayout: true,
layoutDensity: "spacious",
},
})}
/>,
);
await flushEffects();
await flushEffects();
});
const latestPageOptionsCommand = [...(window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls ?? [])]
.reverse()
.find((entry) => entry.command === "setPageOptions");
expect(latestPageOptionsCommand?.payload).toMatchObject({
command: "setPageOptions",
pageOptions: {
wideLayout: true,
smallText: false,
layoutDensity: "spacious",
showHeadingNumbers: true,
embedDefaultBlockId: null,
},
});
});
it("runtime 快照回流为同一内容时不应再次发送 replaceContent", async () => {
const onSnapshot = vi.fn();
const initialProps = buildProps({
initialContent: [
{
id: "block-1",
type: "paragraph",
content: [{ type: "text", text: "hello island" }],
props: {},
children: [],
},
],
onSnapshot,
});
await act(async () => {
root.render(<LeptosTiptapIslandEditorHost {...initialProps} />);
await flushEffects();
await flushEffects();
});
const host = container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
) as HTMLDivElement | null;
expect(host).not.toBeNull();
const initialReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
await act(async () => {
host?.dispatchEvent(
new CustomEvent("mnote:leptos-tiptap-spike:change", {
bubbles: true,
detail: {
protocol: "mnote.leptos_tiptap.bridge.v1",
source: "mnote:leptos-tiptap-spike",
payload: {
title: "标题 A",
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "hello island" }],
},
],
},
meta: {
readOnly: false,
revision: 1,
conflictDetectionKey: "doc-1:1",
},
},
},
}),
);
await flushEffects();
await flushEffects();
});
const nextReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
expect(onSnapshot).toHaveBeenCalled();
expect(nextReplaceCount).toBe(initialReplaceCount);
});
it("空文档初始化后收到等价空段落快照时不应再次发送 replaceContent", async () => {
const onSnapshot = vi.fn();
await act(async () => {
root.render(
<LeptosTiptapIslandEditorHost
{...buildProps({
initialContent: [],
onSnapshot,
})}
/>,
);
await flushEffects();
await flushEffects();
});
const host = container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
) as HTMLDivElement | null;
expect(host).not.toBeNull();
const initialReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
await act(async () => {
host?.dispatchEvent(
new CustomEvent("mnote:leptos-tiptap-spike:change", {
bubbles: true,
detail: {
protocol: "mnote.leptos_tiptap.bridge.v1",
source: "mnote:leptos-tiptap-spike",
payload: {
title: "标题 A",
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [],
},
],
},
meta: {
readOnly: false,
revision: 1,
conflictDetectionKey: "doc-1:1",
},
},
},
}),
);
await flushEffects();
await flushEffects();
await flushEffects();
});
const nextReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
expect(onSnapshot).toHaveBeenCalledTimes(1);
expect(nextReplaceCount).toBe(initialReplaceCount);
});
it("父组件回灌 runtime 等价 blocks 时不应再次发送 replaceContent", async () => {
const onSnapshotSpy = vi.fn();
await act(async () => {
root.render(
<HostEchoHarness
initialContent={[
{
id: "block-1",
type: "paragraph",
content: [{ type: "text", text: "hello island" }],
props: {},
children: [],
},
]}
onSnapshotSpy={onSnapshotSpy}
/>,
);
await flushEffects();
await flushEffects();
});
const host = container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
) as HTMLDivElement | null;
expect(host).not.toBeNull();
const initialReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
await act(async () => {
host?.dispatchEvent(
new CustomEvent("mnote:leptos-tiptap-spike:change", {
bubbles: true,
detail: {
protocol: "mnote.leptos_tiptap.bridge.v1",
source: "mnote:leptos-tiptap-spike",
payload: {
title: "标题 A",
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "hello island" }],
},
],
},
meta: {
readOnly: false,
revision: 1,
conflictDetectionKey: "doc-1:1",
},
},
},
}),
);
await flushEffects();
await flushEffects();
await flushEffects();
});
const nextReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
expect(onSnapshotSpy).toHaveBeenCalledTimes(1);
expect(nextReplaceCount).toBe(initialReplaceCount);
});
it("应兼容 runtime 把 tiptap 文档作为 Map 回传", async () => {
const onSnapshot = vi.fn();
await act(async () => {
root.render(
<LeptosTiptapIslandEditorHost
{...buildProps({
initialContent: [],
onSnapshot,
})}
/>,
);
await flushEffects();
await flushEffects();
});
const host = container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
) as HTMLDivElement | null;
expect(host).not.toBeNull();
await act(async () => {
host?.dispatchEvent(
new CustomEvent("mnote:leptos-tiptap-spike:change", {
bubbles: true,
detail: {
protocol: "mnote.leptos_tiptap.bridge.v1",
source: "mnote:leptos-tiptap-spike",
payload: {
title: "标题 A",
content: new Map([
["type", "doc"],
[
"content",
[
{
type: "paragraph",
content: [{ type: "text", text: "hello from map" }],
},
],
],
]),
meta: {
readOnly: false,
revision: 1,
conflictDetectionKey: "doc-1:1",
},
},
},
}),
);
await flushEffects();
await flushEffects();
});
expect(onSnapshot).toHaveBeenCalledTimes(1);
expect(onSnapshot.mock.calls[0]?.[0]?.blocks).toEqual([
{
id: "block-1",
type: "paragraph",
content: "hello from map",
},
]);
});
});
@@ -0,0 +1,832 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
DocumentEditorHostProps,
EditorHostFallbackReason,
} from "@/components/editor/editor-host-types";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { Json } from "@/types/supabase";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import {
blocksFromTiptapDoc,
editorBlockDocumentFromTiptapDoc,
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import {
loadLeptosTiptapIslandAssets,
} from "@/components/editor/leptos-tiptap-island-loader";
const EVENT_PREFIX = "mnote:leptos-tiptap-spike";
const PROTOCOL = "mnote.leptos_tiptap.bridge.v1";
const READY_EVENT = `${EVENT_PREFIX}:ready`;
const CHANGE_EVENT = `${EVENT_PREFIX}:change`;
const STATE_EVENT = `${EVENT_PREFIX}:state`;
const STATUS_EVENT = `${EVENT_PREFIX}:status`;
const SELECTION_EVENT = `${EVENT_PREFIX}:selection`;
const HEIGHT_EVENT = `${EVENT_PREFIX}:height`;
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
const FALLBACK_MIN_HEIGHT = 720;
const SAVE_DEBOUNCE_MS = 900;
type IslandRuntimeModule = {
default: (input?: RequestInfo | URL | Response | BufferSource | WebAssembly.Module) => Promise<unknown>;
mount: (container: Element, options: unknown) => number;
unmount: (mountId: number) => void;
};
type RuntimeEnvelope<T = unknown> = {
protocol?: string;
runtime?: string;
version?: string;
source?: string;
event?: string;
payload?: T;
};
type ChangePayload = {
documentId?: string | null;
workspaceId?: string | null;
title?: string;
content?: unknown;
meta?: {
dirtyCount?: number;
editorFocused?: boolean;
slashOpen?: boolean;
toolbarOpen?: boolean;
selectedBlockIndex?: number | null;
revision?: number | null;
conflictDetectionKey?: string | null;
readOnly?: boolean;
};
};
type StatePayload = {
title?: string;
dirtyCount?: number;
selectedBlockIndex?: number | null;
editorFocused?: boolean;
slashOpen?: boolean;
toolbarOpen?: boolean;
readOnly?: boolean;
};
type StatusPayload = {
currentBlockId?: string | null;
selectedBlockIndex?: number | null;
};
type SelectionPayload = {
currentBlockId?: string | null;
currentBlockIndex?: number | null;
};
type HeightPayload = {
height?: number;
};
type ErrorPayload = {
message?: string;
};
type RuntimeBridgeState = {
ready: boolean;
runtimeName: string;
runtimeVersion: string;
runtimeUrl: string;
documentId: string;
workspaceId: string;
status: string;
lastChangeAt?: string | null;
lastSaveRequestAt?: string | null;
lastError?: string | null;
};
type IslandBootstrapPayload = {
documentId: string;
workspaceId: string;
title: string | null;
content: unknown;
revision: number | null;
conflictDetectionKey: string | null;
readOnly: boolean;
pageOptions: RuntimePageOptionsPayload;
};
type RuntimePageOptionsPayload = Pick<
PageOptionsState,
"wideLayout" | "smallText" | "layoutDensity" | "showHeadingNumbers" | "embedDefaultBlockId"
>;
function buildRuntimePageOptions(pageOptions: PageOptionsState): RuntimePageOptionsPayload {
return {
wideLayout: pageOptions.wideLayout,
smallText: pageOptions.smallText,
layoutDensity: pageOptions.layoutDensity,
showHeadingNumbers: pageOptions.showHeadingNumbers,
embedDefaultBlockId: pageOptions.embedDefaultBlockId,
};
}
function toIsoNow(): string {
return new Date().toISOString();
}
function flattenText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(flattenText).join("");
if (value && typeof value === "object") {
const record = value as { text?: unknown; content?: unknown };
return `${flattenText(record.text)}${flattenText(record.content)}`;
}
return "";
}
function serializeHostSyncValue(value: unknown): string {
try {
return JSON.stringify(value) ?? "null";
} catch {
return String(value);
}
}
function normalizeRuntimeValue(value: unknown): unknown {
if (value instanceof Map) {
return Object.fromEntries(
Array.from(value.entries()).map(([key, nestedValue]) => [
key,
normalizeRuntimeValue(nestedValue),
]),
);
}
if (Array.isArray(value)) {
return value.map((item) => normalizeRuntimeValue(item));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, nestedValue]) => [
key,
normalizeRuntimeValue(nestedValue),
]),
);
}
return value;
}
function buildHostSyncKey(input: {
documentId: string;
workspaceId: string;
title: string | null;
content: unknown;
readOnly: boolean;
}): string {
return serializeHostSyncValue({
documentId: input.documentId,
workspaceId: input.workspaceId,
title: input.title,
content: input.content,
readOnly: input.readOnly,
});
}
function canonicalizeTiptapDoc(value: unknown): Json {
const normalizedValue = normalizeRuntimeValue(value);
if (
normalizedValue &&
typeof normalizedValue === "object" &&
(normalizedValue as { type?: unknown }).type === "doc"
) {
return tiptapDocFromBlocks(blocksFromTiptapDoc(normalizedValue) as Json) as Json;
}
return tiptapDocFromBlocks(normalizedValue as Json) as Json;
}
function buildStats(blocks: Json): DocumentStats {
const items = Array.isArray(blocks) ? blocks : [];
const text = items
.map((item) =>
item && typeof item === "object"
? flattenText((item as { content?: unknown }).content)
: "",
)
.join("\n")
.trim();
const todoItems = items.filter(
(item) => item && typeof item === "object" && (item as { type?: unknown }).type === "todo",
);
const todoDone = todoItems.filter(
(item) =>
item &&
typeof item === "object" &&
Boolean((item as { props?: { checked?: unknown } }).props?.checked),
).length;
return {
wordCount: text ? text.split(/\s+/).filter(Boolean).length : 0,
characterCount: text.length,
blockCount: items.length,
todoTotal: todoItems.length,
todoDone,
};
}
function isRuntimeEnvelope(value: unknown): value is RuntimeEnvelope {
if (!value || typeof value !== "object") {
return false;
}
const maybe = value as RuntimeEnvelope;
return maybe.protocol === PROTOCOL && maybe.source === EVENT_PREFIX;
}
async function loadIslandRuntime(): Promise<{
runtimeModule: IslandRuntimeModule;
entryAssetUrl: string;
wasmAssetUrl: string | null;
}> {
const { entryAssetUrl, wasmAssetUrl } = await loadLeptosTiptapIslandAssets();
if (!entryAssetUrl) {
throw new Error("island manifest 缺少 entryAssetPath");
}
const runtimeModule = (await import(
/* webpackIgnore: true */ entryAssetUrl
)) as IslandRuntimeModule;
if (typeof runtimeModule.default !== "function") {
throw new Error("island entry 缺少默认 wasm 初始化函数");
}
if (typeof runtimeModule.mount !== "function") {
throw new Error("island entry 缺少 mount 导出");
}
if (typeof runtimeModule.unmount !== "function") {
throw new Error("island entry 缺少 unmount 导出");
}
await runtimeModule.default(wasmAssetUrl ?? undefined);
return {
runtimeModule,
entryAssetUrl,
wasmAssetUrl,
};
}
function dispatchRuntimeCommand(target: EventTarget, payload: unknown) {
const envelope: RuntimeEnvelope = {
protocol: PROTOCOL,
runtime: "leptos-tiptap-island-host",
version: "1.0.0",
source: EVENT_PREFIX,
event: COMMAND_EVENT,
payload,
};
const event = new CustomEvent(COMMAND_EVENT, {
bubbles: true,
detail: envelope,
});
target.dispatchEvent(event);
}
export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
const mountRef = useRef<HTMLDivElement | null>(null);
const mountIdRef = useRef<number | null>(null);
const runtimeModuleRef = useRef<IslandRuntimeModule | null>(null);
const runtimeTargetRef = useRef<EventTarget | null>(null);
const latestDocRef = useRef<Json>(tiptapDocFromBlocks(props.initialContent as Json) as Json);
const latestBlocksRef = useRef<Json>(props.initialContent as Json);
const currentBlockIdRef = useRef<string | null>(null);
const hostIdentityRef = useRef({
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
readOnly: Boolean(props.readOnly),
});
const revisionRef = useRef<number | null>(props.initialRevision ?? null);
const conflictDetectionKeyRef = useRef<string | null>(props.initialConflictDetectionKey ?? null);
const lastHostSyncKeyRef = useRef<string | null>(null);
const onSnapshotRef = useRef(props.onSnapshot);
const onStatsChangeRef = useRef(props.onStatsChange);
const onPersistedMetaChangeRef = useRef(props.onPersistedMetaChange);
const onHostEventRef = useRef(props.onHostEvent);
const onRequestFallbackRef = useRef(props.onRequestFallback);
const bootstrapPayloadRef = useRef<IslandBootstrapPayload>({
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: tiptapDocFromBlocks(props.initialContent as Json) as Json,
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
pageOptions: buildRuntimePageOptions(props.pageOptions),
});
const [editorHeight, setEditorHeight] = useState(FALLBACK_MIN_HEIGHT);
const [bridgeState, setBridgeState] = useState<RuntimeBridgeState>({
ready: false,
runtimeName: "leptos-tiptap-island",
runtimeVersion: "1.0.0",
runtimeUrl: "",
documentId: props.documentId,
workspaceId: props.workspaceId,
status: "booting",
lastChangeAt: null,
lastSaveRequestAt: null,
lastError: null,
});
const debouncedPersistRef = useRef<ReturnType<typeof useDebouncedCallback> | null>(null);
const mountIdentity = useMemo(
() => `${props.workspaceId}:${props.documentId}`,
[props.documentId, props.workspaceId],
);
useEffect(() => {
onSnapshotRef.current = props.onSnapshot;
onStatsChangeRef.current = props.onStatsChange;
onPersistedMetaChangeRef.current = props.onPersistedMetaChange;
onHostEventRef.current = props.onHostEvent;
onRequestFallbackRef.current = props.onRequestFallback;
hostIdentityRef.current = {
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
readOnly: Boolean(props.readOnly),
};
revisionRef.current = props.initialRevision ?? null;
conflictDetectionKeyRef.current = props.initialConflictDetectionKey ?? null;
}, [
props.documentId,
props.initialConflictDetectionKey,
props.initialRevision,
props.onHostEvent,
props.onPersistedMetaChange,
props.onRequestFallback,
props.onSnapshot,
props.onStatsChange,
props.readOnly,
props.title,
props.workspaceId,
]);
useEffect(() => {
bootstrapPayloadRef.current = {
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: tiptapDocFromBlocks(props.initialContent as Json) as Json,
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
pageOptions: buildRuntimePageOptions(props.pageOptions),
};
}, [
mountIdentity,
props.documentId,
props.initialConflictDetectionKey,
props.initialContent,
props.initialRevision,
props.pageOptions,
props.readOnly,
props.title,
props.workspaceId,
]);
const requestFallback = useCallback(
(reason: EditorHostFallbackReason, error: string) => {
const at = toIsoNow();
if (reason !== "explicit_fallback") {
onHostEventRef.current?.({
kind:
reason === "save_failed"
? "save_failed"
: reason === "runtime_load_failed"
? "runtime_load_failed"
: reason === "command_failed"
? "command_failed"
: "host_init_failed",
at,
message: error,
});
}
onRequestFallbackRef.current?.({
reason,
error,
at,
});
},
[],
);
const persistDocument = useCallback(async () => {
const normalizedDoc = latestDocRef.current;
const normalizedBlocks = blocksFromTiptapDoc(normalizedDoc) as Json;
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(normalizedDoc, props.documentId),
content: normalizedBlocks,
tiptapDocument: normalizedDoc,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(normalizedBlocks) ? normalizedBlocks.length : 0,
}),
),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(
typeof payload?.error === "string" ? payload.error : `保存失败(${response.status}`,
);
}
revisionRef.current =
typeof payload?.revision === "number" ? payload.revision : revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload?.conflictDetectionKey === "string"
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
});
setBridgeState((prev) => ({
...prev,
ready: true,
status: "saved",
lastSaveRequestAt: toIsoNow(),
lastError: null,
}));
}, [props.documentId, props.workspaceId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistDocument().catch((error) => {
const message = error instanceof Error ? error.message : "保存失败";
setBridgeState((prev) => ({
...prev,
status: "error",
lastError: message,
}));
requestFallback("save_failed", message);
});
}, SAVE_DEBOUNCE_MS);
useEffect(() => {
debouncedPersistRef.current = debouncedPersist;
}, [debouncedPersist]);
useEffect(() => {
const container = mountRef.current;
if (!container) {
return;
}
const bootstrapPayload = bootstrapPayloadRef.current;
let disposed = false;
let removeListeners: Array<() => void> = [];
const attachEvent = <T,>(eventName: string, handler: (payload: T) => void) => {
const listener = (event: Event) => {
const customEvent = event as CustomEvent<RuntimeEnvelope<T>>;
if (!isRuntimeEnvelope(customEvent.detail)) {
return;
}
handler((customEvent.detail.payload ?? {}) as T);
};
container.addEventListener(eventName, listener);
removeListeners.push(() => container.removeEventListener(eventName, listener));
};
void loadIslandRuntime()
.then(({ runtimeModule, entryAssetUrl }) => {
if (disposed) {
return;
}
runtimeModuleRef.current = runtimeModule;
runtimeTargetRef.current = container;
container.dataset.editorHostKind = "leptos_tiptap_island";
container.dataset.mnoteRuntime = "leptos_tiptap_island";
container.dataset.mnoteRuntimeBridge = "island";
container.dataset.mnoteRuntimeUrl = entryAssetUrl;
attachEvent<ChangePayload>(CHANGE_EVENT, (payload) => {
const hostIdentity = hostIdentityRef.current;
const nextDoc = canonicalizeTiptapDoc(payload.content ?? latestDocRef.current);
const nextBlocks = blocksFromTiptapDoc(nextDoc) as Json;
const nextStats = buildStats(nextBlocks);
const nextReadOnly = payload.meta?.readOnly ?? hostIdentity.readOnly;
const nextTitle = payload.title ?? hostIdentity.title;
latestDocRef.current = nextDoc;
latestBlocksRef.current = nextBlocks;
lastHostSyncKeyRef.current = buildHostSyncKey({
documentId: hostIdentity.documentId,
workspaceId: hostIdentity.workspaceId,
title: nextTitle,
content: nextDoc,
readOnly: nextReadOnly,
});
onSnapshotRef.current?.({ blocks: nextBlocks, stats: nextStats });
onStatsChangeRef.current?.(nextStats);
if (payload.meta) {
revisionRef.current =
typeof payload.meta.revision === "number"
? payload.meta.revision
: revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload.meta.conflictDetectionKey === "string"
? payload.meta.conflictDetectionKey
: conflictDetectionKeyRef.current;
}
setBridgeState((prev) => ({
...prev,
ready: true,
status: "dirty",
lastChangeAt: toIsoNow(),
lastError: null,
}));
debouncedPersistRef.current?.();
});
attachEvent<StatePayload>(STATE_EVENT, (payload) => {
setBridgeState((prev) => ({
...prev,
ready: true,
status:
payload.readOnly === true
? "read_only"
: payload.slashOpen || payload.toolbarOpen
? "interacting"
: "ready",
lastError: null,
}));
onHostEventRef.current?.({
kind: "status_changed",
status:
payload.readOnly === true
? "read_only"
: payload.slashOpen || payload.toolbarOpen
? "interacting"
: "ready",
at: toIsoNow(),
message: null,
});
});
attachEvent<StatusPayload>(STATUS_EVENT, (payload) => {
currentBlockIdRef.current =
typeof payload.currentBlockId === "string" ? payload.currentBlockId : null;
});
attachEvent<SelectionPayload>(SELECTION_EVENT, (payload) => {
currentBlockIdRef.current =
typeof payload.currentBlockId === "string" ? payload.currentBlockId : null;
});
attachEvent<HeightPayload>(HEIGHT_EVENT, (payload) => {
const nextHeight = Number(payload.height);
if (Number.isFinite(nextHeight)) {
setEditorHeight(Math.max(FALLBACK_MIN_HEIGHT, Math.ceil(nextHeight)));
}
});
attachEvent<ErrorPayload>(ERROR_EVENT, (payload) => {
const message =
typeof payload.message === "string" ? payload.message : "leptos-tiptap island 初始化失败";
setBridgeState((prev) => ({
...prev,
status: "error",
lastError: message,
}));
requestFallback("host_init_failed", message);
});
attachEvent(READY_EVENT, () => {
setBridgeState((prev) => ({
...prev,
ready: true,
status: "ready",
lastError: null,
}));
});
latestDocRef.current = bootstrapPayload.content as Json;
latestBlocksRef.current = blocksFromTiptapDoc(bootstrapPayload.content) as Json;
lastHostSyncKeyRef.current = buildHostSyncKey({
documentId: bootstrapPayload.documentId,
workspaceId: bootstrapPayload.workspaceId,
title: bootstrapPayload.title,
content: bootstrapPayload.content,
readOnly: bootstrapPayload.readOnly,
});
mountIdRef.current = runtimeModule.mount(container, {
documentId: bootstrapPayload.documentId,
workspaceId: bootstrapPayload.workspaceId,
title: bootstrapPayload.title,
content: bootstrapPayload.content,
readOnly: bootstrapPayload.readOnly,
revision: bootstrapPayload.revision,
conflictDetectionKey: bootstrapPayload.conflictDetectionKey,
pageOptions: bootstrapPayload.pageOptions,
editable: !bootstrapPayload.readOnly,
});
setBridgeState((prev) => ({
...prev,
runtimeUrl: entryAssetUrl,
status: "mounting",
lastError: null,
}));
})
.catch((error) => {
if (disposed) {
return;
}
const message = error instanceof Error ? error.message : "加载 leptos-tiptap island 失败";
setBridgeState((prev) => ({
...prev,
status: "error",
lastError: message,
}));
requestFallback("runtime_load_failed", message);
});
return () => {
disposed = true;
debouncedPersistRef.current?.cancel();
removeListeners.forEach((dispose) => dispose());
removeListeners = [];
if (mountIdRef.current != null && runtimeModuleRef.current) {
try {
runtimeModuleRef.current.unmount(mountIdRef.current);
} catch {
// 说明:页面卸载时不再追加错误提示,避免离场噪音。
}
}
mountIdRef.current = null;
runtimeTargetRef.current = null;
runtimeModuleRef.current = null;
useEditorBridgeStore.getState().registerBridge(null);
};
}, [
mountIdentity,
requestFallback,
]);
useEffect(() => {
const target = runtimeTargetRef.current;
if (!target || mountIdRef.current == null) {
return;
}
const nextDoc = tiptapDocFromBlocks(props.initialContent as Json);
const nextHostSyncKey = buildHostSyncKey({
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: nextDoc,
readOnly: Boolean(props.readOnly),
});
if (lastHostSyncKeyRef.current === nextHostSyncKey) {
return;
}
lastHostSyncKeyRef.current = nextHostSyncKey;
dispatchRuntimeCommand(target, {
command: "replaceContent",
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: nextDoc,
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
editable: !props.readOnly,
});
}, [
props.documentId,
props.initialConflictDetectionKey,
props.initialContent,
props.initialRevision,
props.readOnly,
props.title,
props.workspaceId,
]);
useEffect(() => {
const target = runtimeTargetRef.current;
if (!target || mountIdRef.current == null) {
return;
}
dispatchRuntimeCommand(target, {
command: "setPageOptions",
pageOptions: buildRuntimePageOptions(props.pageOptions),
});
}, [props.pageOptions]);
useEffect(() => {
const target = runtimeTargetRef.current;
if (!target || mountIdRef.current == null) {
return;
}
const editorBridge: EditorReferenceBridge = {
insertInlineReference: (targetDocument, aliasText) => {
try {
dispatchRuntimeCommand(target, {
command: "insertInlineReference",
referenceDocumentId: targetDocument.id,
text: aliasText?.trim() || targetDocument.title || "无标题",
});
} catch (error) {
requestFallback(
"command_failed",
error instanceof Error ? error.message : "插入行内引用失败",
);
}
return { blockId: currentBlockIdRef.current };
},
insertEmbedReference: (targetDocument) => {
try {
dispatchRuntimeCommand(target, {
command: "insertEmbedReference",
referenceDocumentId: targetDocument.id,
text: targetDocument.title || "无标题",
});
} catch (error) {
requestFallback(
"command_failed",
error instanceof Error ? error.message : "插入嵌入引用失败",
);
}
return { blockId: currentBlockIdRef.current };
},
undo: () => {
try {
dispatchRuntimeCommand(target, { command: "undo" });
} catch (error) {
requestFallback("command_failed", error instanceof Error ? error.message : "撤销失败");
}
},
redo: () => {
try {
dispatchRuntimeCommand(target, { command: "redo" });
} catch (error) {
requestFallback("command_failed", error instanceof Error ? error.message : "重做失败");
}
},
getCursorBlockId: () => currentBlockIdRef.current,
replaceWithSnapshot: (blocks: Json) => {
const hostIdentity = hostIdentityRef.current;
const nextDoc = tiptapDocFromBlocks(blocks) as Json;
latestDocRef.current = nextDoc;
latestBlocksRef.current = Array.isArray(blocks) ? blocks : blocksFromTiptapDoc(nextDoc);
lastHostSyncKeyRef.current = buildHostSyncKey({
documentId: hostIdentity.documentId,
workspaceId: hostIdentity.workspaceId,
title: hostIdentity.title,
content: nextDoc,
readOnly: hostIdentity.readOnly,
});
try {
dispatchRuntimeCommand(target, {
command: "replaceContent",
documentId: hostIdentity.documentId,
workspaceId: hostIdentity.workspaceId,
title: hostIdentity.title,
content: nextDoc,
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
readOnly: hostIdentity.readOnly,
editable: !hostIdentity.readOnly,
});
} catch (error) {
requestFallback(
"command_failed",
error instanceof Error ? error.message : "替换编辑器快照失败",
);
}
},
requestFallbackToBlockNote: () => {
requestFallback("explicit_fallback", "通过页面壳显式切回 BlockNote");
},
};
useEditorBridgeStore.getState().registerBridge(editorBridge);
return () => {
useEditorBridgeStore.getState().registerBridge(null);
};
}, [
props.documentId,
props.readOnly,
props.title,
props.workspaceId,
requestFallback,
]);
return (
<div
ref={mountRef}
className="min-h-[720px] py-6"
data-editor-host-kind="leptos_tiptap_island"
data-runtime-editor-status={bridgeState.status}
data-testid="mnote-leptos-tiptap-island-editor-root"
style={{ minHeight: `${editorHeight}px` }}
/>
);
}
@@ -0,0 +1,40 @@
"use client";
const ISLAND_MANIFEST_URL = "/api/leptos-tiptap-runtime/manifest.json";
export type LeptosTiptapIslandManifest = {
entryAssetPath: string | null;
wasmAssetPath: string | null;
assetPaths: string[];
generatedRootPath: string | null;
};
export type LeptosTiptapIslandAssetUrls = {
manifest: LeptosTiptapIslandManifest | null;
entryAssetUrl: string | null;
wasmAssetUrl: string | null;
};
export function buildLeptosTiptapIslandAssetUrl(assetPath: string | null): string | null {
if (!assetPath) {
return null;
}
return `/api/leptos-tiptap-runtime/${assetPath}`;
}
export async function loadLeptosTiptapIslandAssets(): Promise<LeptosTiptapIslandAssetUrls> {
const response = await fetch(ISLAND_MANIFEST_URL, { cache: "no-store" }).catch(() => null);
if (!response || !response.ok) {
return {
manifest: null,
entryAssetUrl: null,
wasmAssetUrl: null,
};
}
const manifest = (await response.json().catch(() => null)) as LeptosTiptapIslandManifest | null;
return {
manifest,
entryAssetUrl: buildLeptosTiptapIslandAssetUrl(manifest?.entryAssetPath ?? null),
wasmAssetUrl: buildLeptosTiptapIslandAssetUrl(manifest?.wasmAssetPath ?? null),
};
}
@@ -47,6 +47,11 @@ export type LoadedLeptosTiptapRuntime = {
let runtimePromise: Promise<LoadedLeptosTiptapRuntime> | null = null;
function formatRuntimeLoaderError(stage: string, error: unknown): Error {
const message = error instanceof Error ? error.message : "未知错误";
return new Error(`[leptos_tiptap_runtime_loader:${stage}] ${message}`);
}
function toRuntimeAssetUrl(relativePath: string): string {
return `/api/leptos-tiptap-runtime/${relativePath}`;
}
@@ -65,26 +70,46 @@ function extractRegisterFunction(module: Record<string, unknown>): (() => void)
}
async function fetchRuntimeManifest(): Promise<RuntimeManifest> {
const response = await fetch(MANIFEST_URL, { cache: "no-store" });
const response = await fetch(MANIFEST_URL, { cache: "no-store" }).catch((error) => {
throw formatRuntimeLoaderError("manifest_fetch", error);
});
if (!response.ok) {
throw new Error(`读取 leptos-tiptap runtime manifest 失败(${response.status}`);
throw new Error(
`[leptos_tiptap_runtime_loader:manifest_fetch] 读取 leptos-tiptap runtime manifest 失败(${response.status}`,
);
}
const manifest = (await response.json()) as RuntimeManifest;
const manifest = (await response.json().catch((error) => {
throw formatRuntimeLoaderError("manifest_parse", error);
})) as RuntimeManifest;
if (!manifest.bridgeRuntimePath) {
throw new Error("runtime manifest 缺少 bridgeRuntimePath");
throw new Error("[leptos_tiptap_runtime_loader:manifest_validate] runtime manifest 缺少 bridgeRuntimePath");
}
return manifest;
}
async function loadRuntimeModules(): Promise<LoadedLeptosTiptapRuntime> {
const manifest = await fetchRuntimeManifest();
const bridge = await importRuntimeModule<BridgeRuntimeModule>(manifest.bridgeRuntimePath!);
bridge.init_bridge_runtime();
const manifest = await fetchRuntimeManifest().catch((error) => {
throw formatRuntimeLoaderError("manifest", error);
});
const bridge = await importRuntimeModule<BridgeRuntimeModule>(manifest.bridgeRuntimePath!).catch((error) => {
throw formatRuntimeLoaderError("bridge_import", error);
});
try {
bridge.init_bridge_runtime();
} catch (error) {
throw formatRuntimeLoaderError("bridge_init", error);
}
for (const modulePath of manifest.extensionModulePaths) {
const extensionModule = await importRuntimeModule<Record<string, unknown>>(modulePath);
const extensionModule = await importRuntimeModule<Record<string, unknown>>(modulePath).catch((error) => {
throw formatRuntimeLoaderError(`extension_import:${modulePath}`, error);
});
const register = extractRegisterFunction(extensionModule);
register?.();
try {
register?.();
} catch (error) {
throw formatRuntimeLoaderError(`extension_register:${modulePath}`, error);
}
}
return { manifest, bridge };
@@ -0,0 +1,84 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { PageOptionsSidebar } from "./page-options-sidebar";
import type { PageOptionsState, DocumentStats } from "@/types/page-options";
function buildOptions(overrides: Partial<PageOptionsState> = {}): PageOptionsState {
return {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
...overrides,
};
}
function buildStats(): DocumentStats {
return {
wordCount: 3,
characterCount: 12,
blockCount: 1,
todoTotal: 0,
todoDone: 0,
};
}
describe("PageOptionsSidebar", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("对已保存但未完成编辑器语义的设置应显示降级说明", () => {
act(() => {
root.render(
<PageOptionsSidebar
documentId="doc-1"
options={buildOptions({ showHeadingNumbers: true, embedDefaultBlockId: "block-1" })}
stats={buildStats()}
onToggle={() => undefined}
onExport={() => undefined}
onOpenHistory={() => undefined}
/>,
);
});
expect(container.textContent).toContain("标题编号");
expect(container.textContent).toContain("已保存字段");
expect(container.textContent).toContain("编辑器语义暂未正式接通");
const customTab = container.querySelectorAll("button")[1];
expect(customTab).not.toBeNull();
act(() => {
customTab?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("嵌入默认位置");
expect(container.textContent).toContain("这是已保存字段");
expect(container.textContent).toContain("当前编辑器语义暂未正式接通");
});
});
@@ -32,7 +32,7 @@ const OPTION_META: Record<
},
showHeadingNumbers: {
label: "标题编号",
description: "自动为标题添加编号",
description: "自动为标题添加编号(已保存字段,编辑器语义暂未正式接通)",
icon: ListOrdered,
},
showToc: {
@@ -289,6 +289,9 @@ export function PageOptionsSidebar({
<p className="mt-1 text-xs text-gray-400">
/...
</p>
<p className="mt-1 text-xs text-amber-600">
</p>
<div className="mt-3 rounded-xl bg-[#f9fafc] px-3 py-2 text-xs text-gray-600">
{options.embedDefaultBlockId ? options.embedDefaultBlockId : "未设置"}
</div>
@@ -0,0 +1,156 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { PreferredSidebarSnapshotProvider } from "@/components/sidebar/preferred-sidebar-snapshot-context";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildSidebarInitialData } from "@/lib/sidebar-data";
import type { DocumentRecord } from "@/lib/documents";
import { usePageHeadTitle } from "./use-page-head-title";
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
return {
access_scope: "private",
id: "doc-1",
workspace_id: "ws-1",
title: "标题 A",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
...overrides,
};
}
function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
return buildSidebarInitialData({
activeWorkspaceId: "ws-1",
workspaces: [],
documents,
trashedDocuments: [],
mindmaps: [],
mediaAssets: [],
trashedMediaAssets: [],
tables: [],
});
}
function HookProbe(props: { documentId: string; fallbackTitle: string }) {
const state = usePageHeadTitle(props);
return (
<>
<div
data-testid="page-head-title"
data-display-title={state.displayTitle}
data-committed-title={state.committedTitle}
data-has-draft={state.hasDraft ? "1" : "0"}
/>
<button type="button" data-testid="set-draft" onClick={() => state.setDraftTitle(" 新标题 ")}>
稿
</button>
<button type="button" data-testid="commit-persisted" onClick={() => state.commitPersistedTitle("新标题")}>
</button>
</>
);
}
describe("usePageHeadTitle", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("应优先使用 live sidebar snapshot 中的标题作为 committed title", async () => {
const snapshot = buildSidebarData([
buildDocument({
id: "doc-1",
title: "树标题",
}),
]);
await act(async () => {
root.render(
<PreferredSidebarSnapshotProvider data={snapshot}>
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
</PreferredSidebarSnapshotProvider>,
);
});
const probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-committed-title")).toBe("树标题");
expect(probe?.getAttribute("data-display-title")).toBe("树标题");
expect(probe?.getAttribute("data-has-draft")).toBe("0");
});
it("应只把本地输入保留为短暂 draft,并在 live 标题追平后自动清空", async () => {
const initialSnapshot = buildSidebarData([
buildDocument({
id: "doc-1",
title: "旧标题",
}),
]);
await act(async () => {
root.render(
<PreferredSidebarSnapshotProvider data={initialSnapshot}>
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
</PreferredSidebarSnapshotProvider>,
);
});
const setDraftButton = container.querySelector<HTMLButtonElement>("[data-testid='set-draft']");
act(() => {
setDraftButton?.click();
});
let probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-display-title")).toBe(" 新标题 ");
expect(probe?.getAttribute("data-committed-title")).toBe("旧标题");
expect(probe?.getAttribute("data-has-draft")).toBe("1");
const commitPersistedButton = container.querySelector<HTMLButtonElement>("[data-testid='commit-persisted']");
act(() => {
commitPersistedButton?.click();
});
probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-display-title")).toBe("新标题");
expect(probe?.getAttribute("data-has-draft")).toBe("1");
const syncedSnapshot = buildSidebarData([
buildDocument({
id: "doc-1",
title: "新标题",
updated_at: "2026-04-21T00:00:02.000Z",
}),
]);
await act(async () => {
root.render(
<PreferredSidebarSnapshotProvider data={syncedSnapshot}>
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
</PreferredSidebarSnapshotProvider>,
);
});
probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-display-title")).toBe("新标题");
expect(probe?.getAttribute("data-committed-title")).toBe("新标题");
expect(probe?.getAttribute("data-has-draft")).toBe("0");
});
});
@@ -0,0 +1,44 @@
"use client";
import { useMemo, useState } from "react";
import { usePreferredSidebarDocumentTitle } from "@/components/sidebar/preferred-sidebar-snapshot-context";
function normalizePageHeadTitle(title: string | null | undefined): string {
const normalized = String(title ?? "").trim();
return normalized || "无标题";
}
export function usePageHeadTitle(input: { documentId: string; fallbackTitle: string }) {
const liveSidebarTitle = usePreferredSidebarDocumentTitle(input.documentId);
const committedTitle = useMemo(
() => normalizePageHeadTitle(liveSidebarTitle ?? input.fallbackTitle),
[input.fallbackTitle, liveSidebarTitle],
);
const [draftState, setDraftState] = useState<{
documentId: string;
title: string | null;
}>({
documentId: input.documentId,
title: null,
});
const draftTitle = draftState.documentId === input.documentId ? draftState.title : null;
const hasDraft = draftTitle != null && normalizePageHeadTitle(draftTitle) !== committedTitle;
return {
displayTitle: hasDraft ? draftTitle ?? committedTitle : committedTitle,
committedTitle,
hasDraft,
setDraftTitle: (title: string) => {
setDraftState({
documentId: input.documentId,
title,
});
},
commitPersistedTitle: (title: string) => {
setDraftState({
documentId: input.documentId,
title: normalizePageHeadTitle(title),
});
},
};
}