Files
mnote/wolai-frontend/src/components/sidebar/file-tree.tsx
T
2026-04-26 04:29:23 +08:00

360 lines
15 KiB
TypeScript

"use client";
import { AudioLines, BookOpen, ChevronRight, FileImage, FileText, FileVideo, Folder, Paperclip, Plus } from "lucide-react";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import type { FileTreeRow } from "@/lib/file-tree/types";
import { getFileTreeRowLabel } from "@/lib/file-tree/types";
import { isRealFileAsset } from "@/lib/file-tree/asset";
import { isTextInputTarget } from "@/lib/file-tree/clipboard";
interface FileTreeProps {
rows: FileTreeRow[];
activeId: string;
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;
onToggleAssetFolderExpand?: (assetId: string) => void;
onCreateChild: (parentId: string | null) => void;
onBlankMouseDown?: (event: React.MouseEvent) => void;
onDropFiles?: (docId: string, files: FileList, targetRow?: FileTreeRow) => void;
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
}
const INDENT = 16;
function resolveAssetIconKind(row: Extract<FileTreeRow, { kind: "asset" | "asset-folder" }>) {
const assetType = String(row.asset.asset_type ?? "").trim().toLowerCase();
if (assetType === "mindmap") return "mindmap";
if (assetType === "luckysheet") return "table";
const mimeType = String(row.asset.mime_type ?? "").trim().toLowerCase();
const fileName = String(row.asset.file_name ?? "").trim().toLowerCase();
const ext = fileName.includes(".") ? fileName.split(".").pop() ?? "" : "";
if (ext === "pdf" || mimeType.includes("pdf")) return "pdf";
if (ext === "epub" || mimeType.includes("epub")) return "book";
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "audio";
return "file";
}
function renderAssetIcon(row: Extract<FileTreeRow, { kind: "asset" | "asset-folder" }>) {
const iconKind = resolveAssetIconKind(row);
const baseClass = "w-4 h-4 shrink-0";
switch (iconKind) {
case "mindmap":
return <Folder className={`${baseClass} text-[#7c3aed]`} />;
case "table":
return <FileText className={`${baseClass} text-[#b45309]`} />;
case "pdf":
return <FileText className={`${baseClass} text-[#dc2626]`} />;
case "book":
return <BookOpen className={`${baseClass} text-[#0f766e]`} />;
case "image":
return <FileImage className={`${baseClass} text-[#0891b2]`} />;
case "video":
return <FileVideo className={`${baseClass} text-[#ea580c]`} />;
case "audio":
return <AudioLines className={`${baseClass} text-[#16a34a]`} />;
default:
return <Paperclip className={`${baseClass} text-wolai-text-secondary`} />;
}
}
export function FileTree({
rows,
activeId,
selectedRowIds,
onRowClick,
onRowDoubleClick,
onRowContextMenu,
onRowDragStart,
onToggleExpand,
onToggleAssetFolderExpand,
onCreateChild,
onBlankMouseDown,
onDropFiles,
onInternalDrop,
}: FileTreeProps) {
const [dragOverRowId, setDragOverRowId] = useState<string | null>(null);
const dragOverRange = useMemo(() => {
if (!dragOverRowId) return null;
const startIndex = rows.findIndex((row) => row.rowId === dragOverRowId);
if (startIndex < 0) return null;
const target = rows[startIndex];
const targetDepth = target.depth;
// VS Code 的树在拖拽悬停到“展开的文件夹”时,会把该节点的可渲染范围都
// 标记为 drop feedback(包含它的所有可见子节点)。我们用“扁平化 rows +
// depth”来近似计算该范围。
let endIndex = startIndex + 1;
if ((target.kind === "doc" || target.kind === "asset-folder") && target.isExpanded && target.hasChildren) {
while (endIndex < rows.length && rows[endIndex].depth > targetDepth) {
endIndex += 1;
}
}
return { startIndex, endIndex };
}, [dragOverRowId, rows]);
if (rows.length === 0) {
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
}
return (
<div
className="w-full min-w-0 max-w-full space-y-0.5 overflow-x-hidden"
onDragOver={(event) => {
if (!onDropFiles) return;
if (event.target === event.currentTarget) {
setDragOverRowId(null);
}
const types = Array.from(event.dataTransfer.types ?? []);
const hasFiles = types.includes("Files") || Boolean(event.dataTransfer.files?.length);
if (!hasFiles) return;
event.preventDefault();
if (event.dataTransfer.files?.length) {
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={(event) => {
if (onDropFiles && event.dataTransfer.files?.length) {
event.preventDefault();
setDragOverRowId(null);
const files = event.dataTransfer.files;
const activeDocRow = rows.find(
(row) => row.kind === "doc" && row.docId === activeId,
);
const firstDocRow = rows.find((row) => row.kind === "doc");
const targetDocId = activeDocRow?.docId ?? firstDocRow?.docId ?? "";
onDropFiles(targetDocId, files);
}
}}
onDragLeave={(event) => {
const relatedTarget = (event as unknown as { relatedTarget?: EventTarget | null }).relatedTarget;
if (relatedTarget && event.currentTarget.contains(relatedTarget as Node)) return;
setDragOverRowId(null);
}}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onBlankMouseDown?.(event);
}
}}
>
{rows.map((row, index) => {
const label = getFileTreeRowLabel(row);
const selected = selectedRowIds.has(row.rowId);
// 说明:只有“页面本身(doc/index.md)”才需要 active 高亮。
// 否则当你打开某个页面时,它下面的思维导图文件夹/附件会出现“灰色假选中”的视觉误导。
const active = (row.kind === "doc" || row.kind === "index") && row.docId === activeId;
const draggable =
row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset));
const inDropFeedback =
dragOverRange &&
index >= dragOverRange.startIndex &&
index < dragOverRange.endIndex;
const baseClass =
"group flex items-center gap-2 px-3 py-[2px] min-h-[27px] w-full select-none cursor-pointer rounded-[3px] text-wolai-text-primary text-[14px] font-medium hover:bg-wolai-bg-hover transition-colors duration-150 data-[active=true]:bg-wolai-bg-active";
const activeClass =
row.kind === "index"
? "text-[#2563eb]"
: row.kind === "doc"
? "text-[#2563eb]"
: "";
const paddingLeft =
row.kind === "doc" ? row.depth * INDENT + 8 : row.depth * INDENT + 32;
return (
<div
key={row.rowId}
data-testid={
row.kind === "doc"
? "filetree-doc-row"
: row.kind === "index"
? "filetree-index-row"
: row.kind === "asset-folder"
? "filetree-asset-folder-row"
: "filetree-asset-row"
}
data-row-id={row.rowId}
data-row-kind={row.kind}
data-doc-id={row.docId}
className={cn(
baseClass,
active && activeClass,
selected && "bg-wolai-bg-active text-[#2563eb]",
inDropFeedback && "bg-gray-200/70",
)}
data-active={active}
style={{ paddingLeft }}
onClick={(event) => onRowClick(row, event)}
onDoubleClick={(event) => onRowDoubleClick(row, event)}
onContextMenu={(event) => onRowContextMenu(row, event)}
onMouseDown={(event) => {
// 避免 Shift 多选时触发浏览器默认的文本范围选择(VS Code 资源管理器不会出现该行为)
if (event.button !== 0) return;
if (!event.shiftKey) return;
if (isTextInputTarget(event.target)) return;
event.preventDefault();
}}
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";
}}
onDragEnd={() => {
setDragOverRowId(null);
}}
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";
setDragOverRowId((prev) => (prev === row.rowId ? prev : row.rowId));
return;
}
if (!onDropFiles) return;
const hasFiles = types.includes("Files") || Boolean(event.dataTransfer.files?.length);
if (!hasFiles) return;
event.preventDefault();
if (event.dataTransfer.files?.length) {
event.dataTransfer.dropEffect = "copy";
}
setDragOverRowId((prev) => (prev === row.rowId ? prev : row.rowId));
}}
onDrop={(event) => {
setDragOverRowId(null);
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();
event.stopPropagation();
onDropFiles(row.docId, event.dataTransfer.files, row);
}
}}
role="button"
tabIndex={0}
>
{row.kind === "doc" ? (
<>
{row.hasChildren ? (
<button
type="button"
aria-label="展开或折叠"
data-testid="filetree-toggle"
onClick={(event) => {
event.stopPropagation();
onToggleExpand(row.docId);
}}
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100 shrink-0"
>
<ChevronRight
className={cn(
"w-4 h-4 text-wolai-text-secondary transition-transform group-hover:text-wolai-text-primary",
row.isExpanded && "rotate-90",
)}
/>
</button>
) : (
<span className="w-5 h-5 shrink-0" />
)}
<Folder className="w-4 h-4 text-[#2563eb] shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
<button
type="button"
aria-label="新建子页面"
data-testid="filetree-create"
onClick={(event) => {
event.stopPropagation();
onCreateChild(row.docId);
}}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 shrink-0"
>
<Plus className="w-4 h-4" />
</button>
</>
) : row.kind === "index" ? (
<>
<span className="w-4 h-4 shrink-0" />
<FileText className="w-4 h-4 text-wolai-text-secondary shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
</>
) : row.kind === "asset-folder" ? (
<>
{row.hasChildren ? (
<button
type="button"
aria-label="展开或折叠"
data-testid="filetree-toggle"
onClick={(event) => {
event.stopPropagation();
onToggleAssetFolderExpand?.(row.asset.id);
}}
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100 shrink-0"
>
<ChevronRight
className={cn(
"w-4 h-4 text-wolai-text-secondary transition-transform group-hover:text-wolai-text-primary",
row.isExpanded && "rotate-90",
)}
/>
</button>
) : (
<span className="w-5 h-5 shrink-0" />
)}
{renderAssetIcon(row)}
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
</>
) : (
<>
<span className="w-4 h-4 shrink-0" />
{renderAssetIcon(row)}
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
</>
)}
</div>
);
})}
</div>
);
}