feat: sidebar
This commit is contained in:
@@ -7,8 +7,7 @@ import { Outlet, useParams, useNavigate } from 'react-router-dom'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
|
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
|
||||||
import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext'
|
import { WorkspaceTreeProvider } from './layout/WorkspaceTreeContext'
|
||||||
import { FluxSceneProvider } from './flux/FluxSceneContext'
|
|
||||||
import { RecollectionMenubar } from './layout/RecollectionMenubar'
|
import { RecollectionMenubar } from './layout/RecollectionMenubar'
|
||||||
import { RecollectionSidebar } from './layout/RecollectionSidebar'
|
import { RecollectionSidebar } from './layout/RecollectionSidebar'
|
||||||
|
|
||||||
@@ -50,19 +49,17 @@ export function RecollectionLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RecollectionActionsProvider>
|
<RecollectionActionsProvider>
|
||||||
<RecollectionSidebarProvider>
|
<WorkspaceTreeProvider>
|
||||||
<FluxSceneProvider>
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<RecollectionMenubar />
|
||||||
<RecollectionMenubar />
|
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
|
||||||
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
|
<RecollectionSidebar />
|
||||||
<RecollectionSidebar />
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<Outlet />
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FluxSceneProvider>
|
</div>
|
||||||
</RecollectionSidebarProvider>
|
</WorkspaceTreeProvider>
|
||||||
</RecollectionActionsProvider>
|
</RecollectionActionsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import React, { useEffect, useRef } from 'react'
|
|||||||
import { useParams, useSearchParams } from 'react-router-dom'
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { useFluxScenes } from './FluxSceneContext'
|
import { useFluxScenes } from '../layout/WorkspaceTreeContext'
|
||||||
|
|
||||||
export function FluxRoute() {
|
export function FluxRoute() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
|||||||
@@ -1,237 +1,275 @@
|
|||||||
/**
|
/**
|
||||||
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
|
* Recollection sidebar: VS Code-style collapsible/resizable sections.
|
||||||
|
* - Workspace: unified tree (pages, scenes, folders)
|
||||||
|
* - Katalogos: artifacts & run history
|
||||||
|
* Horizontally resizable via right-edge drag. Sections vertically resizable via divider drag.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useRef, useState } from 'react'
|
||||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Plus, FileText, Folder, ChevronDown, ChevronRight, Search, X, ChevronsDownUp, ChevronsUpDown } from 'lucide-react'
|
||||||
import { Plus, FileText, Pencil, Trash2, Check, X } from 'lucide-react'
|
|
||||||
import { FluxIcon } from '@/lib/icons'
|
import { FluxIcon } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
import { useWorkspaceTree } from './WorkspaceTreeContext'
|
||||||
import { useOptionalFluxScenes } from '../flux/FluxSceneContext'
|
import { TreeBrowser, type TreeBrowserHandle } from './TreeBrowser'
|
||||||
import { TreeBrowser } from './TreeBrowser'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
SidebarContent,
|
DropdownMenu,
|
||||||
SidebarGroup,
|
DropdownMenuContent,
|
||||||
SidebarGroupContent,
|
DropdownMenuItem,
|
||||||
SidebarGroupLabel,
|
DropdownMenuTrigger,
|
||||||
SidebarMenu,
|
} from '@/components/ui/dropdown-menu'
|
||||||
SidebarMenuItem,
|
|
||||||
sidebarMenuButtonVariants,
|
|
||||||
} from '@/components/ui/sidebar'
|
|
||||||
|
|
||||||
export function RecollectionSidebar() {
|
// ---------------------------------------------------------------------------
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
// Constants
|
||||||
const { pathname } = useLocation()
|
// ---------------------------------------------------------------------------
|
||||||
const navigate = useNavigate()
|
|
||||||
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
|
||||||
|
|
||||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
const MIN_WIDTH = 180
|
||||||
const baseLogos = `${base}/logos`
|
const MAX_WIDTH = 480
|
||||||
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
const DEFAULT_WIDTH = 240
|
||||||
const isFluxView = pathname.endsWith('/flux')
|
const SECTION_HEADER_H = 28
|
||||||
|
const MIN_SECTION_H = SECTION_HEADER_H + 32 // header + one row
|
||||||
|
|
||||||
const handleAddPage = useCallback(() => {
|
// ---------------------------------------------------------------------------
|
||||||
// Add a new top-level page
|
// Section header (VS Code style: uppercase label, chevron, actions on hover)
|
||||||
const maxPos = Math.max(0, ...tree.filter((p) => p.parentId === null).map((p) => p.position), -1)
|
// ---------------------------------------------------------------------------
|
||||||
const newPage = {
|
|
||||||
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
||||||
title: 'Untitled',
|
|
||||||
parentId: null,
|
|
||||||
position: maxPos + 1,
|
|
||||||
}
|
|
||||||
handleTreeChange([...tree, newPage])
|
|
||||||
handleSelectPage(newPage.id)
|
|
||||||
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
|
|
||||||
}, [tree, handleTreeChange, handleSelectPage, baseLogos, navigate])
|
|
||||||
|
|
||||||
|
function SectionHeader({
|
||||||
|
title,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
open: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
actions?: React.ReactNode
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
|
className="group/header flex h-7 shrink-0 items-center gap-1 px-2 select-none cursor-pointer border-b border-sidebar-border/50 bg-sidebar hover:bg-sidebar-accent/40 transition-colors"
|
||||||
style={{ '--sidebar-width': '16rem' } as React.CSSProperties}
|
onClick={onToggle}
|
||||||
>
|
>
|
||||||
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
|
{open
|
||||||
<SidebarGroup>
|
? <ChevronDown className="size-3 shrink-0 opacity-50" />
|
||||||
<div className="flex items-center justify-between gap-2 py-1.5">
|
: <ChevronRight className="size-3 shrink-0 opacity-50" />}
|
||||||
<SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel>
|
<span className="flex-1 min-w-0 truncate text-[11px] font-semibold uppercase tracking-wider opacity-70">
|
||||||
<Button
|
{title}
|
||||||
type="button"
|
</span>
|
||||||
variant="ghost"
|
{actions && (
|
||||||
size="sm"
|
<div
|
||||||
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
className="flex shrink-0 items-center opacity-0 group-hover/header:opacity-100 transition-opacity"
|
||||||
onClick={handleAddPage}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
{actions}
|
||||||
New page
|
</div>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<TreeBrowser />
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>Katalogos</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-active={isKatalogosView}
|
|
||||||
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
|
||||||
onClick={() => navigate(`${base}/logos/katalogos`)}
|
|
||||||
>
|
|
||||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
|
||||||
<span className="truncate">Artifacts</span>
|
|
||||||
</button>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
<FluxScenesSection base={base} isFluxView={isFluxView} />
|
|
||||||
</SidebarContent>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Flux Scenes Section
|
// Katalogos section content
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function FluxScenesSection({ base, isFluxView }: { base: string; isFluxView: boolean }) {
|
function KatalogosContent() {
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const { pathname } = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const fluxScenes = useOptionalFluxScenes()
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
||||||
const [renameValue, setRenameValue] = useState('')
|
|
||||||
|
|
||||||
// Fallback: single "Canvas" link when provider isn't mounted
|
|
||||||
if (!fluxScenes) {
|
|
||||||
return (
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>Flux</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-active={isFluxView}
|
|
||||||
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
|
||||||
onClick={() => navigate(`${base}/flux`)}
|
|
||||||
>
|
|
||||||
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
|
||||||
<span className="truncate">Canvas</span>
|
|
||||||
</button>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { scenes, activeSceneId, handleSelectScene, handleAddScene, handleRenameScene, handleDeleteScene } = fluxScenes
|
|
||||||
|
|
||||||
const startRename = (id: string, currentTitle: string) => {
|
|
||||||
setRenamingId(id)
|
|
||||||
setRenameValue(currentTitle)
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitRename = () => {
|
|
||||||
if (renamingId && renameValue.trim()) {
|
|
||||||
handleRenameScene(renamingId, renameValue.trim())
|
|
||||||
}
|
|
||||||
setRenamingId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelRename = () => {
|
|
||||||
setRenamingId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<div className="flex flex-col gap-0.5 p-1 overflow-y-auto">
|
||||||
<div className="flex items-center justify-between gap-2 py-1.5">
|
<button
|
||||||
<SidebarGroupLabel className="py-0">Flux</SidebarGroupLabel>
|
type="button"
|
||||||
<Button
|
data-active={isKatalogosView || undefined}
|
||||||
type="button"
|
className={cn(
|
||||||
variant="ghost"
|
'flex items-center gap-2 rounded-sm px-2 h-[30px] text-[13px] w-full text-left transition-colors',
|
||||||
size="sm"
|
'hover:bg-sidebar-accent/60',
|
||||||
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
isKatalogosView && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
|
||||||
onClick={() => {
|
)}
|
||||||
const id = handleAddScene()
|
onClick={() => navigate(`${base}/logos/katalogos`)}
|
||||||
navigate(`${base}/flux?scene=${encodeURIComponent(id)}`)
|
>
|
||||||
}}
|
<FileText className="size-[15px] shrink-0 opacity-60" />
|
||||||
>
|
<span className="truncate">Artifacts</span>
|
||||||
<Plus className="size-3.5" />
|
</button>
|
||||||
New scene
|
</div>
|
||||||
</Button>
|
)
|
||||||
</div>
|
}
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
// ---------------------------------------------------------------------------
|
||||||
{scenes.map((scene) => {
|
// Sidebar
|
||||||
const isActive = isFluxView && scene.id === activeSceneId
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
return (
|
export function RecollectionSidebar() {
|
||||||
<SidebarMenuItem key={scene.id}>
|
const { handleAddNode } = useWorkspaceTree()
|
||||||
{renamingId === scene.id ? (
|
|
||||||
<div className="flex w-full items-center gap-1 px-2 py-1">
|
// Search & tree ref
|
||||||
<input
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
autoFocus
|
const [searchVisible, setSearchVisible] = useState(false)
|
||||||
className="h-6 flex-1 min-w-0 rounded border border-sidebar-border bg-sidebar px-1.5 text-xs outline-none focus:ring-1 focus:ring-sidebar-ring"
|
const treeBrowserRef = useRef<TreeBrowserHandle>(null)
|
||||||
value={renameValue}
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
// Horizontal resize
|
||||||
if (e.key === 'Enter') commitRename()
|
const [width, setWidth] = useState(DEFAULT_WIDTH)
|
||||||
if (e.key === 'Escape') cancelRename()
|
const onHResizeStart = useCallback((e: React.PointerEvent) => {
|
||||||
}}
|
e.preventDefault()
|
||||||
onBlur={commitRename}
|
const startX = e.clientX
|
||||||
/>
|
const startW = width
|
||||||
<button type="button" onClick={commitRename} className="shrink-0 text-sidebar-foreground/70 hover:text-sidebar-foreground">
|
const onMove = (ev: PointerEvent) => setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startW + (ev.clientX - startX))))
|
||||||
<Check className="size-3.5" />
|
const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = '' }
|
||||||
</button>
|
document.addEventListener('pointermove', onMove)
|
||||||
<button type="button" onClick={cancelRename} className="shrink-0 text-sidebar-foreground/70 hover:text-sidebar-foreground">
|
document.addEventListener('pointerup', onUp)
|
||||||
<X className="size-3.5" />
|
document.body.style.cursor = 'col-resize'
|
||||||
</button>
|
document.body.style.userSelect = 'none'
|
||||||
</div>
|
}, [width])
|
||||||
) : (
|
|
||||||
<div className="group flex w-full items-center">
|
// Section collapse
|
||||||
<button
|
const [workspaceOpen, setWorkspaceOpen] = useState(true)
|
||||||
type="button"
|
const [katalogosOpen, setKatalogosOpen] = useState(true)
|
||||||
data-active={isActive}
|
|
||||||
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'flex-1 min-w-0')}
|
// Vertical section resize (drag the divider between sections)
|
||||||
onClick={() => {
|
// workspaceFlex: fraction of available space for workspace section (0.2–0.8)
|
||||||
handleSelectScene(scene.id)
|
const [workspaceFlex, setWorkspaceFlex] = useState(0.75)
|
||||||
navigate(`${base}/flux?scene=${encodeURIComponent(scene.id)}`)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
}}
|
|
||||||
>
|
const onVResizeStart = useCallback((e: React.PointerEvent) => {
|
||||||
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
e.preventDefault()
|
||||||
<span className="truncate">{scene.title}</span>
|
const container = containerRef.current
|
||||||
</button>
|
if (!container) return
|
||||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-1">
|
const startY = e.clientY
|
||||||
<button
|
const startFlex = workspaceFlex
|
||||||
type="button"
|
const containerH = container.clientHeight
|
||||||
onClick={(e) => { e.stopPropagation(); startRename(scene.id, scene.title) }}
|
|
||||||
className="rounded p-0.5 text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent"
|
const onMove = (ev: PointerEvent) => {
|
||||||
title="Rename scene"
|
const delta = ev.clientY - startY
|
||||||
>
|
const newFlex = startFlex + delta / containerH
|
||||||
<Pencil className="size-3" />
|
setWorkspaceFlex(Math.min(0.85, Math.max(0.15, newFlex)))
|
||||||
</button>
|
}
|
||||||
{scenes.length > 1 && (
|
const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = '' }
|
||||||
<button
|
document.addEventListener('pointermove', onMove)
|
||||||
type="button"
|
document.addEventListener('pointerup', onUp)
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteScene(scene.id) }}
|
document.body.style.cursor = 'row-resize'
|
||||||
className="rounded p-0.5 text-sidebar-foreground/50 hover:text-destructive hover:bg-sidebar-accent"
|
document.body.style.userSelect = 'none'
|
||||||
title="Delete scene"
|
}, [workspaceFlex])
|
||||||
>
|
|
||||||
<Trash2 className="size-3" />
|
const bothOpen = workspaceOpen && katalogosOpen
|
||||||
</button>
|
|
||||||
)}
|
return (
|
||||||
</div>
|
<div
|
||||||
</div>
|
className="relative flex h-full shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground overflow-hidden"
|
||||||
)}
|
style={{ width }}
|
||||||
</SidebarMenuItem>
|
>
|
||||||
)
|
<div ref={containerRef} className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
})}
|
{/* Workspace section */}
|
||||||
</SidebarMenu>
|
<div
|
||||||
</SidebarGroupContent>
|
className="flex flex-col overflow-hidden"
|
||||||
</SidebarGroup>
|
style={workspaceOpen && bothOpen ? { flex: `${workspaceFlex} 1 0%`, minHeight: MIN_SECTION_H } : workspaceOpen ? { flex: '1 1 0%', minHeight: MIN_SECTION_H } : undefined}
|
||||||
|
>
|
||||||
|
<SectionHeader
|
||||||
|
title="Workspace"
|
||||||
|
open={workspaceOpen}
|
||||||
|
onToggle={() => setWorkspaceOpen((v) => !v)}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
|
||||||
|
title="Search"
|
||||||
|
onClick={() => { setSearchVisible((v) => { if (!v) setTimeout(() => searchInputRef.current?.focus(), 0); return !v }); if (searchVisible) setSearchQuery('') }}
|
||||||
|
>
|
||||||
|
<Search className="size-3 opacity-70" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
|
||||||
|
title="Collapse all"
|
||||||
|
onClick={() => treeBrowserRef.current?.closeAll()}
|
||||||
|
>
|
||||||
|
<ChevronsDownUp className="size-3 opacity-70" />
|
||||||
|
</button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
|
||||||
|
<Plus className="size-3 opacity-70" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
|
<DropdownMenuItem onClick={() => handleAddNode('page')}>
|
||||||
|
<FileText className="mr-2 size-3.5" /> Page
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleAddNode('scene')}>
|
||||||
|
<FluxIcon className="mr-2 size-3.5" /> Scene
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleAddNode('folder')}>
|
||||||
|
<Folder className="mr-2 size-3.5" /> Folder
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{workspaceOpen && (
|
||||||
|
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||||
|
{searchVisible && (
|
||||||
|
<div className="flex items-center gap-1 px-1.5 py-1 shrink-0">
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Escape') { setSearchQuery(''); setSearchVisible(false) } }}
|
||||||
|
className="h-6 text-xs pl-2 pr-6 bg-sidebar-accent/30 border-sidebar-border/50 focus:bg-sidebar-accent/50"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button type="button" className="absolute right-1 top-1/2 -translate-y-1/2 flex size-4 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent" onClick={() => setSearchQuery('')}>
|
||||||
|
<X className="size-2.5 opacity-60" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
<TreeBrowser ref={treeBrowserRef} searchQuery={searchQuery} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vertical resize divider (only when both sections open) */}
|
||||||
|
{bothOpen && (
|
||||||
|
<div
|
||||||
|
className="h-px shrink-0 cursor-row-resize bg-sidebar-border/50 hover:bg-primary/30 active:bg-primary/40 transition-colors"
|
||||||
|
onPointerDown={onVResizeStart}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Katalogos section */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col overflow-hidden"
|
||||||
|
style={katalogosOpen && bothOpen ? { flex: `${1 - workspaceFlex} 1 0%`, minHeight: MIN_SECTION_H } : katalogosOpen ? { flex: '1 1 0%', minHeight: MIN_SECTION_H } : undefined}
|
||||||
|
>
|
||||||
|
<SectionHeader
|
||||||
|
title="Katalogos"
|
||||||
|
open={katalogosOpen}
|
||||||
|
onToggle={() => setKatalogosOpen((v) => !v)}
|
||||||
|
/>
|
||||||
|
{katalogosOpen && (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<KatalogosContent />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Horizontal resize handle */}
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors z-10"
|
||||||
|
onPointerDown={onHResizeStart}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* TreeBrowser: A tree browser component using react-arborist for managing
|
* TreeBrowser: Unified workspace tree (pages, scenes, folders).
|
||||||
* Logos pages in a hierarchical folder structure.
|
* Notion/VS Code inspired design. Only folders accept children.
|
||||||
*
|
|
||||||
* 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 React, { useCallback, useMemo, useRef } from 'react'
|
||||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||||
import {
|
import {
|
||||||
Tree,
|
Tree,
|
||||||
NodeApi,
|
|
||||||
NodeRendererProps,
|
NodeRendererProps,
|
||||||
RowRendererProps,
|
RowRendererProps,
|
||||||
TreeApi,
|
TreeApi,
|
||||||
@@ -32,13 +21,10 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Trash2,
|
Trash2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Search,
|
Folder,
|
||||||
ChevronsDownUp,
|
FolderOpen,
|
||||||
ChevronsUpDown,
|
|
||||||
X,
|
|
||||||
GripVertical,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { FluxIcon } from '@/lib/icons'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -46,489 +32,332 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { Input } from '@/components/ui/input'
|
import { useWorkspaceTree } from './WorkspaceTreeContext'
|
||||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
import type { WorkspaceNodeMeta } from '../state/recollectionStore'
|
||||||
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 & {
|
// Tree node type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type TreeNode = WorkspaceNodeMeta & {
|
||||||
children?: TreeNode[]
|
children?: TreeNode[]
|
||||||
isFolder: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert flat tree to hierarchical structure
|
function buildTree(nodes: WorkspaceNodeMeta[]): TreeNode[] {
|
||||||
function buildTree(pages: LogosPageMeta[]): TreeNode[] {
|
const nodeMap = new Map<string, TreeNode>()
|
||||||
const pageMap = new Map<string, TreeNode>()
|
|
||||||
const rootNodes: TreeNode[] = []
|
const rootNodes: TreeNode[] = []
|
||||||
|
|
||||||
// First pass: create all nodes
|
for (const n of nodes) nodeMap.set(n.id, { ...n, children: n.kind === 'folder' ? [] : undefined })
|
||||||
pages.forEach((page) => {
|
|
||||||
pageMap.set(page.id, {
|
|
||||||
...page,
|
|
||||||
children: [],
|
|
||||||
isFolder: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Second pass: build parent-child relationships
|
for (const n of nodes) {
|
||||||
pages.forEach((page) => {
|
const tn = nodeMap.get(n.id)!
|
||||||
const node = pageMap.get(page.id)!
|
if (n.parentId === null) {
|
||||||
if (page.parentId === null) {
|
rootNodes.push(tn)
|
||||||
rootNodes.push(node)
|
|
||||||
} else {
|
} else {
|
||||||
const parent = pageMap.get(page.parentId)
|
const parent = nodeMap.get(n.parentId)
|
||||||
if (parent) {
|
if (parent?.children) parent.children.push(tn)
|
||||||
parent.children?.push(node)
|
else rootNodes.push(tn) // orphan fallback
|
||||||
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)
|
|
||||||
|
|
||||||
|
const sort = (ns: TreeNode[]) => {
|
||||||
|
ns.sort((a, b) => {
|
||||||
|
// Folders first, then by position
|
||||||
|
const aIsFolder = a.kind === 'folder' ? 0 : 1
|
||||||
|
const bIsFolder = b.kind === 'folder' ? 0 : 1
|
||||||
|
if (aIsFolder !== bIsFolder) return aIsFolder - bIsFolder
|
||||||
|
return a.position - b.position
|
||||||
|
})
|
||||||
|
ns.forEach((n) => { if (n.children) sort(n.children) })
|
||||||
|
}
|
||||||
|
sort(rootNodes)
|
||||||
return rootNodes
|
return rootNodes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoize the buildTree result to avoid unnecessary re-creation
|
// ---------------------------------------------------------------------------
|
||||||
function useTreeNodes(tree: LogosPageMeta[]) {
|
// Inline title editor
|
||||||
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<HTMLInputElement>(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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function EditableTitle({ title, onSave, onCancel }: { title: string; onSave: (v: string) => void; onCancel: () => void }) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null)
|
||||||
|
React.useEffect(() => { ref.current?.focus(); ref.current?.select() }, [])
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={ref}
|
||||||
type="text"
|
type="text"
|
||||||
defaultValue={title}
|
defaultValue={title}
|
||||||
onBlur={(e) => onSave(e.currentTarget.value)}
|
onBlur={(e) => onSave(e.currentTarget.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={(e) => { if (e.key === 'Enter') onSave(ref.current?.value || ''); else if (e.key === 'Escape') onCancel() }}
|
||||||
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"
|
className="w-full rounded-sm px-1 py-0.5 text-[13px] outline-none ring-1 ring-primary/50 bg-background"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_INDENT_PX = 20
|
// ---------------------------------------------------------------------------
|
||||||
const ROW_GUTTER_PX = 8
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
const INDENT = 10
|
||||||
* Line cursor = “insert as sibling at this indent” (not “drop into folder”).
|
const GUTTER = 2
|
||||||
* Triangle + dashed rail read as a slot between rows; matches sidebar theme.
|
|
||||||
*/
|
// ---------------------------------------------------------------------------
|
||||||
const LogosDropCursor = React.memo(function LogosDropCursor({ top, left, indent }: CursorProps) {
|
// Drop cursor (line between rows)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DropCursor = React.memo(function DropCursor({ top, left, indent }: CursorProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="presentation"
|
role="presentation"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none absolute z-20 flex items-center gap-1.5"
|
className="pointer-events-none absolute z-20 flex items-center"
|
||||||
style={{
|
style={{ top: top - 1, left: left + GUTTER + 4, right: GUTTER + 4 }}
|
||||||
top: top - 5,
|
|
||||||
left: left + ROW_GUTTER_PX,
|
|
||||||
right: Math.max(indent, ROW_GUTTER_PX),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="flex h-3 w-2.5 shrink-0 items-center justify-center text-primary drop-shadow-sm">
|
<span className="size-1.5 rounded-full bg-primary shrink-0" />
|
||||||
<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor" aria-hidden>
|
<div className="h-0.5 flex-1 bg-primary rounded-full" />
|
||||||
<path d="M0 5 L9 1.5 L9 8.5 Z" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div className="flex h-3 min-w-[2rem] flex-1 items-center">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'h-0 w-full border-t-2 border-dashed border-primary',
|
|
||||||
'shadow-[0_1px_0_0_hsl(var(--primary)/0.35)]',
|
|
||||||
'motion-safe:animate-[tree-drop-line-pulse_1.8s_ease-in-out_infinite]'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------------
|
||||||
* Drag ref on a compact handle at the start of the row; handle is a floating pill shown on row hover
|
// Node icon
|
||||||
* (always visible on coarse pointers). Title/icon strip is click-to-navigate only.
|
// ---------------------------------------------------------------------------
|
||||||
*/
|
|
||||||
function LogosTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
|
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
|
||||||
|
|
||||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
function NodeIcon({ kind, isOpen }: { kind: string; isOpen?: boolean }) {
|
||||||
const baseLogos = `${base}/logos`
|
const cls = 'size-[15px] shrink-0 opacity-60'
|
||||||
|
if (kind === 'scene') return <FluxIcon className={cls} />
|
||||||
|
if (kind === 'folder') return isOpen ? <FolderOpen className={cls} /> : <Folder className={cls} />
|
||||||
|
return <FileText className={cls} />
|
||||||
|
}
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
// ---------------------------------------------------------------------------
|
||||||
(e: React.MouseEvent) => {
|
// Tree node renderer (Notion/VS Code style)
|
||||||
e.stopPropagation()
|
// ---------------------------------------------------------------------------
|
||||||
node.toggle()
|
|
||||||
},
|
|
||||||
[node]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
function WorkspaceTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
|
||||||
(e: React.MouseEvent) => {
|
const { handleSelectItem, handleAddNode, handleRenameNode, handleDeleteNode } = useWorkspaceTree()
|
||||||
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(() => {
|
const isFolder = node.data.kind === 'folder'
|
||||||
node.edit()
|
const hasChildren = isFolder && (node.children?.length || 0) > 0
|
||||||
}, [node])
|
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
const toDelete = new Set<string>()
|
if (node.state.isEditing || node.state.isDragging) return
|
||||||
function collectDescendants(id: string) {
|
if (isFolder) node.toggle()
|
||||||
toDelete.add(id)
|
else handleSelectItem(node.data.id)
|
||||||
tree.forEach((p) => {
|
}, [handleSelectItem, node, isFolder])
|
||||||
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'
|
|
||||||
|
|
||||||
|
// The entire row is the drag handle — Notion style
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
<div
|
||||||
{/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */}
|
ref={dragHandle}
|
||||||
<div
|
style={{ ...style, paddingLeft: 0 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex shrink-0 items-center justify-center overflow-hidden',
|
'flex min-w-0 flex-1 items-center gap-0.5 select-none overflow-hidden',
|
||||||
'transition-[width,opacity] duration-200 ease-out',
|
node.state.isEditing ? 'cursor-text' : node.state.isDragging ? 'cursor-grabbing' : 'cursor-default',
|
||||||
'w-0 opacity-0 group-hover:w-4 group-hover:opacity-100',
|
node.state.isDragging && 'opacity-40'
|
||||||
'[@media(pointer:coarse)]:w-5 [@media(pointer:coarse)]:opacity-100',
|
)}
|
||||||
node.state.isDragging && 'w-4 opacity-100 [@media(pointer:coarse)]:w-5',
|
onClick={handleClick}
|
||||||
node.state.isEditing && 'pointer-events-none w-0 opacity-0'
|
>
|
||||||
)}
|
{/* Chevron (folders only) */}
|
||||||
>
|
{isFolder ? (
|
||||||
<div
|
|
||||||
ref={dragHandle}
|
|
||||||
style={{ ...style, paddingLeft: 0 }}
|
|
||||||
title={node.state.isEditing ? undefined : 'Drag to reorder'}
|
|
||||||
className={cn(
|
|
||||||
'logos-tree-drag-handle group/drag',
|
|
||||||
'relative flex h-6 w-4 shrink-0 touch-none items-center justify-center rounded-md',
|
|
||||||
'[@media(pointer:coarse)]:h-7 [@media(pointer:coarse)]:w-5',
|
|
||||||
'border border-sidebar-border/50 bg-sidebar-accent/40',
|
|
||||||
'text-sidebar-foreground/70',
|
|
||||||
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out',
|
|
||||||
'hover:border-sidebar-border hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
|
||||||
'hover:shadow-sm',
|
|
||||||
'active:scale-[0.97] active:cursor-grabbing',
|
|
||||||
'cursor-grab outline-none',
|
|
||||||
'dark:border-sidebar-border/55 dark:bg-sidebar-accent/35 dark:text-sidebar-foreground/75',
|
|
||||||
'dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground',
|
|
||||||
node.state.isDragging &&
|
|
||||||
'cursor-grabbing border-primary/55 bg-primary/18 text-primary shadow-sm ring-1 ring-primary/30 dark:border-primary/50 dark:bg-primary/22 dark:text-primary'
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<GripVertical
|
|
||||||
className={cn(
|
|
||||||
'size-3 shrink-0 text-current transition-opacity duration-150 opacity-85 group-hover/drag:opacity-100',
|
|
||||||
node.state.isDragging && 'opacity-100'
|
|
||||||
)}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasChildren ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
className="flex size-5 shrink-0 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
|
||||||
onClick={handleToggle}
|
onClick={(e) => { e.stopPropagation(); node.toggle() }}
|
||||||
>
|
>
|
||||||
{node.isOpen ? (
|
{node.isOpen
|
||||||
<ChevronDown className="size-3.5 text-sidebar-foreground/70" />
|
? <ChevronDown className="size-3 opacity-50" />
|
||||||
) : (
|
: <ChevronRight className="size-3 opacity-50" />}
|
||||||
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-4 shrink-0" />
|
<div className="w-5 shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{/* Icon */}
|
||||||
className={cn(
|
<NodeIcon kind={node.data.kind} isOpen={node.isOpen} />
|
||||||
'flex min-h-[28px] min-w-0 flex-1 items-center gap-1 rounded-md px-0.5 -mx-0.5',
|
|
||||||
node.state.isEditing ? 'cursor-text' : 'cursor-pointer hover:bg-sidebar-accent/40'
|
{/* Title */}
|
||||||
|
<div className="min-w-0 flex-1 overflow-hidden pl-1.5">
|
||||||
|
{node.state.isEditing ? (
|
||||||
|
<EditableTitle
|
||||||
|
title={node.data.title}
|
||||||
|
onSave={(v) => { if (v.trim()) handleRenameNode(node.data.id, v.trim()); node.submit(v) }}
|
||||||
|
onCancel={() => node.reset()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="block truncate text-[13px] leading-tight cursor-pointer" title={node.data.title}>{node.data.title || 'Untitled'}</span>
|
||||||
)}
|
)}
|
||||||
onClick={handleSelect}
|
|
||||||
>
|
|
||||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{node.state.isEditing ? (
|
|
||||||
<EditableTitle
|
|
||||||
title={node.data.title}
|
|
||||||
onSave={(newTitle) => {
|
|
||||||
if (newTitle.trim()) {
|
|
||||||
handleTreeChange(
|
|
||||||
tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
node.submit(newTitle)
|
|
||||||
}}
|
|
||||||
onCancel={() => node.reset()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="block truncate select-none">{pageTitle}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{/* Hover actions */}
|
||||||
className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
{!node.state.isEditing && (
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
<div
|
||||||
>
|
className="flex shrink-0 items-center cursor-default opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
<button
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
type="button"
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleAddChild()
|
|
||||||
}}
|
|
||||||
title="Add child page"
|
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5 text-sidebar-foreground/70" />
|
{/* Plus — only on folders (add inside) or any node (add sibling) */}
|
||||||
</button>
|
<DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
|
||||||
<button
|
<Plus className="size-3 opacity-60" />
|
||||||
type="button"
|
</button>
|
||||||
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
</DropdownMenuTrigger>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
>
|
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('page', pid); if (isFolder && !node.isOpen) node.open() }}>
|
||||||
<MoreHorizontal className="size-3.5 text-sidebar-foreground/70" />
|
<FileText className="mr-2 size-3.5" /> Page
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('scene', pid); if (isFolder && !node.isOpen) node.open() }}>
|
||||||
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
|
<FluxIcon className="mr-2 size-3.5" /> Scene
|
||||||
<DropdownMenuItem onClick={handleRename}>
|
</DropdownMenuItem>
|
||||||
<Pencil className="mr-2 size-3.5" />
|
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('folder', pid); if (isFolder && !node.isOpen) node.open() }}>
|
||||||
Rename
|
<Folder className="mr-2 size-3.5" /> Folder
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
</DropdownMenuContent>
|
||||||
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive">
|
</DropdownMenu>
|
||||||
<Trash2 className="mr-2 size-3.5" />
|
|
||||||
Delete
|
{/* More */}
|
||||||
</DropdownMenuItem>
|
<DropdownMenu>
|
||||||
</DropdownMenuContent>
|
<DropdownMenuTrigger asChild>
|
||||||
</DropdownMenu>
|
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
|
||||||
</div>
|
<MoreHorizontal className="size-3 opacity-60" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
|
<DropdownMenuItem onClick={() => node.edit()}>
|
||||||
|
<Pencil className="mr-2 size-3.5" /> Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => handleDeleteNode(node.data.id)} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 size-3.5" /> Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */
|
// ---------------------------------------------------------------------------
|
||||||
|
// Row renderer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) {
|
function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) {
|
||||||
const { pathname } = useLocation()
|
const { activeItemId } = useWorkspaceTree()
|
||||||
const { activePageId } = useRecollectionSidebar()
|
const isActive = activeItemId === node.data.id
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const indentPx = GUTTER + node.level * INDENT
|
||||||
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
{...attrs}
|
{...attrs}
|
||||||
style={{
|
style={{ ...attrs.style, paddingLeft: `${indentPx}px` }}
|
||||||
...attrs.style,
|
|
||||||
paddingLeft: `${indentPx}px`,
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md transition-[box-shadow,background-color,ring-color] duration-150',
|
'group relative flex items-center pr-1 h-[30px] text-[13px] rounded-sm mx-1',
|
||||||
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
'transition-colors duration-100',
|
||||||
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
'hover:bg-sidebar-accent/60',
|
||||||
node.state.isDragging && 'cursor-grabbing opacity-45',
|
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium',
|
||||||
// Folder drop target: index === null in library — reads as “open container”, not a line between rows
|
node.state.isDragging && 'opacity-40',
|
||||||
node.state.willReceiveDrop &&
|
node.state.willReceiveDrop && node.data.kind === 'folder' &&
|
||||||
'cursor-copy bg-sidebar-accent/30 ring-2 ring-inset ring-dashed ring-sidebar-ring/80 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]'
|
'bg-sidebar-accent/40 ring-1 ring-primary/40 cursor-copy'
|
||||||
)}
|
)}
|
||||||
title={node.state.willReceiveDrop ? 'Release to drop inside this folder' : undefined}
|
|
||||||
>
|
>
|
||||||
{level > 0 && (
|
{/* VS Code-style indent guides */}
|
||||||
|
{Array.from({ length: node.level }, (_, i) => (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute bottom-0 top-0 border-l-2 border-sidebar-border/45"
|
key={i}
|
||||||
style={{ left: `${ROW_GUTTER_PX + (level - 1) * ROW_INDENT_PX + ROW_INDENT_PX / 2}px` }}
|
className="pointer-events-none absolute top-0 bottom-0 w-px bg-sidebar-border/40"
|
||||||
|
style={{ left: `${GUTTER + i * INDENT + INDENT / 2}px` }}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreeBrowser() {
|
// ---------------------------------------------------------------------------
|
||||||
const { tree, handleTreeChange } = useRecollectionSidebar()
|
// TreeBrowser
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type TreeBrowserHandle = {
|
||||||
|
openAll: () => void
|
||||||
|
closeAll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TreeBrowserProps = {
|
||||||
|
searchQuery?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TreeBrowser = React.forwardRef<TreeBrowserHandle, TreeBrowserProps>(
|
||||||
|
function TreeBrowser({ searchQuery = '' }, ref) {
|
||||||
|
const { tree, handleTreeChange } = useWorkspaceTree()
|
||||||
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
||||||
const [treeContainerHeight, treeContainerRef] = useResizeHeight(600)
|
const [treeContainerHeight, treeContainerRef] = useResizeHeight(200)
|
||||||
|
|
||||||
// Build tree from flat structure
|
React.useImperativeHandle(ref, () => ({
|
||||||
const treeNodes = useTreeNodes(tree)
|
openAll: () => treeRef.current?.openAll(),
|
||||||
|
closeAll: () => treeRef.current?.closeAll(),
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
const treeNodes = useMemo(() => buildTree(tree), [tree])
|
||||||
|
|
||||||
// Filter tree based on search query
|
|
||||||
const filteredTree = useMemo(() => {
|
const filteredTree = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return treeNodes
|
if (!searchQuery.trim()) return treeNodes
|
||||||
|
const term = searchQuery.toLowerCase()
|
||||||
const searchTerm = searchQuery.toLowerCase()
|
const filter = (nodes: TreeNode[]): TreeNode[] => {
|
||||||
|
const out: TreeNode[] = []
|
||||||
function filterNodes(nodes: TreeNode[]): TreeNode[] {
|
for (const n of nodes) {
|
||||||
const result: TreeNode[] = []
|
const matches = n.title.toLowerCase().includes(term)
|
||||||
for (const node of nodes) {
|
const kids = n.children ? filter(n.children) : []
|
||||||
const matches = node.title.toLowerCase().includes(searchTerm)
|
if (matches || kids.length > 0) out.push({ ...n, children: kids.length > 0 ? kids : n.children ? [] : undefined })
|
||||||
const children = filterNodes(node.children || [])
|
|
||||||
|
|
||||||
if (matches || children.length > 0) {
|
|
||||||
result.push({
|
|
||||||
...node,
|
|
||||||
children: children.length > 0 ? children : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result
|
return out
|
||||||
}
|
}
|
||||||
|
return filter(treeNodes)
|
||||||
return filterNodes(treeNodes)
|
|
||||||
}, [treeNodes, searchQuery])
|
}, [treeNodes, searchQuery])
|
||||||
|
|
||||||
// Get all folder IDs for initial open state
|
|
||||||
const initialOpenState = useMemo(() => {
|
const initialOpenState = useMemo(() => {
|
||||||
const folderIds: Record<string, boolean> = {}
|
const ids: Record<string, boolean> = {}
|
||||||
function collectIds(nodes: TreeNode[]) {
|
const collect = (ns: TreeNode[]) => ns.forEach((n) => { if (n.kind === 'folder') { ids[n.id] = true; if (n.children) collect(n.children) } })
|
||||||
nodes.forEach((node) => {
|
collect(treeNodes)
|
||||||
if (node.isFolder) {
|
return ids
|
||||||
folderIds[node.id] = true
|
|
||||||
collectIds(node.children || [])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
collectIds(treeNodes)
|
|
||||||
return folderIds
|
|
||||||
}, [treeNodes])
|
}, [treeNodes])
|
||||||
|
|
||||||
// Handle drag and drop reordering (react-arborist onMove)
|
|
||||||
const handleMove = useCallback(
|
const handleMove = useCallback(
|
||||||
({
|
({ dragIds, parentId: newParentId, index }: { dragIds: string[]; parentId: string | null; index: number }) => {
|
||||||
dragIds,
|
|
||||||
parentId: newParentId,
|
|
||||||
index,
|
|
||||||
}: {
|
|
||||||
dragIds: string[]
|
|
||||||
parentId: string | null
|
|
||||||
index: number
|
|
||||||
}) => {
|
|
||||||
const dragId = dragIds[0]
|
const dragId = dragIds[0]
|
||||||
if (!dragId) return
|
if (!dragId) return
|
||||||
|
if (newParentId !== null) {
|
||||||
const dragged = tree.find((p) => p.id === dragId)
|
const target = tree.find((n) => n.id === newParentId)
|
||||||
|
if (target && target.kind !== 'folder') return
|
||||||
|
}
|
||||||
|
const dragged = tree.find((n) => n.id === dragId)
|
||||||
if (!dragged) return
|
if (!dragged) return
|
||||||
|
|
||||||
const oldParentId = dragged.parentId
|
const oldParentId = dragged.parentId
|
||||||
const moved: LogosPageMeta = { ...dragged, parentId: newParentId }
|
const moved: WorkspaceNodeMeta = { ...dragged, parentId: newParentId }
|
||||||
|
|
||||||
const newSiblings = tree
|
const newSiblings = tree
|
||||||
.filter((p) => p.parentId === newParentId && p.id !== dragId)
|
.filter((n) => n.parentId === newParentId && n.id !== dragId)
|
||||||
.sort((a, b) => a.position - b.position)
|
.sort((a, b) => a.position - b.position)
|
||||||
newSiblings.splice(index, 0, moved)
|
newSiblings.splice(index, 0, moved)
|
||||||
|
|
||||||
const updates = new Map<string, LogosPageMeta>()
|
const updates = new Map<string, WorkspaceNodeMeta>()
|
||||||
newSiblings.forEach((p, i) => {
|
newSiblings.forEach((n, i) => updates.set(n.id, { ...n, parentId: newParentId, position: i }))
|
||||||
updates.set(p.id, { ...p, parentId: newParentId, position: i })
|
|
||||||
})
|
|
||||||
|
|
||||||
if (oldParentId !== newParentId) {
|
if (oldParentId !== newParentId) {
|
||||||
tree
|
tree
|
||||||
.filter((p) => p.parentId === oldParentId && p.id !== dragId)
|
.filter((n) => n.parentId === oldParentId && n.id !== dragId)
|
||||||
.sort((a, b) => a.position - b.position)
|
.sort((a, b) => a.position - b.position)
|
||||||
.forEach((p, i) => {
|
.forEach((n, i) => updates.set(n.id, { ...n, position: i }))
|
||||||
updates.set(p.id, { ...p, position: i })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTreeChange(tree.map((p) => updates.get(p.id) ?? p))
|
handleTreeChange(tree.map((n) => updates.get(n.id) ?? n))
|
||||||
},
|
},
|
||||||
[tree, handleTreeChange]
|
[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(
|
const arboristTreeProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
data: filteredTree,
|
data: filteredTree,
|
||||||
@@ -536,74 +365,23 @@ export function TreeBrowser() {
|
|||||||
childrenAccessor: 'children' as const,
|
childrenAccessor: 'children' as const,
|
||||||
width: '100%' as const,
|
width: '100%' as const,
|
||||||
height: Math.max(treeContainerHeight, 100),
|
height: Math.max(treeContainerHeight, 100),
|
||||||
rowHeight: 32,
|
rowHeight: 30,
|
||||||
indent: ROW_INDENT_PX,
|
indent: INDENT,
|
||||||
renderRow: TreeRow,
|
renderRow: TreeRow,
|
||||||
initialOpenState,
|
initialOpenState,
|
||||||
onMove: handleMove,
|
onMove: handleMove,
|
||||||
disableDrag: Boolean(searchQuery),
|
disableDrag: Boolean(searchQuery),
|
||||||
disableDrop: Boolean(searchQuery),
|
disableDrop: Boolean(searchQuery),
|
||||||
className: 'react-arborist-tree',
|
className: 'react-arborist-tree',
|
||||||
renderCursor: LogosDropCursor,
|
renderCursor: DropCursor,
|
||||||
children: LogosTreeNode,
|
children: WorkspaceTreeNode,
|
||||||
}),
|
}),
|
||||||
[filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight]
|
[filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 h-full">
|
<div ref={treeContainerRef} className="h-full min-h-0 overflow-hidden">
|
||||||
{/* Search Toolbar */}
|
<Tree ref={treeRef} {...arboristTreeProps} />
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search pages..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-0 top-1/2 -translate-y-1/2 h-7 w-7"
|
|
||||||
onClick={handleClearSearch}
|
|
||||||
>
|
|
||||||
<X className="size-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 shrink-0"
|
|
||||||
title="Expand/Collapse options"
|
|
||||||
>
|
|
||||||
<ChevronsUpDown className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={handleExpandAll}>
|
|
||||||
<ChevronsDownUp className="mr-2 size-3.5" />
|
|
||||||
Expand all
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleCollapseAll}>
|
|
||||||
<ChevronsUpDown className="mr-2 size-3.5" />
|
|
||||||
Collapse all
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tree */}
|
|
||||||
<div ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
|
|
||||||
<Tree ref={treeRef} {...arboristTreeProps} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
265
frontend/src/app/recollections/layout/WorkspaceTreeContext.tsx
Normal file
265
frontend/src/app/recollections/layout/WorkspaceTreeContext.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Unified workspace tree context: pages, scenes, and folders in a single tree.
|
||||||
|
* Replaces both RecollectionSidebarContext and FluxSceneContext.
|
||||||
|
* Exports backward-compat shim hooks for existing consumers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
ensureWorkspaceTree,
|
||||||
|
setWorkspaceTree,
|
||||||
|
removeLogosPageContent,
|
||||||
|
removeSceneGraph,
|
||||||
|
DEFAULT_SCENE_ID,
|
||||||
|
type WorkspaceNodeMeta,
|
||||||
|
type WorkspaceNodeId,
|
||||||
|
type WorkspaceNodeKind,
|
||||||
|
type LogosPageMeta,
|
||||||
|
type FluxSceneMeta,
|
||||||
|
type FluxSceneId,
|
||||||
|
} from '../state/recollectionStore'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type WorkspaceTreeContextValue = {
|
||||||
|
tree: WorkspaceNodeMeta[]
|
||||||
|
setTree: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
|
||||||
|
|
||||||
|
activeItemId: WorkspaceNodeId | null
|
||||||
|
activeItemKind: WorkspaceNodeKind | null
|
||||||
|
|
||||||
|
handleSelectItem: (id: WorkspaceNodeId) => void
|
||||||
|
handleTreeChange: (tree: WorkspaceNodeMeta[]) => void
|
||||||
|
|
||||||
|
handleAddNode: (kind: WorkspaceNodeKind, parentId?: WorkspaceNodeId | null, title?: string) => WorkspaceNodeId
|
||||||
|
handleRenameNode: (id: WorkspaceNodeId, title: string) => void
|
||||||
|
handleDeleteNode: (id: WorkspaceNodeId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceTreeContext = createContext<WorkspaceTreeContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useWorkspaceTree(): WorkspaceTreeContextValue {
|
||||||
|
const ctx = useContext(WorkspaceTreeContext)
|
||||||
|
if (!ctx) throw new Error('useWorkspaceTree must be used within WorkspaceTreeProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let nodeCounter = 0
|
||||||
|
|
||||||
|
export function WorkspaceTreeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const [tree, setTree] = useState<WorkspaceNodeMeta[]>([])
|
||||||
|
const [activeItemId, setActiveItemId] = useState<WorkspaceNodeId | null>(null)
|
||||||
|
|
||||||
|
// Load tree on recollection change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollectionId) return
|
||||||
|
const t = ensureWorkspaceTree(recollectionId)
|
||||||
|
setTree(t)
|
||||||
|
}, [recollectionId])
|
||||||
|
|
||||||
|
// Sync activeItemId from URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollectionId || tree.length === 0) return
|
||||||
|
const pageFromUrl = searchParams.get('page')
|
||||||
|
const sceneFromUrl = searchParams.get('scene')
|
||||||
|
setActiveItemId((prev) => {
|
||||||
|
if (pageFromUrl && tree.some((n) => n.id === pageFromUrl && n.kind === 'page')) return pageFromUrl
|
||||||
|
if (sceneFromUrl && tree.some((n) => n.id === sceneFromUrl && n.kind === 'scene')) return sceneFromUrl
|
||||||
|
if (prev != null && tree.some((n) => n.id === prev)) return prev
|
||||||
|
// Default to first page or scene
|
||||||
|
const firstContent = tree.find((n) => n.kind === 'page' || n.kind === 'scene')
|
||||||
|
return firstContent?.id ?? null
|
||||||
|
})
|
||||||
|
}, [recollectionId, searchParams, tree])
|
||||||
|
|
||||||
|
// Persist tree on changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollectionId || tree.length === 0) return
|
||||||
|
setWorkspaceTree(recollectionId, tree)
|
||||||
|
}, [recollectionId, tree])
|
||||||
|
|
||||||
|
const activeItemKind = useMemo(() => {
|
||||||
|
if (!activeItemId) return null
|
||||||
|
return tree.find((n) => n.id === activeItemId)?.kind ?? null
|
||||||
|
}, [activeItemId, tree])
|
||||||
|
|
||||||
|
const handleSelectItem = useCallback(
|
||||||
|
(id: WorkspaceNodeId) => {
|
||||||
|
if (!recollectionId) return
|
||||||
|
const node = tree.find((n) => n.id === id)
|
||||||
|
if (!node) return
|
||||||
|
setActiveItemId(id)
|
||||||
|
const base = `/recollections/${recollectionId}`
|
||||||
|
if (node.kind === 'page') {
|
||||||
|
navigate(`${base}/logos?page=${encodeURIComponent(id)}`)
|
||||||
|
} else if (node.kind === 'scene') {
|
||||||
|
navigate(`${base}/flux?scene=${encodeURIComponent(id)}`)
|
||||||
|
}
|
||||||
|
// folder: no navigation, just select
|
||||||
|
},
|
||||||
|
[recollectionId, tree, navigate]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTreeChange = useCallback((newTree: WorkspaceNodeMeta[]) => {
|
||||||
|
setTree(newTree)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleAddNode = useCallback(
|
||||||
|
(kind: WorkspaceNodeKind, parentId?: WorkspaceNodeId | null, title?: string) => {
|
||||||
|
nodeCounter += 1
|
||||||
|
const prefix = kind === 'page' ? 'page' : kind === 'scene' ? 'scene' : 'folder'
|
||||||
|
const id = `${prefix}_${Date.now()}_${nodeCounter}`
|
||||||
|
const siblings = tree.filter((n) => n.parentId === (parentId ?? null))
|
||||||
|
const maxPos = siblings.reduce((max, n) => Math.max(max, n.position), -1)
|
||||||
|
const defaultTitle = kind === 'page' ? 'Untitled' : kind === 'scene' ? `Scene ${tree.filter((n) => n.kind === 'scene').length + 1}` : 'New Folder'
|
||||||
|
const node: WorkspaceNodeMeta = {
|
||||||
|
id,
|
||||||
|
title: title ?? defaultTitle,
|
||||||
|
kind,
|
||||||
|
parentId: parentId ?? null,
|
||||||
|
position: maxPos + 1,
|
||||||
|
}
|
||||||
|
setTree((prev) => [...prev, node])
|
||||||
|
if (kind !== 'folder') {
|
||||||
|
handleSelectItem(id)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
[tree, handleSelectItem]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRenameNode = useCallback(
|
||||||
|
(id: WorkspaceNodeId, title: string) => {
|
||||||
|
setTree((prev) => prev.map((n) => (n.id === id ? { ...n, title } : n)))
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDeleteNode = useCallback(
|
||||||
|
(id: WorkspaceNodeId) => {
|
||||||
|
if (!recollectionId) return
|
||||||
|
// Collect this node and all descendants
|
||||||
|
const toDelete = new Set<string>()
|
||||||
|
const collect = (nodeId: string) => {
|
||||||
|
toDelete.add(nodeId)
|
||||||
|
for (const child of tree.filter((n) => n.parentId === nodeId)) {
|
||||||
|
collect(child.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collect(id)
|
||||||
|
|
||||||
|
// Clean up storage for deleted nodes
|
||||||
|
for (const nodeId of toDelete) {
|
||||||
|
const node = tree.find((n) => n.id === nodeId)
|
||||||
|
if (!node) continue
|
||||||
|
if (node.kind === 'page') removeLogosPageContent(recollectionId, nodeId)
|
||||||
|
if (node.kind === 'scene') removeSceneGraph(recollectionId, nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTree((prev) => {
|
||||||
|
const next = prev.filter((n) => !toDelete.has(n.id))
|
||||||
|
// If we deleted the active item, select the first remaining content node
|
||||||
|
if (activeItemId && toDelete.has(activeItemId) && next.length > 0) {
|
||||||
|
const firstContent = next.find((n) => n.kind === 'page' || n.kind === 'scene')
|
||||||
|
if (firstContent) handleSelectItem(firstContent.id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[recollectionId, tree, activeItemId, handleSelectItem]
|
||||||
|
)
|
||||||
|
|
||||||
|
const value: WorkspaceTreeContextValue = {
|
||||||
|
tree,
|
||||||
|
setTree,
|
||||||
|
activeItemId,
|
||||||
|
activeItemKind,
|
||||||
|
handleSelectItem,
|
||||||
|
handleTreeChange,
|
||||||
|
handleAddNode,
|
||||||
|
handleRenameNode,
|
||||||
|
handleDeleteNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <WorkspaceTreeContext.Provider value={value}>{children}</WorkspaceTreeContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backward-compat shim: useRecollectionSidebar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type RecollectionSidebarContextValue = {
|
||||||
|
tree: LogosPageMeta[]
|
||||||
|
activePageId: string | null
|
||||||
|
setTree: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
|
||||||
|
handleSelectPage: (id: string) => void
|
||||||
|
handleTreeChange: (tree: WorkspaceNodeMeta[]) => void
|
||||||
|
handleDeletePage: (pageId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecollectionSidebar(): RecollectionSidebarContextValue {
|
||||||
|
const ctx = useWorkspaceTree()
|
||||||
|
return useMemo(() => ({
|
||||||
|
tree: ctx.tree.filter((n) => n.kind === 'page').map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
parentId: n.parentId,
|
||||||
|
position: n.position,
|
||||||
|
})),
|
||||||
|
activePageId: ctx.activeItemKind === 'page' ? ctx.activeItemId : null,
|
||||||
|
setTree: ctx.setTree,
|
||||||
|
handleSelectPage: ctx.handleSelectItem,
|
||||||
|
handleTreeChange: ctx.handleTreeChange,
|
||||||
|
handleDeletePage: ctx.handleDeleteNode,
|
||||||
|
}), [ctx])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backward-compat shim: useFluxScenes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type FluxSceneContextValue = {
|
||||||
|
scenes: FluxSceneMeta[]
|
||||||
|
activeSceneId: FluxSceneId
|
||||||
|
setScenes: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
|
||||||
|
handleSelectScene: (id: FluxSceneId) => void
|
||||||
|
handleAddScene: (title?: string) => FluxSceneId
|
||||||
|
handleRenameScene: (id: FluxSceneId, title: string) => void
|
||||||
|
handleDeleteScene: (id: FluxSceneId) => void
|
||||||
|
handleReorderScenes: (scenes: FluxSceneMeta[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFluxScenes(): FluxSceneContextValue {
|
||||||
|
const ctx = useWorkspaceTree()
|
||||||
|
return useMemo(() => ({
|
||||||
|
scenes: ctx.tree.filter((n) => n.kind === 'scene').map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
position: n.position,
|
||||||
|
})),
|
||||||
|
activeSceneId: ctx.activeItemKind === 'scene' && ctx.activeItemId ? ctx.activeItemId : DEFAULT_SCENE_ID,
|
||||||
|
setScenes: ctx.setTree,
|
||||||
|
handleSelectScene: ctx.handleSelectItem,
|
||||||
|
handleAddScene: (title?: string) => ctx.handleAddNode('scene', null, title),
|
||||||
|
handleRenameScene: ctx.handleRenameNode,
|
||||||
|
handleDeleteScene: ctx.handleDeleteNode,
|
||||||
|
handleReorderScenes: () => {},
|
||||||
|
}), [ctx])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptionalFluxScenes(): FluxSceneContextValue | null {
|
||||||
|
const ctx = useContext(WorkspaceTreeContext)
|
||||||
|
if (!ctx) return null
|
||||||
|
return useFluxScenes()
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuContro
|
|||||||
import { BlockNoteView } from '@blocknote/shadcn'
|
import { BlockNoteView } from '@blocknote/shadcn'
|
||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import { useRecollectionActions } from '../layout/RecollectionActionsContext'
|
import { useRecollectionActions } from '../layout/RecollectionActionsContext'
|
||||||
import { useRecollectionSidebar } from '../layout/RecollectionSidebarContext'
|
import { useRecollectionSidebar } from '../layout/WorkspaceTreeContext'
|
||||||
import {
|
import {
|
||||||
getLogosContentForPage,
|
getLogosContentForPage,
|
||||||
setLogosContentForPage,
|
setLogosContentForPage,
|
||||||
|
|||||||
@@ -60,9 +60,26 @@ export type FluxSceneMeta = {
|
|||||||
|
|
||||||
export const DEFAULT_SCENE_ID: FluxSceneId = '_default'
|
export const DEFAULT_SCENE_ID: FluxSceneId = '_default'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unified Workspace Tree (pages + scenes + folders in one tree)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type WorkspaceNodeKind = 'page' | 'scene' | 'folder'
|
||||||
|
export type WorkspaceNodeId = string
|
||||||
|
|
||||||
|
/** Unified node in the workspace tree. Replaces separate LogosPageMeta and FluxSceneMeta. */
|
||||||
|
export type WorkspaceNodeMeta = {
|
||||||
|
id: WorkspaceNodeId
|
||||||
|
title: string
|
||||||
|
kind: WorkspaceNodeKind
|
||||||
|
parentId: WorkspaceNodeId | null
|
||||||
|
position: number
|
||||||
|
}
|
||||||
|
|
||||||
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
||||||
const FLUX_SCENE_TREE_PREFIX = 'zui_flux_scenes_'
|
const FLUX_SCENE_TREE_PREFIX = 'zui_flux_scenes_'
|
||||||
const FLUX_SCENE_GRAPH_PREFIX = 'zui_flux_scene_'
|
const FLUX_SCENE_GRAPH_PREFIX = 'zui_flux_scene_'
|
||||||
|
const WORKSPACE_TREE_PREFIX = 'zui_workspace_tree_'
|
||||||
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
||||||
const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_'
|
const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_'
|
||||||
const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_'
|
const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_'
|
||||||
@@ -81,6 +98,10 @@ function getFluxSceneGraphKey(recollectionId: string, sceneId: FluxSceneId): str
|
|||||||
return `${FLUX_SCENE_GRAPH_PREFIX}${recollectionId}_${sceneId}`
|
return `${FLUX_SCENE_GRAPH_PREFIX}${recollectionId}_${sceneId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWorkspaceTreeKey(recollectionId: string): string {
|
||||||
|
return `${WORKSPACE_TREE_PREFIX}${recollectionId}`
|
||||||
|
}
|
||||||
|
|
||||||
function getLogosKey(recollectionId: string): string {
|
function getLogosKey(recollectionId: string): string {
|
||||||
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
||||||
}
|
}
|
||||||
@@ -255,6 +276,92 @@ export function ensureFluxSceneTree(recollectionId: string): FluxSceneMeta[] {
|
|||||||
return tree
|
return tree
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unified Workspace Tree Storage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getWorkspaceTree(recollectionId: string): WorkspaceNodeMeta[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getWorkspaceTreeKey(recollectionId))
|
||||||
|
if (!raw) return []
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(data)) return []
|
||||||
|
return data.filter(
|
||||||
|
(item): item is WorkspaceNodeMeta =>
|
||||||
|
item != null &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
typeof (item as WorkspaceNodeMeta).id === 'string' &&
|
||||||
|
typeof (item as WorkspaceNodeMeta).title === 'string' &&
|
||||||
|
typeof (item as WorkspaceNodeMeta).kind === 'string' &&
|
||||||
|
((item as WorkspaceNodeMeta).parentId === null || typeof (item as WorkspaceNodeMeta).parentId === 'string') &&
|
||||||
|
typeof (item as WorkspaceNodeMeta).position === 'number'
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWorkspaceTree(recollectionId: string, tree: WorkspaceNodeMeta[]): void {
|
||||||
|
localStorage.setItem(getWorkspaceTreeKey(recollectionId), JSON.stringify(tree))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize workspace tree for a recollection. Migrates existing separate
|
||||||
|
* page tree + scene tree into a unified workspace tree on first access.
|
||||||
|
*/
|
||||||
|
export function ensureWorkspaceTree(recollectionId: string): WorkspaceNodeMeta[] {
|
||||||
|
const existing = getWorkspaceTree(recollectionId)
|
||||||
|
if (existing.length > 0) return existing
|
||||||
|
|
||||||
|
const merged: WorkspaceNodeMeta[] = []
|
||||||
|
|
||||||
|
// Migrate Logos pages
|
||||||
|
let pages = getLogosPageTree(recollectionId)
|
||||||
|
if (pages.length === 0) {
|
||||||
|
// Check for legacy single-document Logos content
|
||||||
|
const legacy = getLogosContent(recollectionId)
|
||||||
|
const mainPage: LogosPageMeta = { id: 'main', title: 'Main', parentId: null, position: 0 }
|
||||||
|
setLogosContentForPage(recollectionId, 'main', legacy ?? [])
|
||||||
|
setLogosPageTree(recollectionId, [mainPage])
|
||||||
|
pages = [mainPage]
|
||||||
|
}
|
||||||
|
for (const page of pages) {
|
||||||
|
merged.push({
|
||||||
|
id: page.id,
|
||||||
|
title: page.title,
|
||||||
|
kind: 'page',
|
||||||
|
parentId: page.parentId,
|
||||||
|
position: page.position,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate Flux scenes
|
||||||
|
const scenes = getFluxSceneTree(recollectionId)
|
||||||
|
if (scenes.length === 0) {
|
||||||
|
// Migrate legacy single graph to default scene
|
||||||
|
const legacyGraph = getGraph(recollectionId)
|
||||||
|
const defaultScene: WorkspaceNodeMeta = { id: DEFAULT_SCENE_ID, title: 'Main', kind: 'scene', parentId: null, position: merged.length }
|
||||||
|
merged.push(defaultScene)
|
||||||
|
if (legacyGraph) {
|
||||||
|
setSceneGraph(recollectionId, DEFAULT_SCENE_ID, legacyGraph)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const maxRootPos = merged.filter((n) => n.parentId === null).reduce((max, n) => Math.max(max, n.position), -1)
|
||||||
|
for (const scene of scenes) {
|
||||||
|
merged.push({
|
||||||
|
id: scene.id,
|
||||||
|
title: scene.title,
|
||||||
|
kind: 'scene',
|
||||||
|
parentId: null,
|
||||||
|
position: maxRootPos + 1 + scene.position,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkspaceTree(recollectionId, merged)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Logos Storage
|
// Logos Storage
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -387,6 +494,8 @@ export function removeRecollectionData(recollectionId: string): void {
|
|||||||
removeLogosPageContent(recollectionId, page.id)
|
removeLogosPageContent(recollectionId, page.id)
|
||||||
}
|
}
|
||||||
localStorage.removeItem(getLogosPageTreeKey(recollectionId))
|
localStorage.removeItem(getLogosPageTreeKey(recollectionId))
|
||||||
|
// Workspace tree
|
||||||
|
localStorage.removeItem(getWorkspaceTreeKey(recollectionId))
|
||||||
// Render cache
|
// Render cache
|
||||||
localStorage.removeItem(getRenderCacheKey(recollectionId))
|
localStorage.removeItem(getRenderCacheKey(recollectionId))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ pre {
|
|||||||
.react-arborist-tree {
|
.react-arborist-tree {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-arborist-tree * {
|
.react-arborist-tree * {
|
||||||
|
|||||||
Reference in New Issue
Block a user