主编辑区改造准备
This commit is contained in:
@@ -9,6 +9,7 @@ import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/do
|
||||
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
|
||||
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
|
||||
import { normalizeEditorHostKind, type EditorHostKind } from "@/components/editor/editor-host-config";
|
||||
|
||||
interface DocumentPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -127,6 +128,9 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
const resolvedSearch = (await searchParams) ?? {};
|
||||
const openTableIdRaw = resolvedSearch?.openTableId;
|
||||
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
|
||||
const editorHostRaw = resolvedSearch?.editorHost;
|
||||
const editorHostKind: EditorHostKind =
|
||||
typeof editorHostRaw === "string" ? normalizeEditorHostKind(editorHostRaw) : "blocknote";
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const workspaceIdRaw = resolvedSearch?.workspaceId;
|
||||
@@ -187,6 +191,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
initialOptions={initialOptions}
|
||||
initialStats={initialStats}
|
||||
openTableId={openTableId}
|
||||
editorHostKind={editorHostKind}
|
||||
readOnly={readOnly}
|
||||
disableDownload={disableDownload}
|
||||
disableCopy={disableCopy}
|
||||
|
||||
@@ -15,14 +15,18 @@ import {
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
const body = await request.json() as Partial<DocumentSavePayload> & { content: unknown };
|
||||
const body = await request.json() as Partial<DocumentSavePayload> & { content?: unknown };
|
||||
const normalizedDocumentId = assertDocumentId(body.documentId);
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: normalizedDocumentId,
|
||||
workspaceId: body.workspaceId,
|
||||
revision: body.revision,
|
||||
content: body.content as DocumentSavePayload["content"],
|
||||
editorDocument: body.editorDocument,
|
||||
content: body.content as DocumentSavePayload["content"] | undefined,
|
||||
tiptapDocument: body.tiptapDocument,
|
||||
conflictDetectionKey: body.conflictDetectionKey,
|
||||
snapshotCapturedAt: body.snapshotCapturedAt,
|
||||
blockCount: body.blockCount,
|
||||
});
|
||||
const normalizedWorkspaceId = payload.workspaceId;
|
||||
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RuntimeManifest = {
|
||||
bridgeRuntimePath: string | null;
|
||||
extensionModulePaths: string[];
|
||||
generatedRootPath: string | null;
|
||||
entryScriptPath: string | null;
|
||||
wasmPath: string | null;
|
||||
};
|
||||
|
||||
const DIST_ROOT = path.resolve(process.cwd(), "..", "rust", "spikes", "leptos-tiptap-spike", "dist");
|
||||
const ENTRY_SCRIPT_PATTERN = /^mnote-leptos-tiptap-spike-.*\.js$/;
|
||||
const WASM_PATTERN = /^mnote-leptos-tiptap-spike-.*_bg\.wasm$/;
|
||||
const EXTENSION_MODULE_PATTERN = /^tiptap_[a-z0-9_]+\.js$/;
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function sanitizeRelativePath(asset: string[]): string | null {
|
||||
if (!Array.isArray(asset) || asset.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const decoded = asset.map((segment) => decodeURIComponent(segment));
|
||||
const joined = decoded.join("/");
|
||||
if (!joined || joined.includes("\0")) {
|
||||
return null;
|
||||
}
|
||||
const normalized = path.posix.normalize(joined);
|
||||
if (normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function walkFiles(rootDir: string): Promise<string[]> {
|
||||
const output: string[] = [];
|
||||
const queue: string[] = [rootDir];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
const entries = await fs.readdir(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const absolute = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
queue.push(absolute);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const relative = path.relative(rootDir, absolute);
|
||||
output.push(toPosixPath(relative));
|
||||
}
|
||||
}
|
||||
|
||||
return output.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
async function buildRuntimeManifest(): Promise<RuntimeManifest> {
|
||||
const files = await walkFiles(DIST_ROOT);
|
||||
const bridgeRuntimePath = files.find((item) => item.endsWith("/bridge_runtime.js") || item === "bridge_runtime.js") ?? null;
|
||||
const generatedRootPath = bridgeRuntimePath ? path.posix.dirname(bridgeRuntimePath) : null;
|
||||
const extensionModulePaths = generatedRootPath
|
||||
? files.filter((item) => {
|
||||
if (!item.startsWith(`${generatedRootPath}/`)) {
|
||||
return false;
|
||||
}
|
||||
const basename = path.posix.basename(item);
|
||||
return EXTENSION_MODULE_PATTERN.test(basename);
|
||||
})
|
||||
: [];
|
||||
const entryScriptPath =
|
||||
files.find((item) => ENTRY_SCRIPT_PATTERN.test(path.posix.basename(item))) ?? null;
|
||||
const wasmPath = files.find((item) => WASM_PATTERN.test(path.posix.basename(item))) ?? null;
|
||||
|
||||
return {
|
||||
bridgeRuntimePath,
|
||||
extensionModulePaths,
|
||||
generatedRootPath,
|
||||
entryScriptPath,
|
||||
wasmPath,
|
||||
};
|
||||
}
|
||||
|
||||
function guessContentType(absolutePath: string): string {
|
||||
const extension = path.extname(absolutePath).toLowerCase();
|
||||
if (extension === ".js" || extension === ".mjs") {
|
||||
return "application/javascript; charset=utf-8";
|
||||
}
|
||||
if (extension === ".wasm") {
|
||||
return "application/wasm";
|
||||
}
|
||||
if (extension === ".json") {
|
||||
return "application/json; charset=utf-8";
|
||||
}
|
||||
if (extension === ".html") {
|
||||
return "text/html; charset=utf-8";
|
||||
}
|
||||
if (extension === ".css") {
|
||||
return "text/css; charset=utf-8";
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ asset?: string[] }> },
|
||||
) {
|
||||
const params = await context.params;
|
||||
const asset = params.asset ?? [];
|
||||
|
||||
if (asset.length === 1 && asset[0] === "manifest.json") {
|
||||
try {
|
||||
const manifest = await buildRuntimeManifest();
|
||||
return NextResponse.json(manifest, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "无法生成 leptos-tiptap runtime 清单",
|
||||
detail: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const relativePath = sanitizeRelativePath(asset);
|
||||
if (!relativePath) {
|
||||
return NextResponse.json({ error: "非法 runtime 资源路径" }, { status: 400 });
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(DIST_ROOT, relativePath);
|
||||
if (!absolutePath.startsWith(DIST_ROOT + path.sep)) {
|
||||
return NextResponse.json({ error: "越界访问 runtime 资源被拒绝" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(absolutePath);
|
||||
return new NextResponse(fileContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": guessContentType(absolutePath),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
return NextResponse.json({ error: "runtime 资源不存在" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "读取 runtime 资源失败",
|
||||
detail: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,12 +2,24 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
|
||||
describe("buildDocumentSavePayload", () => {
|
||||
it("统一规范 documents.save 的共享 payload", () => {
|
||||
it("优先规范 editorDocument 并从它派生 legacy content", () => {
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: " doc_1 ",
|
||||
workspaceId: " ws_1 ",
|
||||
revision: 3,
|
||||
content: [{ id: "block_1", type: "paragraph", content: [] }],
|
||||
editorDocument: {
|
||||
documentId: "other_doc",
|
||||
rootBlockIds: ["block_1"],
|
||||
blocks: [
|
||||
{
|
||||
blockId: "block_1",
|
||||
blockType: "paragraph",
|
||||
contentNodes: [{ type: "text", text: "新正文" }],
|
||||
childBlockIds: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
content: [{ id: "legacy_1", type: "paragraph", content: "旧正文" }],
|
||||
conflictDetectionKey: " conflict_1 ",
|
||||
});
|
||||
|
||||
@@ -15,27 +27,103 @@ describe("buildDocumentSavePayload", () => {
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
revision: 3,
|
||||
content: [{ id: "block_1", type: "paragraph", content: [] }],
|
||||
editorDocument: {
|
||||
documentId: "doc_1",
|
||||
rootBlockIds: ["block_1"],
|
||||
blocks: [
|
||||
{
|
||||
blockId: "block_1",
|
||||
blockType: "paragraph",
|
||||
props: {},
|
||||
contentNodes: [{ type: "text", text: "新正文", marks: [] }],
|
||||
childBlockIds: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
content: [{ id: "block_1", type: "paragraph", props: undefined, content: "新正文" }],
|
||||
tiptapDocument: null,
|
||||
conflictDetectionKey: "conflict_1",
|
||||
snapshotCapturedAt: null,
|
||||
blockCount: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("非法 revision/conflictDetectionKey 会回退到 null", () => {
|
||||
it("兼容仅 content 的旧 payload,并补齐 editorDocument", () => {
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "",
|
||||
revision: -1,
|
||||
content: [],
|
||||
conflictDetectionKey: " ",
|
||||
content: [{ id: "heading_1", type: "heading", props: { level: 2 }, content: "章节标题" }],
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
documentId: "doc_1",
|
||||
workspaceId: null,
|
||||
revision: null,
|
||||
content: [],
|
||||
editorDocument: {
|
||||
documentId: "doc_1",
|
||||
rootBlockIds: ["heading_1"],
|
||||
blocks: [
|
||||
{
|
||||
blockId: "heading_1",
|
||||
blockType: "heading",
|
||||
props: { headingLevel: 2 },
|
||||
contentNodes: [{ type: "text", text: "章节标题", marks: [] }],
|
||||
childBlockIds: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
content: [{ id: "heading_1", type: "heading", props: { level: 2 }, content: "章节标题" }],
|
||||
tiptapDocument: null,
|
||||
conflictDetectionKey: null,
|
||||
snapshotCapturedAt: null,
|
||||
blockCount: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("支持将 tiptapDocument 适配为 editorDocument 与兼容 content", () => {
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
tiptapDocument: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "p_1" },
|
||||
content: [{ type: "text", text: "来自 tiptap" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
revision: null,
|
||||
editorDocument: {
|
||||
documentId: "doc_1",
|
||||
rootBlockIds: ["p_1"],
|
||||
blocks: [
|
||||
{
|
||||
blockId: "p_1",
|
||||
blockType: "paragraph",
|
||||
props: {},
|
||||
contentNodes: [{ type: "text", text: "来自 tiptap", marks: [] }],
|
||||
childBlockIds: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
content: [{ id: "p_1", type: "paragraph", props: undefined, content: "来自 tiptap" }],
|
||||
tiptapDocument: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "p_1" },
|
||||
content: [{ type: "text", text: "来自 tiptap" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
conflictDetectionKey: null,
|
||||
snapshotCapturedAt: null,
|
||||
blockCount: null,
|
||||
@@ -53,14 +141,9 @@ describe("buildDocumentSavePayload", () => {
|
||||
blockCount: 1,
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
revision: 4,
|
||||
content: [{ id: "block_1" }],
|
||||
conflictDetectionKey: "doc_1:4",
|
||||
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
|
||||
blockCount: 1,
|
||||
});
|
||||
expect(payload.revision).toBe(4);
|
||||
expect(payload.conflictDetectionKey).toBe("doc_1:4");
|
||||
expect(payload.snapshotCapturedAt).toBe("2026-04-14T14:30:00.000Z");
|
||||
expect(payload.blockCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,78 @@
|
||||
import type { Json } from "@/types/supabase";
|
||||
import {
|
||||
editorBlockDocumentFromContent,
|
||||
editorBlockDocumentFromTiptapDoc,
|
||||
legacyBlocksFromEditorBlockDocument,
|
||||
normalizeEditorBlockDocument,
|
||||
type EditorBlockDocument,
|
||||
type TiptapDoc,
|
||||
} from "@/lib/documents/tiptap-content-converter";
|
||||
|
||||
export type DocumentSavePayload = {
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
revision: number | null;
|
||||
editorDocument: EditorBlockDocument;
|
||||
content: Json;
|
||||
tiptapDocument: TiptapDoc | null;
|
||||
conflictDetectionKey: string | null;
|
||||
snapshotCapturedAt: string | null;
|
||||
blockCount: number | null;
|
||||
};
|
||||
|
||||
export function buildDocumentSavePayload(input: {
|
||||
type BuildDocumentSavePayloadInput = {
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
revision?: number | null;
|
||||
content: Json;
|
||||
editorDocument?: unknown;
|
||||
content?: Json;
|
||||
tiptapDocument?: unknown;
|
||||
conflictDetectionKey?: string | null;
|
||||
snapshotCapturedAt?: string | null;
|
||||
blockCount?: number | null;
|
||||
}): DocumentSavePayload {
|
||||
};
|
||||
|
||||
function resolveEditorDocument(
|
||||
input: BuildDocumentSavePayloadInput,
|
||||
normalizedDocumentId: string,
|
||||
): EditorBlockDocument {
|
||||
if (input.editorDocument != null) {
|
||||
return normalizeEditorBlockDocument(input.editorDocument, normalizedDocumentId);
|
||||
}
|
||||
if (typeof input.content !== "undefined") {
|
||||
return normalizeEditorBlockDocument(
|
||||
editorBlockDocumentFromContent(input.content),
|
||||
normalizedDocumentId,
|
||||
);
|
||||
}
|
||||
if (input.tiptapDocument != null) {
|
||||
return normalizeEditorBlockDocument(
|
||||
editorBlockDocumentFromTiptapDoc(input.tiptapDocument, normalizedDocumentId),
|
||||
normalizedDocumentId,
|
||||
);
|
||||
}
|
||||
return normalizeEditorBlockDocument(
|
||||
{
|
||||
documentId: normalizedDocumentId,
|
||||
rootBlockIds: [],
|
||||
blocks: [],
|
||||
},
|
||||
normalizedDocumentId,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDocumentSavePayload(
|
||||
input: BuildDocumentSavePayloadInput,
|
||||
): DocumentSavePayload {
|
||||
const documentId = input.documentId.trim();
|
||||
const editorDocument = resolveEditorDocument(input, documentId);
|
||||
const shouldDeriveLegacyContent =
|
||||
input.editorDocument != null ||
|
||||
input.tiptapDocument != null ||
|
||||
typeof input.content === "undefined";
|
||||
const content: Json = shouldDeriveLegacyContent
|
||||
? (legacyBlocksFromEditorBlockDocument(editorDocument) as Json)
|
||||
: (input.content as Json);
|
||||
const revision =
|
||||
typeof input.revision === "number" && Number.isInteger(input.revision) && input.revision >= 0
|
||||
? input.revision
|
||||
@@ -35,12 +89,18 @@ export function buildDocumentSavePayload(input: {
|
||||
typeof input.blockCount === "number" && Number.isInteger(input.blockCount) && input.blockCount >= 0
|
||||
? input.blockCount
|
||||
: null;
|
||||
const tiptapDocument =
|
||||
input.tiptapDocument && typeof input.tiptapDocument === "object"
|
||||
? (input.tiptapDocument as TiptapDoc)
|
||||
: null;
|
||||
|
||||
return {
|
||||
documentId: input.documentId.trim(),
|
||||
documentId,
|
||||
workspaceId: input.workspaceId?.trim() || null,
|
||||
revision,
|
||||
content: input.content,
|
||||
editorDocument,
|
||||
content,
|
||||
tiptapDocument,
|
||||
conflictDetectionKey,
|
||||
snapshotCapturedAt,
|
||||
blockCount,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { createDocumentCommand } from "@/lib/documents/tree-command-client";
|
||||
|
||||
export function SidebarCreateDocumentEntry() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const edit = searchParams.get("edit");
|
||||
if (edit !== "1") return;
|
||||
}, [searchParams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createDocumentAndOpenEdit(router: ReturnType<typeof useRouter>, parentId: string | null) {
|
||||
const payload = await createDocumentCommand(parentId);
|
||||
const query = new URLSearchParams();
|
||||
query.set("edit", "1");
|
||||
const workspaceId = payload.workspace_id ?? null;
|
||||
if (workspaceId) query.set("workspaceId", workspaceId);
|
||||
router.push(`/documents/${payload.id}?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
blocksFromTiptapDoc,
|
||||
editorBlockDocumentFromContent,
|
||||
editorBlockDocumentFromTiptapDoc,
|
||||
tiptapDocFromBlocks,
|
||||
tiptapDocFromEditorBlockDocument,
|
||||
} from "@/lib/documents/tiptap-content-converter";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("tiptap-content-converter", () => {
|
||||
it("keeps empty content editable", () => {
|
||||
expect(tiptapDocFromBlocks([])).toEqual({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", attrs: {}, content: [] }],
|
||||
});
|
||||
expect(blocksFromTiptapDoc({ type: "doc", content: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves stable block ids through paragraph round-trip", () => {
|
||||
const blocks = [{ id: "p1", type: "paragraph", content: "你好,世界" }];
|
||||
expect(blocksFromTiptapDoc(tiptapDocFromBlocks(blocks as never))).toEqual([
|
||||
{ id: "p1", type: "paragraph", props: undefined, content: "你好,世界" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips p0.5 block families through editor block document", () => {
|
||||
const document = editorBlockDocumentFromContent([
|
||||
{ id: "p1", type: "paragraph", content: "段落" },
|
||||
{ id: "h1", type: "heading", props: { level: 2 }, content: "标题" },
|
||||
{ id: "b1", type: "bullet_list_item", content: "项目" },
|
||||
{ id: "n1", type: "numbered_list_item", content: "序号" },
|
||||
{ id: "t1", type: "todo", props: { checked: true }, content: "待办" },
|
||||
{ id: "q1", type: "quote", content: "引用" },
|
||||
{ id: "c1", type: "code_block", props: { language: "ts" }, content: "const x = 1" },
|
||||
] as never);
|
||||
|
||||
const tiptapDoc = tiptapDocFromEditorBlockDocument(document);
|
||||
const roundTrip = editorBlockDocumentFromTiptapDoc(tiptapDoc, "doc-1");
|
||||
|
||||
expect(roundTrip.rootBlockIds).toEqual(["p1", "h1", "b1", "n1", "t1", "q1", "c1"]);
|
||||
expect(roundTrip.blocks.map((block) => [block.blockId, block.blockType])).toEqual([
|
||||
["p1", "paragraph"],
|
||||
["h1", "heading"],
|
||||
["b1", "bullet_list_item"],
|
||||
["n1", "numbered_list_item"],
|
||||
["t1", "todo"],
|
||||
["q1", "quote"],
|
||||
["c1", "code_block"],
|
||||
]);
|
||||
expect(roundTrip.blocks[1].props?.headingLevel).toBe(2);
|
||||
expect(roundTrip.blocks[4].props?.checked).toBe(true);
|
||||
expect(roundTrip.blocks[6].props?.language).toBe("ts");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* TipTap 与 EditorBlockDocument 的适配层。
|
||||
* 约定:EditorBlockDocument 是正式语义边界,legacy blocks 仅用于兼容存储与旧链路。
|
||||
*/
|
||||
export type TiptapMark = {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TiptapNode = {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
content?: TiptapNode[];
|
||||
text?: string;
|
||||
marks?: TiptapMark[];
|
||||
};
|
||||
|
||||
export type TiptapDoc = {
|
||||
type: "doc";
|
||||
content: TiptapNode[];
|
||||
};
|
||||
|
||||
export type EditorTextMark = "bold" | "italic" | "underline" | "strike" | "code";
|
||||
|
||||
export type EditorBlockType =
|
||||
| "paragraph"
|
||||
| "heading"
|
||||
| "bullet_list_item"
|
||||
| "numbered_list_item"
|
||||
| "todo"
|
||||
| "quote"
|
||||
| "code_block";
|
||||
|
||||
export type EditorContentNode = {
|
||||
type: "text";
|
||||
text: string;
|
||||
marks?: EditorTextMark[];
|
||||
};
|
||||
|
||||
export type EditorBlock = {
|
||||
blockId: string;
|
||||
blockType: EditorBlockType;
|
||||
props?: {
|
||||
headingLevel?: number | null;
|
||||
checked?: boolean | null;
|
||||
language?: string | null;
|
||||
};
|
||||
contentNodes?: EditorContentNode[];
|
||||
childBlockIds?: string[];
|
||||
};
|
||||
|
||||
export type EditorBlockDocument = {
|
||||
documentId: string;
|
||||
rootBlockIds: string[];
|
||||
blocks: EditorBlock[];
|
||||
};
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
type LegacyBlockLike = {
|
||||
id?: string;
|
||||
blockId?: string;
|
||||
type?: string;
|
||||
blockType?: string;
|
||||
content?: unknown;
|
||||
contentNodes?: unknown;
|
||||
props?: Record<string, unknown>;
|
||||
children?: unknown;
|
||||
};
|
||||
|
||||
const EMPTY_DOC: TiptapDoc = {
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", attrs: {}, content: [] }],
|
||||
};
|
||||
|
||||
const MARK_NAME_MAP: Record<string, EditorTextMark> = {
|
||||
bold: "bold",
|
||||
italic: "italic",
|
||||
underline: "underline",
|
||||
strike: "strike",
|
||||
code: "code",
|
||||
};
|
||||
|
||||
const BLOCK_ID_ATTR = "blockId";
|
||||
|
||||
function coerceTextMark(value: unknown): EditorTextMark | null {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
return MARK_NAME_MAP[normalized] ?? null;
|
||||
}
|
||||
|
||||
function readMarks(marks: unknown): EditorTextMark[] {
|
||||
if (!Array.isArray(marks)) return [];
|
||||
const normalized = marks
|
||||
.map((mark) => {
|
||||
if (typeof mark === "string") return coerceTextMark(mark);
|
||||
if (mark && typeof mark === "object") return coerceTextMark((mark as { type?: unknown }).type);
|
||||
return null;
|
||||
})
|
||||
.filter((mark): mark is EditorTextMark => Boolean(mark));
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
|
||||
function toText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value.map(toText).join("");
|
||||
if (value && typeof value === "object") {
|
||||
const obj = value as { text?: unknown; content?: unknown; value?: unknown; contentNodes?: unknown };
|
||||
return `${toText(obj.text)}${toText(obj.value)}${toText(obj.content)}${toText(obj.contentNodes)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function clampHeadingLevel(value: unknown): number {
|
||||
const level = Number(value);
|
||||
return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1;
|
||||
}
|
||||
|
||||
function normalizeBlockId(value: unknown, fallback: string): string {
|
||||
const raw = typeof value === "string" ? value.trim() : "";
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
function normalizeEditorContentNodes(input: unknown): EditorContentNode[] {
|
||||
if (Array.isArray(input)) {
|
||||
const nodes = input.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const record = item as { type?: unknown; text?: unknown; marks?: unknown; payload?: unknown };
|
||||
if (record.type === "text") {
|
||||
const text = toText(record.text);
|
||||
return text ? [{ type: "text" as const, text, marks: readMarks(record.marks) }] : [];
|
||||
}
|
||||
if (record.payload && typeof record.payload === "object") {
|
||||
const payload = record.payload as { type?: unknown; text?: unknown; marks?: unknown };
|
||||
if (payload.type === "text") {
|
||||
const text = toText(payload.text);
|
||||
return text ? [{ type: "text" as const, text, marks: readMarks(payload.marks) }] : [];
|
||||
}
|
||||
}
|
||||
const text = toText(item);
|
||||
return text ? [{ type: "text" as const, text, marks: [] }] : [];
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
const text = toText(input);
|
||||
return text ? [{ type: "text", text, marks: [] }] : [];
|
||||
}
|
||||
|
||||
function normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBlock {
|
||||
const blockType = String(block.blockType ?? block.type ?? "paragraph").trim().toLowerCase();
|
||||
const blockId = normalizeBlockId(block.blockId ?? block.id, `block-${index + 1}`);
|
||||
const props = block.props ?? {};
|
||||
const contentNodes = normalizeEditorContentNodes(block.contentNodes ?? block.content ?? props.content ?? props.text ?? props.title);
|
||||
|
||||
switch (blockType) {
|
||||
case "heading":
|
||||
return {
|
||||
blockId,
|
||||
blockType: "heading",
|
||||
props: { headingLevel: clampHeadingLevel(props.level ?? props.headingLevel) },
|
||||
contentNodes,
|
||||
childBlockIds: [],
|
||||
};
|
||||
case "bullet_list_item":
|
||||
case "bullet_list":
|
||||
case "bullet-list":
|
||||
return { blockId, blockType: "bullet_list_item", props: {}, contentNodes, childBlockIds: [] };
|
||||
case "numbered_list_item":
|
||||
case "ordered_list":
|
||||
case "ordered-list":
|
||||
return { blockId, blockType: "numbered_list_item", props: {}, contentNodes, childBlockIds: [] };
|
||||
case "todo":
|
||||
case "task":
|
||||
return {
|
||||
blockId,
|
||||
blockType: "todo",
|
||||
props: { checked: Boolean(props.checked) },
|
||||
contentNodes,
|
||||
childBlockIds: [],
|
||||
};
|
||||
case "quote":
|
||||
case "blockquote":
|
||||
return { blockId, blockType: "quote", props: {}, contentNodes, childBlockIds: [] };
|
||||
case "code":
|
||||
case "code_block":
|
||||
case "code-block":
|
||||
return {
|
||||
blockId,
|
||||
blockType: "code_block",
|
||||
props: { language: typeof props.language === "string" ? props.language : null },
|
||||
contentNodes,
|
||||
childBlockIds: [],
|
||||
};
|
||||
case "paragraph":
|
||||
case "text":
|
||||
default:
|
||||
return { blockId, blockType: "paragraph", props: {}, contentNodes, childBlockIds: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInputToEditorDocument(value: Json): EditorBlockDocument {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const maybe = value as Partial<EditorBlockDocument>;
|
||||
if (typeof maybe.documentId === "string" && Array.isArray(maybe.blocks)) {
|
||||
const normalizedBlocks = maybe.blocks.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index));
|
||||
const rootBlockIds = Array.isArray(maybe.rootBlockIds) && maybe.rootBlockIds.length > 0
|
||||
? maybe.rootBlockIds.map((id, index) => normalizeBlockId(id, normalizedBlocks[index]?.blockId ?? `block-${index + 1}`))
|
||||
: normalizedBlocks.map((block) => block.blockId);
|
||||
return {
|
||||
documentId: maybe.documentId,
|
||||
rootBlockIds,
|
||||
blocks: normalizedBlocks,
|
||||
};
|
||||
}
|
||||
if (Array.isArray((value as { blocks?: unknown }).blocks)) {
|
||||
const blocks = ((value as { blocks: unknown[] }).blocks ?? []).map((block, index) =>
|
||||
normalizeLegacyBlock(block as LegacyBlockLike, index),
|
||||
);
|
||||
return {
|
||||
documentId: typeof (value as { documentId?: unknown }).documentId === "string"
|
||||
? String((value as { documentId?: unknown }).documentId)
|
||||
: "document",
|
||||
rootBlockIds: blocks.map((block) => block.blockId),
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = Array.isArray(value)
|
||||
? value.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index))
|
||||
: [];
|
||||
|
||||
return {
|
||||
documentId: "document",
|
||||
rootBlockIds: blocks.map((block) => block.blockId),
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
function textNodesToInline(contentNodes: EditorContentNode[] | undefined): TiptapNode[] {
|
||||
return (contentNodes ?? []).flatMap((node) => {
|
||||
const text = toText(node.text);
|
||||
if (!text) return [];
|
||||
const marks = (node.marks ?? [])
|
||||
.map((mark) => ({ type: mark }))
|
||||
.filter((mark) => Boolean(mark.type));
|
||||
return [{ type: "text", text, marks: marks.length > 0 ? marks : undefined }];
|
||||
});
|
||||
}
|
||||
|
||||
function blockToTiptapNode(block: EditorBlock): TiptapNode {
|
||||
const commonAttrs: Record<string, unknown> = { [BLOCK_ID_ATTR]: block.blockId };
|
||||
switch (block.blockType) {
|
||||
case "heading":
|
||||
return {
|
||||
type: "heading",
|
||||
attrs: { ...commonAttrs, level: clampHeadingLevel(block.props?.headingLevel) },
|
||||
content: textNodesToInline(block.contentNodes),
|
||||
};
|
||||
case "bullet_list_item":
|
||||
return {
|
||||
type: "bulletList",
|
||||
attrs: commonAttrs,
|
||||
content: [{ type: "listItem", attrs: commonAttrs, content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],
|
||||
};
|
||||
case "numbered_list_item":
|
||||
return {
|
||||
type: "orderedList",
|
||||
attrs: commonAttrs,
|
||||
content: [{ type: "listItem", attrs: commonAttrs, content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],
|
||||
};
|
||||
case "todo":
|
||||
return {
|
||||
type: "taskList",
|
||||
attrs: commonAttrs,
|
||||
content: [{ type: "taskItem", attrs: { ...commonAttrs, checked: Boolean(block.props?.checked) }, content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],
|
||||
};
|
||||
case "quote":
|
||||
return {
|
||||
type: "blockquote",
|
||||
attrs: commonAttrs,
|
||||
content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }],
|
||||
};
|
||||
case "code_block":
|
||||
return {
|
||||
type: "codeBlock",
|
||||
attrs: { ...commonAttrs, language: block.props?.language ?? null },
|
||||
content: textNodesToInline(block.contentNodes),
|
||||
};
|
||||
case "paragraph":
|
||||
default:
|
||||
return {
|
||||
type: "paragraph",
|
||||
attrs: commonAttrs,
|
||||
content: textNodesToInline(block.contentNodes),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function extractInlineContent(node: TiptapNode | undefined): EditorContentNode[] {
|
||||
if (!node) return [];
|
||||
if (Array.isArray(node.content)) {
|
||||
return node.content.flatMap((child) => {
|
||||
if (child.type === "text") {
|
||||
const text = toText(child.text);
|
||||
if (!text) return [];
|
||||
return [{ type: "text" as const, text, marks: readMarks(child.marks) }];
|
||||
}
|
||||
if (child.type === "hardBreak") {
|
||||
return [{ type: "text" as const, text: "\n", marks: [] }];
|
||||
}
|
||||
return extractInlineContent(child);
|
||||
});
|
||||
}
|
||||
if (node.type === "text") {
|
||||
const text = toText(node.text);
|
||||
return text ? [{ type: "text", text, marks: readMarks(node.marks) }] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function firstChild(node: TiptapNode | undefined): TiptapNode | undefined {
|
||||
return Array.isArray(node?.content) ? node?.content?.[0] : undefined;
|
||||
}
|
||||
|
||||
function normalizeDocumentId(value: unknown, fallback = "document"): string {
|
||||
const raw = typeof value === "string" ? value.trim() : "";
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
function nodeBlockId(node: TiptapNode, index: number): string {
|
||||
return normalizeBlockId(node.attrs?.[BLOCK_ID_ATTR], `block-${index + 1}`);
|
||||
}
|
||||
|
||||
function tiptapNodeToBlock(node: TiptapNode, index: number): EditorBlock | null {
|
||||
const blockId = nodeBlockId(node, index);
|
||||
switch (node.type) {
|
||||
case "paragraph":
|
||||
return { blockId, blockType: "paragraph", props: {}, contentNodes: extractInlineContent(node), childBlockIds: [] };
|
||||
case "heading":
|
||||
return {
|
||||
blockId,
|
||||
blockType: "heading",
|
||||
props: { headingLevel: clampHeadingLevel(node.attrs?.level) },
|
||||
contentNodes: extractInlineContent(node),
|
||||
childBlockIds: [],
|
||||
};
|
||||
case "bulletList": {
|
||||
const paragraph = firstChild(firstChild(node));
|
||||
return {
|
||||
blockId,
|
||||
blockType: "bullet_list_item",
|
||||
props: {},
|
||||
contentNodes: extractInlineContent(paragraph),
|
||||
childBlockIds: [],
|
||||
};
|
||||
}
|
||||
case "orderedList": {
|
||||
const paragraph = firstChild(firstChild(node));
|
||||
return {
|
||||
blockId,
|
||||
blockType: "numbered_list_item",
|
||||
props: {},
|
||||
contentNodes: extractInlineContent(paragraph),
|
||||
childBlockIds: [],
|
||||
};
|
||||
}
|
||||
case "taskList": {
|
||||
const taskItem = firstChild(node);
|
||||
const paragraph = firstChild(taskItem);
|
||||
return {
|
||||
blockId,
|
||||
blockType: "todo",
|
||||
props: { checked: Boolean(taskItem?.attrs?.checked) },
|
||||
contentNodes: extractInlineContent(paragraph),
|
||||
childBlockIds: [],
|
||||
};
|
||||
}
|
||||
case "blockquote":
|
||||
return { blockId, blockType: "quote", props: {}, contentNodes: extractInlineContent(firstChild(node)), childBlockIds: [] };
|
||||
case "codeBlock":
|
||||
return {
|
||||
blockId,
|
||||
blockType: "code_block",
|
||||
props: { language: typeof node.attrs?.language === "string" ? String(node.attrs.language) : null },
|
||||
contentNodes: extractInlineContent(node),
|
||||
childBlockIds: [],
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function editorBlockDocumentFromContent(content: Json): EditorBlockDocument {
|
||||
return normalizeInputToEditorDocument(content);
|
||||
}
|
||||
|
||||
export function normalizeEditorBlockDocument(
|
||||
value: unknown,
|
||||
fallbackDocumentId?: string,
|
||||
): EditorBlockDocument {
|
||||
const normalized = normalizeInputToEditorDocument(value as Json);
|
||||
const documentId = normalizeDocumentId(
|
||||
fallbackDocumentId ?? normalized.documentId,
|
||||
normalized.documentId,
|
||||
);
|
||||
const blockIdSet = new Set(normalized.blocks.map((block) => block.blockId));
|
||||
const rootBlockIds = Array.from(
|
||||
new Set(
|
||||
normalized.rootBlockIds
|
||||
.map((id, index) => normalizeBlockId(id, normalized.blocks[index]?.blockId ?? `block-${index + 1}`))
|
||||
.filter((id) => blockIdSet.has(id)),
|
||||
),
|
||||
);
|
||||
return {
|
||||
...normalized,
|
||||
documentId,
|
||||
rootBlockIds: rootBlockIds.length > 0 ? rootBlockIds : normalized.blocks.map((block) => block.blockId),
|
||||
};
|
||||
}
|
||||
|
||||
export function tiptapDocFromEditorBlockDocument(document: EditorBlockDocument): TiptapDoc {
|
||||
const blockById = new Map(document.blocks.map((block) => [block.blockId, block]));
|
||||
const nodes = document.rootBlockIds
|
||||
.map((blockId) => blockById.get(blockId))
|
||||
.filter((block): block is EditorBlock => Boolean(block))
|
||||
.map(blockToTiptapNode);
|
||||
return {
|
||||
type: "doc",
|
||||
content: nodes.length > 0 ? nodes : EMPTY_DOC.content,
|
||||
};
|
||||
}
|
||||
|
||||
export function editorBlockDocumentFromTiptapDoc(doc: unknown, documentId = "document"): EditorBlockDocument {
|
||||
if (!doc || typeof doc !== "object") {
|
||||
return { documentId, rootBlockIds: [], blocks: [] };
|
||||
}
|
||||
const root = doc as { type?: unknown; content?: unknown };
|
||||
if (root.type !== "doc" || !Array.isArray(root.content)) {
|
||||
return { documentId, rootBlockIds: [], blocks: [] };
|
||||
}
|
||||
const blocks = root.content.flatMap((node, index) => {
|
||||
if (!node || typeof node !== "object") return [];
|
||||
const block = tiptapNodeToBlock(node as TiptapNode, index);
|
||||
return block ? [block] : [];
|
||||
});
|
||||
return {
|
||||
documentId,
|
||||
rootBlockIds: blocks.map((block) => block.blockId),
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
function orderedBlocksForLegacy(document: EditorBlockDocument): EditorBlock[] {
|
||||
const blockById = new Map(document.blocks.map((block) => [block.blockId, block]));
|
||||
const visited = new Set<string>();
|
||||
const ordered: EditorBlock[] = [];
|
||||
|
||||
const visit = (blockId: string) => {
|
||||
if (visited.has(blockId)) return;
|
||||
visited.add(blockId);
|
||||
const block = blockById.get(blockId);
|
||||
if (!block) return;
|
||||
ordered.push(block);
|
||||
for (const childId of block.childBlockIds ?? []) {
|
||||
visit(childId);
|
||||
}
|
||||
};
|
||||
|
||||
for (const rootId of document.rootBlockIds) {
|
||||
visit(rootId);
|
||||
}
|
||||
for (const block of document.blocks) {
|
||||
visit(block.blockId);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function legacyBlocksFromEditorBlockDocument(document: EditorBlockDocument): Json {
|
||||
return orderedBlocksForLegacy(document).map((block) => ({
|
||||
id: block.blockId,
|
||||
type: block.blockType,
|
||||
props:
|
||||
block.blockType === "heading"
|
||||
? { level: block.props?.headingLevel ?? 1 }
|
||||
: block.blockType === "todo"
|
||||
? { checked: Boolean(block.props?.checked) }
|
||||
: block.blockType === "code_block"
|
||||
? { language: block.props?.language ?? null }
|
||||
: undefined,
|
||||
content: (block.contentNodes ?? []).map((node) => node.text).join(""),
|
||||
}));
|
||||
}
|
||||
|
||||
export function tiptapDocFromBlocks(blocks: Json): TiptapDoc {
|
||||
return tiptapDocFromEditorBlockDocument(editorBlockDocumentFromContent(blocks));
|
||||
}
|
||||
|
||||
export function blocksFromTiptapDoc(doc: unknown): Json {
|
||||
return legacyBlocksFromEditorBlockDocument(editorBlockDocumentFromTiptapDoc(doc));
|
||||
}
|
||||
|
||||
export const blocksToTiptapDoc = tiptapDocFromBlocks;
|
||||
export const tiptapDocToBlocks = blocksFromTiptapDoc;
|
||||
export const tiptapDocumentToEditorBlockDocumentAdapter = editorBlockDocumentFromTiptapDoc;
|
||||
export const editorBlockDocumentToLegacyBlocksAdapter = legacyBlocksFromEditorBlockDocument;
|
||||
@@ -32,6 +32,14 @@ export type MnoteRuntimeConfig = {
|
||||
* 说明:实验壳必须显式开启,禁止仅因配置了 mnoteWebBaseUrl 就自动进入主界面链路。
|
||||
*/
|
||||
mnoteWebTreeShellEnabled?: boolean;
|
||||
/**
|
||||
* 编辑器 host 选择。
|
||||
* 说明:这里只允许显式选择实验 host,默认主链仍由 BlockNote 承担。
|
||||
*/
|
||||
documentEditorHost?:
|
||||
| "blocknote"
|
||||
| "leptos_tiptap_inline"
|
||||
| "leptos_tiptap_iframe_debug";
|
||||
/**
|
||||
* 是否为桌面端(Electron)运行。
|
||||
*/
|
||||
@@ -140,7 +148,7 @@ const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
|
||||
// 说明:桌面端与网页端共用 public/mnote-env.json 作为“公共环境文件”。
|
||||
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
|
||||
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
|
||||
|
||||
|
||||
// 说明:这里不能直接静态引入 `node:fs` / `node:path`,
|
||||
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖。
|
||||
// Node 22 优先走 `process.getBuiltinModule`,其余环境再兜底到 runtime require。
|
||||
@@ -184,13 +192,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
|
||||
cfg.isDesktop ??
|
||||
(typeof window === "undefined" ? process.env.MNOTE_DESKTOP === "1" : false);
|
||||
|
||||
// 说明:`onlyofficeBaseUrl` / `onlyofficeStorageHostOverride` 属于“通用默认值”。
|
||||
// 但我们在 `public/mnote-env.json` 里同时提供了 Web/Desktop 两套配置,
|
||||
// 因此这里应当优先选择与平台匹配的字段,避免被环境变量中的默认值覆盖。
|
||||
//
|
||||
// 典型场景:开发机环境变量仍是 `http://localhost:8081`,但网页端需要走
|
||||
// `https://onlyoffice.<域名>`。若不调整优先级,远程浏览器会尝试访问它自己
|
||||
// 的 localhost,从而导致 docx 打不开。
|
||||
const onlyofficeBaseUrl = isDesktop
|
||||
? cfg.onlyofficeBaseUrlDesktop ||
|
||||
cfg.onlyofficeBaseUrl ||
|
||||
@@ -201,9 +202,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
|
||||
cfg.onlyofficeBaseUrlDesktop ||
|
||||
"";
|
||||
|
||||
// 说明:这里需要用 `??` 而不是 `||`,允许通过配置显式传入空字符串来“关闭 override”。
|
||||
// 否则 Web 端配置为 "" 时,会被环境变量里的默认值(例如 host.docker.internal)误覆盖,
|
||||
// 进而导致 ONLYOFFICE 文档服务器无法访问真实的存储地址。
|
||||
const onlyofficeStorageHostOverride = isDesktop
|
||||
? (cfg.onlyofficeStorageHostOverrideDesktop ??
|
||||
cfg.onlyofficeStorageHostOverride ??
|
||||
@@ -214,26 +212,13 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
|
||||
cfg.onlyofficeStorageHostOverrideDesktop ??
|
||||
"");
|
||||
|
||||
// 说明:`onlyofficeProxyOrigin` 也属于“通用默认值”。在 Web 端需要优先使用
|
||||
// onlyofficeProxyOriginWeb,避免被旧的通用值(例如 Cloudflare 域名)覆盖,
|
||||
// 否则会导致 ONLYOFFICE 文档服务器回源到错误的公网入口。
|
||||
const onlyofficeProxyOrigin = isDesktop
|
||||
? cfg.onlyofficeProxyOrigin || ""
|
||||
: cfg.onlyofficeProxyOriginWeb ||
|
||||
cfg.onlyofficeProxyOrigin ||
|
||||
"";
|
||||
? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeProxyOrigin ?? cfg.onlyofficeProxyOriginWeb)
|
||||
: (cfg.onlyofficeProxyOriginWeb ?? cfg.onlyofficeProxyOrigin ?? cfg.onlyofficeCallbackOriginDesktop);
|
||||
|
||||
// 说明:ONLYOFFICE 回调(保存)必须是“文档服务器可访问”的地址。
|
||||
// Web 端优先用 onlyofficeCallbackOriginWeb(通常是 http://host.docker.internal:3000)。
|
||||
const onlyofficeCallbackOrigin = isDesktop
|
||||
? cfg.onlyofficeCallbackOriginDesktop ||
|
||||
cfg.onlyofficeCallbackOrigin ||
|
||||
cfg.onlyofficeCallbackOriginWeb ||
|
||||
""
|
||||
: cfg.onlyofficeCallbackOriginWeb ||
|
||||
cfg.onlyofficeCallbackOrigin ||
|
||||
cfg.onlyofficeCallbackOriginDesktop ||
|
||||
"";
|
||||
? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginWeb)
|
||||
: (cfg.onlyofficeCallbackOriginWeb ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginDesktop);
|
||||
|
||||
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
|
||||
const mnoteWebTreeShellEnabled =
|
||||
@@ -251,7 +236,7 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
|
||||
};
|
||||
};
|
||||
|
||||
export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
|
||||
export function getMnoteRuntimeConfig(): MnoteRuntimeConfig {
|
||||
if (typeof window !== "undefined") {
|
||||
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
|
||||
}
|
||||
@@ -272,13 +257,8 @@ export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
|
||||
...envRuntime,
|
||||
// 说明:Rust Web 的 tree shell 属于运行期开关,必须允许 public/mnote-env.json
|
||||
// 在网页端覆盖环境变量;否则开发机上的旧 NEXT_PUBLIC_* 会把显式开关吃掉。
|
||||
...(publicRuntime.mnoteWebBaseUrl !== undefined
|
||||
? { mnoteWebBaseUrl: publicRuntime.mnoteWebBaseUrl }
|
||||
: {}),
|
||||
...(publicRuntime.mnoteWebTreeShellEnabled !== undefined
|
||||
? { mnoteWebTreeShellEnabled: publicRuntime.mnoteWebTreeShellEnabled }
|
||||
: {}),
|
||||
isDesktop,
|
||||
mnoteWebTreeShellEnabled:
|
||||
publicRuntime.mnoteWebTreeShellEnabled ?? envRuntime.mnoteWebTreeShellEnabled,
|
||||
};
|
||||
return normalizeRuntimeConfig(merged);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user