0.4.0 convex及界面修改

This commit is contained in:
liaibo
2026-02-01 08:47:40 +08:00
parent d1f055f51a
commit af92c4b149
636 changed files with 7522 additions and 1815 deletions
@@ -5,6 +5,7 @@ import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkle
import type { Json } from "@/types/supabase";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -154,10 +155,11 @@ export function DocumentAiAgentPanel({
const open = useAiAgentUiStore((s) => s.documentAgentOpen);
const setOpen = useAiAgentUiStore((s) => s.setDocumentAgentOpen);
const setAvailable = useAiAgentUiStore((s) => s.setDocumentAgentAvailable);
const flightMode = useAppPreferencesStore((s) => s.flightMode);
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_SESSION_MESSAGES);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [networkOn, setNetworkOn] = useState(true);
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
const [toolPickerOpen, setToolPickerOpen] = useState(false);
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
@@ -179,6 +181,12 @@ export function DocumentAiAgentPanel({
const abortRef = useRef<AbortController | null>(null);
const syncTimerRef = useRef<number | null>(null);
useEffect(() => {
if (flightMode) {
setNetworkOn(false);
}
}, [flightMode]);
useEffect(() => {
setAvailable(true);
return () => {
@@ -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 计为完成
// - checkListItemBlockNote 默认块):按 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 />
@@ -19,6 +19,7 @@ import type { MediaKind } from "@/types/media";
import { emitAssetsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { useCurrentDocumentStore } from "@/store/current-document";
import { toBrowserAccessibleUrl } from "@/lib/url/browser-file-url";
import {
DropdownMenu,
DropdownMenuContent,
@@ -81,6 +82,12 @@ const MediaBlockContent = ({ block, editor }: any) => {
const { openPicker } = useImagePicker();
const [busy, setBusy] = useState(false);
const fileUrl = block.props.fileUrl as string;
const browserFileUrl = useMemo(() => toBrowserAccessibleUrl(fileUrl) ?? fileUrl, [fileUrl]);
const rawThumbUrl = (block.props.thumbnailUrl as string | undefined) || "";
const browserThumbUrl = useMemo(
() => (rawThumbUrl ? toBrowserAccessibleUrl(rawThumbUrl) ?? rawThumbUrl : ""),
[rawThumbUrl],
);
const rawAssetType = (block.props.assetType as string) || "image";
const assetType: MediaKind =
rawAssetType === "video" || rawAssetType === "audio" || rawAssetType === "file"
@@ -279,7 +286,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
const openWithOnlyOffice = async () => {
if (!fileUrl) return;
if (!officeBase) {
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
return;
}
const currentDocReadOnly = useCurrentDocumentStore.getState().readOnly;
@@ -400,10 +407,10 @@ const MediaBlockContent = ({ block, editor }: any) => {
<video
controls
className="max-h-[420px] w-full rounded-2xl bg-black"
poster={block.props.thumbnailUrl || undefined}
poster={browserThumbUrl || undefined}
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
>
<source src={fileUrl} type={block.props.mimeType || "video/mp4"} />
<source src={browserFileUrl} type={block.props.mimeType || "video/mp4"} />
</video>
);
}
@@ -411,7 +418,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
return (
<div className="rounded-2xl border border-gray-200 bg-white/80 p-5">
<audio controls className="w-full">
<source src={fileUrl} type={block.props.mimeType || "audio/mpeg"} />
<source src={browserFileUrl} type={block.props.mimeType || "audio/mpeg"} />
</audio>
<p className="mt-2 text-sm text-gray-500">{displayFileName}</p>
</div>
@@ -533,7 +540,13 @@ const MediaBlockContent = ({ block, editor }: any) => {
);
}
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
return (
<img
src={browserThumbUrl || browserFileUrl}
alt={block.props.caption || typeLabel}
style={inlineStyle}
/>
);
};
const figure = (
@@ -5,6 +5,7 @@ import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkle
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { useAppPreferencesStore } from "@/store/app-preferences";
type AgentAssetItem = {
kind: "media" | "local-mindmap" | "test-pdf";
@@ -158,11 +159,12 @@ export function MindmapAiAgentPanel({
activeNodes: unknown[];
onClose?: () => void;
}) {
const flightMode = useAppPreferencesStore((s) => s.flightMode);
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_SESSION_MESSAGES);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [networkOn, setNetworkOn] = useState(true);
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
const [toolPickerOpen, setToolPickerOpen] = useState(false);
@@ -185,6 +187,12 @@ export function MindmapAiAgentPanel({
const abortRef = useRef<AbortController | null>(null);
const syncTimerRef = useRef<number | null>(null);
useEffect(() => {
if (flightMode) {
setNetworkOn(false);
}
}, [flightMode]);
useEffect(() => {
try {
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
@@ -34,6 +34,9 @@ import { Input } from "@/components/ui/input";
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
import iconConfig from "./mindmapIconConfig";
import { emitAssetsChanged } from "@/lib/events";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { useQuery } from "convex/react";
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
const loadIconModules = async () => {
@@ -462,6 +465,11 @@ const MindmapBlockView = ({
const mindmapReadyRef = useRef(false);
const mindmapRef = useRef<MindMapInstance | null>(null);
const hasLocalEditsRef = useRef(false);
const lastLocalEditAtRef = useRef(0);
const markLocalEdited = useCallback(() => {
hasLocalEditsRef.current = true;
lastLocalEditAtRef.current = Date.now();
}, []);
const applyingRemoteRef = useRef(false);
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
@@ -513,7 +521,10 @@ const MindmapBlockView = ({
if (!docId) return;
void (async () => {
try {
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}`);
// 说明:仅用于拿 workspaceId(上传图片需要),避免拉取整套 AI 资产列表导致打开页面变慢。
const res = await fetch(
`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}&workspaceOnly=1`,
);
const json: unknown = await res.json().catch(() => null);
if (!res.ok) return;
if (cancelled) return;
@@ -530,6 +541,7 @@ const MindmapBlockView = ({
useEffect(() => {
hasLocalEditsRef.current = false;
lastLocalEditAtRef.current = 0;
applyingRemoteRef.current = false;
}, [docId]);
@@ -537,6 +549,15 @@ const MindmapBlockView = ({
if (docId) return `${STORAGE_PREFIX}${docId}:${mindmapId}`;
return `${STORAGE_PREFIX}${mindmapId}`;
}, [docId, mindmapId]);
// 记录本端最近一次成功写入到后端的 updated_at,用于避免 Convex 订阅回放覆盖(清空历史/打断编辑)。
const lastLocalSavedAtRef = useRef<string | null>(null);
const lastAppliedRemoteUpdatedAtRef = useRef<string | null>(null);
const remoteMindmap = useQuery(
api.mindmaps.get,
isConvexEnabled() && docId ? { docId, mindmapId } : "skip",
);
const initialDataRef = useRef<unknown>(null);
if (initialDataRef.current === null) {
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
@@ -584,6 +605,83 @@ const MindmapBlockView = ({
};
}, [docId, mindmap, mindmapId]);
// Convex 实时订阅:当其它客户端更新/删除导图时,同步到当前实例。
useEffect(() => {
if (!remoteMindmap || typeof remoteMindmap !== "object") return;
const meta = (remoteMindmap as any).meta as Record<string, unknown> | undefined;
const deletedAt = typeof meta?.deleted_at === "string" ? (meta.deleted_at as string) : null;
const updatedAt = typeof meta?.updated_at === "string" ? (meta.updated_at as string) : null;
if (deletedAt) {
if (!docId || deletingRef.current) return;
deletingRef.current = true;
try {
window.localStorage.removeItem(autosaveKey);
} catch {
// ignore
}
// 全屏页:退出到文档页
if (effectiveFullscreen && typeof onExitFullscreen === "function") {
window.alert("该思维导图已被删除(已移入垃圾桶),将返回页面。");
onExitFullscreen();
return;
}
// 嵌入编辑器:复用编辑器监听链路移除块
emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]);
return;
}
if (!updatedAt) return;
if (lastAppliedRemoteUpdatedAtRef.current === updatedAt) return;
// 如果这是本端刚刚保存产生的回放,跳过应用,避免清空历史/打断输入
if (lastLocalSavedAtRef.current && lastLocalSavedAtRef.current === updatedAt) {
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
return;
}
// 本地仍有未同步编辑时,不覆盖
if (hasLocalEditsRef.current || deletingRef.current) return;
const incoming = canonicalizeMindmapData((remoteMindmap as any).data ?? defaultMindmapData);
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
initialDataRef.current = incoming;
try {
window.localStorage.setItem(autosaveKey, JSON.stringify(incoming));
} catch {
// ignore
}
if (!effectiveFullscreen) {
try {
editor.updateBlock(block, { props: { ...block.props, data: incoming } });
} catch {
// ignore
}
}
if (mindmap) {
applyingRemoteRef.current = true;
try {
mindmap.setData(incoming);
mindmap.command.clearHistory();
} finally {
window.setTimeout(() => {
applyingRemoteRef.current = false;
}, 0);
}
}
}, [
autosaveKey,
block,
docId,
editor,
effectiveFullscreen,
mindmap,
mindmapId,
onExitFullscreen,
remoteMindmap,
]);
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
useEffect(() => {
const onPointerDownCapture = (e: Event) => {
@@ -956,7 +1054,7 @@ const MindmapBlockView = ({
// 兜底:某些情况下 INSERT_NODE 不触发 data_change(例如被外层捕获键盘
// 事件拦截导致内部 Keyboard 插件不走),这里主动做一次防抖保存,确保
// 切换全屏/刷新后不会丢失。
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -974,7 +1072,7 @@ const MindmapBlockView = ({
if (!node) return;
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
inst.execCommand?.("INSERT_CHILD_NODE", false, [node]);
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -993,7 +1091,7 @@ const MindmapBlockView = ({
const inst = ensureActiveBefore(mm);
if (!inst) return;
inst.execCommand?.("REMOVE_NODE");
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1029,7 +1127,7 @@ const MindmapBlockView = ({
const copyData = renderer.beingCopyData ?? null;
if (copyData) {
inst.execCommand?.("PASTE_NODE", copyData);
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1039,7 +1137,7 @@ const MindmapBlockView = ({
return;
}
renderer.paste?.();
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1053,7 +1151,7 @@ const MindmapBlockView = ({
return () => {
window.removeEventListener("keydown", onKeyDownCapture, true);
};
}, []);
}, [markLocalEdited]);
// 兜底:在非全屏嵌入 BlockNote 时,Ctrl+V 可能仍然触发编辑器的 paste,导致思维导图块被替换成纯文本。
// 这里在 capture 阶段拦截 paste:当“最近一次指针交互在思维导图块内”且当前不在节点文本编辑态时,
@@ -1114,7 +1212,7 @@ const MindmapBlockView = ({
}
// 兜底持久化:避免快速切换视图导致“看起来没保存”
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1127,7 +1225,7 @@ const MindmapBlockView = ({
return () => {
window.removeEventListener("paste", onPasteCapture, true);
};
}, []);
}, [markLocalEdited]);
// 兜底:Ctrl+C 可能被 BlockNote/ProseMirror 先行拦截,导致我们 keydown 捕获不到。
// 这里直接在 copy 事件的 capture 阶段接管,确保“选中节点 -> Ctrl+C”一定能复制节点数据。
@@ -1243,7 +1341,7 @@ const MindmapBlockView = ({
// ignore
}
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1256,7 +1354,7 @@ const MindmapBlockView = ({
return () => {
window.removeEventListener("beforeinput", onBeforeInputCapture, true);
};
}, []);
}, [markLocalEdited]);
const persistData = useCallback(
(data: unknown) => {
@@ -1279,14 +1377,30 @@ const MindmapBlockView = ({
editor.updateBlock(block, { props: { ...block.props, data: safe } });
}
if (docId) {
// 同步到本地文件 + Supabase(弱依赖)
// 同步到本地文件 + Convex(弱依赖)
const requestStartedAt = Date.now();
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: safe }),
})
.then((resp) => {
.then(async (resp) => {
if (resp.ok) {
try {
const payload = (await resp.json().catch(() => null)) as any;
const updatedAt = payload && typeof payload.updated_at === "string" ? payload.updated_at : null;
if (updatedAt) {
lastLocalSavedAtRef.current = updatedAt;
// 避免 Convex 订阅回放对本端“已应用的保存”重复 setData/clearHistory 导致闪烁
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
// 仅当保存期间没有新增编辑时,才允许接收远端更新;否则会出现“插入节点后闪一下又没了”
if (lastLocalEditAtRef.current <= requestStartedAt) {
hasLocalEditsRef.current = false;
}
}
} catch {
// ignore
}
const fileName = `mindmap-${mindmapId}.json`;
emitAssetsChanged(docId, {
id: mindmapId,
@@ -1699,7 +1813,7 @@ const MindmapBlockView = ({
!deletingRef.current &&
shouldPersistAfterCommand(cmd)
) {
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot =
instance.getData?.(true) ?? instance.getData?.() ?? null;
@@ -1845,13 +1959,13 @@ const MindmapBlockView = ({
});
instance.on?.("painter_start", () => setPainterMode(true));
instance.on?.("painter_end", () => setPainterMode(false));
instance.on?.("data_change", () => {
if (!applyingRemoteRef.current) {
hasLocalEditsRef.current = true;
}
// data_change 的参数在部分场景下可能不是“可直接序列化的完整数据”
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
instance.on?.("data_change", () => {
if (!applyingRemoteRef.current) {
markLocalEdited();
}
// data_change 的参数在部分场景下可能不是“可直接序列化的完整数据”
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
const snapshot =
instance.getData?.(true) ?? instance.getData?.() ?? null;
if (snapshot) schedulePersist(snapshot);
@@ -2688,7 +2802,7 @@ const MindmapBlockView = ({
editor.removeBlocks([block.id]);
return;
}
const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。");
const confirmed = window.confirm("确认删除当前思维导图?将移入垃圾桶(10 分钟内可恢复),到期将自动清理。");
if (!confirmed) return;
deletingRef.current = true;
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "DELETE" });
@@ -11,7 +11,15 @@ const normalizeTitle = (value?: string | null) => {
return value;
};
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
const PageReferenceContent = ({
pageId,
title,
asChildPage,
}: {
pageId: string;
title: string;
asChildPage: boolean;
}) => {
const router = useRouter();
// 说明:Convex 迁移阶段,直接使用 block props 里的 title,不做实时订阅/拉取。
// 页面引用的标题会由编辑器同步更新 block.props.title。
@@ -25,6 +33,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
return (
<div
data-child-page={asChildPage ? "true" : "false"}
role="button"
tabIndex={0}
onClick={navigate}
@@ -52,10 +61,17 @@ export const pageReferenceBlock = createReactBlockSpec(
propSchema: {
pageId: { default: "" },
title: { default: "未命名页面" },
asChildPage: { default: false },
},
content: "none",
},
() => ({
render: ({ block }) => <PageReferenceContent pageId={block.props.pageId} title={block.props.title} />,
render: ({ block }) => (
<PageReferenceContent
pageId={block.props.pageId}
title={block.props.title}
asChildPage={Boolean((block.props as any).asChildPage)}
/>
),
}),
)();
@@ -0,0 +1,508 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import { MessageSquare, CornerDownRight, CheckCircle2, Circle, ExternalLink } from "lucide-react";
import { api } from "@/lib/convex/api";
import { useCommentsUiStore } from "@/store/comments-ui";
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
const makeId = (): string => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
};
const jumpToBlock = (blockId: string) => {
if (!blockId) return;
const target = document.querySelector<HTMLElement>(`[data-id="${blockId}"]`);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
} else {
window.alert("未找到对应块(可能已被删除或未渲染)");
}
};
export function DocumentCommentsDrawer() {
const { isAuthenticated } = useConvexAuth();
const open = useCommentsUiStore((s) => s.open);
const target = useCommentsUiStore((s) => s.target);
const close = useCommentsUiStore((s) => s.close);
const documentId = target?.documentId ?? "";
const workspaceId = target?.workspaceId ?? "";
const focusBlockId = target?.blockId ?? null;
const [includeResolved, setIncludeResolved] = useState(false);
const threads = useQuery(
api.comments.listThreadsByDocument,
open && isAuthenticated && documentId ? { documentId, includeResolved } : "skip",
);
const currentUser = useQuery(api.users.currentUser, open && isAuthenticated ? {} : "skip");
const createThread = useMutation(api.comments.createThread);
const reply = useMutation(api.comments.reply);
const setResolved = useMutation(api.comments.setResolved);
const editMessage = useMutation(api.comments.editMessage);
const deleteMessage = useMutation(api.comments.deleteMessage);
// 说明:messages 需要 threadId;在选择线程后再订阅。
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const activeMessages = useQuery(
api.comments.listMessagesByThread,
open && isAuthenticated && activeThreadId ? { threadId: activeThreadId } : "skip",
);
const [activeTab, setActiveTab] = useState<"page" | "block">("page");
useEffect(() => {
if (!open) return;
setActiveTab(focusBlockId ? "block" : "page");
setActiveThreadId(null);
setEditingMessageId(null);
setEditingDraft("");
}, [focusBlockId, open]);
const { pageThreads, blockThreads } = useMemo(() => {
const list = Array.isArray(threads) ? threads : [];
const pageThreads = list.filter((t: any) => !t.blockId);
const blockThreads = list.filter((t: any) => Boolean(t.blockId));
return { pageThreads, blockThreads };
}, [threads]);
const blockThreadsForFocus = useMemo(() => {
if (!focusBlockId) return blockThreads;
return blockThreads.filter((t: any) => String(t.blockId) === String(focusBlockId));
}, [blockThreads, focusBlockId]);
const activeThread = useMemo(() => {
const list = Array.isArray(threads) ? threads : [];
return list.find((t: any) => String(t.id) === String(activeThreadId)) ?? null;
}, [activeThreadId, threads]);
const [draft, setDraft] = useState("");
const [replyDraft, setReplyDraft] = useState("");
const [submitting, setSubmitting] = useState(false);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editingDraft, setEditingDraft] = useState("");
const submitNewThread = async (blockId: string | null) => {
if (!documentId || !workspaceId) return;
const body = draft.trim();
if (!body) {
window.alert("请输入评论内容");
return;
}
setSubmitting(true);
try {
const threadId = makeId();
const messageId = makeId();
await createThread({
id: threadId,
documentId,
workspaceId,
blockId,
messageId,
body,
});
setDraft("");
setActiveThreadId(threadId);
} catch (e) {
window.alert(e instanceof Error ? e.message : "创建评论失败");
} finally {
setSubmitting(false);
}
};
const submitReply = async () => {
if (!activeThreadId) return;
const body = replyDraft.trim();
if (!body) {
window.alert("请输入回复内容");
return;
}
setSubmitting(true);
try {
await reply({ threadId: activeThreadId, messageId: makeId(), body });
setReplyDraft("");
} catch (e) {
window.alert(e instanceof Error ? e.message : "回复失败");
} finally {
setSubmitting(false);
}
};
const submitEdit = async () => {
if (!editingMessageId) return;
const body = editingDraft.trim();
if (!body) {
window.alert("请输入评论内容");
return;
}
setSubmitting(true);
try {
await editMessage({ messageId: editingMessageId, body });
setEditingMessageId(null);
setEditingDraft("");
} catch (e) {
window.alert(e instanceof Error ? e.message : "编辑失败");
} finally {
setSubmitting(false);
}
};
const submitDelete = async (messageId: string) => {
const ok = window.confirm("确定删除这条评论吗?");
if (!ok) return;
setSubmitting(true);
try {
await deleteMessage({ messageId });
if (editingMessageId === messageId) {
setEditingMessageId(null);
setEditingDraft("");
}
} catch (e) {
window.alert(e instanceof Error ? e.message : "删除失败");
} finally {
setSubmitting(false);
}
};
const toggleResolved = async () => {
if (!activeThreadId || !activeThread) return;
const next = !activeThread.resolvedAt;
setSubmitting(true);
try {
await setResolved({ threadId: activeThreadId, resolved: next });
} catch (e) {
window.alert(e instanceof Error ? e.message : "更新状态失败");
} finally {
setSubmitting(false);
}
};
const ThreadList = ({ list }: { list: any[] }) => {
if (!list.length) {
return (
<div className="rounded-lg border border-dashed border-[#e2e8f0] px-4 py-10 text-center text-sm text-gray-500">
</div>
);
}
return (
<div className="space-y-2">
{list.map((t) => {
const isActive = String(t.id) === String(activeThreadId);
const resolved = Boolean(t.resolvedAt);
return (
<button
key={t.id}
type="button"
className={cn(
"w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors",
isActive ? "border-[#c7d2fe] bg-[#eef2ff]" : "border-[#e2e8f0] bg-white hover:bg-gray-50",
)}
onClick={() => setActiveThreadId(t.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs text-gray-500">
{resolved ? (
<span className="inline-flex items-center gap-1 text-green-700">
<CheckCircle2 className="h-4 w-4" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-gray-500">
<Circle className="h-4 w-4" />
</span>
)}
<span>·</span>
<span>{t.commentCount} </span>
{t.blockId ? (
<>
<span>·</span>
<span className="inline-flex items-center gap-1">
<ExternalLink
className="h-3.5 w-3.5"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
jumpToBlock(String(t.blockId));
}}
/>
</span>
</>
) : null}
</div>
<div className="mt-1 truncate font-medium text-gray-900">
{t.lastCommentPreview || "(无预览)"}
</div>
<div className="mt-1 text-xs text-gray-500">
{t.lastCommentBy?.name || "匿名"} · {new Date(t.lastActivityAt).toLocaleString()}
</div>
</div>
</div>
</button>
);
})}
</div>
);
};
const ThreadDetail = () => {
if (!activeThreadId || !activeThread) {
return (
<div className="rounded-lg border border-dashed border-[#e2e8f0] px-4 py-10 text-center text-sm text-gray-500">
</div>
);
}
const list = Array.isArray(activeMessages) ? activeMessages : [];
return (
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-[#e2e8f0] bg-white px-4 py-3">
<div className="text-sm font-medium text-gray-800">
{activeThread.blockId ? "块评论" : "页面评论"}
</div>
<div className="flex items-center gap-2">
{activeThread.blockId ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => jumpToBlock(String(activeThread.blockId))}
>
</Button>
) : null}
<Button type="button" size="sm" variant="outline" onClick={toggleResolved} disabled={submitting}>
{activeThread.resolvedAt ? "取消解决" : "标记解决"}
</Button>
</div>
</div>
<div className="max-h-[38vh] space-y-2 overflow-y-auto rounded-lg border border-[#eef2ff] bg-[#fbfbff] p-3">
{list.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-500">...</div>
) : (
list.map((m: any) => (
<div key={m.id} className="rounded-md border border-[#e2e8f0] bg-white p-3 text-sm">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{m.createdBy?.name || "匿名"}</span>
<span>{new Date(m.createdAt).toLocaleString()}</span>
</div>
<div className={cn("mt-2 whitespace-pre-wrap text-gray-800", m.deletedAt && "text-gray-400")}>
{m.deletedAt ? (
"该评论已删除"
) : editingMessageId === String(m.id) ? (
<div className="space-y-2">
<Textarea
value={editingDraft}
onChange={(e) => setEditingDraft(e.target.value)}
className="min-h-[90px]"
disabled={submitting}
/>
<div className="flex gap-2">
<Button type="button" size="sm" onClick={submitEdit} disabled={submitting}>
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEditingMessageId(null);
setEditingDraft("");
}}
disabled={submitting}
>
</Button>
</div>
</div>
) : (
m.body
)}
</div>
{!m.deletedAt && editingMessageId !== String(m.id) ? (
<div className="mt-2 flex gap-2 text-xs">
{currentUser && String((currentUser as any)?._id ?? "") === String(m.createdBy?.id ?? "") ? (
<>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEditingMessageId(String(m.id));
setEditingDraft(String(m.body ?? ""));
}}
disabled={submitting}
>
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => submitDelete(String(m.id))}
disabled={submitting}
>
</Button>
</>
) : null}
</div>
) : null}
</div>
))
)}
</div>
<div className="rounded-lg border border-[#e2e8f0] bg-white p-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<CornerDownRight className="h-4 w-4" />
</div>
<Textarea
value={replyDraft}
onChange={(e) => setReplyDraft(e.target.value)}
placeholder="输入回复内容..."
className="mt-2 min-h-20"
/>
<div className="mt-2 flex justify-end">
<Button type="button" onClick={submitReply} disabled={submitting}>
</Button>
</div>
</div>
</div>
);
};
return (
<Drawer
open={open}
onOpenChange={(next) => {
if (!next) close();
}}
>
<DrawerContent className="max-h-[92vh]">
<DrawerHeader className="text-left">
<DrawerTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
</DrawerTitle>
<DrawerDescription></DrawerDescription>
</DrawerHeader>
{!isAuthenticated ? (
<div className="px-4 pb-6 text-sm text-gray-500"></div>
) : !documentId ? (
<div className="px-4 pb-6 text-sm text-gray-500"> documentId</div>
) : (
<div className="grid gap-4 px-4 pb-6 lg:grid-cols-2">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold text-gray-800">线</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setIncludeResolved((prev) => !prev)}
>
{includeResolved ? "隐藏已解决" : "显示已解决"}
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)}>
<TabsList className="w-full">
<TabsTrigger value="page" className="flex-1">
{pageThreads.length}
</TabsTrigger>
<TabsTrigger value="block" className="flex-1">
{blockThreads.length}
</TabsTrigger>
</TabsList>
<TabsContent value="page" className="mt-3 space-y-3">
<div className="rounded-lg border border-[#e2e8f0] bg-white p-3">
<div className="text-xs font-medium text-gray-600"></div>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="输入评论内容..."
className="mt-2 min-h-20"
/>
<div className="mt-2 flex justify-end">
<Button type="button" onClick={() => submitNewThread(null)} disabled={submitting}>
</Button>
</div>
</div>
<ThreadList list={pageThreads} />
</TabsContent>
<TabsContent value="block" className="mt-3 space-y-3">
<div className="rounded-lg border border-[#e2e8f0] bg-white p-3">
<div className="text-xs font-medium text-gray-600"></div>
<div className="mt-2 flex items-center gap-2">
<Input
value={focusBlockId ?? ""}
readOnly
placeholder="从块菜单进入后会自动带上 blockId"
className="h-9 text-xs"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={!focusBlockId}
onClick={() => (focusBlockId ? jumpToBlock(focusBlockId) : null)}
>
</Button>
</div>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={focusBlockId ? "对该块发表评论..." : "请从块菜单进入以指定 blockId"}
className="mt-2 min-h-20"
disabled={!focusBlockId}
/>
<div className="mt-2 flex justify-end">
<Button
type="button"
onClick={() => submitNewThread(focusBlockId)}
disabled={submitting || !focusBlockId}
>
</Button>
</div>
</div>
<ThreadList list={blockThreadsForFocus} />
</TabsContent>
</Tabs>
</div>
<div className="space-y-3">
<div className="text-sm font-semibold text-gray-800">线</div>
<ThreadDetail />
</div>
</div>
)}
</DrawerContent>
</Drawer>
);
}
@@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options";
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
import { useEditorBridgeStore } from "@/store/editor-bridge";
@@ -10,12 +10,17 @@ import { usePageLayoutStore } from "@/store/page-layout";
import { useCurrentDocumentStore } from "@/store/current-document";
import type { Json } from "@/types/supabase";
import { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
import { DocumentCommentsDrawer } from "@/components/editor/document-comments-drawer";
import type { DocumentSnapshot } from "@/types/document";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { ImagePickerProvider } from "@/components/media/image-picker-context";
import { useRouter } from "next/navigation";
import { DocumentAiAgentPanel } from "./DocumentAiAgentPanel";
import { CONTENT_LOADING_DELAY_MS } from "@/lib/constants";
import { useCommentsUiStore } from "@/store/comments-ui";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
import { cn } from "@/lib/utils";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -49,8 +54,14 @@ const defaultOptions: PageOptionsState = {
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
};
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0 };
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
export function DocumentContent({
documentId,
@@ -74,6 +85,9 @@ export function DocumentContent({
const editorBridge = useEditorBridgeStore((state) => state.bridge);
const router = useRouter();
const showInspector = usePageLayoutStore((state) => state.showInspector);
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
const [content, setContent] = useState<unknown>(initialContent);
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
@@ -314,15 +328,221 @@ export function DocumentContent({
[documentId, readOnly],
);
const toggleOption = (key: keyof PageOptionsState) => {
const toggleOption = useCallback(
(key: BooleanPageOptionKey) => {
if (readOnly) return;
setOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
return next;
});
},
[persistOptions, readOnly],
);
const setOptionPatch = useCallback(
(patch: Partial<PageOptionsState>) => {
if (readOnly) return;
setOptions((prev) => {
const next = { ...prev, ...patch };
void persistOptions(patch);
return next;
});
},
[persistOptions, readOnly],
);
const closeToc = useCallback(() => {
if (readOnly) return;
setOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
if (!prev.showToc) return prev;
const next = { ...prev, showToc: false };
void persistOptions({ showToc: false });
return next;
});
};
}, [persistOptions, readOnly]);
const handleSetPageFont = useCallback(
(font: PageFont) => {
setOptionPatch({ pageFont: font });
},
[setOptionPatch],
);
const handleSetLayoutDensity = useCallback(
(density: PageLayoutDensity) => {
setOptionPatch({ layoutDensity: density });
},
[setOptionPatch],
);
const handleSetEmbedDefaultToCursor = useCallback(() => {
if (readOnly) return;
const blockId = editorBridge?.getCursorBlockId?.() ?? null;
if (!blockId) {
window.alert("未找到当前光标所在块,请先在编辑器中点击任意块");
return;
}
setOptionPatch({ embedDefaultBlockId: blockId });
window.alert("已设置“嵌入默认位置”");
}, [editorBridge, readOnly, setOptionPatch]);
const handleClearEmbedDefault = useCallback(() => {
if (readOnly) return;
setOptionPatch({ embedDefaultBlockId: null });
window.alert("已清除“嵌入默认位置”");
}, [readOnly, setOptionPatch]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const root = pageRootRef.current;
if (root) {
const target = event.target;
if (target && target instanceof Node && !root.contains(target)) {
return;
}
}
const ctrlOrMeta = event.ctrlKey || event.metaKey;
if (!ctrlOrMeta) return;
if (!event.shiftKey) return;
const key = String(event.key ?? "").toLowerCase();
if (key === "l") {
event.preventDefault();
toggleOption("showToc");
} else if (key === "c") {
event.preventDefault();
toggleOption("protectEditing");
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [toggleOption]);
const buildDocumentUrl = useCallback((id: string): string => {
if (typeof window === "undefined" || !window.location) {
return `/documents/${id}`;
}
return `${window.location.origin}/documents/${id}`;
}, []);
const copyText = useCallback(async (text: string, successMessage: string) => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
window.alert(successMessage);
return;
} catch {
// ignore and fallback
}
}
window.prompt("复制失败,请手动复制内容", text);
}, []);
const handleCopyPageLink = useCallback(
async (includeTitle: boolean) => {
const url = buildDocumentUrl(documentId);
if (includeTitle) {
const text = `${pageTitle || "无标题"}\n${url}`;
await copyText(text, "标题 + 链接已复制");
return;
}
await copyText(url, "页面链接已复制");
},
[buildDocumentUrl, copyText, documentId, pageTitle],
);
const handleCopyPageReference = useCallback(
async (mode: "inline" | "embed") => {
const template = mode === "inline" ? `((${documentId}))` : `{{${documentId}}}`;
await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制");
},
[copyText, documentId],
);
const handleUndo = useCallback(() => {
editorBridge?.undo?.();
}, [editorBridge]);
const handleRedo = useCallback(() => {
editorBridge?.redo?.();
}, [editorBridge]);
const handleDeletePage = useCallback(async () => {
if (readOnly) return;
const ok = window.confirm("确定删除该页面吗?删除后会进入垃圾桶。");
if (!ok) return;
const resp = await fetch("/api/documents/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除失败");
return;
}
router.push("/");
router.refresh();
}, [documentId, readOnly, router]);
const handleOpenMoveEmbed = useCallback(() => {
if (readOnly) return;
openMoveEmbedPicker({
workspaceId,
defaultMode: "move",
modes: ["move", "embed"],
allowRoot: true,
excludeIds: [documentId],
onPick: async (mode, targetId) => {
if (mode === "move") {
const resp = await fetch("/api/documents/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, parentId: targetId ?? null, position: 999999 }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "移动失败");
return;
}
window.alert("移动成功");
router.refresh();
return;
}
const resp = await fetch("/api/documents/embed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: documentId, targetId }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "嵌入失败");
return;
}
window.alert("已嵌入到目标页面");
},
});
}, [documentId, openMoveEmbedPicker, readOnly, router, workspaceId]);
const handleAddToTemplates = useCallback(async () => {
if (readOnly) return;
const ok = window.confirm("将该页面添加为模板?");
if (!ok) return;
const resp = await fetch("/api/documents/template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, isTemplate: true }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "设置模板失败");
return;
}
window.alert("已添加为模板");
router.refresh();
}, [documentId, readOnly, router]);
const formattedUpdatedAt = useMemo(() => {
if (!updatedAt) return "";
@@ -349,6 +569,16 @@ export function DocumentContent({
URL.revokeObjectURL(url);
}, [disableDownload, history, title]);
const pageRootClass = cn(
"flex h-full overflow-hidden bg-wolai-bg",
options.pageFont === "song" && "wolai-page-font-song",
options.pageFont === "kai" && "wolai-page-font-kai",
options.layoutDensity === "compact" && "wolai-page-density-compact",
options.layoutDensity === "spacious" && "wolai-page-density-spacious",
options.smallText && "wolai-small-text",
options.hideChildPages && "wolai-hide-child-pages",
);
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
latestBlocksRef.current = payload.blocks;
setHistory((prev) => {
@@ -398,7 +628,7 @@ export function DocumentContent({
return (
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
<div className="flex h-full overflow-hidden bg-wolai-bg" ref={pageRootRef}>
<div className={pageRootClass} ref={pageRootRef}>
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
<div className="relative">
@@ -411,6 +641,7 @@ export function DocumentContent({
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
aria-label="页面标题"
disabled={options.protectEditing || readOnly}
spellCheck={spellCheck}
/>
</div>
{readOnly ? (
@@ -453,9 +684,15 @@ export function DocumentContent({
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
/>
)}
<PageBacklinksPanel className="mt-10" workspaceId={workspaceId} documentId={documentId} />
<PageBacklinksPanel
className="mt-10"
workspaceId={workspaceId}
documentId={documentId}
defaultCollapsed={options.collapseBacklinks}
/>
</div>
</div>
{showInspector && (
@@ -464,8 +701,20 @@ export function DocumentContent({
options={options}
stats={stats}
onToggle={toggleOption}
onSetPageFont={handleSetPageFont}
onSetLayoutDensity={handleSetLayoutDensity}
onSetEmbedDefaultToCursor={handleSetEmbedDefaultToCursor}
onClearEmbedDefault={handleClearEmbedDefault}
onExport={handleExport}
onOpenHistory={() => setHistoryOpen(true)}
onOpenComments={() => openCommentsForPage({ workspaceId, documentId })}
onUndo={handleUndo}
onRedo={handleRedo}
onDeletePage={handleDeletePage}
onOpenMoveEmbedPicker={handleOpenMoveEmbed}
onCopyPageLink={handleCopyPageLink}
onCopyPageReference={handleCopyPageReference}
onAddToTemplates={handleAddToTemplates}
/>
)}
</div>
@@ -475,6 +724,7 @@ export function DocumentContent({
history={history}
onRestore={handleRestoreSnapshot}
/>
<DocumentCommentsDrawer />
<DocumentAiAgentPanel documentId={documentId} getLatestBlocks={() => latestBlocksRef.current} />
</ImagePickerProvider>
);
@@ -1,6 +1,18 @@
"use client";
import { useMemo, useState } from "react";
import { MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface TocEntry {
id: string;
@@ -13,19 +25,54 @@ interface DocumentTocProps {
entries: TocEntry[];
visible: boolean;
onJump: (id: string) => void;
onClose?: () => void;
}
export function DocumentToc({ entries, visible, onJump }: DocumentTocProps) {
export function DocumentToc({ entries, visible, onJump, onClose }: DocumentTocProps) {
const [maxLevel, setMaxLevel] = useState<number>(4);
const [showFullTitle, setShowFullTitle] = useState(false);
const filteredEntries = useMemo(() => entries.filter((entry) => entry.level <= maxLevel), [entries, maxLevel]);
if (!visible || entries.length === 0) {
return null;
}
return (
<div className="pointer-events-none absolute right-0 top-0 z-10 hidden lg:block">
<div className="pointer-events-auto mt-2 w-48 max-h-[65vh] overflow-y-auto rounded-2xl border border-[#e4e4e7] bg-white/90 p-3 text-xs text-gray-600 shadow-lg backdrop-blur">
<div className="mb-2 text-[11px] font-semibold text-gray-400"></div>
<div className="pointer-events-auto mt-2 w-[min(320px,22vw)] min-w-40 max-w-80 max-h-[65vh] overflow-y-auto rounded-2xl border border-[#e4e4e7] bg-white/90 p-3 text-xs text-gray-600 shadow-lg backdrop-blur">
<div className="mb-2 flex items-center justify-between">
<div className="text-[11px] font-semibold text-gray-400"></div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-md p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
aria-label="标题目录菜单"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={String(maxLevel)} onValueChange={(v) => setMaxLevel(Number(v) || 4)}>
<DropdownMenuRadioItem value="1"> H1</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="2"> H2</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="3"> H3</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="4"> H4</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked={showFullTitle} onCheckedChange={(v) => setShowFullTitle(Boolean(v))}>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onClose?.()} disabled={!onClose}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<ul className="space-y-1">
{entries.map((entry) => (
{filteredEntries.map((entry) => (
<li key={entry.id}>
<button
type="button"
@@ -33,6 +80,7 @@ export function DocumentToc({ entries, visible, onJump }: DocumentTocProps) {
"w-full rounded-md px-2 py-1 text-left text-[11px] text-gray-500 transition-colors hover:bg-[#eef2ff] hover:text-[#2563eb]",
entry.level > 1 && "pl-4",
entry.level > 2 && "pl-6",
showFullTitle ? "whitespace-normal" : "truncate",
)}
onClick={() => onJump(entry.id)}
>
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import type { Block, PartialBlock } from "@blocknote/core";
import {
BlockColorsItem,
@@ -18,6 +18,7 @@ import type { CustomBlockSchema } from "../schema";
import { deleteOnlineTable } from "@/lib/online-table";
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
import { useCommentsUiStore } from "@/store/comments-ui";
type InlineNode = { text?: unknown };
type TableMenuBlock = Parameters<
@@ -55,11 +56,57 @@ const extractText = (block: Block<CustomBlockSchema>) => {
return "未命名页面";
};
const clearMindmapAutosaveCache = (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 = (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
}
};
const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomDragProps) => {
const Components = useComponentsContext()!;
const editor = useBlockNoteEditor<CustomBlockSchema>();
const router = useRouter();
const openPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const openCommentsForBlock = useCommentsUiStore((s) => s.openForBlock);
const duplicateBlock = useCallback(() => {
const blockWithoutId: DraftBlock = { ...block };
@@ -93,8 +140,13 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
if (block.type === "onlineTable") {
const tableId = block.props.tableId as string | undefined;
if (tableId) {
void deleteOnlineTable(tableId)
.catch((error) => console.error("删除在线表格失败", error));
try {
await deleteOnlineTable(tableId);
} catch (error) {
console.error("删除在线表格失败", error);
window.alert("删除在线表格失败,请稍后重试");
return;
}
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
emitAssetsChanged(currentDocumentId);
@@ -122,6 +174,9 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
return;
}
if (block.type === "mindmap") {
// 关键:必须先标记“删除中”,避免 MindmapBlock 卸载清理把数据 POST 回去导致“删除后复活”。
markMindmapDeleting(currentDocumentId, block.id);
clearMindmapAutosaveCache(currentDocumentId, block.id);
const resp = await fetch(`/api/mindmap/${currentDocumentId}/${block.id}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
@@ -162,7 +217,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
[
{
type: "pageReference",
props: { pageId, title },
props: { pageId, title, asChildPage: true },
} as PartialBlock<CustomBlockSchema>,
],
);
@@ -365,7 +420,13 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
<Components.Generic.Menu.Item
className="bn-menu-item"
onClick={() => window.alert("评论功能暂未开放")}
onClick={() => {
if (!workspaceId) {
window.alert("缺少 workspaceId,无法打开评论");
return;
}
openCommentsForBlock({ workspaceId, documentId: currentDocumentId, blockId: block.id });
}}
>
</Components.Generic.Menu.Item>
@@ -418,14 +479,27 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
workspaceId: string | null;
unresolvedCommentCountByBlockId?: Record<string, number>;
};
const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
const Components = useComponentsContext()!;
const { editor, block, blockDragStart, blockDragEnd, freezeMenu, unfreezeMenu, currentDocumentId, workspaceId } = props;
const [activeLine, setActiveLine] = useState<null | "top" | "bottom">(null);
const {
editor,
block,
blockDragStart,
blockDragEnd,
freezeMenu,
unfreezeMenu,
currentDocumentId,
workspaceId,
unresolvedCommentCountByBlockId,
} = props;
const [insertHovered, setInsertHovered] = useState<null | "top" | "bottom">(null);
const [menuOpen, setMenuOpen] = useState(false);
const [hovering, setHovering] = useState(false);
const [activeCursorBlockId, setActiveCursorBlockId] = useState<string | null>(null);
const hoverAreaRef = useRef<HTMLDivElement | null>(null);
const menuFrozenRef = useRef(false);
// 说明:Wolai 的附件(file)手柄是在行中线左侧居中显示。
@@ -433,8 +507,19 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
const isFileAttachmentRow = block.type === "media" && (block as any)?.props?.assetType === "file";
const rowHeightPx = isFileAttachmentRow ? 26 : 24;
const hoverPadPx = 14;
const lineOffsetPx = 6;
const lineGapPx = 5;
const lineGapPx = 3;
const insertBtnSizePx = 16;
const handleBtnSizePx = 22;
const unresolvedCount = unresolvedCommentCountByBlockId?.[block.id] ?? 0;
useEffect(() => {
const update = () => {
const cursor = editor.getTextCursorPosition();
setActiveCursorBlockId(cursor?.block?.id ?? null);
};
update();
return editor.onSelectionChange(update);
}, [editor]);
const setFrozen = useCallback(
(next: boolean) => {
@@ -449,6 +534,32 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
[freezeMenu, unfreezeMenu],
);
useEffect(() => {
// 说明:Win+Shift+S 截图会触发窗口失焦/可见性变化;若用右键取消,
// 有些环境下不会触发正常的 mouseleave,导致手柄状态卡死(看起来像“消失”)。
// 这里在失焦/隐藏时强制解除冻结并重置 hover 状态。
const reset = () => {
setHovering(false);
setMenuOpen(false);
setInsertHovered(null);
setFrozen(false);
};
const onBlur = () => reset();
const onVisibility = () => {
if (document.visibilityState === "hidden") {
reset();
}
};
window.addEventListener("blur", onBlur);
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.removeEventListener("blur", onBlur);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [setFrozen]);
const insertParagraph = useCallback(
(position: "before" | "after") => {
const inserted = editor.insertBlocks([{ type: "paragraph" } as any], block as any, position as any)?.[0];
@@ -465,6 +576,47 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
e.stopPropagation();
};
const paragraphPlainText = useMemo(() => {
if (block.type !== "paragraph") return null;
const content = Array.isArray(block.content) ? (block.content as any[]) : [];
const text = content
.map((node) =>
node && typeof (node as { text?: unknown }).text === "string" ? (node as { text: string }).text : ""
)
.join("");
return text;
}, [block]);
const isEmptyParagraph = block.type === "paragraph" && (paragraphPlainText ?? "").trim().length === 0;
const showEmptyPlus = isEmptyParagraph && (activeCursorBlockId === block.id || hovering || menuOpen);
const openSlashMenuFromEmptyPlus = (e: ReactMouseEvent) => {
stop(e);
if (!isEmptyParagraph) return;
try {
editor.setTextCursorPosition(block as any, "start");
} catch {
// ignore
}
editor.focus();
// 说明:按 Wolai 手感,点击“+”等同于在空行输入 “/” 打开斜杠菜单。
// deleteTriggerCharacter=true 会把 “/” 写入编辑器,并在选择条目后由插件清理掉。
editor.openSuggestionMenu("/", { deleteTriggerCharacter: true, ignoreQueryLength: true });
};
const forceShowInsertButtons = block.type === "mindmap" || block.type === "onlineTable";
// 说明:思维导图/在线表格等嵌入块内部可能接管鼠标事件,导致 hover 状态不稳定。
// 对这些块直接常驻显示插入控件,避免“看不到横杠/加号”。
const showInsertButtons = !showEmptyPlus && (forceShowInsertButtons || hovering || menuOpen);
const handleCenterY = hoverPadPx + rowHeightPx / 2;
// 说明:插入按钮的位置必须“跟着手柄走”,不能依赖容器上下边界。
// 否则对于思维导图/在线表格等高块,容器可能被撑高,导致按钮跑到块底部。
// 说明:插入按钮不能与六点手柄发生重叠,否则 hover/click 会被手柄拦截(表现为“看得见但点不到/hover 没反应”)。
// 这里用“手柄按钮尺寸 + 插入按钮尺寸 + 间距”计算中心距,确保永不重叠。
const insertDistPx = handleBtnSizePx / 2 + insertBtnSizePx / 2 + lineGapPx;
const insertBeforeTopPx = handleCenterY - insertDistPx - insertBtnSizePx / 2;
const insertAfterTopPx = handleCenterY + insertDistPx - insertBtnSizePx / 2;
return (
<Components.Generic.Menu.Root
onOpenChange={(open: boolean) => {
@@ -474,70 +626,122 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
position={"left"}
>
<div
ref={hoverAreaRef}
data-testid="wolai-handle-area"
className="relative w-7 overflow-visible"
style={{ height: rowHeightPx + hoverPadPx * 2, marginTop: -hoverPadPx }}
onMouseEnter={() => {
onPointerEnter={() => {
setHovering(true);
setFrozen(menuOpen || true);
}}
onMouseLeave={() => {
onPointerLeave={() => {
setHovering(false);
setActiveLine(null);
setInsertHovered(null);
setFrozen(menuOpen || false);
}}
>
{activeLine !== "bottom" && (
<button
type="button"
data-testid="wolai-insert-before"
title="在上方插入块"
className={`absolute left-1/2 z-30 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${hovering ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ top: hoverPadPx - lineOffsetPx - lineGapPx }}
onMouseEnter={() => setActiveLine("top")}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("before");
}}
>
{activeLine === "top" ? <Plus className="h-3.5 w-3.5" /> : <span className="block h-px w-3 bg-[#CFCFCF]" />}
</button>
)}
<button
type="button"
data-testid="wolai-insert-before"
title="在上方插入块"
className={`absolute left-1/2 z-[2147483647] flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${showInsertButtons ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ top: insertBeforeTopPx }}
onPointerEnter={() => setInsertHovered("top")}
onPointerLeave={() => {
queueMicrotask(() => {
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
if (!stillHovering) setInsertHovered(null);
});
}}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("before");
}}
>
{insertHovered === "top"
? <Plus className="h-3.5 w-3.5" />
: <span className="block h-px w-3 bg-[#CFCFCF]" />}
</button>
<div
className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2"
className="absolute left-1/2 z-[2147483647] -translate-x-1/2 -translate-y-1/2"
style={{ top: hoverPadPx + rowHeightPx / 2 }}
onMouseEnter={() => setActiveLine(null)}
>
<Components.Generic.Menu.Trigger>
{showEmptyPlus ? (
<button
type="button"
data-testid="wolai-empty-plus"
aria-label="打开斜杠命令"
className="flex h-[22px] w-[22px] items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
onMouseDown={stop}
onClick={openSlashMenuFromEmptyPlus}
>
<Plus className="h-4 w-4" />
</button>
) : null}
{!showEmptyPlus ? (
<Components.Generic.Menu.Trigger>
<Components.SideMenu.Button
label="块操作"
draggable={true}
onDragStart={(e) => blockDragStart(e, block)}
onDragEnd={blockDragEnd}
className={"bn-button bn-drag-handle"}
icon={<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />}
onPointerEnter={() => {
setHovering(true);
setFrozen(menuOpen || true);
}}
onPointerLeave={() => {
// 说明:从手柄移到插入按钮时,也会触发手柄的 pointerleave
// 这里用 :hover 兜底,避免插入按钮一闪而过。
queueMicrotask(() => {
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
if (!stillHovering && !menuOpen) {
setHovering(false);
setInsertHovered(null);
setFrozen(false);
}
});
}}
className={`bn-button transition-opacity duration-200 ${showInsertButtons || hovering || menuOpen ? "opacity-100" : "opacity-0"}`}
icon={
<span className="relative inline-flex">
<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />
{unresolvedCount > 0 ? (
<span className="absolute -right-1 -top-1 rounded-full bg-[#2563eb] px-1 text-[10px] font-semibold leading-[14px] text-white">
{unresolvedCount > 9 ? "9+" : unresolvedCount}
</span>
) : null}
</span>
}
/>
</Components.Generic.Menu.Trigger>
</Components.Generic.Menu.Trigger>
) : null}
</div>
{activeLine !== "top" && (
<button
type="button"
data-testid="wolai-insert-after"
title="在下方插入块"
className={`absolute left-1/2 z-30 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${hovering ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ bottom: hoverPadPx - lineOffsetPx - lineGapPx }}
onMouseEnter={() => setActiveLine("bottom")}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("after");
}}
>
{activeLine === "bottom" ? <Plus className="h-3.5 w-3.5" /> : <span className="block h-px w-3 bg-[#CFCFCF]" />}
</button>
)}
<button
type="button"
data-testid="wolai-insert-after"
title="在下方插入块"
className={`absolute left-1/2 z-[2147483647] flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${showInsertButtons ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ top: insertAfterTopPx }}
onPointerEnter={() => setInsertHovered("bottom")}
onPointerLeave={() => {
queueMicrotask(() => {
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
if (!stillHovering) setInsertHovered(null);
});
}}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("after");
}}
>
{insertHovered === "bottom"
? <Plus className="h-3.5 w-3.5" />
: <span className="block h-px w-3 bg-[#CFCFCF]" />}
</button>
</div>
<CustomDragHandleMenu block={block} currentDocumentId={currentDocumentId} workspaceId={workspaceId} />
@@ -262,7 +262,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
const { pageId, title } = await response.json();
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "pageReference",
props: { pageId, title },
props: { pageId, title, asChildPage: true },
content: [],
});
router.refresh();
@@ -373,6 +373,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
const handleMediaPick = (mediaType: MediaKind) => {
openPicker({
mediaType,
multiple: true,
onSelect: (selection) => {
insertMediaSelection({
...selection,
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { useBacklinks } from "@/hooks/use-backlinks";
import type { BacklinkRecord } from "@/types/references";
@@ -10,6 +10,7 @@ interface PageBacklinksPanelProps {
workspaceId: string;
documentId: string;
className?: string;
defaultCollapsed?: boolean;
}
const formatRelative = (value: string) => {
@@ -37,13 +38,16 @@ const BacklinkItem = ({ record }: { record: BacklinkRecord }) => (
</div>
);
export function PageBacklinksPanel({ workspaceId, documentId, className }: PageBacklinksPanelProps) {
export function PageBacklinksPanel({ workspaceId, documentId, className, defaultCollapsed }: PageBacklinksPanelProps) {
const { data, isLoading, error, refetch, isFetching } = useBacklinks({
workspaceId,
documentId,
});
const records = useMemo(() => data ?? [], [data]);
// 说明:collapsed 需要可交互;这里用一个轻量的局部状态,但默认值来自 props(用于“自定义页面:折叠引用列表”)。
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed));
useEffect(() => setIsCollapsed(Boolean(defaultCollapsed)), [defaultCollapsed]);
// 避免页面切换时“先出现加载态、随后又消失”的闪烁:
// 当尚未拿到任何记录且正在加载时,直接不渲染面板。
if (!error && records.length === 0 && (isLoading || isFetching)) {
@@ -65,12 +69,29 @@ export function PageBacklinksPanel({ workspaceId, documentId, className }: PageB
{isFetching ? "刷新中..." : "刷新"}
</Button>
</div>
{records.length > 0 && (
<div className="mb-4 flex items-center justify-between text-xs text-gray-500">
<span> {records.length} </span>
<Button
size="sm"
variant="ghost"
onClick={() => setIsCollapsed((prev) => !prev)}
className="h-7 px-2"
>
{isCollapsed ? "展开" : "折叠"}
</Button>
</div>
)}
{isLoading ? (
<div className="py-6 text-center text-sm text-gray-500">...</div>
) : error ? (
<div className="py-6 text-center text-sm text-red-500">{(error as Error).message}</div>
) : records.length === 0 ? (
<EmptyState />
) : isCollapsed ? (
<div className="rounded-xl border border-dashed border-[#e2e8f0] bg-white/60 px-4 py-4 text-center text-xs text-gray-500">
</div>
) : (
<div className="space-y-3">
{records.map((record) => (
@@ -1,11 +1,12 @@
"use client";
import { useState, type ComponentType } from "react";
import { BookOpenCheck, Focus, ListOrdered, ListTree, Maximize2, ShieldCheck, Type } from "lucide-react";
import { BookOpenCheck, Focus, ListOrdered, ListTree, Maximize2, ShieldCheck, Type, MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { BooleanPageOptionKey, DocumentStats, PageFont, PageLayoutDensity, PageOptionsState } from "@/types/page-options";
import { DocumentTaskPanel } from "@/components/document-task-panel";
import { Button } from "@/components/ui/button";
import { useAppPreferencesStore, type ThemeMode } from "@/store/app-preferences";
type TabId = "page" | "custom" | "global";
@@ -16,7 +17,7 @@ const TABS: Array<{ id: TabId; label: string }> = [
];
const OPTION_META: Record<
keyof PageOptionsState,
BooleanPageOptionKey,
{ label: string; description: string; icon: ComponentType<{ className?: string }> }
> = {
wideLayout: {
@@ -39,11 +40,6 @@ const OPTION_META: Record<
description: "在右侧展示目录导航",
icon: ListTree,
},
showStructure: {
label: "块结构线框",
description: "显示块级元素的结构边界",
icon: Focus,
},
protectEditing: {
label: "编辑保护",
description: "保护内容避免误触修改",
@@ -54,19 +50,53 @@ const OPTION_META: Record<
description: "实时展示字数和块统计",
icon: BookOpenCheck,
},
collapseBacklinks: {
label: "折叠反向引用",
description: "默认折叠页面底部的反向引用列表",
icon: Focus,
},
hideChildPages: {
label: "隐藏子页面",
description: "隐藏通过 /ym 创建的子页面块(不会删除内容)",
icon: Focus,
},
showBlockRefCount: {
label: "显示块引用数字",
description: "显示块被引用次数(当前为占位,后续补齐)",
icon: Focus,
},
};
const CUSTOM_LAYOUT_OPTIONS: (keyof PageOptionsState)[] = ["wideLayout", "smallText"];
const CUSTOM_STRUCTURE_OPTIONS: (keyof PageOptionsState)[] = ["showHeadingNumbers", "showToc"];
const GLOBAL_OPTIONS: (keyof PageOptionsState)[] = ["showStructure", "protectEditing", "showWordCount"];
const PAGE_OPTIONS: BooleanPageOptionKey[] = [
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"protectEditing",
"showWordCount",
];
const CUSTOM_PAGE_OPTIONS: BooleanPageOptionKey[] = ["collapseBacklinks", "hideChildPages", "showBlockRefCount"];
interface PageOptionsSidebarProps {
documentId: string;
options: PageOptionsState;
stats?: DocumentStats;
onToggle: (key: keyof PageOptionsState) => void;
onToggle: (key: BooleanPageOptionKey) => void;
onSetPageFont?: (font: PageFont) => void;
onSetLayoutDensity?: (density: PageLayoutDensity) => void;
onSetEmbedDefaultToCursor?: () => void;
onClearEmbedDefault?: () => void;
onExport: () => void;
onOpenHistory: () => void;
onOpenComments?: () => void;
onUndo?: () => void;
onRedo?: () => void;
onDeletePage?: () => void;
onOpenMoveEmbedPicker?: () => void;
onCopyPageLink?: (includeTitle: boolean) => void;
onCopyPageReference?: (mode: "inline" | "embed") => void;
onAddToTemplates?: () => void;
}
export function PageOptionsSidebar({
@@ -74,10 +104,30 @@ export function PageOptionsSidebar({
options,
stats,
onToggle,
onSetPageFont,
onSetLayoutDensity,
onSetEmbedDefaultToCursor,
onClearEmbedDefault,
onExport,
onOpenHistory,
onOpenComments,
onUndo,
onRedo,
onDeletePage,
onOpenMoveEmbedPicker,
onCopyPageLink,
onCopyPageReference,
onAddToTemplates,
}: PageOptionsSidebarProps) {
const [activeTab, setActiveTab] = useState<TabId>("page");
const theme = useAppPreferencesStore((s) => s.theme);
const showStructure = useAppPreferencesStore((s) => s.showStructure);
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
const flightMode = useAppPreferencesStore((s) => s.flightMode);
const setTheme = useAppPreferencesStore((s) => s.setTheme);
const setShowStructure = useAppPreferencesStore((s) => s.setShowStructure);
const setSpellCheck = useAppPreferencesStore((s) => s.setSpellCheck);
const setFlightMode = useAppPreferencesStore((s) => s.setFlightMode);
return (
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-[#f0f0f0] bg-white/95">
@@ -107,8 +157,13 @@ export function PageOptionsSidebar({
<StatsCell label="字符" value={stats.characterCount} />
<StatsCell label="块数" value={stats.blockCount} />
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-center text-xs text-gray-500">
<StatsCell label="待办总数" value={stats.todoTotal} />
<StatsCell label="已完成" value={stats.todoDone} />
</div>
</section>
)}
<OptionToggleGroup title="页面选项" optionKeys={PAGE_OPTIONS} options={options} onToggle={onToggle} />
<section className="rounded-2xl border border-[#eef1f6] p-4 text-sm text-gray-600">
<div className="flex items-center justify-between">
<span className="font-semibold text-gray-800"></span>
@@ -119,34 +174,197 @@ export function PageOptionsSidebar({
<Button type="button" variant="outline" size="sm" onClick={onOpenHistory}>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onOpenComments}
disabled={!onOpenComments}
title={onOpenComments ? "打开评论" : "评论功能未启用"}
>
<MessageSquare className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
<p className="mt-2 text-xs text-gray-400"></p>
</section>
<section className="rounded-2xl border border-[#eef1f6] p-4 text-sm text-gray-600">
<div className="text-sm font-semibold text-gray-800"></div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={onUndo} disabled={!onUndo}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={onRedo} disabled={!onRedo}>
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={onOpenMoveEmbedPicker}
disabled={!onOpenMoveEmbedPicker}
>
/...
</Button>
<Button type="button" size="sm" variant="outline" onClick={onAddToTemplates} disabled={!onAddToTemplates}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageLink?.(false)} disabled={!onCopyPageLink}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageLink?.(true)} disabled={!onCopyPageLink}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageReference?.("inline")} disabled={!onCopyPageReference}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageReference?.("embed")} disabled={!onCopyPageReference}>
</Button>
<Button type="button" size="sm" variant="destructive" onClick={onDeletePage} disabled={!onDeletePage}>
</Button>
</div>
<p className="mt-2 text-xs text-gray-400">
<span className="font-mono">Ctrl/Cmd + Shift + L</span>
</p>
</section>
<DocumentTaskPanel documentId={documentId} />
</div>
)}
{activeTab === "custom" && (
<div className="space-y-5">
<OptionToggleGroup
title="布局与排版"
optionKeys={CUSTOM_LAYOUT_OPTIONS}
options={options}
onToggle={onToggle}
/>
<OptionToggleGroup
title="结构与目录"
optionKeys={CUSTOM_STRUCTURE_OPTIONS}
options={options}
onToggle={onToggle}
/>
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<p className="mt-1 text-xs text-gray-400"></p>
<div className="mt-3 flex flex-wrap gap-2">
{([
{ id: "default", label: "默认" },
{ id: "song", label: "宋体" },
{ id: "kai", label: "楷体" },
] as Array<{ id: PageFont; label: string }>).map((item) => (
<Button
key={item.id}
type="button"
size="sm"
variant={options.pageFont === item.id ? "default" : "outline"}
onClick={() => onSetPageFont?.(item.id)}
disabled={!onSetPageFont}
>
{item.label}
</Button>
))}
</div>
</section>
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<p className="mt-1 text-xs text-gray-400"></p>
<div className="mt-3 flex flex-wrap gap-2">
{([
{ id: "compact", label: "紧凑" },
{ id: "normal", label: "默认" },
{ id: "spacious", label: "宽容" },
] as Array<{ id: PageLayoutDensity; label: string }>).map((item) => (
<Button
key={item.id}
type="button"
size="sm"
variant={options.layoutDensity === item.id ? "default" : "outline"}
onClick={() => onSetLayoutDensity?.(item.id)}
disabled={!onSetLayoutDensity}
>
{item.label}
</Button>
))}
</div>
</section>
<OptionToggleGroup title="反向链接" optionKeys={CUSTOM_PAGE_OPTIONS} options={options} onToggle={onToggle} />
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<p className="mt-1 text-xs text-gray-400">
/...
</p>
<div className="mt-3 rounded-xl bg-[#f9fafc] px-3 py-2 text-xs text-gray-600">
{options.embedDefaultBlockId ? options.embedDefaultBlockId : "未设置"}
</div>
<div className="mt-3 flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={onSetEmbedDefaultToCursor}
disabled={!onSetEmbedDefaultToCursor}
>
使
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={onClearEmbedDefault}
disabled={!onClearEmbedDefault || !options.embedDefaultBlockId}
>
</Button>
</div>
</section>
<section className="rounded-2xl border border-[#eef1f6] p-4 text-xs text-gray-500">
<div className="text-sm font-semibold text-gray-800"></div>
<ul className="mt-2 list-disc space-y-1 pl-4">
<li></li>
</ul>
</section>
</div>
)}
{activeTab === "global" && (
<div className="space-y-5">
<OptionToggleGroup title="全局偏好" optionKeys={GLOBAL_OPTIONS} options={options} onToggle={onToggle} />
<section className="rounded-2xl border border-dashed border-[#e3e3e3] p-4 text-xs text-gray-400">
Good Night
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<PreferenceRow
title="显示块结构"
description="显示块级元素的结构边界(虚线框)。快捷键:Ctrl/Cmd + Shift + U。"
enabled={showStructure}
onToggle={() => setShowStructure(!showStructure)}
/>
<PreferenceRow
title="拼写检查"
description="控制编辑器的浏览器拼写检查(spellcheck)。"
enabled={spellCheck}
onToggle={() => setSpellCheck(!spellCheck)}
/>
</section>
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<p className="mt-1 text-xs text-gray-400">Good NightCtrl/Cmd + Alt/Opt + G</p>
<div className="mt-3 flex gap-2">
{(["system", "light", "dark"] as ThemeMode[]).map((mode) => (
<Button
key={mode}
type="button"
size="sm"
variant={theme === mode ? "default" : "outline"}
onClick={() => setTheme(mode)}
>
{mode === "system" ? "跟随系统" : mode === "dark" ? "深色" : "浅色"}
</Button>
))}
</div>
</section>
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<PreferenceRow
title="飞行模式"
description="开启后默认关闭 AI 面板的“联网”开关(避免误触外部网络)。"
enabled={flightMode}
onToggle={() => setFlightMode(!flightMode)}
/>
</section>
</div>
)}
@@ -162,9 +380,9 @@ function OptionToggleGroup({
onToggle,
}: {
title: string;
optionKeys: (keyof PageOptionsState)[];
optionKeys: BooleanPageOptionKey[];
options: PageOptionsState;
onToggle: (key: keyof PageOptionsState) => void;
onToggle: (key: BooleanPageOptionKey) => void;
}) {
return (
<section>
@@ -183,9 +401,9 @@ function OptionToggle({
options,
onToggle,
}: {
optionKey: keyof PageOptionsState;
optionKey: BooleanPageOptionKey;
options: PageOptionsState;
onToggle: (key: keyof PageOptionsState) => void;
onToggle: (key: BooleanPageOptionKey) => void;
}) {
const meta = OPTION_META[optionKey];
const Icon = meta.icon;
@@ -226,3 +444,27 @@ function StatsCell({ label, value }: { label: string; value: number }) {
</div>
);
}
function PreferenceRow({
title,
description,
enabled,
onToggle,
}: {
title: string;
description: string;
enabled: boolean;
onToggle: () => void;
}) {
return (
<div className="mt-3 flex items-start justify-between gap-3 rounded-xl bg-[#f9fafc] px-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900">{title}</div>
<div className="text-xs text-gray-400">{description}</div>
</div>
<Button type="button" size="sm" variant={enabled ? "default" : "outline"} onClick={onToggle}>
{enabled ? "已开启" : "已关闭"}
</Button>
</div>
);
}