827 lines
26 KiB
TypeScript
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` }}
|
|
/>
|
|
);
|
|
}
|