2026-04-14 13:22:29 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import "@blocknote/core/style.css";
|
|
|
|
|
|
import "@blocknote/react/style.css";
|
|
|
|
|
|
import "@blocknote/mantine/style.css";
|
|
|
|
|
|
|
|
|
|
|
|
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";
|
2026-04-14 13:22:29 +08:00
|
|
|
|
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";
|
|
|
|
|
|
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
|
|
|
|
|
|
import { useSearchPaletteStore } from "@/store/search-palette";
|
|
|
|
|
|
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
2025-11-23 10:55:04 +08:00
|
|
|
|
import type { ReferenceTarget } from "@/types/search";
|
2025-11-23 17:22:35 +08:00
|
|
|
|
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-02-01 08:47:40 +08:00
|
|
|
|
import { ASSETS_CHANGED_EVENT, ASSETS_RESTORED_EVENT, emitAssetsChanged } from "@/lib/events";
|
|
|
|
|
|
import { deleteOnlineTable } from "@/lib/online-table";
|
|
|
|
|
|
import { useAppPreferencesStore } from "@/store/app-preferences";
|
|
|
|
|
|
import { useCommentsUiStore } from "@/store/comments-ui";
|
|
|
|
|
|
import { useConvexAuth, useQuery } from "convex/react";
|
|
|
|
|
|
import { api } from "@/lib/convex/api";
|
2026-04-15 03:06:29 +08:00
|
|
|
|
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
2026-04-17 23:36:24 +08:00
|
|
|
|
import type { EditorReferenceBridge } from "@/store/editor-bridge";
|
2026-04-14 13:22:29 +08:00
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
interface BlockNoteEditorProps {
|
|
|
|
|
|
documentId: string;
|
|
|
|
|
|
workspaceId: string;
|
|
|
|
|
|
initialContent: unknown;
|
2026-04-15 03:06:29 +08:00
|
|
|
|
initialRevision?: number | null;
|
|
|
|
|
|
initialConflictDetectionKey?: string | null;
|
2025-11-23 10:55:04 +08:00
|
|
|
|
pageOptions: PageOptionsState;
|
2026-01-22 18:53:20 +08:00
|
|
|
|
readOnly?: boolean;
|
2025-11-23 10:55:04 +08:00
|
|
|
|
onStatsChange?: (stats: DocumentStats) => void;
|
|
|
|
|
|
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
|
2026-02-01 08:47:40 +08:00
|
|
|
|
onCloseToc?: () => void;
|
2026-04-15 03:06:29 +08:00
|
|
|
|
onPersistedMetaChange?: (payload: {
|
|
|
|
|
|
revision: number | null;
|
|
|
|
|
|
conflictDetectionKey: string | null;
|
|
|
|
|
|
}) => void;
|
2025-11-23 10:55:04 +08:00
|
|
|
|
}
|
2026-04-14 13:22:29 +08:00
|
|
|
|
|
|
|
|
|
|
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 = clamp(Number(block.props.level) || 1, MIN_BLOCK_LEVEL, MAX_BLOCK_LEVEL);
|
|
|
|
|
|
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;
|
|
|
|
|
|
if (block.type !== "progressMeter") 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,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
export function BlockNoteEditor({
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
workspaceId,
|
|
|
|
|
|
initialContent,
|
2026-04-15 03:06:29 +08:00
|
|
|
|
initialRevision = null,
|
|
|
|
|
|
initialConflictDetectionKey = null,
|
2025-11-23 10:55:04 +08:00
|
|
|
|
pageOptions,
|
2026-01-22 18:53:20 +08:00
|
|
|
|
readOnly = false,
|
2025-11-23 10:55:04 +08:00
|
|
|
|
onStatsChange,
|
|
|
|
|
|
onSnapshot,
|
2026-02-01 08:47:40 +08:00
|
|
|
|
onCloseToc,
|
2026-04-15 03:06:29 +08:00
|
|
|
|
onPersistedMetaChange,
|
2025-11-23 10:55:04 +08:00
|
|
|
|
}: BlockNoteEditorProps) {
|
2026-04-14 13:22:29 +08:00
|
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
2026-04-15 03:06:29 +08:00
|
|
|
|
const [saveError, setSaveError] = useState<string | null>(null);
|
2026-04-14 13:22:29 +08:00
|
|
|
|
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
|
|
|
|
|
|
const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null);
|
|
|
|
|
|
const isFullScreenTableOpen = fullScreenTableId !== null;
|
2026-04-15 03:06:29 +08:00
|
|
|
|
const revisionRef = useRef<number | null>(initialRevision);
|
|
|
|
|
|
const conflictDetectionKeyRef = useRef<string | null>(initialConflictDetectionKey);
|
2026-04-17 23:36:24 +08:00
|
|
|
|
const onStatsChangeRef = useRef(onStatsChange);
|
|
|
|
|
|
const onSnapshotRef = useRef(onSnapshot);
|
|
|
|
|
|
const onPersistedMetaChangeRef = useRef(onPersistedMetaChange);
|
|
|
|
|
|
const stableInitialContentRef = useRef<{ documentId: string; content: Json | undefined } | null>(null);
|
2026-04-14 13:22:29 +08:00
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
|
|
|
|
|
|
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
|
2026-02-01 08:47:40 +08:00
|
|
|
|
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
|
|
|
|
|
|
const showStructure = useAppPreferencesStore((s) => s.showStructure);
|
|
|
|
|
|
const openCommentsForBlock = useCommentsUiStore((s) => s.openForBlock);
|
|
|
|
|
|
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
|
|
|
|
|
|
const { isAuthenticated } = useConvexAuth();
|
|
|
|
|
|
const threads = useQuery(
|
|
|
|
|
|
api.comments.listThreadsByDocument,
|
|
|
|
|
|
isAuthenticated && documentId ? { documentId, includeResolved: false } : "skip",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const unresolvedCommentCountByBlockId = useMemo(() => {
|
|
|
|
|
|
const map: Record<string, number> = {};
|
|
|
|
|
|
if (!Array.isArray(threads)) return map;
|
|
|
|
|
|
for (const t of threads as any[]) {
|
|
|
|
|
|
const bid = String(t?.blockId ?? "");
|
|
|
|
|
|
if (!bid) continue;
|
|
|
|
|
|
map[bid] = (map[bid] ?? 0) + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return map;
|
|
|
|
|
|
}, [threads]);
|
2026-04-14 13:22:29 +08:00
|
|
|
|
|
|
|
|
|
|
const normalizedInitialContent = useMemo(
|
|
|
|
|
|
() => extractInitialBlocks(initialContent),
|
|
|
|
|
|
[initialContent],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
|
if (
|
|
|
|
|
|
!stableInitialContentRef.current ||
|
|
|
|
|
|
stableInitialContentRef.current.documentId !== documentId
|
|
|
|
|
|
) {
|
|
|
|
|
|
stableInitialContentRef.current = {
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
content: normalizedInitialContent,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const editor = useCreateBlockNote(
|
|
|
|
|
|
{
|
2026-04-17 23:36:24 +08:00
|
|
|
|
initialContent: stableInitialContentRef.current?.content as never,
|
2025-11-23 10:55:04 +08:00
|
|
|
|
schema: customBlockSchema,
|
2026-02-01 08:47:40 +08:00
|
|
|
|
placeholders: {
|
|
|
|
|
|
default: "输入'/'选择,按 空格 打开AI...",
|
|
|
|
|
|
emptyDocument: "输入'/'选择,按 空格 打开AI...",
|
|
|
|
|
|
},
|
2025-11-23 10:55:04 +08:00
|
|
|
|
collaboration: collaboration
|
|
|
|
|
|
? {
|
|
|
|
|
|
provider: collaboration.provider,
|
|
|
|
|
|
fragment: collaboration.doc.getXmlFragment("wolai"),
|
|
|
|
|
|
user: {
|
2026-04-14 13:22:29 +08:00
|
|
|
|
name: "访客",
|
|
|
|
|
|
color: "#2563eb",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
},
|
2026-04-17 23:36:24 +08:00
|
|
|
|
[documentId],
|
2026-04-14 13:22:29 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(
|
|
|
|
|
|
() => () => {
|
|
|
|
|
|
collaboration?.provider.destroy();
|
|
|
|
|
|
collaboration?.doc.destroy();
|
|
|
|
|
|
},
|
|
|
|
|
|
[collaboration],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
|
const editorRef = useRef(editor);
|
|
|
|
|
|
const insertMediaAssetRef = useRef<(asset: MediaAsset) => void>(() => {});
|
|
|
|
|
|
const insertMindmapRef = useRef<(args: { documentId: string; mindmapId: string }) => void>(() => {});
|
|
|
|
|
|
const insertOnlineTableRef = useRef<(args: { documentId: string; tableId: string }) => void>(() => {});
|
|
|
|
|
|
const setFullScreenTableIdRef = useRef(setFullScreenTableId);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
editorRef.current = editor;
|
|
|
|
|
|
}, [editor]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setFullScreenTableIdRef.current = setFullScreenTableId;
|
|
|
|
|
|
}, [setFullScreenTableId]);
|
|
|
|
|
|
|
2026-04-15 03:06:29 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
revisionRef.current = initialRevision;
|
|
|
|
|
|
}, [initialRevision]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
conflictDetectionKeyRef.current = initialConflictDetectionKey;
|
|
|
|
|
|
}, [initialConflictDetectionKey]);
|
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
onStatsChangeRef.current = onStatsChange;
|
|
|
|
|
|
}, [onStatsChange]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
onSnapshotRef.current = onSnapshot;
|
|
|
|
|
|
}, [onSnapshot]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
onPersistedMetaChangeRef.current = onPersistedMetaChange;
|
|
|
|
|
|
}, [onPersistedMetaChange]);
|
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
|
const saveContent = useCallback(
|
|
|
|
|
|
async (content: Json) => {
|
|
|
|
|
|
setIsSaving(true);
|
|
|
|
|
|
try {
|
2026-04-15 03:06:29 +08:00
|
|
|
|
setSaveError(null);
|
|
|
|
|
|
const blockCount = Array.isArray(content) ? content.length : null;
|
|
|
|
|
|
const response = await fetch("/api/documents/save", {
|
2026-04-14 13:22:29 +08:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-04-15 03:06:29 +08:00
|
|
|
|
body: JSON.stringify(
|
|
|
|
|
|
buildDocumentSavePayload({
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
workspaceId,
|
|
|
|
|
|
revision: revisionRef.current,
|
|
|
|
|
|
content,
|
|
|
|
|
|
conflictDetectionKey: conflictDetectionKeyRef.current,
|
|
|
|
|
|
snapshotCapturedAt: new Date().toISOString(),
|
|
|
|
|
|
blockCount,
|
|
|
|
|
|
}),
|
|
|
|
|
|
),
|
2026-04-14 13:22:29 +08:00
|
|
|
|
});
|
2026-04-15 03:06:29 +08:00
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
let message = "保存失败";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
|
if (payload && typeof payload === "object" && typeof payload.error === "string") {
|
|
|
|
|
|
message = payload.error;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
setSaveError(message);
|
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
|
}
|
|
|
|
|
|
const payload = await response.json() as {
|
|
|
|
|
|
revision?: number | null;
|
|
|
|
|
|
conflictDetectionKey?: string | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
const nextRevision =
|
|
|
|
|
|
typeof payload.revision === "number" && Number.isInteger(payload.revision)
|
|
|
|
|
|
? payload.revision
|
|
|
|
|
|
: revisionRef.current;
|
|
|
|
|
|
const nextConflictDetectionKey =
|
|
|
|
|
|
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
|
|
|
|
|
|
? payload.conflictDetectionKey
|
|
|
|
|
|
: conflictDetectionKeyRef.current;
|
|
|
|
|
|
revisionRef.current = nextRevision ?? null;
|
|
|
|
|
|
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
|
2026-04-17 23:36:24 +08:00
|
|
|
|
onPersistedMetaChangeRef.current?.({
|
2026-04-15 03:06:29 +08:00
|
|
|
|
revision: revisionRef.current,
|
|
|
|
|
|
conflictDetectionKey: conflictDetectionKeyRef.current,
|
|
|
|
|
|
});
|
|
|
|
|
|
setSaveError(null);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error instanceof Error) {
|
|
|
|
|
|
setSaveError(error.message);
|
|
|
|
|
|
}
|
2026-04-14 13:22:29 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setIsSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-17 23:36:24 +08:00
|
|
|
|
[documentId, workspaceId],
|
2026-04-14 13:22:29 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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());
|
2026-02-01 08:47:40 +08:00
|
|
|
|
const previousOnlineTableIdsRef = useRef<Set<string>>(new Set());
|
|
|
|
|
|
const onlineTableDeleteTimestampsRef = useRef<Map<string, number>>(new Map());
|
|
|
|
|
|
const onlineTableRestoreRetryCountRef = useRef<Map<string, number>>(new Map());
|
|
|
|
|
|
const onlineTableRestoreRetryTimerRef = useRef<Map<string, number>>(new Map());
|
|
|
|
|
|
const onlineTableRestoringRef = useRef<Set<string>>(new Set());
|
2026-04-14 13:22:29 +08:00
|
|
|
|
const clearMindmapAutosaveCache = useCallback((targetDocumentId: string, mindmapId?: string) => {
|
|
|
|
|
|
if (typeof window === "undefined") return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const prefix = "wolai-mindmap-autosave-";
|
|
|
|
|
|
const targetPrefix = `${prefix}${targetDocumentId}`;
|
|
|
|
|
|
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;
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
const markMindmapDeleting = useCallback((targetDocumentId: string, mindmapId?: string) => {
|
|
|
|
|
|
if (typeof window === "undefined") return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const w = window as unknown as {
|
|
|
|
|
|
__wolaiMindmapDeletingKeys?: Set<string>;
|
|
|
|
|
|
};
|
|
|
|
|
|
if (!w.__wolaiMindmapDeletingKeys) {
|
|
|
|
|
|
w.__wolaiMindmapDeletingKeys = new Set<string>();
|
|
|
|
|
|
}
|
|
|
|
|
|
const key = mindmapId ? `${targetDocumentId}:${mindmapId}` : targetDocumentId;
|
|
|
|
|
|
w.__wolaiMindmapDeletingKeys.add(key);
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
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-02-01 08:47:40 +08:00
|
|
|
|
const onlineTableIds = 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
|
|
|
|
}
|
2026-02-01 08:47:40 +08:00
|
|
|
|
if (b.type === "onlineTable") {
|
|
|
|
|
|
const id = (b.props as { tableId?: string })?.tableId;
|
|
|
|
|
|
if (id) onlineTableIds.add(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-02-01 08:47:40 +08:00
|
|
|
|
return { assetIds, mindmapBlockIds, onlineTableIds };
|
2026-01-02 07:25:50 +08:00
|
|
|
|
}, []);
|
2026-04-14 13:22:29 +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;
|
2026-02-01 08:47:40 +08:00
|
|
|
|
// 关键:当用户在编辑器里“直接删除 mindmap 块”(例如 Backspace/原生删除)时,
|
|
|
|
|
|
// React 会先卸载 MindmapBlock;若此时未及时标记“删除中”,MindmapBlock 的卸载清理会
|
|
|
|
|
|
// 把最后一次数据 POST 回 /api/mindmap/...,导致侧边栏的 mindmap 文件看起来没有被同步删除。
|
|
|
|
|
|
// 因此这里必须在发起 DELETE 前先标记并清理 autosave,确保卸载清理跳过持久化写回。
|
|
|
|
|
|
const unique = Array.from(new Set(mindmapIds)).filter((id) => typeof id === "string" && id);
|
|
|
|
|
|
unique.forEach((mindmapId) => {
|
|
|
|
|
|
markMindmapDeleting(documentId, mindmapId);
|
|
|
|
|
|
clearMindmapAutosaveCache(documentId, mindmapId);
|
|
|
|
|
|
});
|
2026-01-08 06:28:14 +08:00
|
|
|
|
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
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
|
const deleteOnlineTables = useCallback(
|
|
|
|
|
|
async (tableIds: string[]) => {
|
|
|
|
|
|
const unique = Array.from(new Set(tableIds)).filter((id) => typeof id === "string" && id);
|
|
|
|
|
|
if (unique.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
|
unique.map(async (tableId) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteOnlineTable(tableId);
|
|
|
|
|
|
// 通知侧边栏/其它视图:立即从文件树移除,并触发订阅更新
|
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
|
|
|
|
|
|
emitAssetsChanged(documentId);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("删除在线表格失败", tableId, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
[documentId],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
// 清理 restore 重试计时器,避免页面卸载后继续触发网络请求
|
|
|
|
|
|
onlineTableRestoreRetryTimerRef.current.forEach((timerId) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
window.clearTimeout(timerId);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
onlineTableRestoreRetryTimerRef.current.clear();
|
|
|
|
|
|
onlineTableRestoreRetryCountRef.current.clear();
|
|
|
|
|
|
onlineTableRestoringRef.current.clear();
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const restoreOnlineTableIfNeeded = useCallback(
|
|
|
|
|
|
async (tableId: string) => {
|
|
|
|
|
|
const ts = onlineTableDeleteTimestampsRef.current.get(tableId);
|
|
|
|
|
|
if (!ts) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 仅对“最近删除”的表格做恢复(用于 Ctrl+Z / Undo),避免首次加载时误触发 restore
|
|
|
|
|
|
if (Date.now() - ts > 10 * 60 * 1000) {
|
|
|
|
|
|
onlineTableDeleteTimestampsRef.current.delete(tableId);
|
|
|
|
|
|
onlineTableRestoreRetryCountRef.current.delete(tableId);
|
|
|
|
|
|
const pendingTimer = onlineTableRestoreRetryTimerRef.current.get(tableId);
|
|
|
|
|
|
if (pendingTimer) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
window.clearTimeout(pendingTimer);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
onlineTableRestoreRetryTimerRef.current.delete(tableId);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (onlineTableRestoringRef.current.has(tableId)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onlineTableRestoringRef.current.add(tableId);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await fetch("/api/tables/restore", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ tableId }),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
|
const prev = onlineTableRestoreRetryCountRef.current.get(tableId) ?? 0;
|
|
|
|
|
|
const next = prev + 1;
|
|
|
|
|
|
onlineTableRestoreRetryCountRef.current.set(tableId, next);
|
|
|
|
|
|
if (next <= 3 && typeof window !== "undefined" && !onlineTableRestoreRetryTimerRef.current.has(tableId)) {
|
|
|
|
|
|
const timerId = window.setTimeout(() => {
|
|
|
|
|
|
onlineTableRestoreRetryTimerRef.current.delete(tableId);
|
|
|
|
|
|
void restoreOnlineTableIfNeeded(tableId);
|
|
|
|
|
|
}, 600 * next);
|
|
|
|
|
|
onlineTableRestoreRetryTimerRef.current.set(tableId, timerId);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
|
|
|
|
|
|
emitAssetsChanged(documentId);
|
|
|
|
|
|
}
|
|
|
|
|
|
onlineTableDeleteTimestampsRef.current.delete(tableId);
|
|
|
|
|
|
onlineTableRestoreRetryCountRef.current.delete(tableId);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("恢复在线表格失败", tableId, error);
|
|
|
|
|
|
const prev = onlineTableRestoreRetryCountRef.current.get(tableId) ?? 0;
|
|
|
|
|
|
const next = prev + 1;
|
|
|
|
|
|
onlineTableRestoreRetryCountRef.current.set(tableId, next);
|
|
|
|
|
|
if (next <= 3 && typeof window !== "undefined" && !onlineTableRestoreRetryTimerRef.current.has(tableId)) {
|
|
|
|
|
|
const timerId = window.setTimeout(() => {
|
|
|
|
|
|
onlineTableRestoreRetryTimerRef.current.delete(tableId);
|
|
|
|
|
|
void restoreOnlineTableIfNeeded(tableId);
|
|
|
|
|
|
}, 600 * next);
|
|
|
|
|
|
onlineTableRestoreRetryTimerRef.current.set(tableId, timerId);
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
onlineTableRestoringRef.current.delete(tableId);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
[documentId],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
// 监听侧边栏删除事件,主动移除编辑区遗留块
|
|
|
|
|
|
useEffect(() => {
|
2026-04-14 13:22:29 +08:00
|
|
|
|
const handler = (event: Event) => {
|
|
|
|
|
|
const detail = (event as CustomEvent)?.detail as {
|
|
|
|
|
|
docId?: string;
|
|
|
|
|
|
assetIds?: string[];
|
|
|
|
|
|
mindmapDeleted?: boolean;
|
|
|
|
|
|
mindmapAssetIds?: string[];
|
|
|
|
|
|
};
|
|
|
|
|
|
if (!detail || detail.docId !== documentId) return;
|
|
|
|
|
|
const assetIds = detail.assetIds ?? [];
|
|
|
|
|
|
const mindmapDeleted = Boolean(detail.mindmapDeleted);
|
|
|
|
|
|
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 写回导致“复活”
|
|
|
|
|
|
if (mindmapAssetIds.length > 0) {
|
|
|
|
|
|
mindmapAssetIds.forEach((id) => markMindmapDeleting(documentId, id));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
markMindmapDeleting(documentId);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 双保险:等卸载完成后再清一次 autosave,避免删除后再次插入出现旧内容(重复)
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
if (mindmapAssetIds.length > 0) {
|
|
|
|
|
|
mindmapAssetIds.forEach((id) => clearMindmapAutosaveCache(documentId, id));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearMindmapAutosaveCache(documentId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
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 (b.type === "mindmap") {
|
|
|
|
|
|
if (mindmapDeleted) {
|
|
|
|
|
|
toRemove.push(b.id);
|
|
|
|
|
|
} else if (mindmapAssetIds.length > 0 && mindmapAssetIds.includes(b.id)) {
|
|
|
|
|
|
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) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
editor.removeBlocks(toRemove);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore:可能已被其它链路先行删除(例如块菜单/工具栏触发的 removeBlocks)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
|
|
|
|
|
|
return () => window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
|
2026-01-07 22:06:49 +08:00
|
|
|
|
}, [clearMindmapAutosaveCache, documentId, editor, markMindmapDeleting]);
|
2025-11-23 10:55:04 +08:00
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
|
// 监听在线表格删除事件(文件树/其它页面触发),主动移除编辑区的 onlineTable 块,并关闭全屏窗口
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!editor) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const handler = (event: Event) => {
|
|
|
|
|
|
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
|
|
|
|
|
|
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
|
|
|
|
|
|
if (!tableId) return;
|
|
|
|
|
|
onlineTableDeleteTimestampsRef.current.set(tableId, Date.now());
|
|
|
|
|
|
const blocks = editor?.topLevelBlocks as Block<CustomBlockSchema>[] | undefined;
|
|
|
|
|
|
if (!blocks || blocks.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
const toRemove: string[] = [];
|
|
|
|
|
|
const walk = (target: Block<CustomBlockSchema>[]) => {
|
|
|
|
|
|
target.forEach((b) => {
|
|
|
|
|
|
if (b.type === "onlineTable") {
|
|
|
|
|
|
const id = (b.props as { tableId?: string })?.tableId;
|
|
|
|
|
|
if (id && id === tableId) {
|
|
|
|
|
|
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) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
editor.removeBlocks(toRemove);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
setFullScreenTableId((prev) => (prev === tableId ? null : prev));
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener("online-table-deleted", handler as EventListener);
|
|
|
|
|
|
return () => window.removeEventListener("online-table-deleted", handler as EventListener);
|
|
|
|
|
|
}, [editor]);
|
2026-04-14 13:22:29 +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);
|
2026-04-17 23:36:24 +08:00
|
|
|
|
onStatsChangeRef.current?.(stats);
|
|
|
|
|
|
onSnapshotRef.current?.({ blocks: blocks as Json, stats });
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
|
2026-02-01 08:47:40 +08:00
|
|
|
|
const { assetIds, mindmapBlockIds, onlineTableIds } = 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;
|
2026-02-01 08:47:40 +08:00
|
|
|
|
|
|
|
|
|
|
const prevTables = previousOnlineTableIdsRef.current;
|
|
|
|
|
|
const removedTables = [...prevTables].filter((id) => !onlineTableIds.has(id));
|
|
|
|
|
|
if (removedTables.length > 0) {
|
|
|
|
|
|
removedTables.forEach((id) => onlineTableDeleteTimestampsRef.current.set(id, Date.now()));
|
|
|
|
|
|
void deleteOnlineTables(removedTables);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const addedTables = [...onlineTableIds].filter((id) => !prevTables.has(id));
|
|
|
|
|
|
if (addedTables.length > 0) {
|
|
|
|
|
|
addedTables.forEach((id) => void restoreOnlineTableIfNeeded(id));
|
|
|
|
|
|
}
|
|
|
|
|
|
previousOnlineTableIdsRef.current = onlineTableIds;
|
2025-11-23 10:55:04 +08:00
|
|
|
|
};
|
2026-04-14 13:22:29 +08:00
|
|
|
|
|
|
|
|
|
|
runSync();
|
|
|
|
|
|
const unsubscribe = editor.onEditorContentChange(runSync) as unknown as
|
|
|
|
|
|
| undefined
|
|
|
|
|
|
| (() => void);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
disposed = true;
|
|
|
|
|
|
unsubscribe?.();
|
|
|
|
|
|
};
|
2026-04-17 23:36:24 +08:00
|
|
|
|
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, restoreOnlineTableIfNeeded]);
|
2026-04-14 13:22:29 +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]",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const blocknoteClass = cn(
|
|
|
|
|
|
"wolai-editor min-h-full",
|
2026-02-01 08:47:40 +08:00
|
|
|
|
(showStructure || pageOptions.showStructure) && "wolai-editor-show-structure",
|
|
|
|
|
|
pageOptions.showHeadingNumbers && "wolai-heading-numbering",
|
2025-11-29 05:16:23 +08:00
|
|
|
|
isFullScreenTableOpen && "pointer-events-none select-none",
|
2025-11-23 10:55:04 +08:00
|
|
|
|
);
|
2026-04-14 13:22:29 +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] : []) as any[];
|
|
|
|
|
|
for (let index = content.length - 1; index >= 0; index -= 1) {
|
|
|
|
|
|
const node = content[index] as any;
|
|
|
|
|
|
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 as any), text: nextText } as any;
|
|
|
|
|
|
}
|
|
|
|
|
|
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)}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats => {
|
|
|
|
|
|
let characterCount = 0;
|
|
|
|
|
|
let wordCount = 0;
|
2026-02-01 08:47:40 +08:00
|
|
|
|
let todoTotal = 0;
|
|
|
|
|
|
let todoDone = 0;
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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") {
|
2026-04-14 13:22:29 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-01 08:47:40 +08:00
|
|
|
|
|
|
|
|
|
|
// 待办统计:
|
|
|
|
|
|
// - advancedTodo:取消不计入总数;done 计为完成
|
|
|
|
|
|
// - checkListItem(BlockNote 默认块):按 checked 统计
|
|
|
|
|
|
if (block.type === "advancedTodo") {
|
|
|
|
|
|
const status = String((block.props as any)?.status ?? "todo");
|
|
|
|
|
|
if (status !== "cancelled") {
|
|
|
|
|
|
todoTotal += 1;
|
|
|
|
|
|
if (status === "done") {
|
|
|
|
|
|
todoDone += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (block.type === "checkListItem") {
|
|
|
|
|
|
todoTotal += 1;
|
|
|
|
|
|
if (Boolean((block.props as any)?.checked)) {
|
|
|
|
|
|
todoDone += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
if (block.children && block.children.length > 0) {
|
|
|
|
|
|
accumulate(block.children as Block<CustomBlockSchema>[]);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
accumulate(blocks);
|
|
|
|
|
|
return {
|
|
|
|
|
|
wordCount,
|
|
|
|
|
|
characterCount,
|
|
|
|
|
|
blockCount: blocks.length,
|
2026-02-01 08:47:40 +08:00
|
|
|
|
todoTotal,
|
|
|
|
|
|
todoDone,
|
2025-11-23 10:55:04 +08:00
|
|
|
|
};
|
|
|
|
|
|
};
|
2026-04-14 13:22:29 +08:00
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const insertMediaAssetBlock = useCallback(
|
|
|
|
|
|
(asset: MediaAsset) => {
|
|
|
|
|
|
if (!editor) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-01 08:47:40 +08:00
|
|
|
|
const topBlocks = editor.topLevelBlocks as any[];
|
|
|
|
|
|
// 避免重复插入(例如:恢复事件重复触发/多端同时恢复)
|
|
|
|
|
|
const exists = topBlocks.some((b) => {
|
|
|
|
|
|
if (b.type !== "media") return false;
|
|
|
|
|
|
const id = (b.props as { assetId?: string })?.assetId;
|
|
|
|
|
|
return Boolean(id && asset.id && String(id) === String(asset.id));
|
|
|
|
|
|
});
|
|
|
|
|
|
if (exists) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const fileUrl = asset.file_url ?? "";
|
|
|
|
|
|
if (!fileUrl) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const cursor = editor.getTextCursorPosition();
|
2026-02-01 08:47:40 +08:00
|
|
|
|
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
|
2025-11-23 10:55:04 +08:00
|
|
|
|
if (!referenceBlock) {
|
2026-02-01 08:47:40 +08:00
|
|
|
|
try {
|
|
|
|
|
|
// 兜底:部分情况下(例如删除掉最后一个块)topLevelBlocks 可能为空,先补一个段落作为插入锚点
|
|
|
|
|
|
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
|
|
|
|
|
|
const nextTopBlocks = editor.topLevelBlocks as any[];
|
|
|
|
|
|
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!referenceBlock) return;
|
2025-11-23 10:55:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
editor.insertBlocks(
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
type: "media",
|
2026-04-14 13:22:29 +08:00
|
|
|
|
props: {
|
|
|
|
|
|
fileUrl,
|
|
|
|
|
|
thumbnailUrl: asset.thumbnail_url ?? fileUrl,
|
|
|
|
|
|
assetId: asset.id,
|
|
|
|
|
|
assetType: asset.asset_type ?? "image",
|
|
|
|
|
|
fileName: asset.file_name ?? "",
|
|
|
|
|
|
fileSize: asset.file_size ?? undefined,
|
|
|
|
|
|
mimeType: asset.mime_type ?? "",
|
|
|
|
|
|
ocrStatus: asset.ocr_status ?? "idle",
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
referenceBlock,
|
|
|
|
|
|
"after",
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
2026-01-02 07:25:50 +08:00
|
|
|
|
[documentId, editor],
|
2025-11-23 10:55:04 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
|
const insertMindmapBlock = useCallback(
|
|
|
|
|
|
(args: { documentId: string; mindmapId: string }) => {
|
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
// 仅允许插入到当前打开的页面
|
|
|
|
|
|
if (!args.documentId || String(args.documentId) !== String(documentId)) return;
|
|
|
|
|
|
const mindmapId = String(args.mindmapId ?? "").trim();
|
|
|
|
|
|
if (!mindmapId) return;
|
|
|
|
|
|
|
|
|
|
|
|
const topBlocks = editor.topLevelBlocks as any[];
|
|
|
|
|
|
const exists = topBlocks.some((b) => b.type === "mindmap" && String(b.id) === mindmapId);
|
|
|
|
|
|
if (exists) return;
|
|
|
|
|
|
|
|
|
|
|
|
const cursor = editor.getTextCursorPosition();
|
|
|
|
|
|
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
|
|
|
|
|
|
if (!referenceBlock) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
|
|
|
|
|
|
const nextTopBlocks = editor.topLevelBlocks as any[];
|
|
|
|
|
|
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!referenceBlock) return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
editor.insertBlocks(
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
id: mindmapId,
|
|
|
|
|
|
type: "mindmap",
|
|
|
|
|
|
props: { docId: documentId },
|
|
|
|
|
|
content: [],
|
|
|
|
|
|
} as any,
|
|
|
|
|
|
],
|
|
|
|
|
|
referenceBlock,
|
|
|
|
|
|
"after",
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
[documentId, editor],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const insertOnlineTableBlock = useCallback(
|
|
|
|
|
|
(args: { documentId: string; tableId: string }) => {
|
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
// 仅允许插入到当前打开的页面
|
|
|
|
|
|
if (!args.documentId || String(args.documentId) !== String(documentId)) return;
|
|
|
|
|
|
const tableId = String(args.tableId ?? "").trim();
|
|
|
|
|
|
if (!tableId) return;
|
|
|
|
|
|
|
|
|
|
|
|
const topBlocks = editor.topLevelBlocks as any[];
|
|
|
|
|
|
const exists = topBlocks.some((b) => {
|
|
|
|
|
|
if (b.type !== "onlineTable") return false;
|
|
|
|
|
|
const id = (b.props as { tableId?: string })?.tableId;
|
|
|
|
|
|
return Boolean(id && String(id) === tableId);
|
|
|
|
|
|
});
|
|
|
|
|
|
if (exists) return;
|
|
|
|
|
|
|
|
|
|
|
|
const cursor = editor.getTextCursorPosition();
|
|
|
|
|
|
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
|
|
|
|
|
|
if (!referenceBlock) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
|
|
|
|
|
|
const nextTopBlocks = editor.topLevelBlocks as any[];
|
|
|
|
|
|
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!referenceBlock) return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
editor.insertBlocks(
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
type: "onlineTable",
|
|
|
|
|
|
props: { tableId, title: "未命名表格" },
|
|
|
|
|
|
content: [],
|
|
|
|
|
|
} as any,
|
|
|
|
|
|
],
|
|
|
|
|
|
referenceBlock,
|
|
|
|
|
|
"after",
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
[documentId, editor],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
insertMediaAssetRef.current = insertMediaAssetBlock;
|
|
|
|
|
|
}, [insertMediaAssetBlock]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
insertMindmapRef.current = insertMindmapBlock;
|
|
|
|
|
|
}, [insertMindmapBlock]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
insertOnlineTableRef.current = insertOnlineTableBlock;
|
|
|
|
|
|
}, [insertOnlineTableBlock]);
|
|
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!editor) return undefined;
|
|
|
|
|
|
|
|
|
|
|
|
const handler = (event: Event) => {
|
|
|
|
|
|
const detail = (event as CustomEvent)?.detail as
|
|
|
|
|
|
| { docId?: string; kind?: "media"; assetId?: string; asset?: MediaAsset }
|
|
|
|
|
|
| { docId?: string; kind?: "mindmap"; mindmapId?: string }
|
|
|
|
|
|
| { docId?: string; kind?: "table"; tableId?: string };
|
|
|
|
|
|
if (!detail || !detail.docId) return;
|
|
|
|
|
|
if (String(detail.docId) !== String(documentId)) return;
|
|
|
|
|
|
|
|
|
|
|
|
if ((detail as any).kind === "mindmap") {
|
|
|
|
|
|
const mindmapId = String((detail as any).mindmapId ?? "").trim();
|
|
|
|
|
|
if (!mindmapId) return;
|
|
|
|
|
|
insertMindmapBlock({ documentId: detail.docId, mindmapId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ((detail as any).kind === "table") {
|
|
|
|
|
|
const tableId = String((detail as any).tableId ?? "").trim();
|
|
|
|
|
|
if (!tableId) return;
|
|
|
|
|
|
insertOnlineTableBlock({ documentId: detail.docId, tableId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ((detail as any).kind === "media") {
|
|
|
|
|
|
const assetId = String((detail as any).assetId ?? "").trim();
|
|
|
|
|
|
const asset = ((detail as any).asset ?? null) as MediaAsset | null;
|
|
|
|
|
|
if (!assetId) return;
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
|
// 恢复列表里的 file_url 可能为空/不可用,优先用 sign 接口拿最新可访问链接
|
|
|
|
|
|
let fileUrl = (asset?.file_url ?? "").trim();
|
|
|
|
|
|
if (!fileUrl) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
const payload = (await res.json().catch(() => null)) as any;
|
|
|
|
|
|
fileUrl = String(payload?.signedUrl ?? "").trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!fileUrl) return;
|
|
|
|
|
|
|
|
|
|
|
|
insertMediaAssetBlock({
|
|
|
|
|
|
...(asset ?? ({} as MediaAsset)),
|
|
|
|
|
|
id: assetId,
|
|
|
|
|
|
document_id: documentId,
|
|
|
|
|
|
file_url: fileUrl,
|
|
|
|
|
|
thumbnail_url: (asset?.thumbnail_url ?? fileUrl) as any,
|
|
|
|
|
|
} as MediaAsset);
|
|
|
|
|
|
})();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener(ASSETS_RESTORED_EVENT, handler);
|
|
|
|
|
|
return () => window.removeEventListener(ASSETS_RESTORED_EVENT, handler);
|
|
|
|
|
|
}, [documentId, editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock]);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const uploadClipboardMedia = useCallback(
|
|
|
|
|
|
async (file: File) => {
|
|
|
|
|
|
if (!workspaceId) {
|
|
|
|
|
|
throw new Error("缺少空间信息,无法上传文件");
|
|
|
|
|
|
}
|
2026-04-14 13:22:29 +08:00
|
|
|
|
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("上传返回数据缺失");
|
|
|
|
|
|
}
|
|
|
|
|
|
emitAssetsChanged(documentId, payload.asset);
|
|
|
|
|
|
return payload.asset;
|
|
|
|
|
|
},
|
|
|
|
|
|
[documentId, workspaceId],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
|
const stableEditorBridge = useMemo<EditorReferenceBridge>(
|
|
|
|
|
|
() => ({
|
2026-02-01 08:47:40 +08:00
|
|
|
|
undo: () => {
|
|
|
|
|
|
try {
|
2026-04-17 23:36:24 +08:00
|
|
|
|
const currentEditor = editorRef.current;
|
|
|
|
|
|
if (!currentEditor) return;
|
|
|
|
|
|
currentEditor.focus();
|
|
|
|
|
|
currentEditor.undo();
|
2026-02-01 08:47:40 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
redo: () => {
|
|
|
|
|
|
try {
|
2026-04-17 23:36:24 +08:00
|
|
|
|
const currentEditor = editorRef.current;
|
|
|
|
|
|
if (!currentEditor) return;
|
|
|
|
|
|
currentEditor.focus();
|
|
|
|
|
|
currentEditor.redo();
|
2026-02-01 08:47:40 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2025-11-23 17:22:35 +08:00
|
|
|
|
openTableFullScreen: (tableId: string) => {
|
2026-04-17 23:36:24 +08:00
|
|
|
|
setFullScreenTableIdRef.current(tableId);
|
2025-11-23 17:22:35 +08:00
|
|
|
|
},
|
2026-01-10 10:35:21 +08:00
|
|
|
|
insertMediaAsset: (asset: MediaAsset) => {
|
2026-04-17 23:36:24 +08:00
|
|
|
|
insertMediaAssetRef.current(asset);
|
2026-01-10 10:35:21 +08:00
|
|
|
|
},
|
2026-02-01 08:47:40 +08:00
|
|
|
|
insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => {
|
2026-04-17 23:36:24 +08:00
|
|
|
|
insertMindmapRef.current(args);
|
2026-02-01 08:47:40 +08:00
|
|
|
|
},
|
|
|
|
|
|
insertOnlineTableAsset: (args: { documentId: string; tableId: string }) => {
|
2026-04-17 23:36:24 +08:00
|
|
|
|
insertOnlineTableRef.current(args);
|
2026-02-01 08:47:40 +08:00
|
|
|
|
},
|
2025-11-23 10:55:04 +08:00
|
|
|
|
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
|
2026-04-17 23:36:24 +08:00
|
|
|
|
const currentEditor = editorRef.current;
|
|
|
|
|
|
if (!currentEditor) return { blockId: null };
|
|
|
|
|
|
currentEditor.focus();
|
|
|
|
|
|
const cursor = currentEditor.getTextCursorPosition();
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const blockId = cursor?.block?.id ?? null;
|
2026-04-14 13:22:29 +08:00
|
|
|
|
const text = aliasText || target.title || "无标题";
|
2026-04-17 23:36:24 +08:00
|
|
|
|
currentEditor.insertInlineContent([
|
2026-04-14 13:22:29 +08:00
|
|
|
|
{
|
|
|
|
|
|
type: "link",
|
|
|
|
|
|
href: buildDocumentPath(target.id),
|
|
|
|
|
|
content: text,
|
|
|
|
|
|
},
|
|
|
|
|
|
" ",
|
|
|
|
|
|
]);
|
|
|
|
|
|
return { blockId };
|
|
|
|
|
|
},
|
|
|
|
|
|
insertEmbedReference: (target: ReferenceTarget) => {
|
2026-04-17 23:36:24 +08:00
|
|
|
|
const currentEditor = editorRef.current;
|
|
|
|
|
|
if (!currentEditor) return { blockId: null };
|
|
|
|
|
|
currentEditor.focus();
|
|
|
|
|
|
const cursor = currentEditor.getTextCursorPosition();
|
2026-04-14 13:22:29 +08:00
|
|
|
|
const referenceBlock =
|
2026-04-17 23:36:24 +08:00
|
|
|
|
cursor?.block ?? currentEditor.topLevelBlocks[currentEditor.topLevelBlocks.length - 1];
|
2026-04-14 13:22:29 +08:00
|
|
|
|
const blockId = generateBlockId();
|
2026-04-17 23:36:24 +08:00
|
|
|
|
currentEditor.insertBlocks(
|
2026-04-14 13:22:29 +08:00
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
id: blockId,
|
|
|
|
|
|
type: "pageReference",
|
|
|
|
|
|
props: {
|
|
|
|
|
|
pageId: target.id,
|
|
|
|
|
|
title: target.title ?? "无标题",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
referenceBlock,
|
|
|
|
|
|
"after",
|
|
|
|
|
|
);
|
|
|
|
|
|
return { blockId };
|
|
|
|
|
|
},
|
2025-11-23 10:55:04 +08:00
|
|
|
|
replaceWithSnapshot: (payload: Json) => {
|
2026-04-17 23:36:24 +08:00
|
|
|
|
const currentEditor = editorRef.current;
|
|
|
|
|
|
if (!currentEditor) return;
|
|
|
|
|
|
currentEditor.focus();
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
|
currentEditor.replaceBlocks(currentEditor.topLevelBlocks, nextBlocks as never);
|
2025-11-23 10:55:04 +08:00
|
|
|
|
},
|
2026-02-01 08:47:40 +08:00
|
|
|
|
getCursorBlockId: () => {
|
|
|
|
|
|
try {
|
2026-04-17 23:36:24 +08:00
|
|
|
|
const currentEditor = editorRef.current;
|
|
|
|
|
|
if (!currentEditor) return null;
|
|
|
|
|
|
const cursor = currentEditor.getTextCursorPosition();
|
2026-02-01 08:47:40 +08:00
|
|
|
|
return cursor?.block?.id ?? null;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-17 23:36:24 +08:00
|
|
|
|
}),
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
registerEditorBridge(editor ? stableEditorBridge : null);
|
|
|
|
|
|
}, [editor, registerEditorBridge, stableEditorBridge]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-11-23 10:55:04 +08:00
|
|
|
|
return () => registerEditorBridge(null);
|
2026-04-17 23:36:24 +08:00
|
|
|
|
}, [registerEditorBridge]);
|
2026-02-01 08:47:40 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
if (!workspaceId) return;
|
|
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
|
|
|
|
const ctrlOrMeta = event.ctrlKey || event.metaKey;
|
|
|
|
|
|
if (!ctrlOrMeta) return;
|
|
|
|
|
|
if (!event.altKey) return;
|
|
|
|
|
|
const key = String(event.key ?? "").toLowerCase();
|
|
|
|
|
|
if (key !== "m") return;
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
const cursor = editor.getTextCursorPosition();
|
|
|
|
|
|
const blockId = cursor?.block?.id ?? null;
|
|
|
|
|
|
if (blockId) {
|
|
|
|
|
|
openCommentsForBlock({ workspaceId, documentId, blockId });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
openCommentsForPage({ workspaceId, documentId });
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener("keydown", onKeyDown);
|
|
|
|
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
|
|
|
|
}, [documentId, editor, openCommentsForBlock, openCommentsForPage, workspaceId]);
|
2026-04-14 13:22:29 +08:00
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!editor) {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
2026-02-01 08:47:40 +08:00
|
|
|
|
// 说明:BlockNote 的 contenteditable 节点不直接暴露 spellcheck props。
|
|
|
|
|
|
// 这里用 DOM 属性实现“全局选项:拼写检查”。
|
|
|
|
|
|
try {
|
|
|
|
|
|
const root = document.querySelector<HTMLElement>(".wolai-editor");
|
|
|
|
|
|
if (root) {
|
|
|
|
|
|
root.setAttribute("spellcheck", spellCheck ? "true" : "false");
|
|
|
|
|
|
root.querySelectorAll<HTMLElement>("[contenteditable]").forEach((el) => {
|
|
|
|
|
|
el.setAttribute("spellcheck", spellCheck ? "true" : "false");
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const handlePaste = (event: ClipboardEvent) => {
|
|
|
|
|
|
const activeElement = event.target;
|
|
|
|
|
|
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-14 13:22:29 +08:00
|
|
|
|
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 ?? "粘贴图片失败,请稍后重试");
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
};
|
2025-11-23 10:55:04 +08:00
|
|
|
|
window.addEventListener("paste", handlePaste);
|
|
|
|
|
|
return () => window.removeEventListener("paste", handlePaste);
|
2026-02-01 08:47:40 +08:00
|
|
|
|
}, [editor, insertMediaAssetBlock, spellCheck, uploadClipboardMedia]);
|
2026-04-14 13:22:29 +08:00
|
|
|
|
|
|
|
|
|
|
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}>
|
2025-11-23 17:22:35 +08:00
|
|
|
|
<BlockNoteView
|
|
|
|
|
|
editor={editor}
|
|
|
|
|
|
theme="light"
|
2026-01-08 06:28:14 +08:00
|
|
|
|
slashMenu={false}
|
2026-02-01 08:47:40 +08:00
|
|
|
|
sideMenu={false}
|
2026-01-22 18:53:20 +08:00
|
|
|
|
editable={!pageOptions.protectEditing && !readOnly}
|
2025-11-23 17:22:35 +08:00
|
|
|
|
className={blocknoteClass}
|
|
|
|
|
|
>
|
2026-01-17 10:12:53 +08:00
|
|
|
|
{!isFullScreenTableOpen && (
|
|
|
|
|
|
<SideMenuController
|
|
|
|
|
|
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
2026-02-01 08:47:40 +08:00
|
|
|
|
<CustomSideMenu
|
|
|
|
|
|
{...props}
|
|
|
|
|
|
currentDocumentId={documentId}
|
|
|
|
|
|
workspaceId={workspaceId}
|
|
|
|
|
|
unresolvedCommentCountByBlockId={unresolvedCommentCountByBlockId}
|
|
|
|
|
|
/>
|
2026-01-17 10:12:53 +08:00
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-04-14 13:22:29 +08:00
|
|
|
|
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
|
|
|
|
|
</BlockNoteView>
|
|
|
|
|
|
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
|
2026-04-15 03:06:29 +08:00
|
|
|
|
{isSaving ? "保存中..." : saveError ? saveError : "已保存"}
|
2026-04-14 13:22:29 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-01 08:47:40 +08:00
|
|
|
|
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
|
2025-11-23 10:55:04 +08:00
|
|
|
|
</div>
|
2026-04-14 13:22:29 +08:00
|
|
|
|
{/* 全屏表格编辑器 Modal */}
|
|
|
|
|
|
{fullScreenTableId && (
|
|
|
|
|
|
<FullScreenTableEditor
|
|
|
|
|
|
tableId={fullScreenTableId}
|
|
|
|
|
|
onClose={() => setFullScreenTableId(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
2026-04-15 03:06:29 +08:00
|
|
|
|
}
|