0.4.0 convex及界面修改
This commit is contained in:
@@ -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 计为完成
|
||||
// - checkListItem(BlockNote 默认块):按 checked 统计
|
||||
if (block.type === "advancedTodo") {
|
||||
const status = String((block.props as any)?.status ?? "todo");
|
||||
if (status !== "cancelled") {
|
||||
todoTotal += 1;
|
||||
if (status === "done") {
|
||||
todoDone += 1;
|
||||
}
|
||||
}
|
||||
} else if (block.type === "checkListItem") {
|
||||
todoTotal += 1;
|
||||
if (Boolean((block.props as any)?.checked)) {
|
||||
todoDone += 1;
|
||||
}
|
||||
}
|
||||
if (block.children && block.children.length > 0) {
|
||||
accumulate(block.children as Block<CustomBlockSchema>[]);
|
||||
}
|
||||
@@ -553,6 +788,8 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
wordCount,
|
||||
characterCount,
|
||||
blockCount: blocks.length,
|
||||
todoTotal,
|
||||
todoDone,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -561,15 +798,32 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const topBlocks = editor.topLevelBlocks as any[];
|
||||
// 避免重复插入(例如:恢复事件重复触发/多端同时恢复)
|
||||
const exists = topBlocks.some((b) => {
|
||||
if (b.type !== "media") return false;
|
||||
const id = (b.props as { assetId?: string })?.assetId;
|
||||
return Boolean(id && asset.id && String(id) === String(asset.id));
|
||||
});
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
const fileUrl = asset.file_url ?? "";
|
||||
if (!fileUrl) {
|
||||
return;
|
||||
}
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const referenceBlock =
|
||||
cursor?.block ?? (editor.topLevelBlocks[editor.topLevelBlocks.length - 1] as Block<CustomBlockSchema> | undefined);
|
||||
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
|
||||
if (!referenceBlock) {
|
||||
return;
|
||||
try {
|
||||
// 兜底:部分情况下(例如删除掉最后一个块)topLevelBlocks 可能为空,先补一个段落作为插入锚点
|
||||
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
|
||||
const nextTopBlocks = editor.topLevelBlocks as any[];
|
||||
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!referenceBlock) return;
|
||||
}
|
||||
editor.insertBlocks(
|
||||
[
|
||||
@@ -595,6 +849,151 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
[documentId, editor],
|
||||
);
|
||||
|
||||
const insertMindmapBlock = useCallback(
|
||||
(args: { documentId: string; mindmapId: string }) => {
|
||||
if (!editor) return;
|
||||
// 仅允许插入到当前打开的页面
|
||||
if (!args.documentId || String(args.documentId) !== String(documentId)) return;
|
||||
const mindmapId = String(args.mindmapId ?? "").trim();
|
||||
if (!mindmapId) return;
|
||||
|
||||
const topBlocks = editor.topLevelBlocks as any[];
|
||||
const exists = topBlocks.some((b) => b.type === "mindmap" && String(b.id) === mindmapId);
|
||||
if (exists) return;
|
||||
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
|
||||
if (!referenceBlock) {
|
||||
try {
|
||||
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
|
||||
const nextTopBlocks = editor.topLevelBlocks as any[];
|
||||
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!referenceBlock) return;
|
||||
}
|
||||
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
id: mindmapId,
|
||||
type: "mindmap",
|
||||
props: { docId: documentId },
|
||||
content: [],
|
||||
} as any,
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
},
|
||||
[documentId, editor],
|
||||
);
|
||||
|
||||
const insertOnlineTableBlock = useCallback(
|
||||
(args: { documentId: string; tableId: string }) => {
|
||||
if (!editor) return;
|
||||
// 仅允许插入到当前打开的页面
|
||||
if (!args.documentId || String(args.documentId) !== String(documentId)) return;
|
||||
const tableId = String(args.tableId ?? "").trim();
|
||||
if (!tableId) return;
|
||||
|
||||
const topBlocks = editor.topLevelBlocks as any[];
|
||||
const exists = topBlocks.some((b) => {
|
||||
if (b.type !== "onlineTable") return false;
|
||||
const id = (b.props as { tableId?: string })?.tableId;
|
||||
return Boolean(id && String(id) === tableId);
|
||||
});
|
||||
if (exists) return;
|
||||
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
|
||||
if (!referenceBlock) {
|
||||
try {
|
||||
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
|
||||
const nextTopBlocks = editor.topLevelBlocks as any[];
|
||||
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!referenceBlock) return;
|
||||
}
|
||||
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "onlineTable",
|
||||
props: { tableId, title: "未命名表格" },
|
||||
content: [],
|
||||
} as any,
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
},
|
||||
[documentId, editor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return undefined;
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent)?.detail as
|
||||
| { docId?: string; kind?: "media"; assetId?: string; asset?: MediaAsset }
|
||||
| { docId?: string; kind?: "mindmap"; mindmapId?: string }
|
||||
| { docId?: string; kind?: "table"; tableId?: string };
|
||||
if (!detail || !detail.docId) return;
|
||||
if (String(detail.docId) !== String(documentId)) return;
|
||||
|
||||
if ((detail as any).kind === "mindmap") {
|
||||
const mindmapId = String((detail as any).mindmapId ?? "").trim();
|
||||
if (!mindmapId) return;
|
||||
insertMindmapBlock({ documentId: detail.docId, mindmapId });
|
||||
return;
|
||||
}
|
||||
|
||||
if ((detail as any).kind === "table") {
|
||||
const tableId = String((detail as any).tableId ?? "").trim();
|
||||
if (!tableId) return;
|
||||
insertOnlineTableBlock({ documentId: detail.docId, tableId });
|
||||
return;
|
||||
}
|
||||
|
||||
if ((detail as any).kind === "media") {
|
||||
const assetId = String((detail as any).assetId ?? "").trim();
|
||||
const asset = ((detail as any).asset ?? null) as MediaAsset | null;
|
||||
if (!assetId) return;
|
||||
void (async () => {
|
||||
// 恢复列表里的 file_url 可能为空/不可用,优先用 sign 接口拿最新可访问链接
|
||||
let fileUrl = (asset?.file_url ?? "").trim();
|
||||
if (!fileUrl) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
|
||||
if (res.ok) {
|
||||
const payload = (await res.json().catch(() => null)) as any;
|
||||
fileUrl = String(payload?.signedUrl ?? "").trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (!fileUrl) return;
|
||||
|
||||
insertMediaAssetBlock({
|
||||
...(asset ?? ({} as MediaAsset)),
|
||||
id: assetId,
|
||||
document_id: documentId,
|
||||
file_url: fileUrl,
|
||||
thumbnail_url: (asset?.thumbnail_url ?? fileUrl) as any,
|
||||
} as MediaAsset);
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(ASSETS_RESTORED_EVENT, handler);
|
||||
return () => window.removeEventListener(ASSETS_RESTORED_EVENT, handler);
|
||||
}, [documentId, editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock]);
|
||||
|
||||
const uploadClipboardMedia = useCallback(
|
||||
async (file: File) => {
|
||||
if (!workspaceId) {
|
||||
@@ -628,12 +1027,34 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
return;
|
||||
}
|
||||
const bridge = {
|
||||
undo: () => {
|
||||
try {
|
||||
editor.focus();
|
||||
editor.undo();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
redo: () => {
|
||||
try {
|
||||
editor.focus();
|
||||
editor.redo();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
openTableFullScreen: (tableId: string) => {
|
||||
setFullScreenTableId(tableId);
|
||||
},
|
||||
insertMediaAsset: (asset: MediaAsset) => {
|
||||
insertMediaAssetBlock(asset);
|
||||
},
|
||||
insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => {
|
||||
insertMindmapBlock(args);
|
||||
},
|
||||
insertOnlineTableAsset: (args: { documentId: string; tableId: string }) => {
|
||||
insertOnlineTableBlock(args);
|
||||
},
|
||||
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
|
||||
editor.focus();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
@@ -683,15 +1104,60 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
}
|
||||
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
|
||||
},
|
||||
getCursorBlockId: () => {
|
||||
try {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
return cursor?.block?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
registerEditorBridge(bridge);
|
||||
return () => registerEditorBridge(null);
|
||||
}, [editor, insertMediaAssetBlock, registerEditorBridge]);
|
||||
}, [editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock, registerEditorBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!workspaceId) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const ctrlOrMeta = event.ctrlKey || event.metaKey;
|
||||
if (!ctrlOrMeta) return;
|
||||
if (!event.altKey) return;
|
||||
const key = String(event.key ?? "").toLowerCase();
|
||||
if (key !== "m") return;
|
||||
event.preventDefault();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const blockId = cursor?.block?.id ?? null;
|
||||
if (blockId) {
|
||||
openCommentsForBlock({ workspaceId, documentId, blockId });
|
||||
} else {
|
||||
openCommentsForPage({ workspaceId, documentId });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [documentId, editor, openCommentsForBlock, openCommentsForPage, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return undefined;
|
||||
}
|
||||
// 说明:BlockNote 的 contenteditable 节点不直接暴露 spellcheck props。
|
||||
// 这里用 DOM 属性实现“全局选项:拼写检查”。
|
||||
try {
|
||||
const root = document.querySelector<HTMLElement>(".wolai-editor");
|
||||
if (root) {
|
||||
root.setAttribute("spellcheck", spellCheck ? "true" : "false");
|
||||
root.querySelectorAll<HTMLElement>("[contenteditable]").forEach((el) => {
|
||||
el.setAttribute("spellcheck", spellCheck ? "true" : "false");
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const activeElement = event.target;
|
||||
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
|
||||
@@ -718,7 +1184,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [editor, insertMediaAssetBlock, uploadClipboardMedia]);
|
||||
}, [editor, insertMediaAssetBlock, spellCheck, uploadClipboardMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
@@ -777,16 +1243,20 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
editor={editor}
|
||||
theme="light"
|
||||
slashMenu={false}
|
||||
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
|
||||
sideMenu={false}
|
||||
editable={!pageOptions.protectEditing && !readOnly}
|
||||
className={blocknoteClass}
|
||||
>
|
||||
{!isFullScreenTableOpen && (
|
||||
<SideMenuController
|
||||
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
||||
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
|
||||
<CustomSideMenu
|
||||
{...props}
|
||||
currentDocumentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
unresolvedCommentCountByBlockId={unresolvedCommentCountByBlockId}
|
||||
/>
|
||||
)}
|
||||
floatingOptions={{ placement: "left" }}
|
||||
/>
|
||||
)}
|
||||
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
||||
@@ -795,7 +1265,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
{isSaving ? "保存中..." : "已保存"}
|
||||
</div>
|
||||
</div>
|
||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
|
||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
|
||||
</div>
|
||||
|
||||
<MoveEmbedPickerHost />
|
||||
|
||||
@@ -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 Night(深色主题)与跟随系统。快捷键:Ctrl/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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user