主编辑区改造准备
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user