0.1.04 文件树与导入mmap

This commit is contained in:
liaibo
2026-01-07 18:38:56 +08:00
parent 103e36b1c5
commit 294d1d5fb1
28 changed files with 2703 additions and 442 deletions
@@ -464,10 +464,9 @@ const MindmapBlockView = ({
const onFsChange = () => {
const active = Boolean(document.fullscreenElement);
setFullscreenApiActive(active);
// 用户按 ESC 退出浏览器全屏时,同步退出沉浸式全屏界面
if (!active && localFullscreen) {
setLocalFullscreen(false);
}
// 注意:浏览器在打开原生对话框(例如 file picker / alert / confirm)时可能会自动退出
// Fullscreen API。此时不应退出“沉浸式全屏”UI,否则会导致用户在全屏编辑中执行导入/新建
// 等操作时被强制退出全屏。
};
document.addEventListener("fullscreenchange", onFsChange);
@@ -1432,7 +1431,7 @@ const MindmapBlockView = ({
const data = await xmindParser.default.parseXmindFile(blob, (content) => {
const list = content;
if (list.length > 1) {
window.alert("检测到 XMind 多画布,自动导入第一个画布。");
window.alert("检测到 XMind 多画布,自动导入第一个画布。");
}
return list.length > 0 ? list[0] : content;
});
@@ -1442,6 +1441,16 @@ const MindmapBlockView = ({
return;
}
// MindManager (.mmap)
if (ext === "mmap") {
const { parseMindManagerMmapFile } = await import("./mindmapMindManagerImport");
const data = await parseMindManagerMmapFile(file);
mindmap?.setData(data);
mindmap?.command.clearHistory();
debouncedPersist(data);
return;
}
// Markdown
if (ext === "md" || ext === "markdown") {
const { transformMarkdownTo } = await import(
@@ -1458,7 +1467,7 @@ const MindmapBlockView = ({
return;
}
window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .md");
window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .mmap / .md");
} catch (error) {
console.error(error);
window.alert("导入失败:文件格式或内容错误");
@@ -1798,7 +1807,7 @@ const MindmapBlockView = ({
>
<div className="fixed left-0 right-0 top-0 z-30 flex h-12 items-center justify-between border-b border-gray-200 bg-white/90 px-4 backdrop-blur">
<div className="text-sm font-medium text-gray-700">
{fullscreenApiActive ? "(全屏)" : ""}
{fullscreenApiActive ? "·真" : ""}
</div>
<button
type="button"
@@ -209,7 +209,7 @@ export const MindmapToolbar = ({
<input
ref={fileInputRef}
type="file"
accept=".json,.smm,.xmind,.md,.markdown,application/json"
accept=".json,.smm,.xmind,.mmap,.md,.markdown,application/json,application/vnd.mindjet.mindmanager,application/x-mindmanager"
className="hidden"
onChange={onImport}
/>
@@ -0,0 +1,233 @@
type MindMapData = {
data: Record<string, unknown>;
children?: MindMapData[];
};
const readBlobAsDataUrl = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = (err) => reject(err);
reader.readAsDataURL(blob);
});
const getImageSizeFromBlob = async (blob: Blob): Promise<{ width: number; height: number } | null> => {
try {
if (typeof createImageBitmap === "function") {
const bmp = await createImageBitmap(blob);
const res = { width: bmp.width, height: bmp.height };
try {
bmp.close();
} catch {
// ignore
}
return res;
}
} catch {
// ignore
}
return new Promise((resolve) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const res = { width: img.naturalWidth, height: img.naturalHeight };
URL.revokeObjectURL(url);
resolve(res);
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
img.src = url;
});
};
const walkElements = (root: Element): Element[] => {
const stack: Element[] = [root];
const out: Element[] = [];
while (stack.length) {
const el = stack.pop()!;
out.push(el);
for (let i = el.children.length - 1; i >= 0; i--) {
stack.push(el.children[i] as Element);
}
}
return out;
};
const findFirstByLocalName = (root: Element, name: string): Element | null => {
for (const el of walkElements(root)) {
if (el.localName === name) return el;
}
return null;
};
const findFirstChildByLocalName = (root: Element, name: string): Element | null => {
for (const el of Array.from(root.children)) {
if ((el as Element).localName === name) return el as Element;
}
return null;
};
const findChildrenByLocalName = (root: Element, name: string): Element[] =>
Array.from(root.children).filter((el) => (el as Element).localName === name) as Element[];
const getTopicPlainText = (topicEl: Element): string => {
const textEl = findFirstChildByLocalName(topicEl, "Text");
const raw = textEl?.getAttribute("PlainText") ?? "";
return raw;
};
const getTopicHyperlink = (topicEl: Element): { url: string; title: string } | null => {
const linkEl = findFirstChildByLocalName(topicEl, "Hyperlink");
if (!linkEl) return null;
const url = linkEl.getAttribute("Url") ?? "";
const title = linkEl.getAttribute("Title") ?? "";
if (!url) return null;
return { url, title };
};
const guessMimeFromImageType = (imageType: string): string => {
const t = (imageType || "").toLowerCase();
if (t.includes("png")) return "image/png";
if (t.includes("jpeg") || t.includes("jpg")) return "image/jpeg";
if (t.includes("gif")) return "image/gif";
if (t.includes("bmp")) return "image/bmp";
if (t.includes("svg")) return "image/svg+xml";
return "application/octet-stream";
};
const parseMmArchiveUriToZipPath = (uri: string): string | null => {
// 常见格式:mmarch://bin/<uuid>.bin
const raw = String(uri || "");
const lower = raw.toLowerCase();
const prefix = "mmarch://";
if (!lower.startsWith(prefix)) return null;
let rest = raw.slice(prefix.length);
while (rest.startsWith("/")) rest = rest.slice(1);
return rest || null;
};
const getTopicImageInfo = (topicEl: Element): { uri: string; mime: string } | null => {
const oneImage = findFirstChildByLocalName(topicEl, "OneImage");
if (!oneImage) return null;
const imageEl = findFirstByLocalName(oneImage, "Image");
if (!imageEl) return null;
const imageDataEl = findFirstByLocalName(imageEl, "ImageData");
const uriEl = imageEl ? findFirstByLocalName(imageEl, "Uri") : null;
const uri = (uriEl?.textContent ?? "").trim();
if (!uri) return null;
const mime = guessMimeFromImageType(imageDataEl?.getAttribute("ImageType") ?? "");
return { uri, mime };
};
const compactTree = (node: MindMapData, isRoot = false): MindMapData => {
const children = (node.children ?? []).map((c) => compactTree(c, false));
const data = node.data ?? {};
const hasContent =
Boolean(String(data.text ?? "").trim()) ||
Boolean(String((data as Record<string, unknown>).hyperlink ?? "").trim()) ||
Boolean(String((data as Record<string, unknown>).note ?? "").trim()) ||
Boolean(String((data as Record<string, unknown>).image ?? "").trim()) ||
Boolean(((data as Record<string, unknown>).tag as unknown[] | undefined)?.length) ||
Boolean(String((data as Record<string, unknown>).attachmentUrl ?? "").trim());
if (!isRoot && !hasContent && children.length === 1) {
return children[0];
}
return { ...node, children };
};
export const parseMindManagerMmapFile = async (file: File): Promise<MindMapData> => {
const JSZip = (await import("jszip")).default;
const zip = await JSZip.loadAsync(file);
const xmlFile = zip.file("Document.xml");
if (!xmlFile) {
throw new Error("Document.xml 不存在");
}
const xmlText = await xmlFile.async("string");
const xmlDoc = new DOMParser().parseFromString(xmlText, "application/xml");
if (xmlDoc.getElementsByTagName("parsererror").length > 0) {
throw new Error("Document.xml 解析失败");
}
const docEl = xmlDoc.documentElement;
if (!docEl) throw new Error("Document.xml 内容为空");
const defaultsGroup = findFirstByLocalName(docEl, "RootTopicDefaultsGroup");
const defaultTextEl = defaultsGroup ? findFirstByLocalName(defaultsGroup, "DefaultText") : null;
const defaultRootText = defaultTextEl?.getAttribute("PlainText") ?? "中心主题";
const oneTopicEl = findFirstByLocalName(docEl, "OneTopic");
const rootTopicEl = oneTopicEl ? findFirstChildByLocalName(oneTopicEl, "Topic") : null;
if (!rootTopicEl) {
throw new Error("未找到根主题");
}
const binCache = new Map<string, { dataUrl: string; width: number; height: number }>();
const resolveBinToImageDataUrl = async (
uri: string,
mime: string,
): Promise<{ dataUrl: string; width: number; height: number } | null> => {
const zipPath = parseMmArchiveUriToZipPath(uri);
if (!zipPath) return null;
if (binCache.has(zipPath)) return binCache.get(zipPath)!;
const entry = zip.file(zipPath) ?? zip.file(`/${zipPath}`);
if (!entry) return null;
const bytes = await entry.async("uint8array");
const blob = new Blob([bytes], { type: mime });
const dataUrl = await readBlobAsDataUrl(blob);
const size = await getImageSizeFromBlob(blob);
const width = size?.width ?? 0;
const height = size?.height ?? 0;
const res = { dataUrl, width, height };
binCache.set(zipPath, res);
return res;
};
const walkTopic = async (topicEl: Element, isRoot: boolean): Promise<MindMapData> => {
const text = getTopicPlainText(topicEl);
const hyperlink = getTopicHyperlink(topicEl);
const imageInfo = getTopicImageInfo(topicEl);
const nodeData: Record<string, unknown> = {
text: text ?? "",
};
if (hyperlink?.url) {
nodeData.hyperlink = hyperlink.url;
if (hyperlink.title) nodeData.hyperlinkTitle = hyperlink.title;
// MindManager 常见:文本为空但带链接标题,作为节点可见文本更符合导入预期
if (!String(nodeData.text || "").trim() && hyperlink.title) {
nodeData.text = hyperlink.title;
}
}
if (imageInfo?.uri) {
const resolved = await resolveBinToImageDataUrl(imageInfo.uri, imageInfo.mime);
if (resolved?.dataUrl) {
nodeData.image = resolved.dataUrl;
nodeData.imageSize = {
width: resolved.width || 0,
height: resolved.height || 0,
custom: true,
};
nodeData.imgPlacement = "top";
}
}
if (isRoot && !String(nodeData.text || "").trim()) {
nodeData.text = defaultRootText || "中心主题";
}
const subTopicsEl = findFirstChildByLocalName(topicEl, "SubTopics");
const childTopicEls = subTopicsEl ? findChildrenByLocalName(subTopicsEl, "Topic") : [];
const children = await Promise.all(childTopicEls.map((c) => walkTopic(c, false)));
return { data: nodeData, children };
};
const tree = await walkTopic(rootTopicEl, true);
return compactTree(tree, true);
};
@@ -0,0 +1,14 @@
export type MindMapNode = {
getStyle: (key: string, checkRoot?: boolean) => unknown;
setStyle?: (key: string, value: unknown) => void;
setIcon?: (icons: string[]) => void;
setImage?: (img: { url: string; width?: number; height?: number } | null) => void;
getData: (key: string) => unknown;
nodeData?: { data?: Record<string, unknown> };
data?: Record<string, unknown>;
active?: () => void;
parent?: { children?: unknown[] } | null;
children?: unknown[];
uid?: string;
};
@@ -1,341 +1,200 @@
"use client";
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
import type { DocumentNode } from "@/lib/documents";
import type { MediaAsset } from "@/types/media";
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import type { FileTreeRow } from "@/lib/file-tree/types";
import { getFileTreeRowLabel } from "@/lib/file-tree/types";
import { isRealFileAsset } from "@/lib/file-tree/asset";
interface FileTreeProps {
nodes: DocumentNode[];
assetsByDoc: Record<string, MediaAsset[]>;
expanded: Set<string>;
rows: FileTreeRow[];
activeId: string;
onToggleExpand: (id: string) => void;
onOpenDocument: (id: string) => void;
onOpenAsset: (asset: MediaAsset) => void;
selectedRowIds: Set<string>;
onRowClick: (row: FileTreeRow, event: React.MouseEvent) => void;
onRowDoubleClick: (row: FileTreeRow, event: React.MouseEvent) => void;
onRowContextMenu: (row: FileTreeRow, event: React.MouseEvent) => void;
onRowDragStart?: (row: FileTreeRow, event: React.DragEvent) => void;
onToggleExpand: (docId: string) => void;
onCreateChild: (parentId: string | null) => void;
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
selectedAssetIds?: Set<string>;
onToggleAssetSelect?: (assetId: string) => void;
onSelectOnlyAsset?: (assetId: string) => void;
disableSelection?: boolean;
onBlankMouseDown?: (event: React.MouseEvent) => void;
onDropFiles?: (docId: string, files: FileList) => void;
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
}
const INDENT = 16;
export function FileTree({
nodes,
assetsByDoc,
expanded,
rows,
activeId,
selectedRowIds,
onRowClick,
onRowDoubleClick,
onRowContextMenu,
onRowDragStart,
onToggleExpand,
onOpenDocument,
onOpenAsset,
onCreateChild,
onContextMenu,
selectedAssetIds = new Set<string>(),
onToggleAssetSelect,
onSelectOnlyAsset,
disableSelection = false,
onBlankMouseDown,
onDropFiles,
onAssetContextMenu,
onAssetClick,
onInternalDrop,
}: FileTreeProps) {
if (nodes.length === 0) {
if (rows.length === 0) {
return <div className="px-4 py-6 text-sm text-gray-400"></div>;
}
return (
<div
className="divide-y divide-[#f4f4f5]"
onDragOver={(e) => {
if (onDropFiles) {
e.preventDefault();
}
className="space-y-0.5"
onDragOver={(event) => {
if (onDropFiles) event.preventDefault();
}}
onDrop={(e) => {
if (onDropFiles && e.dataTransfer.files?.length) {
e.preventDefault();
const files = e.dataTransfer.files;
const targetDoc = nodes[0]?.id ?? "";
onDropFiles(targetDoc, files);
}
}}
>
{nodes.map((node) => (
<FileTreeNode
key={node.id}
node={node}
depth={0}
assetsByDoc={assetsByDoc}
expanded={expanded}
activeId={activeId}
onToggleExpand={onToggleExpand}
onOpenDocument={onOpenDocument}
onOpenAsset={onOpenAsset}
onCreateChild={onCreateChild}
onContextMenu={onContextMenu}
selectedAssetIds={selectedAssetIds}
onToggleAssetSelect={onToggleAssetSelect}
onSelectOnlyAsset={onSelectOnlyAsset}
disableSelection={disableSelection}
onDropFiles={onDropFiles}
onAssetContextMenu={onAssetContextMenu}
onAssetClick={onAssetClick}
/>
))}
</div>
);
}
interface FileTreeNodeProps {
node: DocumentNode;
depth: number;
assetsByDoc: Record<string, MediaAsset[]>;
expanded: Set<string>;
activeId: string;
onToggleExpand: (id: string) => void;
onOpenDocument: (id: string) => void;
onOpenAsset: (asset: MediaAsset) => void;
onCreateChild: (parentId: string | null) => void;
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
selectedAssetIds: Set<string>;
onToggleAssetSelect?: (assetId: string) => void;
onSelectOnlyAsset?: (assetId: string) => void;
disableSelection: boolean;
onDropFiles?: (docId: string, files: FileList) => void;
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
}
function FileTreeNode({
node,
depth,
assetsByDoc,
expanded,
activeId,
onToggleExpand,
onOpenDocument,
onOpenAsset,
onCreateChild,
onContextMenu,
selectedAssetIds,
onToggleAssetSelect,
onSelectOnlyAsset,
disableSelection,
onDropFiles,
onAssetContextMenu,
onAssetClick,
}: FileTreeNodeProps) {
const isExpanded = expanded.has(node.id);
const assets = useMemo(() => assetsByDoc[node.id] ?? [], [assetsByDoc, node.id]);
const hasChildren = node.children.length > 0 || assets.length > 0;
return (
<div
className="py-0.5"
onDragOver={(e) => {
if (onDropFiles) e.preventDefault();
}}
onDrop={(e) => {
if (onDropFiles && e.dataTransfer.files?.length) {
e.preventDefault();
onDropFiles(node.id, e.dataTransfer.files);
}
}}
>
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]",
activeId === node.id && "bg-[#e8f2ff] text-[#2563eb]",
)}
style={{ paddingLeft: depth * INDENT + 8 }}
onContextMenu={(event) => onContextMenu(event, node)}
>
{hasChildren ? (
<button
type="button"
aria-label="展开或折叠"
onClick={() => onToggleExpand(node.id)}
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span className="h-5 w-5" />
)}
<button
type="button"
className="flex flex-1 items-center gap-2 text-left"
onClick={() => onOpenDocument(node.id)}
>
<Folder className="h-4 w-4 text-[#2563eb]" />
<span className="truncate">{node.title || "无标题"}</span>
</button>
<button
type="button"
aria-label="新建子页面"
onClick={() => onCreateChild(node.id)}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{isExpanded && (
<div className="space-y-0.5">
<FileLeafRow
depth={depth + 1}
label="index.md"
icon={<FileText className="h-4 w-4 text-gray-500" />}
onClick={() => onOpenDocument(node.id)}
stopBubble
/>
{assets.map((asset) => (
<FileLeafRow
key={asset.id}
depth={depth + 1}
label={asset.file_name || "附件"}
icon={<Paperclip className="h-4 w-4 text-gray-500" />}
selected={selectedAssetIds.has(asset.id)}
selectable={!disableSelection}
onSelectToggle={
onToggleAssetSelect ? () => onToggleAssetSelect(asset.id) : undefined
}
onSelectOnly={onSelectOnlyAsset ? () => onSelectOnlyAsset(asset.id) : undefined}
onClick={(e) => {
if (onAssetClick) {
onAssetClick(asset, e);
} else {
onOpenAsset(asset);
}
}}
onDoubleClick={() => onOpenAsset(asset)}
stopBubble
onContextMenu={
onAssetContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
if (onAssetClick) {
onAssetClick(asset, e);
}
onAssetContextMenu(e, asset);
}
: undefined
}
/>
))}
{node.children.map((child) => (
<FileTreeNode
key={child.id}
node={child}
depth={depth + 1}
assetsByDoc={assetsByDoc}
expanded={expanded}
activeId={activeId}
onToggleExpand={onToggleExpand}
onOpenDocument={onOpenDocument}
onOpenAsset={onOpenAsset}
onCreateChild={onCreateChild}
onContextMenu={onContextMenu}
selectedAssetIds={selectedAssetIds}
onToggleAssetSelect={onToggleAssetSelect}
onSelectOnlyAsset={onSelectOnlyAsset}
disableSelection={disableSelection}
onDropFiles={onDropFiles}
onAssetContextMenu={onAssetContextMenu}
/>
))}
</div>
)}
</div>
);
}
function FileLeafRow({
depth,
label,
icon,
onClick,
selected = false,
selectable = false,
onSelectToggle,
onSelectOnly,
stopBubble = false,
onContextMenu,
}: {
depth: number;
label: string;
icon: React.ReactNode;
onClick: () => void;
selected?: boolean;
selectable?: boolean;
onSelectToggle?: () => void;
onSelectOnly?: () => void;
stopBubble?: boolean;
onContextMenu?: (event: React.MouseEvent) => void;
onDoubleClick?: () => void;
}) {
return (
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f8fafc]",
selected && "bg-[#e8f2ff] text-[#2563eb]",
)}
style={{ paddingLeft: depth * INDENT + 32 }}
onClick={(e) => {
if (stopBubble) e.stopPropagation();
onClick();
}}
onDoubleClick={(e) => {
if (stopBubble) e.stopPropagation();
onDoubleClick?.();
}}
onMouseDown={(e) => {
if (stopBubble) e.stopPropagation();
}}
role="button"
tabIndex={0}
onContextMenu={(e) => {
if (stopBubble) e.stopPropagation();
if (onContextMenu) onContextMenu(e);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
onDrop={(event) => {
if (onDropFiles && event.dataTransfer.files?.length) {
event.preventDefault();
onClick();
const files = event.dataTransfer.files;
const firstDocRow = rows.find((row) => row.kind === "doc");
const targetDocId = firstDocRow?.docId ?? "";
onDropFiles(targetDocId, files);
}
}}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onBlankMouseDown?.(event);
}
}}
>
{selectable ? (
<input
type="checkbox"
checked={selected}
onChange={(e) => {
e.stopPropagation();
if (onSelectToggle) onSelectToggle();
}}
className="h-4 w-4 rounded border-gray-300 text-[#2563eb]"
/>
) : (
<span className="h-4 w-4" />
)}
{icon}
<button
type="button"
className="flex-1 truncate text-left"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
{label}
</button>
{rows.map((row) => {
const label = getFileTreeRowLabel(row);
const selected = selectedRowIds.has(row.rowId);
const active = row.kind !== "asset" && row.docId === activeId;
const draggable =
row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset));
const baseClass =
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]";
const activeClass =
row.kind === "index"
? "text-[#2563eb] font-medium"
: row.kind === "doc"
? "text-[#2563eb]"
: "";
const paddingLeft =
row.kind === "doc" ? row.depth * INDENT + 8 : row.depth * INDENT + 32;
return (
<div
key={row.rowId}
className={cn(baseClass, active && activeClass, selected && "bg-[#e8f2ff] text-[#2563eb]")}
style={{ paddingLeft }}
onClick={(event) => onRowClick(row, event)}
onDoubleClick={(event) => onRowDoubleClick(row, event)}
onContextMenu={(event) => onRowContextMenu(row, event)}
draggable={draggable}
onDragStart={(event) => {
if (!draggable) return;
if (!selectedRowIds.has(row.rowId)) {
onRowDragStart?.(row, event);
}
const rowIds = selectedRowIds.has(row.rowId) ? Array.from(selectedRowIds) : [row.rowId];
const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds });
try {
event.dataTransfer.setData("application/x-mnote-file-tree", payload);
} catch {
// ignore
}
event.dataTransfer.setData("text/plain", payload);
event.dataTransfer.effectAllowed = event.altKey ? "copyMove" : "move";
}}
onDragOver={(event) => {
const types = Array.from(event.dataTransfer.types ?? []);
const hasInternal = types.includes("application/x-mnote-file-tree");
if (hasInternal && onInternalDrop) {
event.preventDefault();
event.dataTransfer.dropEffect = event.altKey ? "copy" : "move";
return;
}
if (onDropFiles) event.preventDefault();
}}
onDrop={(event) => {
const types = Array.from(event.dataTransfer.types ?? []);
const isInternal = types.includes("application/x-mnote-file-tree");
if (isInternal && onInternalDrop) {
const raw =
event.dataTransfer.getData("application/x-mnote-file-tree") || "";
try {
const parsed = JSON.parse(raw) as { type?: string; version?: number; rowIds?: unknown };
if (parsed?.type === "mnote-file-tree-dnd" && parsed.version === 1 && Array.isArray(parsed.rowIds)) {
event.preventDefault();
onInternalDrop({
targetRow: row,
rowIds: parsed.rowIds.filter((id) => typeof id === "string") as string[],
copy: event.altKey,
});
return;
}
} catch {
// ignore
}
}
if (onDropFiles && event.dataTransfer.files?.length) {
event.preventDefault();
onDropFiles(row.docId, event.dataTransfer.files);
}
}}
role="button"
tabIndex={0}
>
{row.kind === "doc" ? (
<>
{row.hasChildren ? (
<button
type="button"
aria-label="展开或折叠"
onClick={(event) => {
event.stopPropagation();
onToggleExpand(row.docId);
}}
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
row.isExpanded && "rotate-90",
)}
/>
</button>
) : (
<span className="h-5 w-5" />
)}
<Folder className="h-4 w-4 text-[#2563eb]" />
<span className="flex-1 truncate text-left">{label}</span>
<button
type="button"
aria-label="新建子页面"
onClick={(event) => {
event.stopPropagation();
onCreateChild(row.docId);
}}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
>
<Plus className="h-3.5 w-3.5" />
</button>
</>
) : row.kind === "index" ? (
<>
<span className="h-4 w-4" />
<FileText className="h-4 w-4 text-gray-500" />
<span className="flex-1 truncate text-left">{label}</span>
</>
) : (
<>
<span className="h-4 w-4" />
<Paperclip className="h-4 w-4 text-gray-500" />
<span className="flex-1 truncate text-left">{label}</span>
</>
)}
</div>
);
})}
</div>
);
}
+509 -104
View File
@@ -43,6 +43,20 @@ import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar
import { supabaseBrowser } from "@/lib/supabase/client";
import { useSearchPaletteStore } from "@/store/search-palette";
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 { parseFileTreeRowId } 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";
@@ -100,10 +114,14 @@ export function Sidebar({ initialData }: SidebarProps) {
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(new Set());
const [lastSelectedAssetId, setLastSelectedAssetId] = useState<string | 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(() => {
@@ -122,8 +140,7 @@ export function Sidebar({ initialData }: SidebarProps) {
}, [sidebarData.mindmapDocs]);
useEffect(() => {
setSelectedAssetIds(new Set());
setLastSelectedAssetId(null);
setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null });
}, [mediaAssets, sidebarData.documents]);
useEffect(() => {
@@ -265,19 +282,49 @@ export function Sidebar({ initialData }: SidebarProps) {
return map;
}, [sidebarData.documents, mediaAssets, mindmapDocs]);
const assetOrder = useMemo(() => {
const list: string[] = [];
Object.values(assetsByDoc).forEach((arr) => {
arr.forEach((asset) => list.push(asset.id));
});
return list;
}, [assetsByDoc]);
const fileTreeRows = useMemo(
() =>
buildVisibleRows({
nodes: filteredPrivateTree,
expanded,
assetsByDoc,
}),
[assetsByDoc, expanded, filteredPrivateTree],
);
const assetIndexMap = useMemo(() => {
const map = new Map<string, number>();
assetOrder.forEach((id, idx) => map.set(id, idx));
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;
}, [assetOrder]);
}, [sidebarData.documents]);
const selectedAssetIdsForMenu = useMemo(() => {
const ids: string[] = [];
fileTreeSelection.selectedRowIds.forEach((rowId) => {
const parsed = parseFileTreeRowId(rowId);
if (parsed?.kind === "asset") {
ids.push(parsed.assetId);
}
});
return ids;
}, [fileTreeSelection.selectedRowIds]);
const activeWorkspace =
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
@@ -285,8 +332,6 @@ export function Sidebar({ initialData }: SidebarProps) {
const handleOpenDocument = useCallback(
(documentId: string, mode: "main" | "sidebar") => {
setSelectedAssetIds(new Set());
setLastSelectedAssetId(null);
const targetPath = `/documents/${documentId}`;
if (mode === "main") {
router.push(targetPath);
@@ -380,67 +425,204 @@ export function Sidebar({ initialData }: SidebarProps) {
}
}, [router, setOpen]);
const handleAssetContextMenu = useCallback(
(event: React.MouseEvent, asset: MediaAsset) => {
event.preventDefault();
if (!selectedAssetIds.has(asset.id)) {
setSelectedAssetIds(new Set([asset.id]));
setLastSelectedAssetId(asset.id);
}
setAssetMenu({ asset, x: event.clientX, y: event.clientY });
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,
},
}),
);
},
[selectedAssetIds],
[fileTreeVisibleRowIds],
);
const handleSelectAsset = useCallback(
(asset: MediaAsset, event?: { type?: string; shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => {
const evtType = event?.type ?? "";
if (evtType === "contextmenu" && selectedAssetIds.has(asset.id)) {
setLastSelectedAssetId(asset.id);
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") {
handleOpenAsset(row.asset);
return;
}
setSelectedAssetIds((prev) => {
let next = new Set(prev);
const withShift = Boolean(event?.shiftKey) && lastSelectedAssetId && assetIndexMap.has(lastSelectedAssetId);
if (withShift) {
const start = assetIndexMap.get(lastSelectedAssetId!) ?? 0;
const end = assetIndexMap.get(asset.id) ?? start;
const [lo, hi] = start < end ? [start, end] : [end, start];
const idsInRange = assetOrder.slice(lo, hi + 1);
next = new Set([...prev, ...idsInRange]);
} else if (event?.metaKey || event?.ctrlKey) {
if (next.has(asset.id)) {
next.delete(asset.id);
} else {
next.add(asset.id);
}
} else {
next = new Set([asset.id]);
}
return next;
});
setLastSelectedAssetId(asset.id);
handleOpenDocument(row.docId, "main");
},
[assetIndexMap, assetOrder, lastSelectedAssetId, selectedAssetIds],
[handleOpenAsset, handleOpenDocument],
);
const toggleAssetCheckbox = useCallback((assetId: string) => {
setSelectedAssetIds((prev) => {
const next = new Set(prev);
if (next.has(assetId)) {
next.delete(assetId);
} else {
next.add(assetId);
}
return next;
});
setLastSelectedAssetId(assetId);
}, []);
const handleFileTreeRowContextMenu = useCallback(
(row: FileTreeRow, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setFileTreeSelection((prev) =>
reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }),
);
const selectOnlyAsset = useCallback((assetId: string) => {
setSelectedAssetIds(new Set([assetId]));
setLastSelectedAssetId(assetId);
}, []);
if (row.kind === "asset") {
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))
.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) => {
@@ -552,39 +734,124 @@ export function Sidebar({ initialData }: SidebarProps) {
const handleDeleteAssets = useCallback(
async (assetIds: string[], assetHint?: MediaAsset) => {
const target =
assetHint ??
mediaAssets.find((item) => assetIds.includes(item.id)) ??
null;
if (target?.asset_type === "mindmap") {
const resp = await fetch(`/api/mindmap/${target.document_id}`, { method: "DELETE" });
const uniqueAssetIds = Array.from(new Set(assetIds));
const assets = uniqueAssetIds
.map((id) => mediaAssets.find((item) => item.id === id))
.filter(Boolean) as MediaAsset[];
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
assets.unshift(assetHint);
}
const mindmapDocIds = Array.from(
new Set(assets.filter((item) => item.asset_type === "mindmap").map((item) => item.document_id)),
);
const fileAssetIdsByDocId = new Map<string, string[]>();
assets
.filter((item) => item.asset_type !== "mindmap")
.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 docId of mindmapDocIds) {
const resp = await fetch(`/api/mindmap/${docId}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
setMindmapDocs((prev) => prev.filter((id) => id !== target.document_id));
setAssetMenu(null);
emitAssetsChanged(target.document_id, undefined, undefined, true);
return;
}
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除失败");
return;
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 (mindmapDocIds.length > 0) {
setMindmapDocs((prev) => prev.filter((id) => !mindmapDocIds.includes(id)));
}
if (uniqueAssetIds.length > 0) {
setMediaAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
}
setMediaAssets((prev) => prev.filter((item) => !assetIds.includes(item.id)));
setAssetMenu(null);
emitAssetsChanged(target?.document_id, undefined, assetIds);
mindmapDocIds.forEach((docId) => emitAssetsChanged(docId, undefined, undefined, true));
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
},
[mediaAssets, sidebarQuery],
);
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 assetText = assetIds.length > 0 ? `${assetIds.length} 个附件(彻底删除)` : "";
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,
refreshTree,
router,
]);
const handleResizeStart = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
@@ -693,6 +960,147 @@ export function Sidebar({ initialData }: SidebarProps) {
[moveLocalNode, refreshTree, setExpanded],
);
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 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))
.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,
}),
});
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,
}),
});
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") {
@@ -1033,22 +1441,19 @@ export function Sidebar({ initialData }: SidebarProps) {
) : (
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<div className="flex-1 px-1 pb-2">
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
<div ref={fileTreeContainerRef} className="h-full rounded-md border border-[#eff2f6] bg-white">
<FileTree
nodes={filteredPrivateTree}
assetsByDoc={assetsByDoc}
expanded={expanded}
rows={fileTreeRows}
activeId={activeId}
selectedRowIds={fileTreeSelection.selectedRowIds}
onRowClick={handleFileTreeRowClick}
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
onRowContextMenu={handleFileTreeRowContextMenu}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
onToggleExpand={toggleExpand}
onOpenDocument={(id) => handleOpenDocument(id, "main")}
onOpenAsset={handleOpenAsset}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
onAssetContextMenu={handleAssetContextMenu}
selectedAssetIds={selectedAssetIds}
onAssetClick={(asset, e) => handleSelectAsset(asset, e)}
onToggleAssetSelect={toggleAssetCheckbox}
onSelectOnlyAsset={selectOnlyAsset}
onBlankMouseDown={handleFileTreeBlankMouseDown}
onInternalDrop={handleFileTreeInternalDrop}
/>
</div>
</div>
@@ -1105,7 +1510,7 @@ export function Sidebar({ initialData }: SidebarProps) {
onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)}
onCreateChild={() => void handleCreate(contextMenu.node.id)}
onConvertChild={() => void handleConvertToChild(contextMenu.node.id)}
onDelete={() => void handleDelete(contextMenu.node.id)}
onDelete={() => void handleDeleteFileTreeSelection()}
/>
)}
{assetMenu && (
@@ -1120,7 +1525,7 @@ export function Sidebar({ initialData }: SidebarProps) {
onMove={handleMoveAsset}
onDelete={(ids) =>
void handleDeleteAssets(
selectedAssetIds.size > 0 ? Array.from(selectedAssetIds) : ids,
selectedAssetIdsForMenu.length > 0 ? selectedAssetIdsForMenu : ids,
assetMenu.asset,
)
}