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";
|
|
|
|
|
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
2026-01-02 07:25:50 +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";
|
2025-11-23 17:22:35 +08:00
|
|
|
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
|
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") {
|
|
|
|
|
const level = Math.min(5, Math.max(1, Number(block.props.level) || 1));
|
|
|
|
|
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;
|
|
|
|
|
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[]>([]);
|
2025-11-23 17:22:35 +08:00
|
|
|
const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null);
|
2025-11-29 05:16:23 +08:00
|
|
|
const isFullScreenTableOpen = fullScreenTableId !== null;
|
2025-11-23 17:22:35 +08:00
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
[documentId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
const hadMindmapRef = useRef(false);
|
|
|
|
|
|
|
|
|
|
const collectAssets = useCallback((blocks: Block<CustomBlockSchema>[]) => {
|
|
|
|
|
const assetIds = new Set<string>();
|
|
|
|
|
let hasMindmap = false;
|
|
|
|
|
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") {
|
|
|
|
|
hasMindmap = true;
|
|
|
|
|
}
|
|
|
|
|
if (Array.isArray(b.children) && b.children.length > 0) {
|
|
|
|
|
walk(b.children as Block<CustomBlockSchema>[]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
walk(blocks);
|
|
|
|
|
return { assetIds, hasMindmap };
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const deleteMindmap = useCallback(async () => {
|
|
|
|
|
const resp = await fetch(`/api/mindmap/${documentId}`, { method: "DELETE" });
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
console.error("删除思维导图失败", await resp.text());
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
emitAssetsChanged(documentId);
|
|
|
|
|
}, [documentId]);
|
|
|
|
|
|
|
|
|
|
// 监听侧边栏删除事件,主动移除编辑区遗留块
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = (event: Event) => {
|
|
|
|
|
const detail = (event as CustomEvent)?.detail as {
|
|
|
|
|
docId?: string;
|
|
|
|
|
assetIds?: string[];
|
|
|
|
|
mindmapDeleted?: boolean;
|
|
|
|
|
};
|
|
|
|
|
if (!detail || detail.docId !== documentId) return;
|
|
|
|
|
const assetIds = detail.assetIds ?? [];
|
|
|
|
|
const mindmapDeleted = Boolean(detail.mindmapDeleted);
|
|
|
|
|
if (assetIds.length === 0 && !mindmapDeleted) return;
|
|
|
|
|
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) => {
|
|
|
|
|
if (mindmapDeleted && b.type === "mindmap") {
|
|
|
|
|
toRemove.push(b.id);
|
|
|
|
|
}
|
|
|
|
|
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) {
|
|
|
|
|
editor.removeBlocks(toRemove);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
|
|
|
|
|
return () => window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
|
|
|
|
|
}, [documentId, editor]);
|
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
|
|
|
|
|
|
|
|
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
|
|
|
|
|
const { assetIds, hasMindmap } = collectAssets(typedBlocks);
|
|
|
|
|
const prevAssets = previousAssetsRef.current;
|
|
|
|
|
const removedAssets = [...prevAssets].filter((id) => !assetIds.has(id));
|
|
|
|
|
if (removedAssets.length > 0) {
|
|
|
|
|
void deleteAssets(removedAssets);
|
|
|
|
|
}
|
|
|
|
|
previousAssetsRef.current = assetIds;
|
|
|
|
|
if (hadMindmapRef.current && !hasMindmap) {
|
|
|
|
|
void deleteMindmap();
|
|
|
|
|
}
|
|
|
|
|
hadMindmapRef.current = hasMindmap;
|
2025-11-23 10:55:04 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
runSync();
|
|
|
|
|
const unsubscribe = editor.onEditorContentChange(runSync);
|
|
|
|
|
return () => {
|
|
|
|
|
disposed = true;
|
|
|
|
|
if (typeof unsubscribe === "function") {
|
|
|
|
|
unsubscribe();
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-01-02 07:25:50 +08:00
|
|
|
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmap, 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;
|
|
|
|
|
}
|
|
|
|
|
const content = Array.isArray(block.content) ? [...block.content] : [];
|
|
|
|
|
for (let index = content.length - 1; index >= 0; index -= 1) {
|
|
|
|
|
const node = content[index] as { text?: string };
|
|
|
|
|
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 {
|
|
|
|
|
content[index] = { ...node, text: nextText };
|
|
|
|
|
}
|
|
|
|
|
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)) {
|
|
|
|
|
block.content.forEach((node: { text?: string }) => {
|
|
|
|
|
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 ?? "",
|
|
|
|
|
fileSize: asset.file_size ?? null,
|
|
|
|
|
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 = {
|
2025-11-23 17:22:35 +08:00
|
|
|
openTableFullScreen: (tableId: string) => {
|
|
|
|
|
setFullScreenTableId(tableId);
|
|
|
|
|
},
|
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,
|
|
|
|
|
},
|
|
|
|
|
{ type: "text", text: " " },
|
|
|
|
|
]);
|
|
|
|
|
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 (
|
2025-11-23 17:22:35 +08:00
|
|
|
<>
|
|
|
|
|
<div className={layoutClass}>
|
|
|
|
|
<div className={editorWrapperClass}>
|
|
|
|
|
<BlockNoteView
|
|
|
|
|
editor={editor}
|
|
|
|
|
theme="light"
|
|
|
|
|
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
|
|
|
|
|
editable={!pageOptions.protectEditing}
|
|
|
|
|
className={blocknoteClass}
|
|
|
|
|
>
|
2025-11-29 05:16:23 +08:00
|
|
|
{!isFullScreenTableOpen && (
|
|
|
|
|
<SideMenuController
|
|
|
|
|
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
|
|
|
|
<CustomSideMenu {...props} currentDocumentId={documentId} />
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-11-23 17:22:35 +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>
|
2025-11-23 17:22:35 +08:00
|
|
|
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
|
2025-11-23 10:55:04 +08:00
|
|
|
</div>
|
2025-11-23 17:22:35 +08:00
|
|
|
|
|
|
|
|
{/* 全屏表格编辑器 Modal */}
|
|
|
|
|
{fullScreenTableId && (
|
|
|
|
|
<FullScreenTableEditor
|
|
|
|
|
tableId={fullScreenTableId}
|
|
|
|
|
onClose={() => setFullScreenTableId(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2025-11-23 10:55:04 +08:00
|
|
|
);
|
|
|
|
|
}
|