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