Files
mnote/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx
T

827 lines
26 KiB
TypeScript

"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<unknown>;
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<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 = ReturnType<typeof pickLeptosTiptapRuntimePageOptions>;
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);
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<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: pickLeptosTiptapRuntimePageOptions(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: 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 = <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: 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 (
<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` }}
/>
);
}