2025-12-27 20:23:35 +08:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-01-07 18:38:56 +08:00
|
|
|
import type { FileTreeRow } from "@/lib/file-tree/types";
|
|
|
|
|
import { getFileTreeRowLabel } from "@/lib/file-tree/types";
|
|
|
|
|
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
2026-01-07 22:06:49 +08:00
|
|
|
import { isTextInputTarget } from "@/lib/file-tree/clipboard";
|
2025-12-27 20:23:35 +08:00
|
|
|
|
|
|
|
|
interface FileTreeProps {
|
2026-01-07 18:38:56 +08:00
|
|
|
rows: FileTreeRow[];
|
2025-12-27 20:23:35 +08:00
|
|
|
activeId: string;
|
2026-01-07 18:38:56 +08:00
|
|
|
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;
|
2025-12-27 20:23:35 +08:00
|
|
|
onCreateChild: (parentId: string | null) => void;
|
2026-01-07 18:38:56 +08:00
|
|
|
onBlankMouseDown?: (event: React.MouseEvent) => void;
|
2025-12-27 20:23:35 +08:00
|
|
|
onDropFiles?: (docId: string, files: FileList) => void;
|
2026-01-07 18:38:56 +08:00
|
|
|
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
2025-12-27 20:23:35 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const INDENT = 16;
|
|
|
|
|
|
|
|
|
|
export function FileTree({
|
2026-01-07 18:38:56 +08:00
|
|
|
rows,
|
2025-12-27 20:23:35 +08:00
|
|
|
activeId,
|
2026-01-07 18:38:56 +08:00
|
|
|
selectedRowIds,
|
|
|
|
|
onRowClick,
|
|
|
|
|
onRowDoubleClick,
|
|
|
|
|
onRowContextMenu,
|
|
|
|
|
onRowDragStart,
|
2025-12-27 20:23:35 +08:00
|
|
|
onToggleExpand,
|
|
|
|
|
onCreateChild,
|
2026-01-07 18:38:56 +08:00
|
|
|
onBlankMouseDown,
|
2025-12-27 20:23:35 +08:00
|
|
|
onDropFiles,
|
2026-01-07 18:38:56 +08:00
|
|
|
onInternalDrop,
|
2025-12-27 20:23:35 +08:00
|
|
|
}: FileTreeProps) {
|
2026-01-07 18:38:56 +08:00
|
|
|
if (rows.length === 0) {
|
2025-12-27 20:23:35 +08:00
|
|
|
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
2026-01-07 18:38:56 +08:00
|
|
|
className="space-y-0.5"
|
|
|
|
|
onDragOver={(event) => {
|
2026-01-08 06:28:14 +08:00
|
|
|
if (!onDropFiles) return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (event.dataTransfer.files?.length) {
|
|
|
|
|
event.dataTransfer.dropEffect = "copy";
|
|
|
|
|
}
|
2025-12-27 20:23:35 +08:00
|
|
|
}}
|
2026-01-07 18:38:56 +08:00
|
|
|
onDrop={(event) => {
|
|
|
|
|
if (onDropFiles && event.dataTransfer.files?.length) {
|
2025-12-27 20:23:35 +08:00
|
|
|
event.preventDefault();
|
2026-01-07 18:38:56 +08:00
|
|
|
const files = event.dataTransfer.files;
|
2026-01-08 06:28:14 +08:00
|
|
|
const activeDocRow = rows.find(
|
|
|
|
|
(row) => row.kind === "doc" && row.docId === activeId,
|
|
|
|
|
);
|
2026-01-07 18:38:56 +08:00
|
|
|
const firstDocRow = rows.find((row) => row.kind === "doc");
|
2026-01-08 06:28:14 +08:00
|
|
|
const targetDocId = activeDocRow?.docId ?? firstDocRow?.docId ?? "";
|
2026-01-07 18:38:56 +08:00
|
|
|
onDropFiles(targetDocId, files);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onMouseDown={(event) => {
|
|
|
|
|
if (event.target === event.currentTarget) {
|
|
|
|
|
onBlankMouseDown?.(event);
|
2025-12-27 20:23:35 +08:00
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-01-07 18:38:56 +08:00
|
|
|
{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 =
|
2026-01-07 22:06:49 +08:00
|
|
|
"flex select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]";
|
2026-01-07 18:38:56 +08:00
|
|
|
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)}
|
2026-01-07 22:06:49 +08:00
|
|
|
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}
|
2026-01-07 18:38:56 +08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-01-08 06:28:14 +08:00
|
|
|
if (!onDropFiles) return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (event.dataTransfer.files?.length) {
|
|
|
|
|
event.dataTransfer.dropEffect = "copy";
|
|
|
|
|
}
|
2026-01-07 18:38:56 +08:00
|
|
|
}}
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2025-12-27 20:23:35 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|