SurfSense/surfsense_web/components/documents/FolderNode.tsx

334 lines
9.3 KiB
TypeScript
Raw Normal View History

"use client";
import {
ChevronDown,
ChevronRight,
Folder,
FolderOpen,
FolderPlus,
MoreHorizontal,
Move,
Pencil,
Trash2,
} from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useDrag, useDrop } from "react-dnd";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export const DND_TYPES = {
FOLDER: "FOLDER",
DOCUMENT: "DOCUMENT",
} as const;
type DropZone = "top" | "middle" | "bottom";
export interface FolderDisplay {
id: number;
name: string;
position: string;
parentId: number | null;
searchSpaceId: number;
}
interface FolderNodeProps {
folder: FolderDisplay;
depth: number;
isExpanded: boolean;
isRenaming: boolean;
childCount: number;
onToggleExpand: (folderId: number) => void;
onRename: (folder: FolderDisplay, newName: string) => void;
onStartRename: (folderId: number) => void;
onCancelRename: () => void;
onDelete: (folder: FolderDisplay) => void;
onMove: (folder: FolderDisplay) => void;
onCreateSubfolder: (parentId: number) => void;
onDropIntoFolder?: (itemType: "folder" | "document", itemId: number, targetFolderId: number) => void;
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
siblingPositions?: { before: string | null; after: string | null };
disabledDropIds?: Set<number>;
}
function getDropZone(monitor: { getClientOffset: () => { y: number } | null }, element: HTMLElement): DropZone {
const offset = monitor.getClientOffset();
if (!offset) return "middle";
const rect = element.getBoundingClientRect();
const y = offset.y - rect.top;
const pct = y / rect.height;
if (pct < 0.25) return "top";
if (pct > 0.75) return "bottom";
return "middle";
}
export const FolderNode = React.memo(function FolderNode({
folder,
depth,
isExpanded,
isRenaming,
childCount,
onToggleExpand,
onRename,
onStartRename,
onCancelRename,
onDelete,
onMove,
onCreateSubfolder,
onDropIntoFolder,
onReorderFolder,
siblingPositions,
disabledDropIds,
}: FolderNodeProps) {
const [renameValue, setRenameValue] = useState(folder.name);
const inputRef = useRef<HTMLInputElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const [dropZone, setDropZone] = useState<DropZone | null>(null);
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_TYPES.FOLDER,
item: { id: folder.id, position: folder.position, parentId: folder.parentId },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
}),
[folder.id, folder.position, folder.parentId],
);
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: [DND_TYPES.FOLDER, DND_TYPES.DOCUMENT],
canDrop: (item: { id: number }) => {
if (item.id === folder.id) return false;
if (disabledDropIds?.has(item.id)) return false;
return true;
},
hover: (_item, monitor) => {
if (!rowRef.current || !monitor.isOver({ shallow: true })) {
setDropZone(null);
return;
}
setDropZone(getDropZone(monitor, rowRef.current));
},
drop: (item: { id: number }, monitor) => {
if (!rowRef.current) return;
const zone = getDropZone(monitor, rowRef.current);
const type = monitor.getItemType();
if (zone === "middle") {
if (type === DND_TYPES.FOLDER) {
onDropIntoFolder?.("folder", item.id, folder.id);
} else {
onDropIntoFolder?.("document", item.id, folder.id);
}
} else if (type === DND_TYPES.FOLDER && onReorderFolder && siblingPositions) {
if (zone === "top") {
onReorderFolder(item.id, siblingPositions.before, folder.position);
} else {
onReorderFolder(item.id, folder.position, siblingPositions.after);
}
}
setDropZone(null);
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
}),
[folder.id, folder.position, disabledDropIds, onDropIntoFolder, onReorderFolder, siblingPositions],
);
useEffect(() => {
if (!isOver) setDropZone(null);
}, [isOver]);
const attachRef = useCallback(
(node: HTMLDivElement | null) => {
rowRef.current = node;
drag(drop(node));
},
[drag, drop],
);
useEffect(() => {
if (isRenaming && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isRenaming]);
const handleRenameSubmit = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== folder.name) {
onRename(folder, trimmed);
}
onCancelRename();
}, [renameValue, folder, onRename, onCancelRename]);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleRenameSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
setRenameValue(folder.name);
onCancelRename();
}
},
[handleRenameSubmit, folder.name, onCancelRename],
);
const startRename = useCallback(() => {
setRenameValue(folder.name);
onStartRename(folder.id);
}, [folder, onStartRename]);
const FolderIcon = isExpanded ? FolderOpen : Folder;
return (
<ContextMenu>
<ContextMenuTrigger asChild disabled={isRenaming}>
<div
ref={attachRef}
className={cn(
"group relative flex h-8 items-center gap-1 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none",
isExpanded && "font-medium",
isDragging && "opacity-40",
isOver && canDrop && dropZone === "middle" && "bg-accent ring-1 ring-primary/40",
isOver && canDrop && dropZone === "top" && "border-t-2 border-primary",
isOver && canDrop && dropZone === "bottom" && "border-b-2 border-primary",
isOver && !canDrop && "cursor-not-allowed",
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onClick={() => onToggleExpand(folder.id)}
onDoubleClick={(e) => {
e.stopPropagation();
startRename();
}}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
</span>
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
{isRenaming ? (
<input
ref={inputRef}
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleRenameSubmit}
onKeyDown={handleRenameKeyDown}
onClick={(e) => e.stopPropagation()}
className="flex-1 min-w-0 rounded border border-primary bg-background px-1 py-0.5 text-sm outline-none"
/>
) : (
<span className="flex-1 min-w-0 truncate">{folder.name}</span>
)}
{!isRenaming && childCount > 0 && (
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
{childCount}
</span>
)}
{!isRenaming && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onCreateSubfolder(folder.id);
}}
>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
startRename();
}}
>
<Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onMove(folder);
}}
>
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(folder);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</ContextMenuTrigger>
{!isRenaming && (
<ContextMenuContent className="w-48">
<ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
</ContextMenuItem>
<ContextMenuItem onClick={() => startRename()}>
<Pencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => onMove(folder)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="text-destructive focus:text-destructive" onClick={() => onDelete(folder)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
);
});