294 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|