Files
mnote/wolai-frontend/src/components/sidebar/sidebar.tsx
T
2026-04-14 13:22:29 +08:00

3169 lines
118 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { useAuthActions } from "@convex-dev/auth/react";
import { useConvex } from "convex/react";
import {
ArrowRightLeft,
ArrowUpRight,
ChevronRight,
Copy,
Edit3,
GitMerge,
Globe,
Hash,
LayoutGrid,
Library,
Link as LinkIcon,
LogOut,
MoreHorizontal,
PanelRightOpen,
Plus,
Search as SearchIcon,
Share2,
Shield,
Star,
Trash2,
Upload,
Users,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import type { DocumentNode } from "@/lib/documents";
import { buildDocumentTree } from "@/lib/documents";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
import { PrivateTree } from "@/components/sidebar/private-tree";
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { useCurrentDocumentStore } from "@/store/current-document";
import { FileTree } from "@/components/sidebar/file-tree";
import { buildVisibleRows } from "@/lib/file-tree/rows";
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "@/lib/file-tree/selection";
import type { FileTreeRow } from "@/lib/file-tree/types";
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
import { isRealFileAsset } from "@/lib/file-tree/asset";
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
import {
inferPasteTargetDocId,
isTextInputTarget,
readFileTreeClipboardPayload,
writeFileTreeClipboardPayload,
} from "@/lib/file-tree/clipboard";
import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
import { api } from "@/lib/convex/api";
import { GroupManagerDialog } from "@/components/groups/group-manager-dialog";
const TOP_BUTTONS = [
{ id: "search", icon: SearchIcon, label: "搜索" },
{ id: "graph", icon: Share2, label: "关系图" },
{ id: "import", icon: Upload, label: "导入" },
{ id: "members", icon: Users, label: "成员" },
{ id: "starred", icon: Star, label: "星标置顶" },
{ id: "public", icon: Globe, label: "公共页面" },
{ id: "shared", icon: Shield, label: "共享页面" },
{ id: "templates", icon: LayoutGrid, label: "模板中心" },
] as const;
const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNode> = {
starred: <Star className="h-4 w-4 text-[#f5a623]" />,
public: <Globe className="h-4 w-4 text-[#60a5fa]" />,
shared: <Shield className="h-4 w-4 text-[#9b87f5]" />,
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
};
const normalizeStoragePath = (storagePath: string): string => storagePath.replaceAll("\\", "/");
const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => {
const name = (fileName ?? "").trim().toLowerCase();
const mt = (mimeType ?? "").trim().toLowerCase();
const ext = name.includes(".") ? name.split(".").pop() : null;
if (ext && ["doc", "docx", "odt", "rtf"].includes(ext)) return ext;
if (ext && ["ppt", "pptx", "odp"].includes(ext)) return ext;
if (ext && ["xls", "xlsx", "ods", "csv"].includes(ext)) return ext;
if (ext && ["pdf"].includes(ext)) return ext;
if (mt.includes("wordprocessingml")) return "docx";
if (mt.includes("presentationml")) return "pptx";
if (mt.includes("spreadsheetml")) return "xlsx";
if (mt.includes("pdf")) return "pdf";
return null;
};
const extractMindmapIdFromStoragePath = (
storagePath: string | null | undefined,
): string | null => {
if (!storagePath) return null;
const normalized = normalizeStoragePath(storagePath);
const prefix = "mindmaps/";
if (normalized.startsWith(prefix)) {
const rest = normalized.slice(prefix.length);
const id = rest.split("/")[0];
return id ? id : null;
}
const marker = "/mindmaps/";
const idx = normalized.indexOf(marker);
if (idx === -1) return null;
const rest = normalized.slice(idx + marker.length);
const id = rest.split("/")[0];
return id ? id : null;
};
interface SidebarProps {
initialData: SidebarInitialData;
}
interface ContextMenuState {
node: DocumentNode;
x: number;
y: number;
}
export function Sidebar({ initialData }: SidebarProps) {
return <SidebarConvex initialData={initialData} />;
}
// Convex 模式专用组件 - 只调用 Convex hooks
function SidebarConvex({ initialData }: SidebarProps) {
const convexData = useConvexSidebarData(initialData.activeWorkspaceId);
return <SidebarContent initialData={initialData} sidebarQuery={convexData} />;
}
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
interface SidebarContentProps {
initialData: SidebarInitialData;
sidebarQuery: {
data: SidebarInitialData | null | undefined;
isLoading: boolean;
refetch: () => Promise<unknown>;
};
}
function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const convex = useConvex();
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
useSidebarStore();
const viewMode = useSidebarStore((state) => state.viewMode);
const setViewMode = useSidebarStore((state) => state.setViewMode);
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
// 处理数据
const sidebarData = useMemo(() => {
return sidebarQuery.data ?? initialData;
}, [sidebarQuery, initialData]);
// isLoading 判断
const isLoading = sidebarQuery.isLoading;
const segments = useSelectedLayoutSegments();
const router = useRouter();
const { signOut } = useAuthActions();
const activeId = segments?.[1] ?? "";
const editorBridge = useEditorBridgeStore((state) => state.bridge);
const [tree, setTree] = useState<DocumentNode[]>(() => buildDocumentTree(sidebarData.documents));
const [filter, setFilter] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [topPanel, setTopPanel] = useState<"starred" | "public" | "shared" | "templates" | null>(null);
const [shareSummary, setShareSummary] = useState<{
incoming: Array<{
workspaceId: string;
workspaceName: string | null;
documentId: string;
documentTitle: string | null;
permission: "read" | "edit";
includeDescendants: boolean;
createdBy: string;
updatedAt: string;
}>;
outgoing: Array<{
workspaceId: string;
workspaceName: string | null;
documentId: string;
documentTitle: string | null;
includeDescendants: boolean;
sharedWithCount: number;
updatedAt: string;
}>;
} | null>(null);
const [shareSummaryError, setShareSummaryError] = useState<string | null>(null);
const [groupPublicSummary, setGroupPublicSummary] = useState<
Array<{
groupId: string;
groupName: string | null;
documents: Array<{ documentId: string; includeDescendants: boolean }>;
}>
>([]);
const [groupPublicError, setGroupPublicError] = useState<string | null>(null);
const [openPublicGroups, setOpenPublicGroups] = useState<Set<string>>(() => new Set());
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [shareTarget, setShareTarget] = useState<{
id: string;
title: string | null;
workspaceId: string;
allowIncludeDescendants: boolean;
} | null>(null);
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
const [signingOut, setSigningOut] = useState(false);
const [trashOpen, setTrashOpen] = useState(false);
const [trashSearch, setTrashSearch] = useState("");
const [trashTab, setTrashTab] = useState<"documents" | "assets">("documents");
const [emptyingTrash, setEmptyingTrash] = useState(false);
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
const [moveEmbedSource, setMoveEmbedSource] = useState<DocumentNode | null>(null);
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
selectedRowIds: new Set(),
anchorRowId: null,
focusedRowId: null,
}));
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
// 删除在线表格后,Convex 订阅刷新存在极短延迟;这里做短暂“乐观隐藏”,避免文件树闪回。
const hiddenTableIdsRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
setTree(() => {
const nextTree = buildDocumentTree(sidebarData.documents);
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
return nextTree;
});
}, [sidebarData.documents]);
useEffect(() => {
setMediaAssets(sidebarData.mediaAssets ?? []);
}, [sidebarData.mediaAssets]);
useEffect(() => {
setMindmapAssets(sidebarData.mindmapAssets ?? []);
}, [sidebarData.mindmapAssets]);
useEffect(() => {
const hidden = hiddenTableIdsRef.current;
const now = Date.now();
hidden.forEach((ts, id) => {
if (now - ts > 15000) {
hidden.delete(id);
}
});
setTableAssets((sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id)));
}, [sidebarData.tableAssets]);
useEffect(() => {
setOpen(false);
}, [activeId, setOpen]);
useEffect(() => {
const onSaved = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
if (tableId) {
hiddenTableIdsRef.current.delete(tableId);
}
void sidebarQuery.refetch();
};
const onDeleted = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
if (tableId) {
hiddenTableIdsRef.current.set(tableId, Date.now());
setTableAssets((prev) => prev.filter((item) => item.id !== tableId));
}
void sidebarQuery.refetch();
};
window.addEventListener("online-table-saved", onSaved as EventListener);
window.addEventListener("online-table-deleted", onDeleted as EventListener);
return () => {
window.removeEventListener("online-table-saved", onSaved as EventListener);
window.removeEventListener("online-table-deleted", onDeleted as EventListener);
};
}, [sidebarQuery]);
const refreshTree = useCallback(async () => {
await sidebarQuery.refetch();
}, [sidebarQuery]);
const refreshShareSummary = useCallback(async () => {
try {
const resp = await convex.query(api.documentShares.listMyShareRoots, {});
setShareSummary(resp as any);
setShareSummaryError(null);
} catch (e: any) {
const msg = e?.message ?? "加载共享摘要失败";
if (String(msg).includes("Could not find public function for 'documentShares:listMyShareRoots'")) {
setShareSummaryError(
"共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。",
);
} else {
setShareSummaryError(msg);
}
setShareSummary(null);
}
}, [convex]);
useEffect(() => {
void refreshShareSummary();
}, [refreshShareSummary]);
const refreshGroupPublicSummary = useCallback(async () => {
const workspaceId = sidebarData.activeWorkspaceId;
if (!workspaceId) {
setGroupPublicSummary([]);
setGroupPublicError(null);
return;
}
try {
const resp = await convex.query(api.documentGroupShares.listPublicByWorkspace, { workspaceId });
const rows = Array.isArray(resp) ? (resp as any[]) : [];
setGroupPublicSummary(
rows.map((r) => ({
groupId: String(r.groupId),
groupName: r.groupName ? String(r.groupName) : null,
documents: Array.isArray(r.documents)
? r.documents.map((d: any) => ({
documentId: String(d.documentId),
includeDescendants: Boolean(d.includeDescendants),
}))
: [],
})),
);
setGroupPublicError(null);
} catch (e: any) {
const msg = e?.message ?? "加载群组公开摘要失败";
if (String(msg).includes("Could not find public function for 'documentGroupShares:listPublicByWorkspace'")) {
setGroupPublicError("群组公开功能后端未部署/未更新:请执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。");
} else {
setGroupPublicError(msg);
}
setGroupPublicSummary([]);
}
}, [convex, sidebarData.activeWorkspaceId]);
useEffect(() => {
void refreshGroupPublicSummary();
}, [refreshGroupPublicSummary]);
useEffect(() => {
const handler = (event: Event) => {
const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>;
const asset = custom.detail?.asset as MediaAsset | undefined;
if (asset?.id) {
if (asset.asset_type === "mindmap") {
setMindmapAssets((prev) => {
if (prev.find((item) => item.id === asset.id)) return prev;
return [asset, ...prev];
});
} else if (asset.asset_type === "luckysheet") {
setTableAssets((prev) => {
if (prev.find((item) => item.id === asset.id)) return prev;
return [asset, ...prev];
});
} else {
setMediaAssets((prev) => {
if (prev.find((item) => item.id === asset.id)) return prev;
return [asset, ...prev];
});
}
}
void sidebarQuery.refetch();
// 同步刷新共享/公共摘要,避免跨页面操作后出现“幽灵共享条目”(点开 404 / 无标题)。
void refreshShareSummary();
void refreshGroupPublicSummary();
};
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
window.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
return () => {
window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
window.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
};
}, [sidebarQuery, refreshShareSummary, refreshGroupPublicSummary]);
useEffect(() => {
// 说明:shareSummary/groupPublicSummary 目前走的是一次性 query + 本地 state
// 为了让 A 侧删除/清空回收站后,B 侧能自动消失(而不是保留 404 幽灵项),这里做轻量轮询刷新。
if (topPanel !== "shared" && topPanel !== "public") {
return;
}
const refresh = () => {
if (topPanel === "shared") void refreshShareSummary();
if (topPanel === "public") void refreshGroupPublicSummary();
};
refresh();
const intervalId = window.setInterval(refresh, 2500);
const onFocus = () => {
if (document.visibilityState && document.visibilityState !== "visible") return;
refresh();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onFocus);
return () => {
window.clearInterval(intervalId);
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onFocus);
};
}, [topPanel, refreshShareSummary, refreshGroupPublicSummary]);
const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]);
const sharedNodes = useMemo(() => sections.find((section) => section.id === "shared")?.nodes ?? [], [sections]);
const templateNodes = useMemo(() => sections.find((section) => section.id === "templates")?.nodes ?? [], [sections]);
const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]);
const nodeById = useMemo(() => {
const map = new Map<string, DocumentNode>();
const walk = (nodes: DocumentNode[]) => {
nodes.forEach((node) => {
map.set(node.id, node);
if (node.children.length > 0) {
walk(node.children);
}
});
};
walk(tree);
return map;
}, [tree]);
const publicGroupNodesByGroupId = useMemo(() => {
const map = new Map<string, DocumentNode[]>();
for (const g of groupPublicSummary) {
const nodes: DocumentNode[] = [];
const uniq = new Map<string, DocumentNode>();
for (const d of g.documents ?? []) {
const node = nodeById.get(d.documentId);
if (node) {
uniq.set(node.id, node);
}
}
uniq.forEach((v) => nodes.push(v));
map.set(g.groupId, nodes);
}
return map;
}, [groupPublicSummary, nodeById]);
const filteredPrivateTree = useMemo(
() => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
[filter, privateTree],
);
const flattenedPrivate = useMemo(
() => flattenDocumentTree(privateTree, expanded),
[privateTree, expanded],
);
const filteredTrash = useMemo(() => {
const keyword = trashSearch.trim().toLowerCase();
if (!keyword) {
return sidebarData.trashedDocuments;
}
return sidebarData.trashedDocuments.filter((item) =>
(item.title ?? "无标题").toLowerCase().includes(keyword),
);
}, [sidebarData.trashedDocuments, trashSearch]);
const filteredTrashedMediaAssets = useMemo(() => {
const assets = [
...(sidebarData.trashedMediaAssets ?? []),
...(sidebarData.trashedMindmapAssets ?? []),
...(sidebarData.trashedTableAssets ?? []),
];
const keyword = trashSearch.trim().toLowerCase();
if (!keyword) {
return assets;
}
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, sidebarData.trashedTableAssets, trashSearch]);
const mindmapChildrenSnapshot = useMemo(() => {
const mapping = sidebarData.mindmapAssetChildren ?? {};
const mindmapDocById = new Map<string, string>(
(mindmapAssets ?? [])
.filter((asset) => asset.asset_type === "mindmap")
.map((asset) => [asset.id, asset.document_id]),
);
const mindmapIds = new Set(mindmapDocById.keys());
const mediaById = new Map<string, MediaAsset>(
(mediaAssets ?? []).map((asset) => [asset.id, asset]),
);
const childAssetsByMindmapId: Record<string, MediaAsset[]> = {};
const childIds = new Set<string>();
const assigned = new Set<string>();
// 物理目录:storage_path 归属到 mindmaps/<mindmapId>/ 的附件,作为导图文件夹内容
(mediaAssets ?? []).forEach((asset) => {
const sp = asset.storage_path;
if (!sp || typeof sp !== "string") return;
const mindmapId = extractMindmapIdFromStoragePath(sp);
if (!mindmapId) return;
if (!mindmapIds.has(mindmapId)) return;
const docId = mindmapDocById.get(mindmapId);
if (docId && asset.document_id !== docId) return;
if (assigned.has(asset.id)) return;
assigned.add(asset.id);
childIds.add(asset.id);
if (!childAssetsByMindmapId[mindmapId]) childAssetsByMindmapId[mindmapId] = [];
childAssetsByMindmapId[mindmapId].push(asset);
});
// 引用图片:从 mindmap JSON 解析出的 assetIds,也放到导图文件夹下(去重)
(mindmapAssets ?? []).forEach((mindmapAsset) => {
const ids = mapping[mindmapAsset.id] ?? [];
if (!Array.isArray(ids) || ids.length === 0) return;
ids.forEach((id) => {
const asset = mediaById.get(id);
if (!asset) return;
if (asset.document_id !== mindmapAsset.document_id) return;
if (assigned.has(asset.id)) return;
assigned.add(asset.id);
childIds.add(asset.id);
if (!childAssetsByMindmapId[mindmapAsset.id]) childAssetsByMindmapId[mindmapAsset.id] = [];
childAssetsByMindmapId[mindmapAsset.id].push(asset);
});
});
return { childAssetsByMindmapId, childIds };
}, [mediaAssets, mindmapAssets, sidebarData.mindmapAssetChildren]);
const assetsByDoc = useMemo(() => {
const map: Record<string, MediaAsset[]> = {};
const assets = [
...((mediaAssets ?? []).filter(
(asset) => !mindmapChildrenSnapshot.childIds.has(asset.id),
)),
...(mindmapAssets ?? []),
...(tableAssets ?? []),
];
assets.forEach((asset) => {
if (!map[asset.document_id]) {
map[asset.document_id] = [];
}
const exists = map[asset.document_id].some((a) => a.id === asset.id && a.asset_type === asset.asset_type);
if (!exists) {
map[asset.document_id].push(asset);
}
});
return map;
}, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]);
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
const fileTreeRows = useMemo(
() =>
buildVisibleRows({
nodes: filteredPrivateTree,
expanded,
assetsByDoc,
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
expandedAssetFolderIds: expandedAssetFolders,
}),
[assetsByDoc, expanded, expandedAssetFolders, filteredPrivateTree, mindmapChildrenSnapshot.childAssetsByMindmapId],
);
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
const fileTreeRowById = useMemo(() => new Map(fileTreeRows.map((row) => [row.rowId, row])), [fileTreeRows]);
useEffect(() => {
setFileTreeSelection((prev) => normalizeFileTreeSelectionForVisibleRows(prev, fileTreeVisibleRowIds));
}, [fileTreeVisibleRowIds]);
const docParentById = useMemo(
() =>
buildParentById(
(sidebarData.documents ?? []).map((doc) => ({
id: doc.id,
parentId: doc.parent_id ?? null,
})),
),
[sidebarData.documents],
);
const childrenCountByParentId = useMemo(() => {
const map = new Map<string | null, number>();
(sidebarData.documents ?? []).forEach((doc) => {
const parentId = doc.parent_id ?? null;
map.set(parentId, (map.get(parentId) ?? 0) + 1);
});
return map;
}, [sidebarData.documents]);
const activeWorkspace =
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
sidebarData.workspaces[0];
const handleOpenDocument = useCallback(
(documentId: string, mode: "main" | "sidebar") => {
const targetPath = `/documents/${documentId}`;
if (mode === "main") {
router.push(targetPath);
setOpen(false);
return;
}
if (typeof window !== "undefined") {
const sidebarUrl = `${buildDocumentUrl(documentId)}?preview=sidebar`;
window.open(sidebarUrl, "_blank", "noopener,noreferrer");
}
},
[router, setOpen],
);
const handleCopyLink = useCallback(async (node: DocumentNode, includeTitle = false) => {
const url = buildDocumentUrl(node.id);
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
await copyText(payload, includeTitle ? "标题 + 链接已复制" : "页面链接已复制");
}, []);
const handleCopyReference = useCallback(async (node: DocumentNode, mode: "inline" | "embed") => {
const template = mode === "inline" ? `((${node.id}))` : `{{${node.id}}}`;
await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制");
}, []);
const handleCopyId = useCallback(async (node: DocumentNode) => {
await copyText(node.id, "页面 ID 已复制");
}, []);
const handleDuplicateDocument = useCallback(
async (node: DocumentNode) => {
const response = await fetch("/api/documents/duplicate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId: node.id }),
});
if (!response.ok) {
window.alert("复制失败,请稍后再试");
return;
}
await refreshTree();
},
[refreshTree],
);
const openMoveEmbedPicker = useCallback((node: DocumentNode, nextMode: MoveEmbedMode) => {
setMoveEmbedSource(node);
setMoveEmbedMode(nextMode);
setMoveEmbedOpen(true);
}, []);
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
openMoveEmbedPicker(node, "embed");
}, [openMoveEmbedPicker]);
const toggleExpand = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const knownMindmapFolderIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
setExpandedAssetFolders((prev) => {
const next = new Set(prev);
(mindmapAssets ?? []).forEach((asset) => {
if (asset.asset_type !== "mindmap") return;
if (knownMindmapFolderIdsRef.current.has(asset.id)) return;
knownMindmapFolderIdsRef.current.add(asset.id);
next.add(asset.id);
});
return next;
});
}, [mindmapAssets]);
const toggleAssetFolderExpand = useCallback((assetId: string) => {
setExpandedAssetFolders((prev) => {
const next = new Set(prev);
if (next.has(assetId)) next.delete(assetId);
else next.add(assetId);
return next;
});
}, []);
const handleOpenAsset = useCallback((asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
router.push(`/mindmap/${asset.document_id}/${asset.id}`);
setOpen(false);
return;
}
if (asset.asset_type === "luckysheet") {
if (activeId && activeId === asset.document_id && editorBridge?.openTableFullScreen) {
editorBridge.openTableFullScreen(asset.id);
setOpen(false);
return;
}
router.push(`/documents/${asset.document_id}?openTableId=${encodeURIComponent(asset.id)}`);
setOpen(false);
return;
}
const officeFileType = officeFileTypeFromAsset(asset.file_name ?? null, asset.mime_type ?? null);
if (officeFileType) {
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
if (!officeBase) {
window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
return;
}
void (async () => {
try {
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`);
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error(payload?.error ?? "生成签名链接失败");
}
const { signedUrl } = (await res.json()) as { signedUrl: string };
const target = new URL("/onlyoffice", window.location.origin);
target.searchParams.set("fileUrl", signedUrl);
target.searchParams.set("fileName", asset.file_name ?? `未命名.${officeFileType}`);
target.searchParams.set("fileType", officeFileType);
target.searchParams.set("assetId", asset.id);
target.searchParams.set("documentId", asset.document_id);
target.searchParams.set("mode", "edit");
window.open(target.toString(), "_blank", "noopener,noreferrer");
setOpen(false);
} catch (error) {
window.alert((error as Error).message);
}
})();
return;
}
void (async () => {
try {
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`);
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error(payload?.error ?? "生成签名链接失败");
}
const { signedUrl } = (await res.json()) as { signedUrl: string };
if (!signedUrl) {
throw new Error("暂无可用的文件链接");
}
if (typeof window !== "undefined") {
window.open(signedUrl, "_blank", "noopener,noreferrer");
}
setOpen(false);
} catch {
const fallback = asset.signed_url ?? asset.file_url;
if (!fallback) {
window.alert("暂无可用的文件链接");
return;
}
if (typeof window !== "undefined") {
window.open(fallback, "_blank", "noopener,noreferrer");
}
setOpen(false);
}
})();
}, [activeId, editorBridge, router, setOpen]);
const handleFileTreeBlankMouseDown = useCallback(() => {
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
}, []);
const handleFileTreeRowClick = useCallback(
(row: FileTreeRow, event: React.MouseEvent) => {
setFileTreeSelection((prev) =>
reduceFileTreeSelection(prev, {
type: "click",
rowId: row.rowId,
visibleRowIds: fileTreeVisibleRowIds,
modifiers: {
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
},
}),
);
// 与 VS Code 的单击打开不同:为了避免误触导致重资源文件(思维导图/表格等)被
// 直接打开,我们在“无修饰键”的单击时只跳转到对应页面的 index.md(即文档本身)。
if (event.button !== 0) return;
if (event.shiftKey || event.ctrlKey || event.metaKey) return;
const targetDocId = row.docId;
if (!targetDocId) return;
if (activeId && activeId === targetDocId) return;
// doc/index/asset 都统一跳转到所属页面(index.md)
handleOpenDocument(targetDocId, "main");
},
[activeId, fileTreeVisibleRowIds, handleOpenDocument],
);
const handleFileTreeRowDragStart = useCallback((row: FileTreeRow) => {
setFileTreeSelection((prev) => {
if (prev.selectedRowIds.has(row.rowId)) return prev;
return reduceFileTreeSelection(prev, {
type: "click",
rowId: row.rowId,
visibleRowIds: fileTreeVisibleRowIds,
modifiers: { shiftKey: false, ctrlKey: false, metaKey: false },
});
});
}, [fileTreeVisibleRowIds]);
const handleFileTreeRowDoubleClick = useCallback(
(row: FileTreeRow, _event?: React.MouseEvent) => {
if (row.kind === "asset" || row.kind === "asset-folder") {
handleOpenAsset(row.asset);
return;
}
handleOpenDocument(row.docId, "main");
},
[handleOpenAsset, handleOpenDocument],
);
const handleFileTreeRowContextMenu = useCallback(
(row: FileTreeRow, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setFileTreeSelection((prev) =>
reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }),
);
if (row.kind === "asset" || row.kind === "asset-folder") {
setAssetMenu({ asset: row.asset, x: event.clientX, y: event.clientY });
return;
}
setContextMenu({
node: row.node,
x: event.clientX,
y: event.clientY,
});
},
[],
);
useEffect(() => {
const handler = async (event: KeyboardEvent) => {
const isCopy =
(event.ctrlKey || event.metaKey) &&
!event.altKey &&
(event.key === "c" || event.key === "C");
const isPaste =
(event.ctrlKey || event.metaKey) &&
!event.altKey &&
(event.key === "v" || event.key === "V");
if (!isCopy && !isPaste) {
return;
}
if (isTextInputTarget(event.target)) {
return;
}
const container = fileTreeContainerRef.current;
const activeElement = document.activeElement;
if (!container || !activeElement || !container.contains(activeElement)) {
return;
}
if (isCopy) {
if (fileTreeSelection.selectedRowIds.size === 0) {
return;
}
event.preventDefault();
const orderedRowIds = fileTreeRows
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId))
.map((row) => row.rowId);
await writeFileTreeClipboardPayload({
type: "mnote-file-tree",
version: 1,
action: "copy",
rowIds: orderedRowIds,
});
return;
}
if (isPaste) {
event.preventDefault();
const payload = await readFileTreeClipboardPayload();
if (!payload || payload.rowIds.length === 0) {
return;
}
const targetDocId = inferPasteTargetDocId({
focusedRowId: fileTreeSelection.focusedRowId,
rowById: fileTreeRowById,
activeDocId: activeId || null,
});
if (!targetDocId) {
setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0);
return;
}
const rows = payload.rowIds
.map((rowId) => fileTreeRowById.get(rowId as any))
.filter(Boolean) as FileTreeRow[];
const docItemsMap = new Map<string, boolean>();
rows.forEach((row) => {
if (row.kind === "doc") {
docItemsMap.set(row.docId, true);
} else if (row.kind === "index") {
if (!docItemsMap.has(row.docId)) {
docItemsMap.set(row.docId, false);
}
}
});
if (docItemsMap.size > 0) {
const resp = await fetch("/api/documents/copy-tree", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({
documentId,
recursive,
})),
targetParentId: targetDocId,
}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
setTimeout(() => window.alert(data?.error ?? "粘贴页面失败"), 0);
return;
}
await sidebarQuery.refetch();
emitDocumentsChanged(targetDocId);
}
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<FileTreeRow, { kind: "asset" }>[];
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
if (copyableAssetIds.length > 0) {
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "copy",
assetIds: copyableAssetIds,
targetDocumentId: targetDocId,
}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
setTimeout(() => window.alert(data?.error ?? "粘贴附件失败"), 0);
return;
}
await sidebarQuery.refetch();
emitAssetsChanged(targetDocId);
} else if (docItemsMap.size === 0) {
setTimeout(() => window.alert("没有可粘贴的真实文件(mindmap.json 等虚拟附件暂不支持)"), 0);
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
activeId,
fileTreeRowById,
fileTreeRows,
fileTreeSelection.focusedRowId,
fileTreeSelection.selectedRowIds,
sidebarQuery,
]);
const handleCopyAssetLink = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
await copyText(buildMindmapUrl(asset.document_id, asset.id), "思维导图链接已复制");
return;
}
if (asset.asset_type === "luckysheet") {
await copyText(buildTableUrl(asset.id), "表格链接已复制");
return;
}
const url = asset.signed_url ?? asset.file_url ?? "";
if (!url) {
window.alert("暂无可用的文件链接");
return;
}
await copyText(url, "附件链接已复制");
},
[],
);
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
const path =
asset.asset_type === "mindmap"
? ((asset.file_url ? asset.file_url.replace(/^\//, "") : "") ||
`documents/${asset.document_id}/${asset.file_name ?? "mindmap.json"}`)
: asset.asset_type === "luckysheet"
? (`tables/${asset.id}`)
: asset.storage_path || asset.file_url || asset.file_name || "附件";
await copyText(path, "存储路径已复制");
}, []);
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
const current = useCurrentDocumentStore.getState();
if (
current.disableDownload &&
current.documentId &&
asset.document_id &&
String(asset.document_id) === String(current.documentId)
) {
window.alert("该页面已禁止下载");
return;
}
if (asset.asset_type === "mindmap") {
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`);
if (!resp.ok) {
window.alert("下载失败");
return;
}
const payload = await resp.json().catch(() => null);
const data = payload?.data ?? {};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = asset.file_name ?? "mindmap.json";
a.click();
URL.revokeObjectURL(url);
return;
}
if (asset.asset_type === "luckysheet") {
window.alert("在线表格暂不支持下载(后续可做导出 JSON/Excel");
return;
}
try {
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`);
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error(payload?.error ?? "生成签名链接失败");
}
const { signedUrl } = (await res.json()) as { signedUrl: string };
if (!signedUrl) {
throw new Error("暂无可用的下载链接");
}
if (typeof window !== "undefined") {
window.open(signedUrl, "_blank", "noopener,noreferrer");
}
} catch {
const fallback = asset.signed_url ?? asset.file_url;
if (!fallback) {
window.alert("暂无可用的下载链接");
return;
}
if (typeof window !== "undefined") {
window.open(fallback, "_blank", "noopener,noreferrer");
}
}
}, []);
const handleRenameAsset = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
window.alert("思维导图暂不支持重命名");
return;
}
if (asset.asset_type === "luckysheet") {
const currentTitle =
(asset.file_name ?? "").toLowerCase().endsWith(".luckysheet")
? (asset.file_name ?? "").slice(0, -".luckysheet".length)
: (asset.file_name ?? "");
const input = window.prompt("输入新表格名", currentTitle);
if (!input || !input.trim()) return;
const newTitle = input.trim();
const resp = await fetch(`/api/tables/${asset.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTitle }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "重命名失败");
return;
}
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: asset.id } }));
await sidebarQuery.refetch();
setAssetMenu(null);
return;
}
const input = window.prompt("输入新文件名", asset.file_name ?? "");
if (!input || !input.trim()) return;
const newName = input.trim();
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "rename", assetIds: [asset.id], newName }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "重命名失败");
return;
}
await sidebarQuery.refetch();
setAssetMenu(null);
emitAssetsChanged(asset.document_id);
},
[sidebarQuery],
);
const handleMoveAsset = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
window.alert("思维导图文件无需移动,请在页面中直接编辑");
return;
}
if (asset.asset_type === "luckysheet") {
window.alert("在线表格暂不支持移动(后续可实现跨页面迁移)");
return;
}
const target = window.prompt("输入目标页面 ID", asset.document_id);
if (!target || !target.trim()) return;
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "move",
assetIds: [asset.id],
targetDocumentId: target.trim(),
}),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "移动失败");
return;
}
await sidebarQuery.refetch();
setAssetMenu(null);
emitAssetsChanged(asset.document_id);
emitAssetsChanged(target.trim());
},
[sidebarQuery],
);
const handleDeleteAssets = useCallback(
async (assetIds: string[], assetHint?: MediaAsset | MediaAsset[]) => {
const uniqueAssetIds = Array.from(new Set(assetIds));
const assets = uniqueAssetIds
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
.filter(Boolean) as MediaAsset[];
const hints = Array.isArray(assetHint) ? assetHint : assetHint ? [assetHint] : [];
hints.forEach((hint) => {
if (!hint) return;
if (!assets.find((item) => item.id === hint.id)) {
assets.unshift(hint);
}
});
const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap");
const tableAssetsToDelete = assets.filter((item) => item.asset_type === "luckysheet");
const fileAssetsToDelete = assets.filter(
(item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet",
);
const mindmapIdsByDocId = new Map<string, string[]>();
mindmapAssetsToDelete.forEach((item) => {
const prev = mindmapIdsByDocId.get(item.document_id) ?? [];
prev.push(item.id);
mindmapIdsByDocId.set(item.document_id, prev);
});
const fileAssetIdsByDocId = new Map<string, string[]>();
fileAssetsToDelete.forEach((item) => {
const prev = fileAssetIdsByDocId.get(item.document_id) ?? [];
prev.push(item.id);
fileAssetIdsByDocId.set(item.document_id, prev);
});
const fileAssetIds = Array.from(fileAssetIdsByDocId.values()).flat();
for (const asset of mindmapAssetsToDelete) {
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
}
for (const asset of tableAssetsToDelete) {
const resp = await fetch(`/api/tables/${asset.id}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除在线表格失败");
return;
}
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId: asset.id } }));
}
if (fileAssetIds.length > 0) {
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds: fileAssetIds }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除失败");
return;
}
}
if (uniqueAssetIds.length > 0) {
const mindmapSet = new Set(mindmapAssetsToDelete.map((item) => item.id));
const tableSet = new Set(tableAssetsToDelete.map((item) => item.id));
const fileSet = new Set(fileAssetsToDelete.map((item) => item.id));
setMediaAssets((prev) => prev.filter((item) => !fileSet.has(item.id)));
setMindmapAssets((prev) => prev.filter((item) => !mindmapSet.has(item.id)));
setTableAssets((prev) => prev.filter((item) => !tableSet.has(item.id)));
}
setAssetMenu(null);
mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids));
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
await sidebarQuery.refetch();
},
[mediaAssets, mindmapAssets, sidebarQuery, tableAssets],
);
const handleDeleteFileTreeSelection = useCallback(async () => {
const { docIds, assetIds } = computeFileTreeDeleteTargets({
visibleRows: fileTreeRows,
selectedRowIds: fileTreeSelection.selectedRowIds,
parentById: docParentById,
});
if (docIds.length === 0 && assetIds.length === 0) {
return;
}
const docText = docIds.length > 0 ? `${docIds.length} 个页面(删除到垃圾桶)` : "";
// 说明:文件树里可能展示“思维导图子文件”等动态资源(不一定在 mindmapAssets 列表里)。
// 为避免出现“看起来选中了,但删除不生效”,这里优先从可见行里拿到选中资源的完整元数据。
const isAssetRow = (
row: FileTreeRow,
): row is Extract<FileTreeRow, { kind: "asset" | "asset-folder" }> =>
row.kind === "asset" || row.kind === "asset-folder";
const selectedAssetHints = Array.from(
new Map(
fileTreeRows
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId))
.filter(isAssetRow)
.map((row) => [row.asset.id, row.asset] as const),
).values(),
);
const mindmapCount = selectedAssetHints.filter((item) => item.asset_type === "mindmap").length;
const tableCount = selectedAssetHints.filter((item) => item.asset_type === "luckysheet").length;
const fileCount = selectedAssetHints.filter(
(item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet",
).length;
const unknownCount = Math.max(0, assetIds.length - mindmapCount - tableCount - fileCount);
const assetTextParts: string[] = [];
if (fileCount > 0) assetTextParts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`);
if (mindmapCount > 0) assetTextParts.push(`${mindmapCount} 个思维导图(移入垃圾桶,10 分钟内可恢复)`);
if (tableCount > 0) assetTextParts.push(`${tableCount} 个在线表格(删除)`);
if (unknownCount > 0) assetTextParts.push(`${unknownCount} 个对象(删除)`);
const assetText = assetTextParts.length > 0 ? assetTextParts.join(" + ") : "";
const joinText = docText && assetText ? " + " : "";
const ok = window.confirm(`确认删除选中的 ${docText}${joinText}${assetText} 吗?`);
if (!ok) return;
try {
if (docIds.length > 0) {
const results = await Promise.all(
docIds.map(async (documentId) => {
const resp = await fetch("/api/documents/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId }),
});
return { documentId, ok: resp.ok };
}),
);
const failed = results.filter((item) => !item.ok).map((item) => item.documentId);
if (failed.length > 0) {
window.alert(`部分页面删除失败:${failed.slice(0, 5).join(", ")}${failed.length > 5 ? "…" : ""}`);
return;
}
docIds.forEach((documentId) => emitDocumentsChanged(documentId));
if (activeId && docIds.includes(activeId)) {
router.push("/");
}
}
if (assetIds.length > 0) {
await handleDeleteAssets(assetIds, selectedAssetHints);
}
await refreshTree();
setContextMenu(null);
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
}
}, [
activeId,
docParentById,
fileTreeRows,
fileTreeSelection.selectedRowIds,
handleDeleteAssets,
mediaAssets,
mindmapAssets,
tableAssets,
refreshTree,
router,
]);
const handleResizeStart = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
const onMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX;
const targetWidth = Math.min(420, Math.max(240, startWidth + delta));
setWidth(targetWidth);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[setWidth, width],
);
const handleCreate = useCallback(
async (parentId: string | null) => {
const creatingKey = parentId ?? "__root__";
if (creatingDocumentUnderParentRef.current.has(creatingKey)) {
return;
}
creatingDocumentUnderParentRef.current.add(creatingKey);
try {
const response = await fetch("/api/documents/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parentId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "新建页面失败,请稍后再试");
return;
}
const payload = (await response.json()) as DocumentNode;
const nextNode: DocumentNode = {
...payload,
access_scope: payload.access_scope ?? "private",
is_template: payload.is_template ?? false,
updated_at: payload.updated_at ?? payload.created_at,
title: payload.title ?? "无标题",
children: [],
};
setTree((prev) => {
// 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
const exists = (nodes: DocumentNode[]): boolean => {
for (const node of nodes) {
if (node.id === nextNode.id) return true;
if (node.children.length > 0 && exists(node.children)) return true;
}
return false;
};
if (exists(prev)) return prev;
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
});
setExpanded((prev) => {
const next = new Set(prev);
if (parentId) {
next.add(parentId);
}
if (!parentId) {
next.add(nextNode.id);
}
return next;
});
await refreshTree();
router.push(`/documents/${nextNode.id}`);
} finally {
creatingDocumentUnderParentRef.current.delete(creatingKey);
}
},
[refreshTree, router],
);
const handleRename = useCallback(
async (documentId: string, currentTitle: string | null) => {
const title = window.prompt("输入新的标题", currentTitle ?? "无标题") ?? "";
if (!title.trim()) {
return;
}
await fetch("/api/documents/title", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentId,
workspaceId: sidebarData.activeWorkspaceId ?? null,
title: title.trim(),
}),
});
await refreshTree();
},
[refreshTree],
);
const moveLocalNode = useCallback((currentTree: DocumentNode[], nodeId: string, parentId: string | null, index: number) => {
const cloned = cloneNodes(currentTree);
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
if (!removed) {
return currentTree;
}
const next = insertNode(withoutTarget, parentId, index, removed);
return next;
}, []);
const handleMove = useCallback(
async (documentId: string, parentId: string | null, index: number) => {
setTree((prev) => moveLocalNode(prev, documentId, parentId, index));
if (parentId) {
setExpanded((prev) => new Set(prev).add(parentId));
}
await fetch("/api/documents/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentId,
parentId,
position: index,
}),
});
await refreshTree();
},
[moveLocalNode, refreshTree, setExpanded],
);
const handleFileTreeDropFiles = useCallback(
(docId: string, files: FileList, targetRow?: FileTreeRow) => {
void (async () => {
const droppedFiles = Array.from(files ?? []);
if (droppedFiles.length === 0) return;
const targetMindmapId = (() => {
if (!targetRow) return null;
if (targetRow.kind === "asset-folder" && targetRow.asset.asset_type === "mindmap") {
return targetRow.asset.id;
}
if (targetRow.kind === "asset") {
return extractMindmapIdFromStoragePath(targetRow.asset.storage_path);
}
return null;
})();
if (targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
}
const inferredTargetDocId =
docId ||
inferPasteTargetDocId({
focusedRowId: fileTreeSelection.focusedRowId,
rowById: fileTreeRowById,
activeDocId: activeId || null,
}) ||
"";
if (!inferredTargetDocId) {
setTimeout(() => window.alert("请选择一个目标页面后再拖入文件"), 0);
return;
}
const targetDoc = sidebarData.documents.find((doc) => doc.id === inferredTargetDocId) ?? null;
const workspaceId = targetDoc?.workspace_id ?? sidebarData.activeWorkspaceId ?? "";
if (!workspaceId) {
setTimeout(() => window.alert("无法识别当前工作区,上传失败"), 0);
return;
}
const errors: string[] = [];
for (const file of droppedFiles) {
try {
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", inferredTargetDocId);
if (targetMindmapId) {
form.append("mindmapId", targetMindmapId);
}
const resp = await fetch("/api/media/upload", { method: "POST", body: form });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
errors.push(`${file.name}: ${payload?.error ?? "上传失败"}`);
continue;
}
const payload = (await resp.json()) as { asset?: MediaAsset };
if (payload.asset?.id) {
emitAssetsChanged(inferredTargetDocId, payload.asset);
// 拖拽文件进文件树:如果落点就是当前打开的页面,则把文件也插入到主编辑区
if (inferredTargetDocId === activeId && !targetMindmapId) {
editorBridge?.insertMediaAsset?.(payload.asset);
}
} else {
errors.push(`${file.name}: 返回数据缺少 asset`);
}
} catch (err) {
errors.push(`${file.name}: ${(err as Error).message}`);
}
}
await sidebarQuery.refetch();
if (errors.length > 0) {
setTimeout(() => {
const shown = errors.slice(0, 6).join("\n");
window.alert(
errors.length === 1
? `部分文件上传失败:\n${shown}`
: `部分文件上传失败(${errors.length}个):\n${shown}${errors.length > 6 ? "\n..." : ""}`,
);
}, 0);
}
})();
},
[
activeId,
editorBridge,
fileTreeRowById,
fileTreeSelection.focusedRowId,
sidebarData.activeWorkspaceId,
sidebarData.documents,
sidebarQuery,
],
);
const handleFileTreeInternalDrop = useCallback(
(args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => {
void (async () => {
const targetDocId = inferDropTargetDocId(args.targetRow);
if (!targetDocId) {
setTimeout(() => window.alert("无法识别拖拽目标页面"), 0);
return;
}
const targetMindmapId = (() => {
if (args.targetRow.kind === "asset-folder" && args.targetRow.asset.asset_type === "mindmap") {
return args.targetRow.asset.id;
}
if (args.targetRow.kind === "asset") {
return extractMindmapIdFromStoragePath(args.targetRow.asset.storage_path);
}
return null;
})();
const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined;
if (targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
}
const uniqueRowIds: string[] = [];
const seen = new Set<string>();
args.rowIds.forEach((id) => {
if (!id || seen.has(id)) return;
seen.add(id);
uniqueRowIds.push(id);
});
const rows = uniqueRowIds
.map((rowId) => fileTreeRowById.get(rowId as any))
.filter(Boolean) as FileTreeRow[];
const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId);
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<FileTreeRow, { kind: "asset" }>[];
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
if (docIds.length === 0 && copyableAssetIds.length === 0) {
setTimeout(() => window.alert("没有可拖拽的对象(虚拟附件暂不支持)"), 0);
return;
}
if (args.copy) {
if (docIds.length > 0) {
const resp = await fetch("/api/documents/copy-tree", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: docIds.map((documentId) => ({ documentId, recursive: true })),
targetParentId: targetDocId,
}),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
setTimeout(() => window.alert(payload?.error ?? "复制页面失败"), 0);
return;
}
await sidebarQuery.refetch();
emitDocumentsChanged(targetDocId);
}
if (copyableAssetIds.length > 0) {
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "copy",
assetIds: copyableAssetIds,
targetDocumentId: targetDocId,
targetSubPath,
}),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
setTimeout(() => window.alert(payload?.error ?? "复制附件失败"), 0);
return;
}
await sidebarQuery.refetch();
emitAssetsChanged(targetDocId);
}
return;
}
const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById);
if (topLevelDocIds.length > 0) {
if (
isInvalidDocDrop({
sourceDocIds: topLevelDocIds,
targetParentId: targetDocId,
parentById: docParentById,
})
) {
setTimeout(() => window.alert("不能把页面移动到自身或其子页面中"), 0);
return;
}
const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0;
setTree((prev) => {
let next = prev;
topLevelDocIds.forEach((id, offset) => {
next = moveLocalNode(next, id, targetDocId, baseIndex + offset);
});
return next;
});
setExpanded((prev) => new Set(prev).add(targetDocId));
for (let i = 0; i < topLevelDocIds.length; i += 1) {
await fetch("/api/documents/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentId: topLevelDocIds[i],
parentId: targetDocId,
position: baseIndex + i,
}),
});
}
await refreshTree();
emitDocumentsChanged(targetDocId);
}
if (copyableAssetIds.length > 0) {
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "move",
assetIds: copyableAssetIds,
targetDocumentId: targetDocId,
targetSubPath,
}),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
setTimeout(() => window.alert(payload?.error ?? "移动附件失败"), 0);
return;
}
await sidebarQuery.refetch();
const sourceDocIds = new Set(assetRows.map((row) => row.asset.document_id));
sourceDocIds.forEach((id) => emitAssetsChanged(id));
emitAssetsChanged(targetDocId);
}
})();
},
[
childrenCountByParentId,
docParentById,
fileTreeRowById,
moveLocalNode,
refreshTree,
sidebarQuery,
],
);
const handleMovePrompt = useCallback(
async (node: DocumentNode) => {
openMoveEmbedPicker(node, "move");
},
[openMoveEmbedPicker],
);
const handleDelete = useCallback(
async (documentId: string) => {
await fetch("/api/documents/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId }),
});
await refreshTree();
emitDocumentsChanged(documentId);
if (activeId === documentId) {
router.push("/");
}
},
[activeId, refreshTree, router],
);
const handleDeleteFromContextMenuNode = useCallback(
async (node: DocumentNode) => {
if (viewMode === "filesystem") {
await handleDeleteFileTreeSelection();
return;
}
const ok = window.confirm("确认删除该页面到垃圾桶吗?");
if (!ok) return;
try {
await handleDelete(node.id);
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
}
},
[handleDelete, handleDeleteFileTreeSelection, viewMode],
);
const handleDeleteFromAssetContextMenu = useCallback(
async (assetIds: string[], assetHint?: MediaAsset) => {
const uniqueAssetIds = Array.from(new Set(assetIds));
if (uniqueAssetIds.length === 0) return;
const assets = uniqueAssetIds
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
.filter(Boolean) as MediaAsset[];
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
assets.unshift(assetHint);
}
const mindmapCount = assets.filter((item) => item.asset_type === "mindmap").length;
const tableCount = assets.filter((item) => item.asset_type === "luckysheet").length;
const fileCount = assets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length;
const unknownCount = Math.max(0, uniqueAssetIds.length - mindmapCount - tableCount - fileCount);
const parts: string[] = [];
if (fileCount > 0) parts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`);
if (mindmapCount > 0) parts.push(`${mindmapCount} 个思维导图(删除)`);
if (tableCount > 0) parts.push(`${tableCount} 个在线表格(删除)`);
if (unknownCount > 0) parts.push(`${unknownCount} 个对象(删除)`);
const ok = window.confirm(`确认删除选中的 ${parts.join(" + ")} 吗?`);
if (!ok) return;
try {
await handleDeleteAssets(uniqueAssetIds, assetHint);
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
}
},
[handleDeleteAssets, mediaAssets, mindmapAssets, tableAssets],
);
const handleConvertToChild = useCallback(
async (documentId: string) => {
const flat = flattenedPrivate;
const currentIndex = flat.findIndex((item) => item.node.id === documentId);
if (currentIndex <= 0) {
return;
}
const targetParentId = flat[currentIndex - 1].node.id;
await handleMove(documentId, targetParentId, flat[currentIndex - 1].node.children.length);
},
[flattenedPrivate, handleMove],
);
const confirmTrashAction = useCallback(
(message: string) => {
if (!trashConfirm) {
return true;
}
return window.confirm(message);
},
[trashConfirm],
);
const handleRestoreFromTrash = useCallback(
async (documentId: string) => {
if (!confirmTrashAction("确认恢复该页面吗?")) {
return;
}
await fetch("/api/documents/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId }),
});
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
},
[confirmTrashAction, refreshTree, sidebarQuery],
);
const handlePurgeFromTrash = useCallback(
async (documentId: string) => {
if (!confirmTrashAction("彻底删除后将无法找回,是否继续?")) {
return;
}
await fetch("/api/documents/purge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId }),
});
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
},
[confirmTrashAction, refreshTree, sidebarQuery],
);
const handleEmptyTrash = useCallback(async () => {
if (!sidebarData.activeWorkspaceId) {
window.alert("暂无可清空的工作空间");
return;
}
if (!confirmTrashAction("清空垃圾桶后将无法恢复,是否继续?")) {
return;
}
setEmptyingTrash(true);
try {
const response = await fetch("/api/documents/empty-trash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "清空垃圾桶失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
} finally {
setEmptyingTrash(false);
}
}, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]);
const handleRestoreMediaAssetFromTrash = useCallback(
async (assetId: string) => {
if (!confirmTrashAction("确认恢复该附件吗?")) {
return;
}
const assetHint =
(filteredTrashedMediaAssets ?? []).find((a) => a.id === assetId) ??
(sidebarData.trashedMediaAssets ?? []).find((a) => a.id === assetId) ??
null;
const response = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "restore", assetIds: [assetId] }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "恢复附件失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
// 重要:删除附件时会同步移除主编辑区块;恢复后向编辑器广播“恢复事件”,由编辑器决定是否插入。
let assetForInsert = assetHint as any;
if (assetHint?.document_id && String(assetHint.document_id) === String(activeId)) {
const fallback = (assetHint.signed_url ?? assetHint.file_url ?? "").trim();
let fileUrl = fallback;
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) {
assetForInsert = {
...(assetHint as any),
id: assetId,
file_url: fileUrl,
thumbnail_url: (assetHint.thumbnail_url ?? fileUrl) as any,
} as any;
editorBridge?.insertMediaAsset?.(assetForInsert);
}
}
if (assetHint?.document_id) {
emitAssetsRestored({ docId: String(assetHint.document_id), kind: "media", assetId, asset: assetForInsert });
}
},
[
activeId,
confirmTrashAction,
editorBridge,
filteredTrashedMediaAssets,
refreshTree,
sidebarData.trashedMediaAssets,
sidebarQuery,
],
);
const handleRestoreTableFromTrash = useCallback(
async (tableId: string) => {
if (!confirmTrashAction("确认恢复该在线表格吗?")) {
return;
}
const tableHint =
(filteredTrashedMediaAssets ?? []).find((a) => a.id === tableId && a.asset_type === "luckysheet") ??
(sidebarData.trashedTableAssets ?? []).find((a) => a.id === tableId) ??
null;
const response = await fetch("/api/tables/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tableId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "恢复在线表格失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
// 重要:删除在线表格会同步移除主编辑区块;恢复后若目标页面正打开,则把表格重新插入主编辑区。
if (tableHint?.document_id && String(tableHint.document_id) === String(activeId)) {
editorBridge?.insertOnlineTableAsset?.({ documentId: String(tableHint.document_id), tableId });
}
if (tableHint?.document_id) {
emitAssetsRestored({ docId: String(tableHint.document_id), kind: "table", tableId });
}
},
[
activeId,
confirmTrashAction,
editorBridge,
filteredTrashedMediaAssets,
refreshTree,
sidebarData.trashedTableAssets,
sidebarQuery,
],
);
const handlePurgeMediaAssetFromTrash = useCallback(
async (assetId: string) => {
if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) {
return;
}
const response = await fetch("/api/media/purge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "彻底删除附件失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
},
[confirmTrashAction, refreshTree, sidebarQuery],
);
const handlePurgeTableFromTrash = useCallback(
async (tableId: string) => {
if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) {
return;
}
const response = await fetch("/api/tables/purge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tableId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "彻底删除在线表格失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
},
[confirmTrashAction, refreshTree, sidebarQuery],
);
const handleEmptyMediaTrash = useCallback(async () => {
if (!sidebarData.activeWorkspaceId) {
window.alert("暂无可清空的工作空间");
return;
}
if (!confirmTrashAction("清空附件垃圾桶后将无法恢复,是否继续?")) {
return;
}
setEmptyingTrash(true);
try {
const [mediaResp, mindmapResp, tableResp] = await Promise.all([
fetch("/api/media/empty-trash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
}),
fetch("/api/mindmap-trash/empty", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
}),
fetch("/api/tables/empty-trash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
}),
]);
if (!mediaResp.ok) {
const payload = await mediaResp.json().catch(() => ({}));
window.alert(payload?.error ?? "清空附件垃圾桶失败,请稍后再试");
return;
}
if (!mindmapResp.ok) {
const payload = await mindmapResp.json().catch(() => ({}));
window.alert(payload?.error ?? "清空思维导图垃圾桶失败,请稍后再试");
return;
}
if (!tableResp.ok) {
const payload = await tableResp.json().catch(() => ({}));
window.alert(payload?.error ?? "清空在线表格垃圾桶失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
} finally {
setEmptyingTrash(false);
}
}, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]);
const handleRestoreMindmapFromTrash = useCallback(
async (documentId: string, mindmapId: string) => {
if (!confirmTrashAction("确认恢复该思维导图吗?")) {
return;
}
const response = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "restore" }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "恢复思维导图失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
// 重要:删除思维导图会同步移除主编辑区块;恢复后若目标页面正打开,则把导图重新插入主编辑区。
if (documentId && String(documentId) === String(activeId)) {
editorBridge?.insertMindmapAsset?.({ documentId, mindmapId });
}
if (documentId) {
emitAssetsRestored({ docId: String(documentId), kind: "mindmap", mindmapId });
}
},
[activeId, confirmTrashAction, editorBridge, refreshTree, sidebarQuery],
);
const handlePurgeMindmapFromTrash = useCallback(
async (documentId: string, mindmapId: string) => {
if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) {
return;
}
const response = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "purge" }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "彻底删除思维导图失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
},
[confirmTrashAction, refreshTree, sidebarQuery],
);
const handleSignOut = useCallback(async () => {
if (signingOut) {
return;
}
if (!window.confirm("确认退出登录吗?")) {
return;
}
setSigningOut(true);
try {
await signOut();
router.replace("/auth");
router.refresh();
} catch (error: any) {
window.alert(`退出登录失败:${error?.message ?? "请稍后再试"}`);
} finally {
setSigningOut(false);
}
}, [router, signOut, signingOut]);
const openContextMenu = useCallback((event: React.MouseEvent, node: DocumentNode) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({
node,
x: event.clientX,
y: event.clientY,
});
}, []);
const openShareDialog = useCallback((node: DocumentNode) => {
setShareTarget({
id: node.id,
title: node.title ?? null,
workspaceId: node.workspace_id,
allowIncludeDescendants: (node.children?.length ?? 0) > 0,
});
setShareDialogOpen(true);
}, []);
const handleTopButtonClick = useCallback(
(buttonId: (typeof TOP_BUTTONS)[number]["id"]) => {
if (buttonId === "search") {
openSearchPalette();
return;
}
if (
buttonId === "starred" ||
buttonId === "public" ||
buttonId === "shared" ||
buttonId === "templates"
) {
setViewMode("section");
setTopPanel((prev) => (prev === buttonId ? null : buttonId));
return;
}
if (buttonId === "members") {
setGroupManagerOpen(true);
return;
}
window.alert("该功能即将上线,敬请期待");
},
[
openSearchPalette,
setViewMode,
],
);
useEffect(() => {
if (!contextMenu) {
return;
}
const closeMenu = () => setContextMenu(null);
window.addEventListener("click", closeMenu);
return () => window.removeEventListener("click", closeMenu);
}, [contextMenu]);
const sidebarBody = (
<div className="flex h-full min-w-0 flex-col">
<div className="border-b border-[#f1f1f1] p-3">
<div className="text-xs text-gray-400">当前工作空间</div>
<div className="mt-1 flex items-center justify-between">
<div>
<div className="text-base font-semibold text-gray-900">{activeWorkspace?.name ?? "我的空间"}</div>
<div className="text-xs text-gray-500">{activeWorkspace?.type === "team" ? "团队空间" : "个人空间"}</div>
</div>
<button
type="button"
className="rounded-md border border-[#e1e1e1] px-2 py-1 text-xs text-gray-400"
onClick={(event) => {
event.preventDefault();
window.alert("工作空间切换功能暂时停用(正在修复中)。");
}}
>
切换(暂停)
</button>
</div>
<div className="mt-2 text-xs text-gray-400">
提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
</div>
<div className="mt-2">
<button
type="button"
className="flex items-center gap-2 rounded-md px-2 py-1 text-xs text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void handleSignOut()}
disabled={signingOut}
>
<LogOut className="h-4 w-4" />
<span>{signingOut ? "正在退出..." : "退出登录"}</span>
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-2 border-b border-[#f1f1f1] p-3">
{TOP_BUTTONS.map((button) => (
<button
key={button.id}
type="button"
title={button.label}
aria-label={button.label}
className="flex h-12 items-center justify-center rounded-md border border-transparent text-gray-500 hover:border-[#d6e3ff] hover:bg-[#f5f7fb] hover:text-[#2563eb]"
onClick={() => handleTopButtonClick(button.id)}
>
<button.icon className="h-5 w-5" />
<span className="sr-only">{button.label}</span>
</button>
))}
</div>
{topPanel ? (
<div className="border-b border-[#f1f1f1] bg-white px-3 pb-3">
<div className="flex items-center gap-2 px-1 py-2 text-sm font-medium text-gray-600">
{SECTION_ICONS[topPanel]}
{topPanel === "starred"
? "星标置顶"
: topPanel === "public"
? "公共页面"
: topPanel === "shared"
? "共享页面"
: "模板中心"}
<span className="ml-auto text-xs text-gray-400">再次点击图标可收起</span>
</div>
<div className="max-h-52 overflow-auto rounded-md border border-[#eff2f6] bg-white px-2 py-2">
{(() => {
const renderList = (nodes: DocumentNode[]) => {
const flat = flattenDocumentTree(nodes, collectNodeIds(nodes));
if (flat.length === 0) {
return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
}
return (
<div className="space-y-1">
{flat.map(({ node, depth }) => (
<Link
key={node.id}
href={`/documents/${node.id}`}
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
style={{ paddingLeft: 8 + depth * 12 }}
>
{node.title || "无标题"}
</Link>
))}
</div>
);
};
if (topPanel === "shared") {
const groupByWorkspace = (
rows: Array<{
workspaceId: string;
workspaceName: string | null;
documentId: string;
documentTitle: string | null;
includeDescendants: boolean;
permission?: "read" | "edit";
sharedWithCount?: number;
}>,
) => {
const map = new Map<string, { workspaceName: string | null; rows: typeof rows }>();
for (const r of rows) {
const existing = map.get(r.workspaceId);
if (!existing) {
map.set(r.workspaceId, { workspaceName: r.workspaceName ?? null, rows: [r] });
} else {
existing.rows.push(r);
}
}
return Array.from(map.entries()).map(([workspaceId, v]) => ({
workspaceId,
workspaceName: v.workspaceName,
rows: v.rows,
}));
};
const renderShareRows = (rows: Array<any>) => {
if (!rows || rows.length === 0) {
return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
}
const groups = groupByWorkspace(rows);
return (
<div className="space-y-3">
{groups.map((g) => (
<div key={g.workspaceId}>
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
{g.workspaceName ?? g.workspaceId}
</div>
<div className="space-y-1">
{g.rows.map((r) => (
<Link
key={`${r.workspaceId}:${r.documentId}`}
href={`/documents/${r.documentId}`}
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
>
{r.documentTitle || "无标题"}
{typeof r.permission === "string" ? (
<span className="ml-2 text-xs text-gray-400">
{r.permission === "edit" ? "可编辑" : "只读"}
</span>
) : null}
{typeof r.sharedWithCount === "number" ? (
<span className="ml-2 text-xs text-gray-400">{r.sharedWithCount} </span>
) : null}
</Link>
))}
</div>
</div>
))}
</div>
);
};
return (
<div className="space-y-3">
{shareSummaryError ? (
<div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">{shareSummaryError}</div>
) : null}
<div>
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
共享给我的({shareSummary?.incoming?.length ?? 0}
</div>
{renderShareRows(shareSummary?.incoming ?? [])}
</div>
<div className="border-t border-[#f1f1f1] pt-2">
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
我共享出去的({shareSummary?.outgoing?.length ?? 0}
</div>
{renderShareRows(shareSummary?.outgoing ?? [])}
</div>
</div>
);
}
if (topPanel === "public") {
const toggleGroup = (groupId: string) => {
setOpenPublicGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
return next;
});
};
return (
<div className="space-y-3">
{groupPublicError ? (
<div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">{groupPublicError}</div>
) : null}
<div>
<div className="px-2 pb-1 text-xs font-medium text-gray-500">全员公开</div>
{renderList(publicNodes)}
</div>
<div className="border-t border-[#f1f1f1] pt-2">
<div className="px-2 pb-1 text-xs font-medium text-gray-500">群组公开</div>
{groupPublicSummary.length === 0 ? (
<div className="px-2 py-2 text-xs text-gray-400">暂无群组公开页面</div>
) : (
<div className="space-y-1">
{groupPublicSummary.map((g) => {
const isOpen = openPublicGroups.has(g.groupId);
const nodes = publicGroupNodesByGroupId.get(g.groupId) ?? [];
return (
<div key={g.groupId} className="rounded-md border border-[#eff2f6]">
<button
type="button"
className="flex w-full items-center justify-between px-2 py-2 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => toggleGroup(g.groupId)}
>
<span className="flex min-w-0 items-center gap-2">
<ChevronRight
className={cn(
"h-4 w-4 text-gray-400 transition-transform",
isOpen && "rotate-90",
)}
/>
<span className="truncate">{g.groupName ?? "未命名群组"}</span>
</span>
<span className="text-xs text-gray-400">{g.documents.length}</span>
</button>
{isOpen ? <div className="px-1 pb-2">{renderList(nodes)}</div> : null}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
const nodes = topPanel === "starred" ? starredNodes : templateNodes;
return renderList(nodes);
})()}
</div>
</div>
) : null}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex h-full min-w-0 flex-col overflow-y-auto overflow-x-hidden">
<div className="border-b border-[#f1f1f1] p-3">
<div className="flex items-center gap-2 text-sm text-gray-500">
<Library className="h-4 w-4" />
页面树
</div>
<div className="mt-2 flex items-center gap-2">
<Input
className="h-8 flex-1 rounded-md border-[#eeeeee]"
placeholder="搜索页面"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<div className="flex rounded-md border border-[#e5e7eb] bg-white">
<button
type="button"
className={cn(
"px-3 py-1 text-xs",
viewMode === "section" ? "bg-[#2563eb] text-white" : "text-gray-600",
)}
onClick={() => setViewMode("section")}
>
分组
</button>
<button
type="button"
className={cn(
"px-3 py-1 text-xs",
viewMode === "filesystem" ? "bg-[#2563eb] text-white" : "text-gray-600",
)}
onClick={() => setViewMode("filesystem")}
>
文件
</button>
</div>
</div>
</div>
{viewMode === "section" ? (
<>
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<button
type="button"
className="flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
onClick={() => toggleSection("private")}
>
<span>私有 / 我的页面</span>
<MoreHorizontal className="h-4 w-4 text-gray-400" />
</button>
{!collapsedSections.private ? (
<div className="flex-1 px-1 pb-2">
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
<PrivateTree
nodes={filteredPrivateTree}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
/>
</div>
</div>
) : (
<div className="px-4 pb-2 text-xs text-gray-400">已折叠</div>
)}
</div>
</>
) : (
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<div className="flex-1 px-1 pb-2">
<div
ref={fileTreeContainerRef}
data-testid="file-tree-container"
className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white"
>
<FileTree
rows={fileTreeRows}
activeId={activeId}
selectedRowIds={fileTreeSelection.selectedRowIds}
onRowClick={handleFileTreeRowClick}
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
onRowContextMenu={handleFileTreeRowContextMenu}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
onToggleExpand={toggleExpand}
onToggleAssetFolderExpand={toggleAssetFolderExpand}
onCreateChild={handleCreate}
onBlankMouseDown={handleFileTreeBlankMouseDown}
onDropFiles={handleFileTreeDropFiles}
onInternalDrop={handleFileTreeInternalDrop}
/>
</div>
</div>
</div>
)}
<div className="border-t border-[#f1f1f1] p-3">
<Button className="w-full justify-center gap-2" variant="outline" onClick={() => handleCreate(null)}>
<Plus className="h-4 w-4" />
新建页面
</Button>
<button
type="button"
className="mt-3 flex w-full items-center justify-between rounded-md border border-[#e8e8e8] px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => setTrashOpen(true)}
>
<span className="flex items-center gap-2">
<Trash2 className="h-4 w-4 text-gray-500" />
垃圾桶
</span>
<span className="text-xs text-gray-400">
{sidebarData.trashedDocuments.length +
(sidebarData.trashedMediaAssets?.length ?? 0) +
(sidebarData.trashedMindmapAssets?.length ?? 0) +
(sidebarData.trashedTableAssets?.length ?? 0)}{" "}
</span>
</button>
</div>
</div>
</div>
</div>
);
return (
<>
<aside className="hidden h-full min-w-0 overflow-hidden md:flex shrink-0" style={{ width }}>
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-r border-wolai-border bg-wolai-bg-sidebar">
{sidebarBody}
</div>
<div className="w-1 cursor-col-resize bg-transparent" onMouseDown={handleResizeStart} />
</aside>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="max-h-[92vh]">
<DrawerHeader className="text-left">
<DrawerTitle>页面目录</DrawerTitle>
</DrawerHeader>
<div className="px-4 pb-6">{sidebarBody}</div>
</DrawerContent>
</Drawer>
{contextMenu && (
<ContextMenu
contextMenu={contextMenu}
onClose={() => setContextMenu(null)}
onOpenRight={(node) => handleOpenDocument(node.id, "sidebar")}
onShare={openShareDialog}
onMove={handleMovePrompt}
onEmbed={handleEmbedPrompt}
onCopyLink={handleCopyLink}
onCopyReference={handleCopyReference}
onCopyId={handleCopyId}
onDuplicate={handleDuplicateDocument}
onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)}
onCreateChild={() => void handleCreate(contextMenu.node.id)}
onConvertChild={() => void handleConvertToChild(contextMenu.node.id)}
onDelete={() => void handleDeleteFromContextMenuNode(contextMenu.node)}
/>
)}
{shareTarget && (
<DocumentShareDialog
open={shareDialogOpen}
onOpenChange={(nextOpen) => {
setShareDialogOpen(nextOpen);
if (!nextOpen) {
setShareTarget(null);
}
}}
documentId={shareTarget.id}
documentTitle={shareTarget.title}
workspaceId={shareTarget.workspaceId}
allowIncludeDescendants={shareTarget.allowIncludeDescendants}
onChanged={async () => {
await refreshTree();
await refreshShareSummary();
await refreshGroupPublicSummary();
}}
/>
)}
<GroupManagerDialog
open={groupManagerOpen}
onOpenChange={setGroupManagerOpen}
workspaceId={sidebarData.activeWorkspaceId || ""}
/>
<MoveEmbedPickerDialog
open={moveEmbedOpen}
onOpenChange={setMoveEmbedOpen}
workspaceId={sidebarData.activeWorkspaceId ?? null}
defaultMode={moveEmbedMode}
excludeIds={moveEmbedSource?.id ? [moveEmbedSource.id] : []}
onPick={async (pickedMode, targetId) => {
const source = moveEmbedSource;
if (!source) {
return;
}
if (pickedMode === "move") {
await handleMove(source.id, targetId, 0);
return;
}
if (!targetId) {
return;
}
if (typeof window !== "undefined" && source.id === targetId) {
window.alert("不能嵌入到自身页面");
return;
}
const response = await fetch("/api/documents/embed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: source.id, targetId }),
});
if (!response.ok) {
if (typeof window !== "undefined") {
window.alert("嵌入失败,请检查目标页面");
}
return;
}
if (typeof window !== "undefined") {
window.alert("已在目标页面末尾插入引用块");
}
}}
/>
{assetMenu && (
<AssetContextMenu
asset={assetMenu.asset}
position={{ x: assetMenu.x, y: assetMenu.y }}
onClose={() => setAssetMenu(null)}
onOpen={handleOpenAsset}
onCopyLink={handleCopyAssetLink}
onCopyPath={handleCopyAssetPath}
onRename={handleRenameAsset}
onMove={handleMoveAsset}
onDelete={(assetIds) => void handleDeleteFromAssetContextMenu(assetIds, assetMenu.asset)}
onDownload={handleDownloadAsset}
/>
)}
<Drawer open={trashOpen} onOpenChange={setTrashOpen}>
<DrawerContent className="max-h-[90vh] overflow-hidden">
<div className="flex max-h-[90vh] flex-col">
<DrawerHeader className="text-left">
<DrawerTitle>垃圾桶</DrawerTitle>
{trashTab === "documents" ? (
<p className="mt-1 text-xs text-gray-500">180 天内的记录都可以在这里恢复。</p>
) : (
<p className="mt-1 text-xs text-gray-500">
附件删除后 10 分钟内可撤销;也可在此手动清空(不可撤销)。
</p>
)}
</DrawerHeader>
<div className="flex-1 overflow-y-auto">
<div className="space-y-4 px-4 pb-6">
<div className="flex gap-2">
<button
type="button"
className={`rounded-md border px-3 py-1 text-sm ${
trashTab === "documents"
? "border-[#d7e5ff] bg-[#eff5ff] text-[#2563eb]"
: "border-[#e8e8e8] text-gray-600 hover:bg-gray-50"
}`}
onClick={() => setTrashTab("documents")}
>
页面 ({sidebarData.trashedDocuments.length})
</button>
<button
type="button"
className={`rounded-md border px-3 py-1 text-sm ${
trashTab === "assets"
? "border-[#d7e5ff] bg-[#eff5ff] text-[#2563eb]"
: "border-[#e8e8e8] text-gray-600 hover:bg-gray-50"
}`}
onClick={() => setTrashTab("assets")}
>
附件 (
{(sidebarData.trashedMediaAssets?.length ?? 0) +
(sidebarData.trashedMindmapAssets?.length ?? 0) +
(sidebarData.trashedTableAssets?.length ?? 0)}
)
</button>
</div>
<Input
placeholder={trashTab === "documents" ? "搜索删除的页面..." : "搜索删除的附件..."}
value={trashSearch}
onChange={(event) => setTrashSearch(event.target.value)}
/>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-gray-600">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={trashConfirm}
onChange={(event) => setTrashConfirm(event.target.checked)}
/>
操作确认
</label>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => void (trashTab === "documents" ? handleEmptyTrash() : handleEmptyMediaTrash())}
disabled={emptyingTrash}
>
{emptyingTrash
? "清空中..."
: trashTab === "documents"
? "清空垃圾桶"
: "清空附件垃圾桶"}
</Button>
</div>
<div className="rounded-lg border border-[#eaeaea]">
{trashTab === "documents" ? (
filteredTrash.length === 0 ? (
<div className="p-4 text-sm text-gray-400">暂无已删除页面</div>
) : (
filteredTrash.map((item) => (
<div
key={item.id}
className="flex items-center justify-between border-b border-[#f3f3f3] px-3 py-2 text-sm last:border-b-0"
>
<div>
<div className="font-medium text-gray-800">{item.title || "无标题"}</div>
<div className="text-xs text-gray-400">
删除时间:{new Date(item.deleted_at).toLocaleString()}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
className="rounded-md border border-[#d7e5ff] px-2 py-1 text-xs text-[#2563eb] hover:bg-[#eff5ff]"
onClick={() => void handleRestoreFromTrash(item.id)}
>
恢复
</button>
<button
type="button"
className="rounded-md border border-red-100 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
onClick={() => void handlePurgeFromTrash(item.id)}
>
彻底删除
</button>
</div>
</div>
))
)
) : filteredTrashedMediaAssets.length === 0 ? (
<div className="p-4 text-sm text-gray-400">暂无已删除附件</div>
) : (
filteredTrashedMediaAssets.map((item) => (
<div
key={item.id}
className="flex items-center justify-between border-b border-[#f3f3f3] px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0 pr-2">
<div className="truncate font-medium text-gray-800">{item.file_name || "未命名附件"}</div>
<div className="text-xs text-gray-400">
删除时间:{item.deleted_at ? new Date(item.deleted_at).toLocaleString() : "-"}
</div>
<div className="text-xs text-gray-400">
类型:{item.asset_type === "mindmap" ? "思维导图" : (item.mime_type ?? item.asset_type ?? "unknown")}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
className="rounded-md border border-[#d7e5ff] px-2 py-1 text-xs text-[#2563eb] hover:bg-[#eff5ff]"
onClick={() =>
void (item.asset_type === "mindmap"
? handleRestoreMindmapFromTrash(item.document_id, item.id)
: item.asset_type === "luckysheet"
? handleRestoreTableFromTrash(item.id)
: handleRestoreMediaAssetFromTrash(item.id))
}
>
恢复
</button>
<button
type="button"
className="rounded-md border border-red-100 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
onClick={() =>
void (item.asset_type === "mindmap"
? handlePurgeMindmapFromTrash(item.document_id, item.id)
: item.asset_type === "luckysheet"
? handlePurgeTableFromTrash(item.id)
: handlePurgeMediaAssetFromTrash(item.id))
}
>
彻底删除
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
</>
);
}
interface SectionListProps {
label: string;
icon: React.ReactNode;
nodes: DocumentNode[];
collapsed: boolean;
onToggle: () => void;
}
function SectionList({ label, icon, nodes, collapsed, onToggle }: SectionListProps) {
return (
<div className="border-b border-[#f5f5f5]">
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
onClick={onToggle}
>
<span className="flex items-center gap-2">
{icon}
{label}
</span>
<span className="text-xs text-gray-400">{nodes.length}</span>
</button>
{!collapsed && (
<div className="space-y-1 px-4 pb-3">
{nodes.length === 0 ? (
<div className="text-xs text-gray-400">暂无内容</div>
) : (
nodes.map((node) => (
<Link
key={node.id}
href={`/documents/${node.id}`}
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
>
{node.title || "无标题"}
</Link>
))
)}
</div>
)}
</div>
);
}
interface ContextMenuProps {
contextMenu: ContextMenuState;
onClose: () => void;
onOpenRight: (node: DocumentNode) => void;
onShare: (node: DocumentNode) => void;
onMove: (node: DocumentNode) => void;
onEmbed: (node: DocumentNode) => void;
onCopyLink: (node: DocumentNode, withTitle?: boolean) => void;
onCopyReference: (node: DocumentNode, mode: "inline" | "embed") => void;
onCopyId: (node: DocumentNode) => void;
onDuplicate: (node: DocumentNode) => void;
onRename: () => void;
onCreateChild: () => void;
onConvertChild: () => void;
onDelete: () => void;
}
function ContextMenu({
contextMenu,
onClose,
onOpenRight,
onShare,
onMove,
onEmbed,
onCopyLink,
onCopyReference,
onCopyId,
onDuplicate,
onRename,
onCreateChild,
onConvertChild,
onDelete,
}: ContextMenuProps) {
const { node } = contextMenu;
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: contextMenu.y, left: contextMenu.x });
useLayoutEffect(() => {
const clampPosition = () => {
const element = menuRef.current;
if (!element) {
setPosition({ top: contextMenu.y, left: contextMenu.x });
return;
}
const rect = element.getBoundingClientRect();
const padding = 12;
const maxLeft = Math.max(padding, window.innerWidth - rect.width - padding);
const maxTop = Math.max(padding, window.innerHeight - rect.height - padding);
const left = Math.min(Math.max(padding, contextMenu.x), maxLeft);
const top = Math.min(Math.max(padding, contextMenu.y), maxTop);
setPosition({ top, left });
};
clampPosition();
window.addEventListener("resize", clampPosition);
return () => window.removeEventListener("resize", clampPosition);
}, [contextMenu]);
const handleAction = (action: () => void) => {
action();
onClose();
};
const buttonClass =
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-700 hover:bg-[#f5f7fb]";
return (
<div
ref={menuRef}
className="fixed z-50 rounded-xl border border-[#ececec] bg-white p-1 text-sm text-gray-700 shadow-2xl"
style={{ top: position.top, left: position.left, minWidth: 220 }}
>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onOpenRight(node))}
>
<PanelRightOpen className="h-4 w-4 text-[#2563eb]" />
<span>在右侧边栏打开</span>
<span className="ml-auto text-[11px] text-gray-400">Alt + O</span>
</button>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onShare(node))}
>
<Share2 className="h-4 w-4 text-gray-500" />
<span>共享...</span>
</button>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onMove(node))}
>
<ArrowRightLeft className="h-4 w-4 text-gray-500" />
<span>移动到...</span>
</button>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onEmbed(node))}
>
<GitMerge className="h-4 w-4 text-gray-500" />
<span>嵌入到...</span>
</button>
<div className="relative">
<button type="button" className={`${buttonClass} pr-6`} onClick={() => handleAction(() => onCopyLink(node))}>
<LinkIcon className="h-4 w-4 text-gray-500" />
<span>复制访问链接</span>
</button>
<button
type="button"
className={`${buttonClass} pr-6`}
onClick={() => handleAction(() => onCopyLink(node, true))}
>
<LinkIcon className="h-4 w-4 text-gray-500" />
<span>复制访问链接(带标题)</span>
</button>
</div>
<div className="relative group">
<button type="button" className={`${buttonClass} pr-6`}>
<Copy className="h-4 w-4 text-gray-500" />
<span>复制页面引用链接</span>
<ChevronRight className="ml-auto h-3.5 w-3.5 text-gray-400" />
</button>
<div className="invisible absolute left-full top-0 z-50 ml-1 min-w-[180px] rounded-md border border-[#e5e5e5] bg-white py-1 shadow-xl group-hover:visible">
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onCopyReference(node, "inline"))}
>
(( 行内页面引用
</button>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onCopyReference(node, "embed"))}
>
{"{{ 嵌入页面引用"}
</button>
</div>
</div>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onCopyId(node))}
>
<Hash className="h-4 w-4 text-gray-500" />
<span>复制页面 ID</span>
</button>
<button
type="button"
className={buttonClass}
onClick={() => handleAction(() => onDuplicate(node))}
>
<Copy className="h-4 w-4 text-gray-500" />
<span>拷贝副本</span>
</button>
<div className="my-1 border-t border-[#f2f2f2]" />
<button type="button" className={buttonClass} onClick={() => handleAction(onRename)}>
<Edit3 className="h-4 w-4 text-gray-500" />
<span>重命名</span>
</button>
<button type="button" className={buttonClass} onClick={() => handleAction(onCreateChild)}>
<Plus className="h-4 w-4 text-gray-500" />
<span>新建子页面</span>
</button>
<button type="button" className={buttonClass} onClick={() => handleAction(onConvertChild)}>
<ArrowUpRight className="h-4 w-4 text-gray-500" />
<span>转为上一个子页面</span>
</button>
<button
type="button"
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
onClick={() => handleAction(onDelete)}
>
<Trash2 className="h-4 w-4" />
<span>删除到垃圾桶</span>
</button>
</div>
);
}
const cloneNodes = (nodes: DocumentNode[]): DocumentNode[] =>
nodes.map((node) => ({
...node,
children: cloneNodes(node.children),
}));
const removeNode = (
nodes: DocumentNode[],
targetId: string,
): { removed: DocumentNode | null; tree: DocumentNode[] } => {
let removed: DocumentNode | null = null;
const nextTree = nodes
.map((node) => {
if (removed) return node;
if (node.id === targetId) {
removed = node;
return null;
}
const { removed: childRemoved, tree: childTree } = removeNode(node.children, targetId);
if (childRemoved) {
removed = childRemoved;
return { ...node, children: childTree };
}
return node;
})
.filter(Boolean) as DocumentNode[];
return { removed, tree: nextTree };
};
const insertNode = (nodes: DocumentNode[], parentId: string | null, index: number, newNode: DocumentNode): DocumentNode[] => {
if (!parentId) {
const next = [...nodes];
next.splice(Math.min(index, next.length), 0, newNode);
return next;
}
return nodes.map((node) => {
if (node.id === parentId) {
const children = [...node.children];
const targetIndex = Math.min(index, children.length);
children.splice(targetIndex, 0, newNode);
return { ...node, children };
}
return { ...node, children: insertNode(node.children, parentId, index, newNode) };
});
};
const filterTree = (nodes: DocumentNode[], keyword: string): DocumentNode[] => {
if (!keyword) {
return nodes;
}
const filtered: DocumentNode[] = [];
nodes.forEach((node) => {
const childMatches = filterTree(node.children, keyword);
const title = (node.title ?? "").toLowerCase();
if (title.includes(keyword) || childMatches.length > 0) {
filtered.push({ ...node, children: childMatches });
}
});
return filtered;
};
function collectNodeIds(nodes: DocumentNode[], bag: Set<string> = new Set()): Set<string> {
nodes.forEach((node) => {
bag.add(node.id);
collectNodeIds(node.children, bag);
});
return bag;
}
const buildDocumentUrl = (documentId: string): string => {
if (typeof window === "undefined" || !window.location) {
return `/documents/${documentId}`;
}
return `${window.location.origin}/documents/${documentId}`;
};
const buildMindmapUrl = (documentId: string, mindmapId: string): string => {
if (typeof window === "undefined" || !window.location) {
return `/mindmap/${documentId}/${mindmapId}`;
}
return `${window.location.origin}/mindmap/${documentId}/${mindmapId}`;
};
const buildTableUrl = (tableId: string): string => {
if (typeof window === "undefined" || !window.location) {
return `/tables/${tableId}/view`;
}
return `${window.location.origin}/tables/${tableId}/view`;
};
const copyText = 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);
};