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
@@ -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>
);
}