/** * TreeBrowser: A tree browser component using react-arborist for managing * Logos pages in a hierarchical folder structure. * * Features: * - Drag and drop reordering * - Folder expansion/collapse * - Context menu for actions * - Shadcn theme integration * - Inline editing for page titles * - Smooth animations * - Search functionality * - Expand/collapse all */ import React, { useCallback, useMemo, useRef, useState } from 'react' import { useResizeHeight } from '@/hooks/useResizeHeight' import { Tree, NodeApi, NodeRendererProps, RowRendererProps, TreeApi, type CursorProps, } from 'react-arborist' import { cn } from '@/lib/utils' import { FileText, ChevronRight, ChevronDown, Plus, MoreHorizontal, Trash2, Pencil, Search, ChevronsDownUp, ChevronsUpDown, X, GripVertical, } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { useRecollectionSidebar } from './RecollectionSidebarContext' import { useParams, useNavigate, useLocation } from 'react-router-dom' import type { LogosPageMeta } from '../state/recollectionStore' import { removeLogosPageContent } from '../state/recollectionStore' // Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object). type TreeNode = LogosPageMeta & { children?: TreeNode[] isFolder: boolean } // Convert flat tree to hierarchical structure function buildTree(pages: LogosPageMeta[]): TreeNode[] { const pageMap = new Map() const rootNodes: TreeNode[] = [] // First pass: create all nodes pages.forEach((page) => { pageMap.set(page.id, { ...page, children: [], isFolder: false, }) }) // Second pass: build parent-child relationships pages.forEach((page) => { const node = pageMap.get(page.id)! if (page.parentId === null) { rootNodes.push(node) } else { const parent = pageMap.get(page.parentId) if (parent) { parent.children?.push(node) parent.isFolder = true } } }) // Sort children by position function sortNodes(nodes: TreeNode[]) { nodes.sort((a, b) => a.position - b.position) nodes.forEach((node) => sortNodes(node.children || [])) } sortNodes(rootNodes) return rootNodes } // Memoize the buildTree result to avoid unnecessary re-creation function useTreeNodes(tree: LogosPageMeta[]) { return useMemo(() => buildTree(tree), [tree]) } // Inline editable title component function EditableTitle({ title, onSave, onCancel, }: { title: string onSave: (newTitle: string) => void onCancel: () => void }) { const inputRef = useRef(null) React.useEffect(() => { inputRef.current?.focus() inputRef.current?.select() }, []) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { onSave(inputRef.current?.value || '') } else if (e.key === 'Escape') { onCancel() } } return ( onSave(e.currentTarget.value)} onKeyDown={handleKeyDown} className="w-full rounded px-1 py-0.5 text-sm outline-none ring-2 ring-ring focus:ring-2 transition-all duration-200 bg-background" onClick={(e) => e.stopPropagation()} /> ) } const ROW_INDENT_PX = 20 const ROW_GUTTER_PX = 8 /** * Line cursor = “insert as sibling at this indent” (not “drop into folder”). * Triangle + dashed rail read as a slot between rows; matches sidebar theme. */ const LogosDropCursor = React.memo(function LogosDropCursor({ top, left, indent }: CursorProps) { return (
) }) /** * Drag ref on a compact handle at the start of the row; handle is a floating pill shown on row hover * (always visible on coarse pointers). Title/icon strip is click-to-navigate only. */ function LogosTreeNode({ node, dragHandle, style }: NodeRendererProps) { const { recollectionId } = useParams<{ recollectionId: string }>() const navigate = useNavigate() const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar() const base = recollectionId ? `/recollections/${recollectionId}` : '' const baseLogos = `${base}/logos` const handleToggle = useCallback( (e: React.MouseEvent) => { e.stopPropagation() node.toggle() }, [node] ) const handleSelect = useCallback( (e: React.MouseEvent) => { if (!node.state.isEditing && !node.state.isDragging) { handleSelectPage(node.data.id) navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`) } }, [handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging] ) const handleRename = useCallback(() => { node.edit() }, [node]) const handleDelete = useCallback(() => { const toDelete = new Set() function collectDescendants(id: string) { toDelete.add(id) tree.forEach((p) => { if (p.parentId === id) collectDescendants(p.id) }) } collectDescendants(node.data.id) if (recollectionId) { toDelete.forEach((pageId) => { removeLogosPageContent(recollectionId, pageId) }) } handleTreeChange(tree.filter((p) => !toDelete.has(p.id))) }, [tree, handleTreeChange, node.data.id, recollectionId]) const handleAddChild = useCallback(() => { const maxPos = Math.max( 0, ...tree.filter((p) => p.parentId === node.data.id).map((p) => p.position), -1 ) const newPage: LogosPageMeta = { id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, title: 'Untitled', parentId: node.data.id, position: maxPos + 1, } handleTreeChange([...tree, newPage]) if (!node.isOpen) node.open() }, [tree, handleTreeChange, node]) const hasChildren = (node.children?.length || 0) > 0 const pageTitle = node.data.title || 'Untitled' return (
{/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */}
e.stopPropagation()} >
{hasChildren ? ( ) : (
)}
{node.state.isEditing ? ( { if (newTitle.trim()) { handleTreeChange( tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p)) ) } node.submit(newTitle) }} onCancel={() => node.reset()} /> ) : ( {pageTitle} )}
e.stopPropagation()} > e.stopPropagation()}> Rename Delete
) } /** Drop target + layout shell; `children` must be the Node renderer (drag layer). */ function TreeRow({ node, innerRef, attrs, children }: RowRendererProps) { const { pathname } = useLocation() const { activePageId } = useRecollectionSidebar() const { recollectionId } = useParams<{ recollectionId: string }>() const base = recollectionId ? `/recollections/${recollectionId}` : '' const baseLogos = `${base}/logos` const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id const level = node.level const indentPx = ROW_GUTTER_PX + level * ROW_INDENT_PX return (
{level > 0 && (
)} {children}
) } export function TreeBrowser() { const { tree, handleTreeChange } = useRecollectionSidebar() const [searchQuery, setSearchQuery] = useState('') const treeRef = useRef | null>(null) const [treeContainerHeight, treeContainerRef] = useResizeHeight(600) // Build tree from flat structure const treeNodes = useTreeNodes(tree) // Filter tree based on search query const filteredTree = useMemo(() => { if (!searchQuery.trim()) return treeNodes const searchTerm = searchQuery.toLowerCase() function filterNodes(nodes: TreeNode[]): TreeNode[] { const result: TreeNode[] = [] for (const node of nodes) { const matches = node.title.toLowerCase().includes(searchTerm) const children = filterNodes(node.children || []) if (matches || children.length > 0) { result.push({ ...node, children: children.length > 0 ? children : undefined, }) } } return result } return filterNodes(treeNodes) }, [treeNodes, searchQuery]) // Get all folder IDs for initial open state const initialOpenState = useMemo(() => { const folderIds: Record = {} function collectIds(nodes: TreeNode[]) { nodes.forEach((node) => { if (node.isFolder) { folderIds[node.id] = true collectIds(node.children || []) } }) } collectIds(treeNodes) return folderIds }, [treeNodes]) // Handle drag and drop reordering (react-arborist onMove) const handleMove = useCallback( ({ dragIds, parentId: newParentId, index, }: { dragIds: string[] parentId: string | null index: number }) => { const dragId = dragIds[0] if (!dragId) return const dragged = tree.find((p) => p.id === dragId) if (!dragged) return const oldParentId = dragged.parentId const moved: LogosPageMeta = { ...dragged, parentId: newParentId } const newSiblings = tree .filter((p) => p.parentId === newParentId && p.id !== dragId) .sort((a, b) => a.position - b.position) newSiblings.splice(index, 0, moved) const updates = new Map() newSiblings.forEach((p, i) => { updates.set(p.id, { ...p, parentId: newParentId, position: i }) }) if (oldParentId !== newParentId) { tree .filter((p) => p.parentId === oldParentId && p.id !== dragId) .sort((a, b) => a.position - b.position) .forEach((p, i) => { updates.set(p.id, { ...p, position: i }) }) } handleTreeChange(tree.map((p) => updates.get(p.id) ?? p)) }, [tree, handleTreeChange] ) // Expand all folders const handleExpandAll = useCallback(() => { treeRef.current?.openAll() }, []) // Collapse all folders const handleCollapseAll = useCallback(() => { treeRef.current?.closeAll() }, []) // Clear search const handleClearSearch = useCallback(() => { setSearchQuery('') }, []) // Fixed prop shape: react-arborist TreeProvider uses [...Object.values(treeProps), …] as useMemo deps; // varying key counts on the props object trigger "dependency array changed size" warnings. const arboristTreeProps = useMemo( () => ({ data: filteredTree, idAccessor: 'id' as const, childrenAccessor: 'children' as const, width: '100%' as const, height: Math.max(treeContainerHeight, 100), rowHeight: 32, indent: ROW_INDENT_PX, renderRow: TreeRow, initialOpenState, onMove: handleMove, disableDrag: Boolean(searchQuery), disableDrop: Boolean(searchQuery), className: 'react-arborist-tree', renderCursor: LogosDropCursor, children: LogosTreeNode, }), [filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight] ) return (
{/* Search Toolbar */}
setSearchQuery(e.target.value)} className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border" /> {searchQuery && ( )}
Expand all Collapse all
{/* Tree */}
) }