chore: init monorepo snapshot
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
|
||||
import { ChevronRight, GripVertical, MoreHorizontal, Plus } from "lucide-react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import type { DocumentNode } from "@/lib/documents";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
|
||||
interface PrivateTreeProps {
|
||||
nodes: DocumentNode[];
|
||||
expanded: Set<string>;
|
||||
activeId: string;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onMove: (nodeId: string, parentId: string | null, index: number) => void;
|
||||
onCreateChild: (parentId: string | null) => void;
|
||||
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
|
||||
export function PrivateTree({
|
||||
nodes,
|
||||
expanded,
|
||||
activeId,
|
||||
onToggleExpand,
|
||||
onMove,
|
||||
onCreateChild,
|
||||
onContextMenu,
|
||||
}: PrivateTreeProps) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
|
||||
const flatNodes = useMemo(() => flattenDocumentTree(nodes, expanded), [nodes, expanded]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flatNodes.length,
|
||||
getScrollElement: () => scrollAreaRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveDragId(event.active.id as string);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeId = event.active.id as string;
|
||||
const overId = event.over?.id as string | undefined;
|
||||
setActiveDragId(null);
|
||||
if (!overId || activeId === overId) {
|
||||
return;
|
||||
}
|
||||
const activeIndex = flatNodes.findIndex((item) => item.node.id === activeId);
|
||||
const overIndex = flatNodes.findIndex((item) => item.node.id === overId);
|
||||
if (activeIndex === -1 || overIndex === -1) {
|
||||
return;
|
||||
}
|
||||
const targetParent = flatNodes[overIndex].parentId;
|
||||
const siblingList = flatNodes.filter((item) => item.parentId === targetParent);
|
||||
const siblingIndex = siblingList.findIndex((item) => item.node.id === overId);
|
||||
const position = siblingIndex === -1 ? siblingList.length : siblingIndex;
|
||||
onMove(activeId, targetParent, position);
|
||||
},
|
||||
[flatNodes, onMove],
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveDragId(null);
|
||||
}, []);
|
||||
|
||||
if (flatNodes.length === 0) {
|
||||
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root type="auto" className="relative h-full w-full">
|
||||
<ScrollAreaPrimitive.Viewport ref={scrollAreaRef} className="h-full w-full">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext items={flatNodes.map((item) => item.node.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="relative h-full">
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const item = flatNodes[virtualRow.index];
|
||||
return (
|
||||
<VirtualRow key={item.node.id} virtualRow={virtualRow}>
|
||||
<SortableTreeRow
|
||||
node={item.node}
|
||||
depth={item.depth}
|
||||
expanded={expanded.has(item.node.id)}
|
||||
hasChildren={item.node.children.length > 0}
|
||||
activeId={activeId}
|
||||
isDragging={activeDragId === item.node.id}
|
||||
onToggleExpand={() => onToggleExpand(item.node.id)}
|
||||
onCreateChild={() => onCreateChild(item.node.id)}
|
||||
onContextMenu={(event) => onContextMenu(event, item.node)}
|
||||
/>
|
||||
</VirtualRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
orientation="vertical"
|
||||
className="flex w-2.5 touch-none select-none border-l border-l-transparent bg-transparent px-0.5 py-2"
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[#c9d6f8]" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function VirtualRow({
|
||||
children,
|
||||
virtualRow,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
virtualRow: VirtualItem;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
height: `${virtualRow.size}px`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableTreeRowProps {
|
||||
node: DocumentNode;
|
||||
depth: number;
|
||||
expanded: boolean;
|
||||
hasChildren: boolean;
|
||||
activeId: string;
|
||||
isDragging: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onCreateChild: () => void;
|
||||
onContextMenu: (event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function SortableTreeRow({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
hasChildren,
|
||||
activeId,
|
||||
isDragging,
|
||||
onToggleExpand,
|
||||
onCreateChild,
|
||||
onContextMenu,
|
||||
}: SortableTreeRowProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: node.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"flex h-full items-center gap-1 border-b border-transparent px-2 text-sm text-gray-700 hover:bg-[#f5f7fb]",
|
||||
activeId === node.id && "bg-[#e8f2ff] text-[#2563eb]",
|
||||
isDragging && "opacity-60",
|
||||
)}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽排序"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:text-gray-600"
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
className="flex flex-1 items-center gap-2 rounded-md px-1 py-1"
|
||||
style={{ paddingLeft: depth * 14 }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开或折叠"
|
||||
onClick={onToggleExpand}
|
||||
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", expanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-5 w-5" />
|
||||
)}
|
||||
<Link href={`/documents/${node.id}`} className="flex-1 truncate text-left">
|
||||
{node.title || "无标题"}
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建子页面"
|
||||
onClick={onCreateChild}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="更多操作"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onContextMenu(event);
|
||||
}}
|
||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
|
||||
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
|
||||
|
||||
export interface TrashRecord {
|
||||
id: string;
|
||||
title: string | null;
|
||||
deleted_at: string;
|
||||
parent_id: string | null;
|
||||
access_scope: DocumentRecord["access_scope"];
|
||||
}
|
||||
|
||||
export interface SidebarInitialData {
|
||||
activeWorkspaceId: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
trashedDocuments: TrashRecord[];
|
||||
}
|
||||
Reference in New Issue
Block a user