Files
mnote/wolai-frontend/src/components/sidebar/sidebar.tsx
T
2026-01-11 12:35:53 +08:00

2475 lines
89 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 {
ArrowRightLeft,
ArrowUpRight,
ChevronRight,
Copy,
Edit3,
GitMerge,
Globe,
Hash,
LayoutGrid,
Library,
Link as LinkIcon,
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 { useSidebarData } from "@/hooks/use-sidebar-data";
import { PrivateTree } from "@/components/sidebar/private-tree";
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
import { supabaseBrowser } from "@/lib/supabase/client";
import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { FileTree } from "@/components/sidebar/file-tree";
import { buildVisibleRows } from "@/lib/file-tree/rows";
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
import { 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 {
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, emitDocumentsChanged } from "@/lib/events";
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 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) {
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } =
useSidebarStore();
const sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen);
const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray);
const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen);
const viewMode = useSidebarStore((state) => state.viewMode);
const setViewMode = useSidebarStore((state) => state.setViewMode);
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
const sidebarQuery = useSidebarData(initialData);
const sidebarData = sidebarQuery.data ?? initialData;
const segments = useSelectedLayoutSegments();
const router = useRouter();
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 [workspaceMenuOpen, setWorkspaceMenuOpen] = 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 [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
selectedRowIds: new Set(),
anchorRowId: null,
focusedRowId: null,
}));
const workspaceMenuRef = useRef<HTMLDivElement>(null);
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
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(() => {
setTableAssets(sidebarData.tableAssets ?? []);
}, [sidebarData.tableAssets]);
useEffect(() => {
setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null });
}, [mediaAssets, mindmapAssets, tableAssets, sidebarData.documents]);
useEffect(() => {
setOpen(false);
}, [activeId, setOpen]);
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();
};
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]);
useEffect(() => {
const onSaved = () => void sidebarQuery.refetch();
const onDeleted = () => 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]);
useEffect(() => {
const channel = supabaseBrowser
.channel("documents-feed")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "documents" },
() => {
void refreshTree();
},
)
.subscribe();
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [refreshTree]);
useEffect(() => {
const channel = supabaseBrowser
.channel("media-assets-feed")
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "media_assets",
filter: `workspace_id=eq.${sidebarData.activeWorkspaceId}`,
},
() => {
void sidebarQuery.refetch();
},
)
.subscribe();
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [sidebarData.activeWorkspaceId, sidebarQuery]);
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 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 ?? []),
];
const keyword = trashSearch.trim().toLowerCase();
if (!keyword) {
return assets;
}
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, 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]);
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 handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
if (typeof window === "undefined") {
return;
}
const target = window.prompt("输入希望嵌入到的页面 ID");
if (!target) {
return;
}
const response = await fetch("/api/documents/embed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: node.id, targetId: target.trim() }),
});
if (!response.ok) {
window.alert("嵌入失败,请检查目标页面 ID");
return;
}
window.alert("已在目标页面末尾插入引用块");
}, []);
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 url = asset.signed_url ?? asset.file_url;
if (!url) {
window.alert("暂无可用的文件链接");
return;
}
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}, [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) => {
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;
}
const url = asset.signed_url ?? asset.file_url;
if (!url) {
window.alert("暂无可用的下载链接");
return;
}
if (typeof window !== "undefined") {
window.open(url, "_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) => {
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[];
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
assets.unshift(assetHint);
}
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} 个页面(删除到垃圾桶)` : "";
const selectedAssets = assetIds
.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 mindmapCount = selectedAssets.filter((item) => item.asset_type === "mindmap").length;
const tableCount = selectedAssets.filter((item) => item.asset_type === "luckysheet").length;
const fileCount = selectedAssets.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} 个思维导图(删除)`);
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);
}
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 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) => 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}`);
},
[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, 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) => {
if (typeof window === "undefined") {
return;
}
const target = window.prompt("输入目标父页面 ID(留空表示移动到根目录)", node.parent_id ?? "");
if (target === null) {
return;
}
const trimmed = target.trim();
await handleMove(node.id, trimmed.length > 0 ? trimmed : null, 0);
},
[handleMove],
);
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 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 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()]);
},
[confirmTrashAction, refreshTree, 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 handleEmptyMediaTrash = useCallback(async () => {
if (!sidebarData.activeWorkspaceId) {
window.alert("暂无可清空的工作空间");
return;
}
if (!confirmTrashAction("清空附件垃圾桶后将无法恢复,是否继续?")) {
return;
}
setEmptyingTrash(true);
try {
const [mediaResp, mindmapResp] = 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 }),
}),
]);
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;
}
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()]);
},
[confirmTrashAction, 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 handleWorkspaceSwitch = useCallback(
async (workspaceId: string) => {
if (workspaceId === sidebarData.activeWorkspaceId) {
setWorkspaceMenuOpen(false);
return;
}
await fetch("/api/workspaces/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId }),
});
setWorkspaceMenuOpen(false);
await sidebarQuery.refetch();
},
[sidebarData.activeWorkspaceId, sidebarQuery],
);
const openContextMenu = useCallback((event: React.MouseEvent, node: DocumentNode) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({
node,
x: event.clientX,
y: event.clientY,
});
}, []);
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");
setSectionsTrayOpen(true);
setSectionCollapsed(buttonId, false);
return;
}
window.alert("该功能即将上线,敬请期待");
},
[openSearchPalette, setSectionCollapsed, setSectionsTrayOpen, setViewMode],
);
useEffect(() => {
if (!contextMenu) {
return;
}
const closeMenu = () => setContextMenu(null);
window.addEventListener("click", closeMenu);
return () => window.removeEventListener("click", closeMenu);
}, [contextMenu]);
useEffect(() => {
if (!workspaceMenuOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
if (
workspaceMenuRef.current &&
!workspaceMenuRef.current.contains(event.target as Node)
) {
setWorkspaceMenuOpen(false);
}
};
window.addEventListener("click", handleClickOutside);
return () => window.removeEventListener("click", handleClickOutside);
}, [workspaceMenuOpen]);
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>
<div className="relative" ref={workspaceMenuRef}>
<button
type="button"
className="rounded-md border border-[#e1e1e1] px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
onClick={(event) => {
event.preventDefault();
setWorkspaceMenuOpen((prev) => !prev);
}}
>
切换
</button>
{workspaceMenuOpen && (
<div className="absolute right-0 z-20 mt-2 w-56 rounded-md border border-[#eaeaea] bg-white shadow-lg">
{sidebarData.workspaces.map((workspace) => (
<button
type="button"
key={workspace.id}
className={cn(
"flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-gray-50",
workspace.id === activeWorkspace?.id && "bg-[#f5f7fb]",
)}
onClick={() => void handleWorkspaceSwitch(workspace.id)}
>
<span>{workspace.name}</span>
{workspace.id === activeWorkspace?.id ? (
<span className="text-xs text-[#2563eb]">当前</span>
) : (
<span className="text-xs text-gray-400">{workspace.memberCount} </span>
)}
</button>
))}
</div>
)}
</div>
</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>
<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" ? (
<>
<button
type="button"
className="flex w-full items-center justify-between border-b border-[#f1f1f1] px-4 py-3 text-sm font-medium text-gray-600 hover:bg-gray-50"
onClick={toggleSectionsTray}
>
<span className="flex items-center gap-2">
<Star className="h-4 w-4 text-[#f5a623]" />
星标 / 公共 / 共享 / 模板
</span>
<ChevronRight
className={cn(
"h-4 w-4 text-gray-400 transition-transform",
sectionsTrayOpen && "rotate-90",
)}
/>
</button>
{sectionsTrayOpen ? (
<>
<SectionList
label="星标置顶"
icon={SECTION_ICONS.starred}
nodes={starredNodes}
collapsed={collapsedSections.starred}
onToggle={() => toggleSection("starred")}
/>
<SectionList
label="公共页面"
icon={SECTION_ICONS.public}
nodes={publicNodes}
collapsed={collapsedSections.public}
onToggle={() => toggleSection("public")}
/>
<SectionList
label="共享页面"
icon={SECTION_ICONS.shared}
nodes={sharedNodes}
collapsed={collapsedSections.shared}
onToggle={() => toggleSection("shared")}
/>
<SectionList
label="模板中心"
icon={SECTION_ICONS.templates}
nodes={templateNodes}
collapsed={collapsedSections.templates}
onToggle={() => toggleSection("templates")}
/>
</>
) : null}
<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)}{" "}
</span>
</button>
</div>
</div>
</div>
</div>
);
return (
<>
<aside className="hidden h-full min-w-0 overflow-hidden md:flex" style={{ width }}>
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-r border-[#e9edf5] bg-white">
{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")}
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 handleDeleteFileTreeSelection()}
/>
)}
{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={() => void handleDeleteFileTreeSelection()}
onDownload={handleDownloadAsset}
/>
)}
<Drawer open={trashOpen} onOpenChange={setTrashOpen}>
<DrawerContent className="max-h-[90vh]">
<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="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)}
)
</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.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)
: 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)
: handlePurgeMediaAssetFromTrash(item.id))
}
>
彻底删除
</button>
</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;
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,
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(() => 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);
};