"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 } from "@/types/page-options"; import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics"; import { blocksFromTiptapDoc, editorBlockDocumentFromTiptapDoc, tiptapDocFromBlocks, } from "@/lib/documents/tiptap-content-converter"; import { buildDocumentSavePayload } from "@/lib/documents/save-contract"; import { executePageBodySavePayload } from "@/lib/documents/page-command-client"; 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; mount: (container: Element, options: unknown) => number; unmount: (mountId: number) => void; mount_mindmap_shell?: (container: Element, options: unknown) => number; unmount_mindmap_shell?: (mountId: number) => void; }; type MindmapRustShellBridge = { mount: (container: Element, options: unknown) => number; unmount: (mountId: number) => void; }; declare global { interface Window { __MNOTE_MINDMAP_RUST_SHELL__?: MindmapRustShellBridge; } } type RuntimeEnvelope = { 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 = ReturnType; 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).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); if ( typeof runtimeModule.mount_mindmap_shell === "function" && typeof runtimeModule.unmount_mindmap_shell === "function" ) { window.__MNOTE_MINDMAP_RUST_SHELL__ = { mount: runtimeModule.mount_mindmap_shell, unmount: runtimeModule.unmount_mindmap_shell, }; } 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(null); const mountIdRef = useRef(null); const runtimeModuleRef = useRef(null); const runtimeTargetRef = useRef(null); const latestDocRef = useRef(tiptapDocFromBlocks(props.initialContent as Json) as Json); const latestBlocksRef = useRef(props.initialContent as Json); const currentBlockIdRef = useRef(null); const hostIdentityRef = useRef({ documentId: props.documentId, workspaceId: props.workspaceId, title: props.title ?? null, readOnly: Boolean(props.readOnly), }); const revisionRef = useRef(props.initialRevision ?? null); const conflictDetectionKeyRef = useRef(props.initialConflictDetectionKey ?? null); const lastHostSyncKeyRef = useRef(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({ 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: pickLeptosTiptapRuntimePageOptions(props.pageOptions), }); const [editorHeight, setEditorHeight] = useState(FALLBACK_MIN_HEIGHT); const [bridgeState, setBridgeState] = useState({ 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 | 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: pickLeptosTiptapRuntimePageOptions(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 persistedMeta = await executePageBodySavePayload( 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, }), ); revisionRef.current = persistedMeta.revision ?? revisionRef.current; conflictDetectionKeyRef.current = persistedMeta.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 = (eventName: string, handler: (payload: T) => void) => { const listener = (event: Event) => { const customEvent = event as CustomEvent>; 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(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(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(STATUS_EVENT, (payload) => { currentBlockIdRef.current = typeof payload.currentBlockId === "string" ? payload.currentBlockId : null; }); attachEvent(SELECTION_EVENT, (payload) => { currentBlockIdRef.current = typeof payload.currentBlockId === "string" ? payload.currentBlockId : null; }); attachEvent(HEIGHT_EVENT, (payload) => { const nextHeight = Number(payload.height); if (Number.isFinite(nextHeight)) { setEditorHeight(Math.max(FALLBACK_MIN_HEIGHT, Math.ceil(nextHeight))); } }); attachEvent(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: pickLeptosTiptapRuntimePageOptions(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 : "替换编辑器快照失败", ); } }, }; useEditorBridgeStore.getState().registerBridge(editorBridge); return () => { useEditorBridgeStore.getState().registerBridge(null); }; }, [ props.documentId, props.readOnly, props.title, props.workspaceId, requestFallback, ]); return (
); }