0.4.0 convex及界面修改
This commit is contained in:
@@ -29,7 +29,12 @@ import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import type { ReferenceTarget } from "@/types/search";
|
||||
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
|
||||
import { clamp, MIN_BLOCK_LEVEL, MAX_BLOCK_LEVEL } from "@/lib/constants";
|
||||
import { ASSETS_CHANGED_EVENT, emitAssetsChanged } from "@/lib/events";
|
||||
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";
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
documentId: string;
|
||||
@@ -39,6 +44,7 @@ interface BlockNoteEditorProps {
|
||||
readOnly?: boolean;
|
||||
onStatsChange?: (stats: DocumentStats) => void;
|
||||
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
|
||||
onCloseToc?: () => void;
|
||||
}
|
||||
|
||||
const extractInitialBlocks = (content: unknown): Json | undefined => {
|
||||
@@ -172,6 +178,7 @@ export function BlockNoteEditor({
|
||||
readOnly = false,
|
||||
onStatsChange,
|
||||
onSnapshot,
|
||||
onCloseToc,
|
||||
}: BlockNoteEditorProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
|
||||
@@ -180,6 +187,26 @@ export function BlockNoteEditor({
|
||||
|
||||
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
|
||||
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
|
||||
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]);
|
||||
|
||||
const normalizedInitialContent = useMemo(
|
||||
() => extractInitialBlocks(initialContent),
|
||||
@@ -202,6 +229,10 @@ export function BlockNoteEditor({
|
||||
{
|
||||
initialContent: normalizedInitialContent as never,
|
||||
schema: customBlockSchema,
|
||||
placeholders: {
|
||||
default: "输入'/'选择,按 空格 打开AI...",
|
||||
emptyDocument: "输入'/'选择,按 空格 打开AI...",
|
||||
},
|
||||
collaboration: collaboration
|
||||
? {
|
||||
provider: collaboration.provider,
|
||||
@@ -243,6 +274,11 @@ export function BlockNoteEditor({
|
||||
const debouncedSave = useDebouncedCallback(saveContent, 800);
|
||||
const previousAssetsRef = useRef<Set<string>>(new Set());
|
||||
const previousMindmapBlockIdsRef = useRef<Set<string>>(new Set());
|
||||
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());
|
||||
const clearMindmapAutosaveCache = useCallback((targetDocumentId: string, mindmapId?: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
@@ -290,6 +326,7 @@ export function BlockNoteEditor({
|
||||
const collectAssets = useCallback((blocks: Block<CustomBlockSchema>[]) => {
|
||||
const assetIds = new Set<string>();
|
||||
const mindmapBlockIds = new Set<string>();
|
||||
const onlineTableIds = new Set<string>();
|
||||
const walk = (target: Block<CustomBlockSchema>[]) => {
|
||||
target.forEach((b) => {
|
||||
if (b.type === "media") {
|
||||
@@ -299,13 +336,17 @@ export function BlockNoteEditor({
|
||||
if (b.type === "mindmap") {
|
||||
mindmapBlockIds.add(b.id);
|
||||
}
|
||||
if (b.type === "onlineTable") {
|
||||
const id = (b.props as { tableId?: string })?.tableId;
|
||||
if (id) onlineTableIds.add(id);
|
||||
}
|
||||
if (Array.isArray(b.children) && b.children.length > 0) {
|
||||
walk(b.children as Block<CustomBlockSchema>[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(blocks);
|
||||
return { assetIds, mindmapBlockIds };
|
||||
return { assetIds, mindmapBlockIds, onlineTableIds };
|
||||
}, []);
|
||||
|
||||
const deleteAssets = useCallback(
|
||||
@@ -331,6 +372,15 @@ export function BlockNoteEditor({
|
||||
const deleteMindmapAssets = useCallback(
|
||||
async (mindmapIds: string[]) => {
|
||||
if (mindmapIds.length === 0) return;
|
||||
// 关键:当用户在编辑器里“直接删除 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);
|
||||
});
|
||||
await Promise.all(
|
||||
mindmapIds.map(async (mindmapId) => {
|
||||
try {
|
||||
@@ -351,6 +401,115 @@ export function BlockNoteEditor({
|
||||
[clearMindmapAutosaveCache, documentId, markMindmapDeleting],
|
||||
);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
// 监听侧边栏删除事件,主动移除编辑区遗留块
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
@@ -419,6 +578,48 @@ export function BlockNoteEditor({
|
||||
return () => window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
}, [clearMindmapAutosaveCache, documentId, editor, markMindmapDeleting]);
|
||||
|
||||
// 监听在线表格删除事件(文件树/其它页面触发),主动移除编辑区的 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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return undefined;
|
||||
@@ -439,7 +640,7 @@ export function BlockNoteEditor({
|
||||
onSnapshot?.({ blocks: blocks as Json, stats });
|
||||
|
||||
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
|
||||
const { assetIds, mindmapBlockIds } = collectAssets(typedBlocks);
|
||||
const { assetIds, mindmapBlockIds, onlineTableIds } = collectAssets(typedBlocks);
|
||||
const prevAssets = previousAssetsRef.current;
|
||||
const removedAssets = [...prevAssets].filter((id) => !assetIds.has(id));
|
||||
if (removedAssets.length > 0) {
|
||||
@@ -452,6 +653,19 @@ export function BlockNoteEditor({
|
||||
void deleteMindmapAssets(removedMindmaps);
|
||||
}
|
||||
previousMindmapBlockIdsRef.current = mindmapBlockIds;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
runSync();
|
||||
@@ -462,7 +676,7 @@ export function BlockNoteEditor({
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, editor, onSnapshot, onStatsChange]);
|
||||
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, onSnapshot, onStatsChange, restoreOnlineTableIfNeeded]);
|
||||
|
||||
const jumpToHeading = useCallback((headingId: string) => {
|
||||
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
|
||||
@@ -478,7 +692,8 @@ export function BlockNoteEditor({
|
||||
|
||||
const blocknoteClass = cn(
|
||||
"wolai-editor min-h-full",
|
||||
pageOptions.showStructure && "wolai-editor-show-structure",
|
||||
(showStructure || pageOptions.showStructure) && "wolai-editor-show-structure",
|
||||
pageOptions.showHeadingNumbers && "wolai-heading-numbering",
|
||||
isFullScreenTableOpen && "pointer-events-none select-none",
|
||||
);
|
||||
|
||||
@@ -523,6 +738,8 @@ const generateBlockId = () => {
|
||||
const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats => {
|
||||
let characterCount = 0;
|
||||
let wordCount = 0;
|
||||
let todoTotal = 0;
|
||||
let todoDone = 0;
|
||||
const accumulate = (targetBlocks: Block<CustomBlockSchema>[]) => {
|
||||
targetBlocks.forEach((block) => {
|
||||
if (Array.isArray(block.content)) {
|
||||
@@ -543,6 +760,24 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 待办统计:
|
||||
// - 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;
|
||||
}
|
||||
}
|
||||
if (block.children && block.children.length > 0) {
|
||||
accumulate(block.children as Block<CustomBlockSchema>[]);
|
||||
}
|
||||
@@ -553,6 +788,8 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
wordCount,
|
||||
characterCount,
|
||||
blockCount: blocks.length,
|
||||
todoTotal,
|
||||
todoDone,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -561,15 +798,32 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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);
|
||||
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
|
||||
if (!referenceBlock) {
|
||||
return;
|
||||
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;
|
||||
}
|
||||
editor.insertBlocks(
|
||||
[
|
||||
@@ -595,6 +849,151 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
[documentId, editor],
|
||||
);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
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]);
|
||||
|
||||
const uploadClipboardMedia = useCallback(
|
||||
async (file: File) => {
|
||||
if (!workspaceId) {
|
||||
@@ -628,12 +1027,34 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
return;
|
||||
}
|
||||
const bridge = {
|
||||
undo: () => {
|
||||
try {
|
||||
editor.focus();
|
||||
editor.undo();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
redo: () => {
|
||||
try {
|
||||
editor.focus();
|
||||
editor.redo();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
openTableFullScreen: (tableId: string) => {
|
||||
setFullScreenTableId(tableId);
|
||||
},
|
||||
insertMediaAsset: (asset: MediaAsset) => {
|
||||
insertMediaAssetBlock(asset);
|
||||
},
|
||||
insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => {
|
||||
insertMindmapBlock(args);
|
||||
},
|
||||
insertOnlineTableAsset: (args: { documentId: string; tableId: string }) => {
|
||||
insertOnlineTableBlock(args);
|
||||
},
|
||||
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
|
||||
editor.focus();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
@@ -683,15 +1104,60 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
}
|
||||
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
|
||||
},
|
||||
getCursorBlockId: () => {
|
||||
try {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
return cursor?.block?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
registerEditorBridge(bridge);
|
||||
return () => registerEditorBridge(null);
|
||||
}, [editor, insertMediaAssetBlock, registerEditorBridge]);
|
||||
}, [editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock, registerEditorBridge]);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return undefined;
|
||||
}
|
||||
// 说明: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
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const activeElement = event.target;
|
||||
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
|
||||
@@ -718,7 +1184,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [editor, insertMediaAssetBlock, uploadClipboardMedia]);
|
||||
}, [editor, insertMediaAssetBlock, spellCheck, uploadClipboardMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
@@ -777,16 +1243,20 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
editor={editor}
|
||||
theme="light"
|
||||
slashMenu={false}
|
||||
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
|
||||
sideMenu={false}
|
||||
editable={!pageOptions.protectEditing && !readOnly}
|
||||
className={blocknoteClass}
|
||||
>
|
||||
{!isFullScreenTableOpen && (
|
||||
<SideMenuController
|
||||
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
||||
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
|
||||
<CustomSideMenu
|
||||
{...props}
|
||||
currentDocumentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
unresolvedCommentCountByBlockId={unresolvedCommentCountByBlockId}
|
||||
/>
|
||||
)}
|
||||
floatingOptions={{ placement: "left" }}
|
||||
/>
|
||||
)}
|
||||
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
||||
@@ -795,7 +1265,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
{isSaving ? "保存中..." : "已保存"}
|
||||
</div>
|
||||
</div>
|
||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
|
||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
|
||||
</div>
|
||||
|
||||
<MoveEmbedPickerHost />
|
||||
|
||||
Reference in New Issue
Block a user