Files
mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx
T

811 lines
26 KiB
TypeScript
Raw Normal View History

2025-11-23 10:55:04 +08:00
"use client";
import "@blocknote/core/style.css";
import "@blocknote/react/style.css";
import "@blocknote/mantine/style.css";
2026-01-02 07:25:50 +08:00
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2025-11-23 10:55:04 +08:00
import { BlockNoteView } from "@blocknote/mantine";
import {
SideMenuController,
useCreateBlockNote,
type SideMenuProps,
} from "@blocknote/react";
import { HocuspocusProvider } from "@hocuspocus/provider";
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 { MediaAsset } from "@/types/media";
import { customBlockSchema, type CustomBlockSchema } from "./schema";
import { CustomSideMenu } from "./menus/CustomSideMenu";
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
2026-01-17 10:12:53 +08:00
import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host";
2025-11-23 10:55:04 +08:00
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
2026-01-21 18:21:10 +08:00
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
2025-11-23 10:55:04 +08:00
import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { ReferenceTarget } from "@/types/search";
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
2026-01-21 18:21:10 +08:00
import { clamp, MIN_BLOCK_LEVEL, MAX_BLOCK_LEVEL } from "@/lib/constants";
2026-01-02 07:25:50 +08:00
import { ASSETS_CHANGED_EVENT, emitAssetsChanged } from "@/lib/events";
2025-11-23 10:55:04 +08:00
interface BlockNoteEditorProps {
documentId: string;
workspaceId: string;
initialContent: unknown;
pageOptions: PageOptionsState;
onStatsChange?: (stats: DocumentStats) => void;
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
}
const extractInitialBlocks = (content: unknown): Json | undefined => {
if (Array.isArray(content) && content.length > 0) {
return content as Json;
}
if (content && typeof content === "object") {
const maybeBlocks = (content as Record<string, unknown>).blocks;
if (Array.isArray(maybeBlocks) && maybeBlocks.length > 0) {
return maybeBlocks as Json;
}
}
return undefined;
};
const extractInlineText = (block: Block<CustomBlockSchema>): string => {
const inlineNodes = (block.content ?? []) as Array<{ text?: string }>;
return inlineNodes.map((node) => (typeof node.text === "string" ? node.text : "")).join("").trim();
};
const buildHeadingToc = (blocks: Block<CustomBlockSchema>[]): TocEntry[] => {
const counters = [0, 0, 0, 0, 0];
const entries: TocEntry[] = [];
const walk = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (block.type === "heading") {
2026-01-21 18:21:10 +08:00
const level = clamp(Number(block.props.level) || 1, MIN_BLOCK_LEVEL, MAX_BLOCK_LEVEL);
2025-11-23 10:55:04 +08:00
counters[level - 1] += 1;
for (let i = level; i < counters.length; i += 1) {
counters[i] = 0;
}
const numbering = counters.slice(0, level).filter((value) => value > 0).join(".");
entries.push({
id: block.id,
level,
numbering,
title: extractInlineText(block),
});
}
if (block.children && block.children.length > 0) {
walk(block.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
return entries;
};
const findBlockById = (
blocks: Block<CustomBlockSchema>[],
id: string,
): Block<CustomBlockSchema> | undefined => {
for (const block of blocks) {
if (block.id === id) return block;
if (block.children && block.children.length > 0) {
const child = findBlockById(block.children as Block<CustomBlockSchema>[], id);
if (child) return child;
}
}
return undefined;
};
const syncProgressMeters = (editorInstance: ReturnType<typeof useCreateBlockNote>) => {
if (!editorInstance) {
return;
}
const blocks = editorInstance.topLevelBlocks as Block<CustomBlockSchema>[];
const progressStats = new Map<
string,
{ done: number; doing: number; total: number }
>();
let activeProgressId: string | null = null;
const traverse = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (block.type === "progressMeter" && block.props.auto) {
activeProgressId = block.id;
progressStats.set(block.id, { done: 0, doing: 0, total: 0 });
} else if (block.type === "progressMeter" && !block.props.auto) {
activeProgressId = null;
} else if (block.type === "heading") {
activeProgressId = null;
} else if (block.type === "advancedTodo" && activeProgressId) {
const currentStat = progressStats.get(activeProgressId);
if (!currentStat) return;
if (block.props.status === "cancelled") {
return;
}
currentStat.total += 1;
if (block.props.status === "done") {
currentStat.done += 1;
} else if (block.props.status === "doing") {
currentStat.doing += 1;
}
}
if (block.children && block.children.length > 0) {
traverse(block.children as Block<CustomBlockSchema>[]);
}
});
};
traverse(blocks);
progressStats.forEach((stat, progressId) => {
const block = findBlockById(blocks, progressId);
if (!block) return;
2026-01-08 06:28:14 +08:00
if (block.type !== "progressMeter") return;
2025-11-23 10:55:04 +08:00
const weightedDone = stat.done + stat.doing * 0.5;
const percent = stat.total === 0 ? 0 : Math.min(100, Math.round((weightedDone / stat.total) * 100));
const summary = stat.total === 0 ? "暂无条目" : `${stat.done}/${stat.total} 完成`;
if (block.props.percent !== percent || block.props.summary !== summary) {
editorInstance.updateBlock(block, {
props: {
percent,
summary,
},
});
}
});
};
export function BlockNoteEditor({
documentId,
workspaceId,
initialContent,
pageOptions,
onStatsChange,
onSnapshot,
}: BlockNoteEditorProps) {
const [isSaving, setIsSaving] = useState(false);
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null);
2025-11-29 05:16:23 +08:00
const isFullScreenTableOpen = fullScreenTableId !== null;
2025-11-23 10:55:04 +08:00
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
const normalizedInitialContent = useMemo(
() => extractInitialBlocks(initialContent),
[initialContent],
);
const collaboration = useMemo(() => {
const url = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL;
if (!url) return null;
const doc = new Y.Doc();
const provider = new HocuspocusProvider({
url,
name: `document.${documentId}`,
document: doc,
});
return { doc, provider };
}, [documentId]);
const editor = useCreateBlockNote(
{
initialContent: normalizedInitialContent as never,
schema: customBlockSchema,
collaboration: collaboration
? {
provider: collaboration.provider,
fragment: collaboration.doc.getXmlFragment("wolai"),
user: {
name: "访客",
color: "#2563eb",
},
}
: undefined,
},
2026-01-10 10:35:21 +08:00
[documentId, normalizedInitialContent],
2025-11-23 10:55:04 +08:00
);
useEffect(
() => () => {
collaboration?.provider.destroy();
collaboration?.doc.destroy();
},
[collaboration],
);
const saveContent = useCallback(
async (content: Json) => {
setIsSaving(true);
try {
await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, content }),
});
} finally {
setIsSaving(false);
}
},
[documentId],
);
const debouncedSave = useDebouncedCallback(saveContent, 800);
2026-01-02 07:25:50 +08:00
const previousAssetsRef = useRef<Set<string>>(new Set());
2026-01-08 06:28:14 +08:00
const previousMindmapBlockIdsRef = useRef<Set<string>>(new Set());
const clearMindmapAutosaveCache = useCallback((targetDocumentId: string, mindmapId?: string) => {
if (typeof window === "undefined") return;
try {
const prefix = "wolai-mindmap-autosave-";
const targetPrefix = `${prefix}${targetDocumentId}`;
2026-01-08 06:28:14 +08:00
const directKey = mindmapId ? `${targetPrefix}:${mindmapId}` : targetPrefix;
const keys: string[] = [];
for (let i = 0; i < window.localStorage.length; i += 1) {
const k = window.localStorage.key(i);
if (!k) continue;
2026-01-08 06:28:14 +08:00
if (mindmapId) {
if (k === directKey) keys.push(k);
} else if (k === targetPrefix || k.startsWith(`${targetPrefix}:`)) {
keys.push(k);
}
}
keys.forEach((k) => window.localStorage.removeItem(k));
} catch {
// ignore
}
}, []);
2026-01-08 06:28:14 +08:00
const markMindmapDeleting = useCallback((targetDocumentId: string, mindmapId?: string) => {
if (typeof window === "undefined") return;
try {
const w = window as unknown as {
2026-01-08 06:28:14 +08:00
__wolaiMindmapDeletingKeys?: Set<string>;
};
2026-01-08 06:28:14 +08:00
if (!w.__wolaiMindmapDeletingKeys) {
w.__wolaiMindmapDeletingKeys = new Set<string>();
}
2026-01-08 06:28:14 +08:00
const key = mindmapId ? `${targetDocumentId}:${mindmapId}` : targetDocumentId;
w.__wolaiMindmapDeletingKeys.add(key);
window.setTimeout(() => {
try {
2026-01-08 06:28:14 +08:00
w.__wolaiMindmapDeletingKeys?.delete(key);
} catch {
// ignore
}
}, 8000);
} catch {
// ignore
}
}, []);
2026-01-02 07:25:50 +08:00
const collectAssets = useCallback((blocks: Block<CustomBlockSchema>[]) => {
const assetIds = new Set<string>();
2026-01-08 06:28:14 +08:00
const mindmapBlockIds = new Set<string>();
2026-01-02 07:25:50 +08:00
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (b.type === "media") {
const id = (b.props as { assetId?: string })?.assetId;
if (id) assetIds.add(id);
}
if (b.type === "mindmap") {
2026-01-08 06:28:14 +08:00
mindmapBlockIds.add(b.id);
2026-01-02 07:25:50 +08:00
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
2026-01-08 06:28:14 +08:00
return { assetIds, mindmapBlockIds };
2026-01-02 07:25:50 +08:00
}, []);
const deleteAssets = useCallback(
async (assetIds: string[]) => {
if (assetIds.length === 0) return;
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds }),
});
if (!resp.ok) {
// 如果后端返回未找到,说明已被其他端删除,忽略即可
if (resp.status !== 404) {
console.error("删除附件失败", await resp.text());
}
return;
}
emitAssetsChanged(documentId);
},
[documentId],
);
2026-01-08 06:28:14 +08:00
const deleteMindmapAssets = useCallback(
async (mindmapIds: string[]) => {
if (mindmapIds.length === 0) return;
await Promise.all(
mindmapIds.map(async (mindmapId) => {
try {
const resp = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, { method: "DELETE" });
if (!resp.ok) {
console.error("删除思维导图失败", mindmapId, await resp.text().catch(() => ""));
}
} catch (error) {
console.error("删除思维导图失败", mindmapId, error);
} finally {
markMindmapDeleting(documentId, mindmapId);
clearMindmapAutosaveCache(documentId, mindmapId);
}
}),
);
emitAssetsChanged(documentId, undefined, undefined, false, mindmapIds);
},
[clearMindmapAutosaveCache, documentId, markMindmapDeleting],
);
2026-01-02 07:25:50 +08:00
// 监听侧边栏删除事件,主动移除编辑区遗留块
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent)?.detail as {
docId?: string;
assetIds?: string[];
mindmapDeleted?: boolean;
2026-01-08 06:28:14 +08:00
mindmapAssetIds?: string[];
2026-01-02 07:25:50 +08:00
};
if (!detail || detail.docId !== documentId) return;
const assetIds = detail.assetIds ?? [];
const mindmapDeleted = Boolean(detail.mindmapDeleted);
2026-01-08 06:28:14 +08:00
const mindmapAssetIds = Array.isArray(detail.mindmapAssetIds)
? (detail.mindmapAssetIds.filter((id) => typeof id === "string") as string[])
: [];
if (assetIds.length === 0 && !mindmapDeleted && mindmapAssetIds.length === 0) return;
if (mindmapDeleted || mindmapAssetIds.length > 0) {
// 标记“删除中”,避免 MindmapBlock 卸载清理里把 autosave 写回导致“复活”
2026-01-08 06:28:14 +08:00
if (mindmapAssetIds.length > 0) {
mindmapAssetIds.forEach((id) => markMindmapDeleting(documentId, id));
} else {
markMindmapDeleting(documentId);
}
// 双保险:等卸载完成后再清一次 autosave,避免删除后再次插入出现旧内容(重复)
2026-01-08 06:28:14 +08:00
window.setTimeout(() => {
if (mindmapAssetIds.length > 0) {
mindmapAssetIds.forEach((id) => clearMindmapAutosaveCache(documentId, id));
} else {
clearMindmapAutosaveCache(documentId);
}
}, 0);
}
2026-01-02 07:25:50 +08:00
const blocks = editor?.topLevelBlocks as Block<CustomBlockSchema>[] | undefined;
if (!blocks || blocks.length === 0 || !editor) return;
const toRemove: string[] = [];
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
2026-01-08 06:28:14 +08:00
if (b.type === "mindmap") {
if (mindmapDeleted) {
toRemove.push(b.id);
} else if (mindmapAssetIds.length > 0 && mindmapAssetIds.includes(b.id)) {
toRemove.push(b.id);
}
2026-01-02 07:25:50 +08:00
}
if (assetIds.length > 0 && b.type === "media") {
const id = (b.props as { assetId?: string })?.assetId;
if (id && assetIds.includes(id)) {
toRemove.push(b.id);
}
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
if (toRemove.length > 0) {
2026-01-08 06:28:14 +08:00
try {
editor.removeBlocks(toRemove);
} catch {
// ignore:可能已被其它链路先行删除(例如块菜单/工具栏触发的 removeBlocks
}
2026-01-02 07:25:50 +08:00
}
};
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
return () => window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
}, [clearMindmapAutosaveCache, documentId, editor, markMindmapDeleting]);
2025-11-23 10:55:04 +08:00
useEffect(() => {
if (!editor) {
return undefined;
}
let disposed = false;
const runSync = () => {
if (disposed) {
return;
}
const blocks = editor.topLevelBlocks;
debouncedSave(blocks as Json);
const typedBlocks = blocks as Block<CustomBlockSchema>[];
setTocEntries(buildHeadingToc(typedBlocks));
syncProgressMeters(editor);
const stats = computeDocumentStats(typedBlocks);
onStatsChange?.(stats);
onSnapshot?.({ blocks: blocks as Json, stats });
2026-01-02 07:25:50 +08:00
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
2026-01-08 06:28:14 +08:00
const { assetIds, mindmapBlockIds } = collectAssets(typedBlocks);
2026-01-02 07:25:50 +08:00
const prevAssets = previousAssetsRef.current;
const removedAssets = [...prevAssets].filter((id) => !assetIds.has(id));
if (removedAssets.length > 0) {
void deleteAssets(removedAssets);
}
previousAssetsRef.current = assetIds;
2026-01-08 06:28:14 +08:00
const prevMindmaps = previousMindmapBlockIdsRef.current;
const removedMindmaps = [...prevMindmaps].filter((id) => !mindmapBlockIds.has(id));
if (removedMindmaps.length > 0) {
void deleteMindmapAssets(removedMindmaps);
2026-01-02 07:25:50 +08:00
}
2026-01-08 06:28:14 +08:00
previousMindmapBlockIdsRef.current = mindmapBlockIds;
2025-11-23 10:55:04 +08:00
};
runSync();
2026-01-08 06:28:14 +08:00
const unsubscribe = editor.onEditorContentChange(runSync) as unknown as
| undefined
| (() => void);
2025-11-23 10:55:04 +08:00
return () => {
disposed = true;
2026-01-08 06:28:14 +08:00
unsubscribe?.();
2025-11-23 10:55:04 +08:00
};
2026-01-08 06:28:14 +08:00
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, editor, onSnapshot, onStatsChange]);
2025-11-23 10:55:04 +08:00
const jumpToHeading = useCallback((headingId: string) => {
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, []);
const editorWrapperClass = cn(
"relative min-h-[60vh] rounded-2xl border border-transparent bg-white p-4 shadow-sm",
pageOptions.smallText ? "text-[15px]" : "text-[16px]",
);
const blocknoteClass = cn(
"wolai-editor min-h-full",
pageOptions.showStructure && "wolai-editor-show-structure",
2025-11-29 05:16:23 +08:00
isFullScreenTableOpen && "pointer-events-none select-none",
2025-11-23 10:55:04 +08:00
);
const buildDocumentPath = (documentId: string): string => {
if (typeof window === "undefined" || !window.location) {
return `/documents/${documentId}`;
}
return `${window.location.origin}/documents/${documentId}`;
};
const trimTrailingCharacter = (
editorInstance: ReturnType<typeof useCreateBlockNote> | null,
block: Block<CustomBlockSchema>,
char: string,
) => {
if (!editorInstance) {
return;
}
2026-01-08 06:28:14 +08:00
const content = (Array.isArray(block.content) ? [...block.content] : []) as any[];
2025-11-23 10:55:04 +08:00
for (let index = content.length - 1; index >= 0; index -= 1) {
2026-01-08 06:28:14 +08:00
const node = content[index] as any;
2025-11-23 10:55:04 +08:00
if (typeof node?.text === "string" && node.text.endsWith(char)) {
const nextText = node.text.slice(0, -1);
if (nextText.length === 0) {
content.splice(index, 1);
} else {
2026-01-08 06:28:14 +08:00
content[index] = { ...(node as any), text: nextText } as any;
2025-11-23 10:55:04 +08:00
}
editorInstance.updateBlock(block, { content });
break;
}
}
};
const generateBlockId = () => {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `ref_${Math.random().toString(36).slice(2, 10)}`;
};
const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats => {
let characterCount = 0;
let wordCount = 0;
const accumulate = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (Array.isArray(block.content)) {
2026-01-08 06:28:14 +08:00
(block.content as any[]).forEach((node: any) => {
2025-11-23 10:55:04 +08:00
if (typeof node.text === "string") {
const text = node.text;
characterCount += text.length;
const trimmed = text.trim();
if (trimmed.length === 0) {
return;
}
const tokens = trimmed.split(/\s+/).filter(Boolean);
if (tokens.length > 1) {
wordCount += tokens.length;
} else {
wordCount += trimmed.replace(/\s+/g, "").length;
}
}
});
}
if (block.children && block.children.length > 0) {
accumulate(block.children as Block<CustomBlockSchema>[]);
}
});
};
accumulate(blocks);
return {
wordCount,
characterCount,
blockCount: blocks.length,
};
};
const insertMediaAssetBlock = useCallback(
(asset: MediaAsset) => {
if (!editor) {
return;
}
const fileUrl = asset.file_url ?? "";
if (!fileUrl) {
return;
}
const cursor = editor.getTextCursorPosition();
const referenceBlock =
cursor?.block ?? (editor.topLevelBlocks[editor.topLevelBlocks.length - 1] as Block<CustomBlockSchema> | undefined);
if (!referenceBlock) {
return;
}
editor.insertBlocks(
[
{
type: "media",
props: {
fileUrl,
thumbnailUrl: asset.thumbnail_url ?? fileUrl,
assetId: asset.id,
assetType: asset.asset_type ?? "image",
fileName: asset.file_name ?? "",
2026-01-08 06:28:14 +08:00
fileSize: asset.file_size ?? undefined,
2025-11-23 10:55:04 +08:00
mimeType: asset.mime_type ?? "",
ocrStatus: asset.ocr_status ?? "idle",
2026-01-02 07:25:50 +08:00
documentId,
2025-11-23 10:55:04 +08:00
},
},
],
referenceBlock,
"after",
);
},
2026-01-02 07:25:50 +08:00
[documentId, editor],
2025-11-23 10:55:04 +08:00
);
const uploadClipboardMedia = useCallback(
async (file: File) => {
if (!workspaceId) {
throw new Error("缺少空间信息,无法上传文件");
}
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset) {
throw new Error("上传返回数据缺失");
}
2026-01-02 07:25:50 +08:00
emitAssetsChanged(documentId, payload.asset);
2025-11-23 10:55:04 +08:00
return payload.asset;
},
[documentId, workspaceId],
);
useEffect(() => {
if (!editor) {
registerEditorBridge(null);
return;
}
const bridge = {
openTableFullScreen: (tableId: string) => {
setFullScreenTableId(tableId);
},
2026-01-10 10:35:21 +08:00
insertMediaAsset: (asset: MediaAsset) => {
insertMediaAssetBlock(asset);
},
2025-11-23 10:55:04 +08:00
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
const blockId = cursor?.block?.id ?? null;
const text = aliasText || target.title || "无标题";
editor.insertInlineContent([
{
type: "link",
href: buildDocumentPath(target.id),
content: text,
},
2026-01-08 06:28:14 +08:00
" ",
2025-11-23 10:55:04 +08:00
]);
return { blockId };
},
insertEmbedReference: (target: ReferenceTarget) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
const referenceBlock =
cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1];
const blockId = generateBlockId();
editor.insertBlocks(
[
{
id: blockId,
type: "pageReference",
props: {
pageId: target.id,
title: target.title ?? "无标题",
},
},
],
referenceBlock,
"after",
);
return { blockId };
},
replaceWithSnapshot: (payload: Json) => {
editor.focus();
const nextBlocks = Array.isArray(payload)
? payload
: Array.isArray((payload as { blocks?: Json }).blocks)
? ((payload as { blocks?: Json }).blocks as Json)
: [];
if (!Array.isArray(nextBlocks)) {
return;
}
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
},
};
registerEditorBridge(bridge);
return () => registerEditorBridge(null);
}, [editor, registerEditorBridge]);
useEffect(() => {
if (!editor) {
return undefined;
}
const handlePaste = (event: ClipboardEvent) => {
const activeElement = event.target;
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
return;
}
const items = Array.from(event.clipboardData?.files ?? []);
if (items.length === 0) {
return;
}
const imageFile = items.find((candidate) => candidate.type?.startsWith("image/"));
if (!imageFile) {
return;
}
event.preventDefault();
void (async () => {
try {
const asset = await uploadClipboardMedia(imageFile);
insertMediaAssetBlock(asset);
} catch (error) {
console.error(error);
window.alert((error as Error).message ?? "粘贴图片失败,请稍后重试");
}
})();
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor, insertMediaAssetBlock, uploadClipboardMedia]);
useEffect(() => {
if (!editor) {
return;
}
const buffer = { char: "", blockId: "", timestamp: 0 };
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "[" && event.key !== "#") {
buffer.char = "";
buffer.blockId = "";
buffer.timestamp = 0;
return;
}
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
return;
}
const { block } = editor.getTextCursorPosition();
if (!block) {
return;
}
const now = Date.now();
if (
buffer.char === event.key &&
buffer.blockId === block.id &&
now - buffer.timestamp < 450
) {
event.preventDefault();
trimTrailingCharacter(editor, block, event.key);
openReferencePalette({
referenceMode: event.key === "[" ? "inline" : "embed",
});
buffer.char = "";
buffer.blockId = "";
buffer.timestamp = 0;
} else {
buffer.char = event.key;
buffer.blockId = block.id;
buffer.timestamp = now;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [editor, openReferencePalette]);
const layoutClass = cn(
"relative mx-auto w-full",
pageOptions.wideLayout ? "max-w-none" : "max-w-[980px]",
);
return (
<>
<div className={layoutClass}>
<div className={editorWrapperClass}>
<BlockNoteView
editor={editor}
theme="light"
2026-01-08 06:28:14 +08:00
slashMenu={false}
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
editable={!pageOptions.protectEditing}
className={blocknoteClass}
>
2026-01-17 10:12:53 +08:00
{!isFullScreenTableOpen && (
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
)}
2026-01-20 07:24:12 +08:00
floatingOptions={{ placement: "left" }}
2026-01-17 10:12:53 +08:00
/>
)}
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
</BlockNoteView>
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
{isSaving ? "保存中..." : "已保存"}
</div>
2025-11-23 10:55:04 +08:00
</div>
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
2025-11-23 10:55:04 +08:00
</div>
2026-01-17 10:12:53 +08:00
<MoveEmbedPickerHost />
{/* 全屏表格编辑器 Modal */}
{fullScreenTableId && (
<FullScreenTableEditor
tableId={fullScreenTableId}
onClose={() => setFullScreenTableId(null)}
/>
)}
</>
2025-11-23 10:55:04 +08:00
);
}