Files
NoteAI/src/components/sidebar/tree-view.tsx
T
2026-03-04 10:28:31 +08:00

294 lines
13 KiB
TypeScript

"use client";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useRouter, usePathname } from "next/navigation";
import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ChevronRight,
FileText,
Folder,
FolderOpen,
MoreHorizontal,
Trash2,
FilePlus,
FolderPlus,
Download,
Upload,
} from "lucide-react";
import { Page, useEditorStore } from "@/lib/store";
import { cn } from "@/lib/utils";
import { exportPageAsMarkdown, exportFolderAsZip } from "@/lib/export";
import { useImport } from "@/components/import-context";
import { useConfirm } from "@/components/confirm-provider";
interface TreeViewProps {
pages: Page[];
parentId: string | null;
expanded: Record<string, boolean>;
toggleExpand: (id: string) => void;
level?: number;
folderFileCount: Record<string, number>;
}
function buildFolderFileCount(pages: Page[]): Record<string, number> {
const byParent = new Map<string | null, Page[]>();
for (const page of pages) {
const siblings = byParent.get(page.parentId) || [];
siblings.push(page);
byParent.set(page.parentId, siblings);
}
const counts: Record<string, number> = {};
const dfs = (folderId: string): number => {
const children = byParent.get(folderId) || [];
let total = 0;
for (const child of children) {
if (child.type === "file") {
total += 1;
} else {
total += dfs(child.id);
}
}
counts[folderId] = total;
return total;
};
for (const page of pages) {
if (page.type === "folder") {
dfs(page.id);
}
}
return counts;
}
function TreeNode({
node,
pages,
level,
expanded,
toggleExpand,
folderFileCount,
}: {
node: Page;
pages: Page[];
level: number;
expanded: Record<string, boolean>;
toggleExpand: (id: string) => void;
folderFileCount: Record<string, number>;
}) {
const { activePageId, setActivePageId, addPage, deletePage } = useEditorStore();
const router = useRouter();
const pathname = usePathname();
const { triggerImport } = useImport();
const confirm = useConfirm();
const isFolder = node.type === "folder";
const hasChildren = pages.some((p) => p.parentId === node.id);
const isExpanded = expanded[node.id];
const isActive = activePageId === node.id;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: node.id,
data: { type: node.type, title: node.title, parentId: node.parentId },
});
const style = {
paddingLeft: `${level * 12 + 6}px`,
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div key={node.id} style={style} ref={setNodeRef} {...attributes} {...listeners}>
<div
className={cn(
"sidebar-tree-item group relative flex h-10 items-center justify-between rounded-lg border border-transparent px-1.5 text-[14px] transition-colors cursor-pointer select-none md:h-9 md:text-[13px]",
isActive
? "border-border/60 bg-accent/75 text-accent-foreground"
: "text-foreground/85 hover:border-border/45 hover:bg-muted/55 hover:text-foreground"
)}
onClick={() => {
setActivePageId(node.id);
if (pathname !== "/") router.push("/");
}}
>
{isActive && <span className="absolute left-0 top-[8px] h-5 w-0.5 rounded-r bg-primary/90" />}
<div className="grid min-w-0 flex-1 grid-cols-[1.75rem_1.25rem_minmax(0,1fr)] items-center gap-1.5">
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
toggleExpand(node.id);
}}
className={cn(
"flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground/80 transition-colors hover:bg-muted-foreground/15 hover:text-foreground md:h-8 md:w-8",
!hasChildren && !isFolder && "invisible"
)}
>
<ChevronRight size={14} className={cn("shrink-0 text-muted-foreground/60 transition-transform", isExpanded && "rotate-90")} />
</button>
<span className="flex h-5 w-5 items-center justify-center">
{node.icon ? (
<span className="flex h-5 w-5 items-center justify-center text-[16px] leading-none grayscale-[0.2]">{node.icon}</span>
) : isFolder ? (
isExpanded ? (
<FolderOpen size={15} className="fill-indigo-500/20 text-indigo-500" />
) : (
<Folder size={15} className="fill-indigo-500/20 text-indigo-500" />
)
) : (
<FileText size={15} className="text-muted-foreground/70" />
)}
</span>
<span className="flex min-w-0 items-center gap-1.5">
<span
className={cn(
"sidebar-title",
level === 0 ? "text-[15px] font-semibold md:text-[14px]" : "text-[14px] md:text-[13px]"
)}
>
{node.title || "无标题"}
</span>
{isFolder && (
<span className="inline-flex min-w-[1.2rem] shrink-0 items-center justify-center rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{folderFileCount[node.id] ?? 0}
</span>
)}
</span>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
onPointerDown={(e) => e.stopPropagation()}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground/85 transition-opacity hover:bg-muted-foreground/15 hover:text-foreground opacity-100 md:h-8 md:w-8 lg:opacity-0 lg:group-hover:opacity-100"
>
<MoreHorizontal size={13} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="ui-card z-50 min-w-[160px] p-1.5 shadow-xl animate-in fade-in zoom-in-95" align="start" sideOffset={6}>
<DropdownMenu.Item
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={(e) => {
e.stopPropagation();
const targetParentId = node.type === "folder" ? node.id : node.parentId;
const order = node.type === "folder" ? undefined : (node.order || 0) + 1;
addPage(targetParentId, "file", undefined, order);
if (node.type === "folder") toggleExpand(node.id);
}}
>
<FilePlus size={14} />
<span>新建文件</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={(e) => {
e.stopPropagation();
const targetParentId = node.type === "folder" ? node.id : node.parentId;
const order = node.type === "folder" ? undefined : (node.order || 0) + 1;
addPage(targetParentId, "folder", undefined, order);
if (node.type === "folder") toggleExpand(node.id);
}}
>
<FolderPlus size={14} />
<span>新建文件夹</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={(e) => {
e.stopPropagation();
const targetParentId = node.type === "folder" ? node.id : node.parentId;
triggerImport(targetParentId);
if (node.type === "folder") toggleExpand(node.id);
}}
>
<Upload size={14} />
<span>导入文档...</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent"
onClick={(e) => {
e.stopPropagation();
if (node.type === "folder") {
exportFolderAsZip(node.id, pages, node.title);
} else {
exportPageAsMarkdown(node);
}
}}
>
<Download size={14} />
<span>导出</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-muted" />
<DropdownMenu.Item
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive outline-none transition-colors hover:bg-destructive/10"
onClick={async (e) => {
e.stopPropagation();
const ok = await confirm({
title: "确认删除",
description: "此操作无法撤销。",
confirmText: "删除",
cancelText: "取消",
tone: "danger",
});
if (ok) {
deletePage(node.id);
}
}}
>
<Trash2 size={14} />
<span>删除</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
{(isExpanded || hasChildren) && isExpanded && (
<TreeView
pages={pages}
parentId={node.id}
level={level + 1}
expanded={expanded}
toggleExpand={toggleExpand}
folderFileCount={folderFileCount}
/>
)}
</div>
);
}
export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, folderFileCount }: TreeViewProps) {
const computedFolderFileCount = level === 0 ? buildFolderFileCount(pages) : folderFileCount;
const nodes = pages
.filter((p) => p.parentId === parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0));
if (nodes.length === 0) return null;
return (
<SortableContext items={nodes.map((n) => n.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-0 md:gap-0.5">
{nodes.map((node) => (
<TreeNode
key={node.id}
node={node}
pages={pages}
level={level}
expanded={expanded}
toggleExpand={toggleExpand}
folderFileCount={computedFolderFileCount}
/>
))}
</div>
</SortableContext>
);
}