主编辑区改造准备

This commit is contained in:
lix-2026
2026-04-21 06:26:35 +08:00
parent 1e686bfa3c
commit 5d1c94eb9e
49 changed files with 6730 additions and 2565 deletions
@@ -16,7 +16,7 @@ import * as Y from "yjs";
import type { Block } from "@blocknote/core";
import { cn } from "@/lib/utils";
import type { Json } from "@/types/supabase";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { DocumentStats } from "@/types/page-options";
import type { MediaAsset } from "@/types/media";
import { customBlockSchema, type CustomBlockSchema } from "./schema";
import { CustomSideMenu } from "./menus/CustomSideMenu";
@@ -36,23 +36,7 @@ import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@/lib/convex/api";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
interface BlockNoteEditorProps {
documentId: string;
workspaceId: string;
initialContent: unknown;
initialRevision?: number | null;
initialConflictDetectionKey?: string | null;
pageOptions: PageOptionsState;
readOnly?: boolean;
onStatsChange?: (stats: DocumentStats) => void;
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
onCloseToc?: () => void;
onPersistedMetaChange?: (payload: {
revision: number | null;
conflictDetectionKey: string | null;
}) => void;
}
import type { BlockNoteEditorProps } from "@/components/editor/editor-host-types";
const extractInitialBlocks = (content: unknown): Json | undefined => {
if (Array.isArray(content) && content.length > 0) {
@@ -27,6 +27,8 @@ import {
moveDocumentCommand,
renameDocumentCommand,
} from "@/lib/documents/tree-command-client";
import { EditorHost } from "@/components/editor/editor-host";
import type { EditorHostKind } from "@/components/editor/editor-host-config";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -38,8 +40,6 @@ const BlockNoteEditor = dynamic(
},
);
// 说明:这些组件都不是“进入文档页首屏”所必需。
// 先拆成独立 chunk,避免 /documents/[id] 首次编译时把评论、历史、弹层、检查器等整串依赖一并拉进来。
const PageOptionsSidebar = dynamic(
() => import("@/components/editor/page-options-sidebar").then((mod) => mod.PageOptionsSidebar),
{ ssr: false },
@@ -89,6 +89,7 @@ export interface DocumentContentProps {
initialOptions: PageOptionsState;
initialStats: DocumentStats | null;
openTableId?: string | null;
editorHostKind?: EditorHostKind;
readOnly?: boolean;
disableDownload?: boolean;
disableCopy?: boolean;
@@ -124,6 +125,7 @@ export function DocumentContent({
initialOptions,
initialStats,
openTableId,
editorHostKind = "blocknote",
readOnly = false,
disableDownload = false,
disableCopy = false,
@@ -154,8 +156,12 @@ export function DocumentContent({
const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0);
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
const [isEditing, setIsEditing] = useState(() => Boolean((openTableId ?? "").trim()) && !readOnly);
const [keepEditorMounted, setKeepEditorMounted] = useState(() => Boolean((openTableId ?? "").trim()) && !readOnly);
const shouldUseExperimentalHost =
editorHostKind === "leptos_tiptap_inline" ||
editorHostKind === "leptos_tiptap_iframe_debug";
const shouldStartEditing = canEditDocument;
const [isEditing, setIsEditing] = useState(() => shouldStartEditing);
const [keepEditorMounted, setKeepEditorMounted] = useState(() => shouldStartEditing);
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editorUnmountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingOpenTableRef = useRef<string | null>(null);
@@ -246,20 +252,17 @@ export function DocumentContent({
editorBridge.openTableFullScreen(tableId);
// 清理 URL 参数,避免刷新/回退时重复触发
if (typeof window !== "undefined") {
try {
const url = new URL(window.location.href);
url.searchParams.delete("openTableId");
window.history.replaceState({}, "", url.toString());
} catch {
// fallback:不影响主流程
router.replace(`/documents/${documentId}`);
}
}
}, [documentId, editorBridge, openTableId, router]);
useEffect(() => {
setPageTitle(title ?? "无标题");
}, [title]);
@@ -347,7 +350,6 @@ export function DocumentContent({
clearTimeout(contentLoadingTimerRef.current);
contentLoadingTimerRef.current = null;
}
// 避免"秒闪"的加载提示:只有当加载超过短阈值时才显示提示
contentLoadingTimerRef.current = setTimeout(() => {
if (!canceled) {
setShowContentLoadingIndicator(true);
@@ -358,9 +360,9 @@ export function DocumentContent({
const response = await fetch(
`/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
{
method: "GET",
credentials: "include",
signal: controller.signal,
method: "GET",
credentials: "include",
signal: controller.signal,
},
);
if (!response.ok) {
@@ -586,7 +588,6 @@ export function DocumentContent({
window.alert(successMessage);
return;
} catch {
// ignore and fallback
}
}
window.prompt("复制失败,请手动复制内容", text);
@@ -890,12 +891,10 @@ export function DocumentContent({
<p className="mt-1 text-sm text-gray-500"></p>
) : options.protectEditing && isEditing ? (
<p className="mt-1 text-sm text-[#b91c1c]"></p>
) : !isEditing && canEditDocument ? (
<p className="mt-1 text-sm text-gray-500"></p>
) : null}
<p className="text-sm text-wolai-text-secondary">{formattedUpdatedAt}</p>
</div>
{canEditDocument && (
{canEditDocument && false && (
<div className="flex shrink-0 items-center gap-2">
{isEditing ? (
<Button type="button" variant="outline" size="sm" onClick={handleExitEditMode}>
@@ -938,22 +937,44 @@ export function DocumentContent({
<div className="relative">
{keepEditorMounted && (
<div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}>
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={({ revision, conflictDetectionKey: nextConflictDetectionKey }) => {
setContentRevision(revision);
setConflictDetectionKey(nextConflictDetectionKey);
}}
/>
{shouldUseExperimentalHost ? (
<EditorHost
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
title={title}
hostKind={editorHostKind}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
/>
) : (
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
title={title}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
/>
)}
</div>
)}
{!isEditing && (
@@ -0,0 +1,29 @@
export type EditorHostKind =
| "blocknote"
| "leptos_tiptap_inline"
| "leptos_tiptap_iframe_debug";
export interface EditorHostConfig {
kind: EditorHostKind;
}
const DEFAULT_EDITOR_HOST_KIND: EditorHostKind = "blocknote";
export function normalizeEditorHostKind(value: unknown): EditorHostKind {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "leptos_tiptap_inline") {
return "leptos_tiptap_inline";
}
if (normalized === "leptos_tiptap_iframe_debug" || normalized === "leptos_tiptap") {
return "leptos_tiptap_iframe_debug";
}
return DEFAULT_EDITOR_HOST_KIND;
}
export function getEditorHostKindFromEnv(value?: unknown): EditorHostKind {
return normalizeEditorHostKind(value);
}
export function getEditorHostConfig(value?: unknown): EditorHostConfig {
return { kind: getEditorHostKindFromEnv(value) };
}
@@ -0,0 +1,54 @@
"use client";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import type { ReferenceTarget } from "@/types/search";
import type { EditorHostKind } from "@/components/editor/editor-host-config";
export interface DocumentEditorHostProps {
documentId: string;
workspaceId: string;
initialContent: unknown;
title?: string | null;
hostKind?: EditorHostKind;
initialRevision?: number | null;
initialConflictDetectionKey?: string | null;
pageOptions: PageOptionsState;
readOnly?: boolean;
onStatsChange?: (stats: DocumentStats) => void;
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
onCloseToc?: () => void;
onPersistedMetaChange?: (payload: {
revision: number | null;
conflictDetectionKey: string | null;
}) => void;
}
export type BlockNoteEditorProps = DocumentEditorHostProps;
export type LeptosTiptapHostBridgeState = {
ready: boolean;
runtimeName: string;
runtimeVersion: string;
runtimeUrl: string;
documentId: string;
workspaceId: string;
status: string;
lastChangeAt?: string | null;
lastSaveRequestAt?: string | null;
lastError?: string | null;
};
export type LeptosTiptapHostBridgeEventDetail = {
documentId: string;
workspaceId: string;
runtimeUrl: string;
status?: string;
payload?: unknown;
snapshot?: { blocks: Json; stats?: DocumentStats };
references?: ReferenceTarget[];
revision?: number | null;
conflictDetectionKey?: string | null;
error?: string | null;
at?: string;
};
@@ -0,0 +1,35 @@
"use client";
import dynamic from "next/dynamic";
import type { ComponentType } from "react";
import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types";
const LeptosTiptapIframeDebugEditor = dynamic(
() => import("@/components/editor/leptos-tiptap-editor-host").then((mod) => mod.LeptosTiptapEditorHost),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
) as ComponentType<DocumentEditorHostProps>;
const LeptosTiptapInlineEditor = dynamic(
() =>
import("@/components/editor/leptos-tiptap-inline-editor-host").then(
(mod) => mod.LeptosTiptapInlineEditorHost,
),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
) as ComponentType<DocumentEditorHostProps>;
export function EditorHost(props: DocumentEditorHostProps) {
if (props.hostKind === "leptos_tiptap_inline") {
return <LeptosTiptapInlineEditor {...props} />;
}
return <LeptosTiptapIframeDebugEditor {...props} />;
}
@@ -0,0 +1,442 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type {
DocumentEditorHostProps,
LeptosTiptapHostBridgeState,
} from "@/components/editor/editor-host-types";
import {
blocksFromTiptapDoc,
editorBlockDocumentFromTiptapDoc,
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { DocumentStats } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
const RUNTIME_PORT = 8123;
const RUNTIME_NAME = "8123-leptos-tiptap-runtime";
const RUNTIME_VERSION = "1.1.0";
const EVENT_PREFIX = "mnote:leptos-tiptap-spike";
const READY_EVENT = `${EVENT_PREFIX}:ready`;
const CHANGE_EVENT = `${EVENT_PREFIX}:change`;
const STATE_EVENT = `${EVENT_PREFIX}:state`;
const SAVE_REQUEST_EVENT = `${EVENT_PREFIX}:save-request`;
const HEIGHT_EVENT = `${EVENT_PREFIX}:height`;
const BOOTSTRAP_EVENT = `${EVENT_PREFIX}:bootstrap`;
const REPLACE_DOCUMENT_EVENT = `${EVENT_PREFIX}:replace-document`;
const PROTOCOL = "mnote.leptos_tiptap.bridge.v1";
const EMPTY_DOC = { type: "doc", content: [{ type: "paragraph", attrs: {}, content: [] }] } as const;
const FALLBACK_IFRAME_MIN_HEIGHT = 720;
type HostEnvelope<T = unknown> = {
protocol?: string;
runtime?: string;
version?: string;
source?: string;
event?: string;
payload?: T;
};
type HostDocumentPayload = {
title?: string | null;
content?: unknown;
meta?: {
dirty_count?: number;
editor_focused?: boolean;
slash_open?: boolean;
toolbar_open?: boolean;
revision?: number | null;
conflict_detection_key?: string | null;
};
};
type HeightPayload = {
height?: number;
};
type BootstrapPayload = {
documentId: string;
workspaceId: string;
title: string | null;
content: unknown;
revision: number | null;
conflictDetectionKey: string | null;
readOnly: boolean;
};
function resolveRuntimeUrl() {
if (typeof window === "undefined") {
return `http://localhost:${RUNTIME_PORT}/`;
}
const cfg = getMnoteRuntimeConfig();
if (cfg.mnoteWebBaseUrl) {
try {
const url = new URL(cfg.mnoteWebBaseUrl);
url.port = String(RUNTIME_PORT);
url.pathname = "/";
url.search = "";
url.hash = "";
return url.toString();
} catch {
// ignore
}
}
try {
const url = new URL(window.location.href);
url.port = String(RUNTIME_PORT);
url.pathname = "/";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return `http://localhost:${RUNTIME_PORT}/`;
}
}
function normalizeRuntimeDoc(tiptapDoc: Json) {
if (!tiptapDoc || typeof tiptapDoc !== "object") {
return EMPTY_DOC;
}
const doc = tiptapDoc as { type?: unknown; content?: unknown };
if (doc.type !== "doc") {
return EMPTY_DOC;
}
if (!Array.isArray(doc.content) || doc.content.length === 0) {
return EMPTY_DOC;
}
return tiptapDoc;
}
function buildIframeSrc(runtimeUrl: string, props: DocumentEditorHostProps, reloadKey: number) {
const url = new URL(runtimeUrl);
url.searchParams.set("embedded", "1");
url.searchParams.set("host", "leptos_tiptap");
url.searchParams.set("documentId", props.documentId);
url.searchParams.set("workspaceId", props.workspaceId);
url.searchParams.set("reloadKey", String(reloadKey));
return url.toString();
}
function isBridgeEnvelope(value: unknown): value is HostEnvelope {
if (!value || typeof value !== "object") {
return false;
}
const maybe = value as HostEnvelope;
return maybe.protocol === PROTOCOL && maybe.runtime === RUNTIME_NAME && maybe.source === EVENT_PREFIX;
}
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 obj = value as { text?: unknown; content?: unknown };
return `${flattenText(obj.text)}${flattenText(obj.content)}`;
}
return "";
}
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 toIsoNow() {
return new Date().toISOString();
}
function coerceHeight(value: unknown): number {
const height = Number(value);
if (!Number.isFinite(height)) {
return FALLBACK_IFRAME_MIN_HEIGHT;
}
return Math.max(FALLBACK_IFRAME_MIN_HEIGHT, Math.ceil(height));
}
function postBridgeMessage(targetWindow: Window | null | undefined, event: string, payload: unknown) {
if (!targetWindow) return;
const envelope: HostEnvelope = {
protocol: PROTOCOL,
runtime: RUNTIME_NAME,
version: RUNTIME_VERSION,
source: EVENT_PREFIX,
event,
payload,
};
targetWindow.postMessage(envelope, "*");
}
export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const runtimeUrl = useMemo(() => resolveRuntimeUrl(), []);
const registerBridge = useEditorBridgeStore((state) => state.registerBridge);
const onSnapshotRef = useRef(props.onSnapshot);
const onStatsChangeRef = useRef(props.onStatsChange);
const onPersistedMetaChangeRef = useRef(props.onPersistedMetaChange);
const revisionRef = useRef<number | null>(props.initialRevision ?? null);
const conflictDetectionKeyRef = useRef<string | null>(props.initialConflictDetectionKey ?? null);
const bootstrapPayloadRef = useRef<BootstrapPayload | null>(null);
const [runtimeDoc, setRuntimeDoc] = useState<Json>(() => tiptapDocFromBlocks(props.initialContent as Json) as Json);
const [reloadKey, setReloadKey] = useState(0);
const [iframeHeight, setIframeHeight] = useState(FALLBACK_IFRAME_MIN_HEIGHT);
const [bridgeState, setBridgeState] = useState<LeptosTiptapHostBridgeState>({
ready: false,
runtimeName: RUNTIME_NAME,
runtimeVersion: RUNTIME_VERSION,
runtimeUrl,
documentId: props.documentId,
workspaceId: props.workspaceId,
status: "booting",
lastChangeAt: null,
lastSaveRequestAt: null,
lastError: null,
});
const iframeSrc = useMemo(
() => buildIframeSrc(runtimeUrl, props, reloadKey),
[props, reloadKey, runtimeUrl],
);
useEffect(() => {
onSnapshotRef.current = props.onSnapshot;
onStatsChangeRef.current = props.onStatsChange;
onPersistedMetaChangeRef.current = props.onPersistedMetaChange;
revisionRef.current = props.initialRevision ?? null;
conflictDetectionKeyRef.current = props.initialConflictDetectionKey ?? null;
}, [props.initialConflictDetectionKey, props.initialRevision, props.onPersistedMetaChange, props.onSnapshot, props.onStatsChange]);
useEffect(() => {
const nextRuntimeDoc = tiptapDocFromBlocks(props.initialContent as Json) as Json;
setRuntimeDoc(nextRuntimeDoc);
bootstrapPayloadRef.current = {
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: normalizeRuntimeDoc(nextRuntimeDoc),
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
};
setIframeHeight(FALLBACK_IFRAME_MIN_HEIGHT);
setReloadKey((value) => value + 1);
setBridgeState((prev) => ({
...prev,
ready: false,
documentId: props.documentId,
workspaceId: props.workspaceId,
status: "booting",
lastError: null,
}));
}, [props.documentId, props.initialContent, props.initialConflictDetectionKey, props.initialRevision, props.readOnly, props.title, props.workspaceId]);
useEffect(() => {
const iframeWindow = iframeRef.current?.contentWindow;
if (!iframeWindow) return;
const payload = bootstrapPayloadRef.current;
if (!payload) return;
postBridgeMessage(iframeWindow, REPLACE_DOCUMENT_EVENT, payload);
setBridgeState((prev) => ({ ...prev, status: prev.ready ? "reloading" : prev.status }));
}, [runtimeDoc]);
useEffect(() => {
const bridge: EditorReferenceBridge = {
insertInlineReference: () => ({ blockId: null }),
insertEmbedReference: () => ({ blockId: null }),
undo: () => {
// 现阶段 iframe runtime 还没有父->子撤销命令,先保持最小 bridge 兼容。
},
redo: () => {
// 现阶段 iframe runtime 还没有父->子重做命令,先保持最小 bridge 兼容。
},
getCursorBlockId: () => null,
replaceWithSnapshot: (blocks: Json) => {
const nextRuntimeDoc = tiptapDocFromBlocks(blocks) as Json;
setRuntimeDoc(nextRuntimeDoc);
bootstrapPayloadRef.current = {
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: normalizeRuntimeDoc(nextRuntimeDoc),
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
readOnly: Boolean(props.readOnly),
};
postBridgeMessage(iframeRef.current?.contentWindow, REPLACE_DOCUMENT_EVENT, bootstrapPayloadRef.current);
setBridgeState((prev) => ({
...prev,
ready: false,
status: "reloading",
lastError: null,
}));
},
};
registerBridge(bridge);
return () => registerBridge(null);
}, [props.documentId, props.readOnly, props.title, props.workspaceId, registerBridge]);
useEffect(() => {
let disposed = false;
const expectedOrigin = (() => {
try {
return new URL(runtimeUrl).origin;
} catch {
return "";
}
})();
const publishSnapshot = (payload: HostDocumentPayload) => {
const blocks = blocksFromTiptapDoc(payload.content) as Json;
const stats = buildStats(blocks);
setRuntimeDoc(normalizeRuntimeDoc(payload.content as Json) as Json);
onSnapshotRef.current?.({ blocks, stats });
onStatsChangeRef.current?.(stats);
return { blocks, stats };
};
const saveSnapshot = async (payload: HostDocumentPayload) => {
const normalizedRuntimeDoc = normalizeRuntimeDoc(payload.content as Json) as Json;
const { blocks } = publishSnapshot(payload);
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: payload.meta?.revision ?? revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
normalizedRuntimeDoc,
props.documentId,
),
content: blocks,
tiptapDocument: normalizedRuntimeDoc,
conflictDetectionKey:
payload.meta?.conflict_detection_key ?? conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(blocks) ? blocks.length : 0,
}),
),
});
const nextMeta = await response.json().catch(() => null);
if (!response.ok) {
const errorMessage = typeof nextMeta?.error === "string" ? nextMeta.error : `保存失败(${response.status}`;
throw new Error(errorMessage);
}
const nextRevision = typeof nextMeta?.revision === "number" ? nextMeta.revision : payload.meta?.revision ?? null;
const nextConflictDetectionKey =
typeof nextMeta?.conflictDetectionKey === "string"
? nextMeta.conflictDetectionKey
: payload.meta?.conflict_detection_key ?? null;
revisionRef.current = nextRevision;
conflictDetectionKeyRef.current = nextConflictDetectionKey;
onPersistedMetaChangeRef.current?.({
revision: nextRevision,
conflictDetectionKey: nextConflictDetectionKey,
});
};
const handleMessage = (event: MessageEvent<HostEnvelope>) => {
if (!expectedOrigin || event.origin !== expectedOrigin) {
return;
}
if (event.source !== iframeRef.current?.contentWindow) {
return;
}
if (!isBridgeEnvelope(event.data)) {
return;
}
const eventName = typeof event.data.event === "string" ? event.data.event : "";
const payload = (event.data.payload ?? {}) as HostDocumentPayload & HeightPayload;
if (eventName === READY_EVENT) {
const bootstrapPayload = bootstrapPayloadRef.current;
if (bootstrapPayload) {
postBridgeMessage(iframeRef.current?.contentWindow, BOOTSTRAP_EVENT, bootstrapPayload);
}
setBridgeState((prev) => ({ ...prev, ready: true, status: "ready", lastError: null }));
return;
}
if (eventName === STATE_EVENT) {
setBridgeState((prev) => ({ ...prev, ready: true, status: "editing", lastError: null }));
return;
}
if (eventName === HEIGHT_EVENT) {
setIframeHeight(coerceHeight(payload.height));
return;
}
if (eventName === CHANGE_EVENT) {
publishSnapshot(payload);
setBridgeState((prev) => ({ ...prev, ready: true, status: "dirty", lastChangeAt: toIsoNow(), lastError: null }));
return;
}
if (eventName === SAVE_REQUEST_EVENT) {
setBridgeState((prev) => ({
...prev,
ready: true,
status: "saving",
lastSaveRequestAt: toIsoNow(),
lastError: null,
}));
void saveSnapshot(payload)
.then(() => {
if (disposed) return;
setBridgeState((prev) => ({ ...prev, ready: true, status: "saved", lastError: null }));
})
.catch((error) => {
if (disposed) return;
setBridgeState((prev) => ({
...prev,
ready: true,
status: "error",
lastError: error instanceof Error ? error.message : "保存失败",
}));
});
}
};
window.addEventListener("message", handleMessage);
return () => {
disposed = true;
window.removeEventListener("message", handleMessage);
};
}, [props.documentId, props.workspaceId, runtimeUrl]);
return (
<div
className="rounded-xl border border-amber-200/80 bg-amber-50/70"
data-editor-host-kind="leptos_tiptap_iframe_debug"
>
<div className="flex items-center justify-between border-b border-amber-200 px-3 py-2 text-xs text-amber-800">
<span>`iframe + postMessage` prototype</span>
<span>{bridgeState.status}</span>
</div>
<iframe
key={`${props.documentId}:${reloadKey}`}
ref={iframeRef}
src={iframeSrc}
title="Leptos Tiptap Editor Debug Bridge"
className="w-full border-0"
style={{ height: `${iframeHeight}px`, minHeight: `${FALLBACK_IFRAME_MIN_HEIGHT}px` }}
data-bridge-status={bridgeState.status}
data-runtime-name={bridgeState.runtimeName}
data-runtime-version={bridgeState.runtimeVersion}
/>
</div>
);
}
@@ -0,0 +1,574 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DocumentEditorHostProps } 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 { DocumentStats } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import {
blocksFromTiptapDoc,
editorBlockDocumentFromTiptapDoc,
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { loadLeptosTiptapRuntime } from "@/components/editor/leptos-tiptap-runtime-loader";
type RuntimeSelectionState = Record<string, boolean | number | null | undefined> & {
current_block_index?: number | null;
current_block_from?: number | null;
current_block_to?: number | null;
selection_from?: number | null;
selection_to?: number | null;
selection_empty?: boolean;
};
type RuntimeSnapshot = {
tiptapDocument: unknown;
blocks: Json;
stats: DocumentStats;
};
const INLINE_RUNTIME_STATUS = {
booting: "booting",
ready: "ready",
dirty: "dirty",
saving: "saving",
saved: "saved",
error: "error",
} as const;
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 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 normalizeMountId(documentId: string): string {
return `leptos-tiptap-inline-${documentId.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
}
function readSelectionInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
}
function readBlockId(value: unknown): string | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as { id?: unknown; blockId?: unknown };
const candidate =
typeof record.id === "string" && record.id.trim()
? record.id
: typeof record.blockId === "string" && record.blockId.trim()
? record.blockId
: null;
return candidate ? candidate.trim() : null;
}
export function LeptosTiptapInlineEditorHost(props: DocumentEditorHostProps) {
const registerBridge = useEditorBridgeStore((state) => state.registerBridge);
const mountId = useMemo(() => normalizeMountId(props.documentId), [props.documentId]);
const generationRef = useRef<number | null>(null);
const onSnapshotRef = useRef(props.onSnapshot);
const onStatsChangeRef = useRef(props.onStatsChange);
const onPersistedMetaChangeRef = useRef(props.onPersistedMetaChange);
const selectionStateRef = useRef<RuntimeSelectionState>({});
const runtimeLoadedRef = useRef(false);
const revisionRef = useRef<number | null>(props.initialRevision ?? null);
const conflictDetectionKeyRef = useRef<string | null>(
props.initialConflictDetectionKey ?? null,
);
const latestSnapshotRef = useRef<RuntimeSnapshot | null>(null);
const [status, setStatus] = useState<string>(INLINE_RUNTIME_STATUS.booting);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
onSnapshotRef.current = props.onSnapshot;
onStatsChangeRef.current = props.onStatsChange;
onPersistedMetaChangeRef.current = props.onPersistedMetaChange;
revisionRef.current = props.initialRevision ?? null;
conflictDetectionKeyRef.current = props.initialConflictDetectionKey ?? null;
}, [
props.initialConflictDetectionKey,
props.initialRevision,
props.onPersistedMetaChange,
props.onSnapshot,
props.onStatsChange,
]);
const readCurrentSnapshot = async (): Promise<RuntimeSnapshot> => {
const generation = generationRef.current;
if (!runtimeLoadedRef.current || generation == null) {
throw new Error("inline host 尚未准备就绪");
}
const runtime = await loadLeptosTiptapRuntime();
const response = runtime.bridge.document({
id: mountId,
generation,
request: {
kind: "get_content",
format: "json",
},
});
if (!response.ok) {
throw new Error(response.error.message);
}
const tiptapDocument = response.value.content.value;
const blocks = blocksFromTiptapDoc(tiptapDocument) as Json;
const stats = buildStats(blocks);
const snapshot = { tiptapDocument, blocks, stats };
latestSnapshotRef.current = snapshot;
return snapshot;
};
const publishSnapshot = async () => {
const snapshot = await readCurrentSnapshot();
onSnapshotRef.current?.({
blocks: snapshot.blocks,
stats: snapshot.stats,
});
onStatsChangeRef.current?.(snapshot.stats);
return snapshot;
};
const persistSnapshot = async () => {
setStatus(INLINE_RUNTIME_STATUS.saving);
const snapshot = latestSnapshotRef.current ?? (await readCurrentSnapshot());
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(
snapshot.tiptapDocument,
props.documentId,
),
content: snapshot.blocks,
tiptapDocument: snapshot.tiptapDocument,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: new Date().toISOString(),
blockCount: snapshot.stats.blockCount,
}),
),
});
const payload = (await response.json().catch(() => null)) as
| {
error?: string;
revision?: number | null;
conflictDetectionKey?: string | null;
}
| null;
if (!response.ok) {
throw new Error(payload?.error ?? `保存失败(${response.status}`);
}
revisionRef.current =
typeof payload?.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload?.conflictDetectionKey === "string" &&
payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
});
setStatus(INLINE_RUNTIME_STATUS.saved);
setErrorMessage(null);
};
const syncExternalBlocksToRuntime = useCallback(
async (blocks: Json, options?: { emitUpdate?: boolean }) => {
const generation = generationRef.current;
if (!runtimeLoadedRef.current || generation == null) {
return;
}
const nextDocument = tiptapDocFromBlocks(blocks);
const nextStats = buildStats(blocks);
latestSnapshotRef.current = {
tiptapDocument: nextDocument,
blocks,
stats: nextStats,
};
onStatsChangeRef.current?.(nextStats);
const runtime = await loadLeptosTiptapRuntime();
const response = runtime.bridge.document({
id: mountId,
generation,
request: {
kind: "set_content",
content: {
format: "json",
value: nextDocument,
},
options: {
emit_update: Boolean(options?.emitUpdate),
},
},
});
if (!response.ok) {
throw new Error(response.error.message);
}
},
[mountId],
);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot().catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(error instanceof Error ? error.message : "保存失败");
});
}, 800);
const executeRuntimeCommand = useCallback(async (command: unknown) => {
const generation = generationRef.current;
if (!runtimeLoadedRef.current || generation == null) {
throw new Error("inline host 尚未准备就绪");
}
const runtime = await loadLeptosTiptapRuntime();
const response = runtime.bridge.command({
id: mountId,
generation,
command,
});
if (!response.ok) {
throw new Error(response.error.message);
}
}, [mountId]);
const resolveCurrentBlockIndex = () =>
readSelectionInteger(selectionStateRef.current.current_block_index);
const resolveCurrentBlockRange = () => {
const from = readSelectionInteger(selectionStateRef.current.current_block_from);
const to = readSelectionInteger(selectionStateRef.current.current_block_to);
if (from == null || to == null || to < from) {
return null;
}
return { from, to };
};
const resolveCurrentBlockId = () => {
const index = resolveCurrentBlockIndex();
if (index == null) {
return null;
}
const blocks = latestSnapshotRef.current?.blocks;
if (!Array.isArray(blocks)) {
return null;
}
return readBlockId(blocks[index]);
};
useEffect(() => {
let disposed = false;
runtimeLoadedRef.current = false;
generationRef.current = null;
selectionStateRef.current = {};
latestSnapshotRef.current = null;
setStatus(INLINE_RUNTIME_STATUS.booting);
setErrorMessage(null);
const initialDocument = tiptapDocFromBlocks(props.initialContent as Json);
void loadLeptosTiptapRuntime()
.then((runtime) => {
if (disposed) return;
runtime.bridge.create(
{
id: mountId,
content: {
format: "json",
value: initialDocument,
},
editable: !props.readOnly,
placeholder: "输入 '/' 选择,AI 将直接进入正文命令链",
extensions: [
"blockquote",
"bold",
"bullet_list",
"code",
"code_block",
"document",
"dropcursor",
"gapcursor",
"hard_break",
"heading",
"history",
"horizontal_rule",
"italic",
"link",
"list_item",
"ordered_list",
"paragraph",
"placeholder",
"strike",
"task_item",
"task_list",
"text",
"text_align",
"text_style",
"underline",
],
},
({ generation }) => {
if (disposed) return;
generationRef.current = generation;
runtimeLoadedRef.current = true;
setStatus(INLINE_RUNTIME_STATUS.ready);
void publishSnapshot().catch(() => {
// 忽略初次快照读取错误,保留编辑器可用。
});
},
() => {
if (disposed) return;
setStatus(INLINE_RUNTIME_STATUS.dirty);
void publishSnapshot().catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "读取 inline editor 快照失败",
);
});
debouncedPersist();
},
(selection) => {
selectionStateRef.current = selection as RuntimeSelectionState;
},
(error) => {
if (disposed) return;
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(error.message);
},
);
})
.catch((error) => {
if (disposed) return;
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "加载 leptos-tiptap runtime 失败",
);
});
return () => {
disposed = true;
debouncedPersist.cancel();
if (runtimeLoadedRef.current) {
void loadLeptosTiptapRuntime()
.then((runtime) => runtime.bridge.destroy(mountId))
.catch(() => {
// ignore
});
}
runtimeLoadedRef.current = false;
generationRef.current = null;
registerBridge(null);
};
}, [mountId, props.documentId, props.readOnly, registerBridge, debouncedPersist]);
useEffect(() => {
const currentBlocks = latestSnapshotRef.current?.blocks;
if (currentBlocks === props.initialContent) {
return;
}
if (!runtimeLoadedRef.current || generationRef.current == null) {
return;
}
void syncExternalBlocksToRuntime(props.initialContent as Json).catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "同步 inline editor 外部内容失败",
);
});
}, [props.initialContent, syncExternalBlocksToRuntime]);
useEffect(() => {
if (!runtimeLoadedRef.current || generationRef.current == null) {
return;
}
void executeRuntimeCommand({
kind: "set_editable",
editable: !props.readOnly,
})
.catch(() => {
// ignore
});
}, [executeRuntimeCommand, mountId, props.readOnly]);
useEffect(() => {
const bridge: EditorReferenceBridge = {
insertInlineReference: (target, aliasText) => {
const blockId = resolveCurrentBlockId();
const label = aliasText?.trim() || target.title || "无标题";
const href = `/documents/${target.id}`;
void (async () => {
await executeRuntimeCommand({ kind: "focus" });
await executeRuntimeCommand({
kind: "insert_content",
content: {
format: "json",
value: [
{
type: "text",
text: label,
marks: [{ type: "link", attrs: { href } }],
},
{
type: "text",
text: " ",
},
],
},
});
})().catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "插入行内引用失败",
);
});
return { blockId };
},
insertEmbedReference: (target) => {
const blockId = resolveCurrentBlockId();
const blockRange = resolveCurrentBlockRange();
const href = `/documents/${target.id}`;
const insertCommand = blockRange
? {
kind: "insert_content_at" as const,
position: blockRange.to + 1,
content: {
format: "json" as const,
value: {
type: "paragraph",
content: [
{
type: "text",
text: target.title || "无标题",
marks: [{ type: "link", attrs: { href } }],
},
],
},
},
options: {
update_selection: true,
},
}
: {
kind: "insert_content" as const,
content: {
format: "json" as const,
value: {
type: "paragraph",
content: [
{
type: "text",
text: target.title || "无标题",
marks: [{ type: "link", attrs: { href } }],
},
],
},
},
options: {
update_selection: true,
},
};
void (async () => {
await executeRuntimeCommand({ kind: "focus" });
await executeRuntimeCommand(insertCommand);
await executeRuntimeCommand({ kind: "select_textblock_end" });
})().catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "插入块级引用失败",
);
});
return { blockId };
},
undo: () => {
void executeRuntimeCommand({ kind: "undo" }).catch(() => {
// ignore
});
},
redo: () => {
void executeRuntimeCommand({ kind: "redo" }).catch(() => {
// ignore
});
},
getCursorBlockId: () => resolveCurrentBlockId(),
replaceWithSnapshot: (blocks: Json) => {
if (!runtimeLoadedRef.current || generationRef.current == null) return;
void syncExternalBlocksToRuntime(blocks, { emitUpdate: true })
.catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "写入 inline editor 内容失败",
);
});
},
};
registerBridge(bridge);
return () => registerBridge(null);
}, [executeRuntimeCommand, mountId, registerBridge, syncExternalBlocksToRuntime]);
return (
<div
className="rounded-xl border border-emerald-200/80 bg-white/90 shadow-sm"
data-editor-host-kind="leptos_tiptap_inline"
data-inline-editor-status={status}
>
<div className="flex items-center justify-between border-b border-emerald-100 px-3 py-2 text-xs text-emerald-700">
<span> `leptos-tiptap inline island`</span>
<span>{status}</span>
</div>
{errorMessage ? (
<div className="border-b border-red-100 bg-red-50 px-3 py-2 text-xs text-red-700">
{errorMessage}
</div>
) : null}
<div
id={mountId}
className="min-h-[720px] px-6 py-5"
data-testid="mnote-leptos-tiptap-inline-editor-root"
/>
</div>
);
}
@@ -0,0 +1,101 @@
"use client";
const MANIFEST_URL = "/api/leptos-tiptap-runtime/manifest.json";
type RuntimeManifest = {
bridgeRuntimePath: string | null;
extensionModulePaths: string[];
generatedRootPath: string | null;
entryScriptPath: string | null;
wasmPath: string | null;
};
type BridgeSuccess<T> = {
ok: true;
value: T;
};
type BridgeFailure = {
ok: false;
error: {
kind: string;
message: string;
operation?: string;
};
};
type BridgeResult<T> = BridgeSuccess<T> | BridgeFailure;
type BridgeRuntimeModule = {
init_bridge_runtime: () => void;
create: (
request: unknown,
onReady: (payload: { generation: number }) => void,
onChange: () => void,
onSelectionChange: (selection: Record<string, unknown>) => void,
onError: (error: { kind: string; message: string; operation?: string }) => void,
) => void;
destroy: (id: string) => void;
command: (request: unknown) => BridgeResult<{ kind: "empty" }>;
document: (request: unknown) => BridgeResult<{ kind: "content"; content: { format: "json" | "html"; value: unknown } }>;
};
export type LoadedLeptosTiptapRuntime = {
manifest: RuntimeManifest;
bridge: BridgeRuntimeModule;
};
let runtimePromise: Promise<LoadedLeptosTiptapRuntime> | null = null;
function toRuntimeAssetUrl(relativePath: string): string {
return `/api/leptos-tiptap-runtime/${relativePath}`;
}
async function importRuntimeModule<T>(relativePath: string): Promise<T> {
return import(/* webpackIgnore: true */ toRuntimeAssetUrl(relativePath)) as Promise<T>;
}
function extractRegisterFunction(module: Record<string, unknown>): (() => void) | null {
for (const value of Object.values(module)) {
if (typeof value === "function") {
return value as () => void;
}
}
return null;
}
async function fetchRuntimeManifest(): Promise<RuntimeManifest> {
const response = await fetch(MANIFEST_URL, { cache: "no-store" });
if (!response.ok) {
throw new Error(`读取 leptos-tiptap runtime manifest 失败(${response.status}`);
}
const manifest = (await response.json()) as RuntimeManifest;
if (!manifest.bridgeRuntimePath) {
throw new Error("runtime manifest 缺少 bridgeRuntimePath");
}
return manifest;
}
async function loadRuntimeModules(): Promise<LoadedLeptosTiptapRuntime> {
const manifest = await fetchRuntimeManifest();
const bridge = await importRuntimeModule<BridgeRuntimeModule>(manifest.bridgeRuntimePath!);
bridge.init_bridge_runtime();
for (const modulePath of manifest.extensionModulePaths) {
const extensionModule = await importRuntimeModule<Record<string, unknown>>(modulePath);
const register = extractRegisterFunction(extensionModule);
register?.();
}
return { manifest, bridge };
}
export async function loadLeptosTiptapRuntime(): Promise<LoadedLeptosTiptapRuntime> {
if (!runtimePromise) {
runtimePromise = loadRuntimeModules().catch((error) => {
runtimePromise = null;
throw error;
});
}
return runtimePromise;
}
@@ -34,6 +34,7 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
@@ -1448,7 +1449,12 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
});
await refreshTree();
router.push(`/documents/${nextNode.id}`);
const query = new URLSearchParams();
query.set("edit", "1");
if (nextNode.workspace_id) {
query.set("workspaceId", nextNode.workspace_id);
}
router.push(`/documents/${nextNode.id}?${query.toString()}`);
} catch (error) {
window.alert(error instanceof Error ? error.message : "新建页面失败,请稍后再试");
} finally {