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:
@@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user