mirror of
https://github.com/reconurge/flowsint.git
synced 2026-04-30 03:09:05 -05:00
407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
import { Button } from '@/components/ui/button'
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
import { useConfirm } from '@/components/use-confirm-dialog'
|
|
import { cn } from '@/lib/utils'
|
|
import { useGraphControls } from '@/stores/graph-controls-store'
|
|
import { useGraphStore } from '@/stores/graph-store'
|
|
import {
|
|
FunnelPlus,
|
|
GitFork,
|
|
GitPullRequestArrow,
|
|
Maximize,
|
|
Merge,
|
|
Minus,
|
|
RotateCw,
|
|
ZoomIn,
|
|
Focus,
|
|
Download,
|
|
LassoSelect,
|
|
ChevronDown,
|
|
SquareDashed
|
|
} from 'lucide-react'
|
|
import { memo, useCallback } from 'react'
|
|
import { toast } from 'sonner'
|
|
import Filters from './filters/filters'
|
|
import { SaveStatusIndicator } from './save-status-indicator'
|
|
import { Separator } from '../ui/separator'
|
|
import { ViewToggle } from './view-toggle'
|
|
import { NetworkIcon } from '../icons/network'
|
|
import { useKeyboard } from '@/hooks/use-keyboard'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { sketchService } from '@/api/sketch-service'
|
|
import { useParams } from '@tanstack/react-router'
|
|
import { exportToPNG } from './graph/utils/export-to-png'
|
|
import { PathFinder } from './graph/actions/path-finder'
|
|
|
|
// Tooltip wrapper component to avoid repetition
|
|
export const ToolbarButton = memo(function ToolbarButton({
|
|
icon,
|
|
tooltip,
|
|
onClick,
|
|
disabled = false,
|
|
badge = null,
|
|
toggled = false,
|
|
showLabel = false
|
|
}: {
|
|
icon: React.ReactNode
|
|
tooltip: string | React.ReactNode
|
|
onClick?: () => void
|
|
disabled?: boolean
|
|
badge?: number | null
|
|
toggled?: boolean | null
|
|
showLabel?: boolean
|
|
}) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div>
|
|
<Button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
variant="ghost"
|
|
size={showLabel ? 'sm' : 'icon'}
|
|
className={cn(
|
|
'h-7 relative items-center shadow-none',
|
|
!showLabel && 'w-7',
|
|
toggled &&
|
|
'bg-primary/30 border-primary/40 text-primary hover:bg-primary/40 hover:text-primary'
|
|
)}
|
|
>
|
|
{icon} {showLabel && <span className="hidden md:block">{tooltip}</span>}
|
|
{badge && (
|
|
<span className="absolute -top-1 -right-2 !z-[500] bg-primary text-white text-[10px] rounded-full w-auto min-w-4.5 h-4.5 p-1 flex items-center justify-center">
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{tooltip}</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
})
|
|
|
|
const FloatingBar = ({
|
|
children,
|
|
className
|
|
}: {
|
|
children: React.ReactNode
|
|
className?: string
|
|
}) => (
|
|
<div
|
|
className={cn(
|
|
'absolute z-50 flex items-center gap-0.5 rounded-lg border bg-card p-1',
|
|
className
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
|
|
export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean }) {
|
|
const { confirm } = useConfirm()
|
|
const { id: sketchId } = useParams({ strict: false })
|
|
const view = useGraphControls((s) => s.view)
|
|
const setView = useGraphControls((s) => s.setView)
|
|
const zoomToFit = useGraphControls((s) => s.zoomToFit)
|
|
const zoomToSelection = useGraphControls((s) => s.zoomToSelection)
|
|
const zoomIn = useGraphControls((s) => s.zoomIn)
|
|
const zoomOut = useGraphControls((s) => s.zoomOut)
|
|
const regenerateLayout = useGraphControls((s) => s.regenerateLayout)
|
|
const refetchGraph = useGraphControls((s) => s.refetchGraph)
|
|
const isSelectorModeActive = useGraphControls((s) => s.isSelectorModeActive)
|
|
const setIsSelectorModeActive = useGraphControls((s) => s.setIsSelectorModeActive)
|
|
const selectionMode = useGraphControls((s) => s.selectionMode)
|
|
const setSelectionMode = useGraphControls((s) => s.setSelectionMode)
|
|
const selectedNodes = useGraphStore((s) => s.selectedNodes)
|
|
const setOpenAddRelationDialog = useGraphStore((state) => state.setOpenAddRelationDialog)
|
|
const setOpenMergeDialog = useGraphStore((state) => state.setOpenMergeDialog)
|
|
const filters = useGraphStore((s) => s.filters)
|
|
|
|
useKeyboard(
|
|
's',
|
|
() => setIsSelectorModeActive(true),
|
|
() => setIsSelectorModeActive(false)
|
|
)
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
try {
|
|
refetchGraph()
|
|
} catch (error) {
|
|
toast.error('Failed to refresh graph data')
|
|
}
|
|
}, [refetchGraph])
|
|
|
|
const handleApplyForceLayout = useCallback(async () => {
|
|
const confirmed = await confirm({
|
|
title: 'Apply force layout?',
|
|
message:
|
|
'This will reset all node positions and regenerate them using the force-directed layout algorithm. Current positions will be lost.'
|
|
})
|
|
|
|
if (!confirmed) {
|
|
return
|
|
}
|
|
try {
|
|
regenerateLayout('force')
|
|
toast.success('Force layout applied successfully')
|
|
} catch (error) {
|
|
toast.error(
|
|
`Failed to apply layout: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
)
|
|
}
|
|
}, [confirm, regenerateLayout])
|
|
|
|
const handleApplyHierarchyLayout = useCallback(async () => {
|
|
const confirmed = await confirm({
|
|
title: 'Apply hierarchy layout?',
|
|
message:
|
|
'This will reset all node positions and regenerate them using the hierarchical layout algorithm. Current positions will be lost.'
|
|
})
|
|
|
|
if (!confirmed) {
|
|
return
|
|
}
|
|
try {
|
|
regenerateLayout('hierarchy')
|
|
toast.success('Hierarchy layout applied successfully')
|
|
} catch (error) {
|
|
toast.error(
|
|
`Failed to apply layout: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
)
|
|
}
|
|
}, [confirm, regenerateLayout])
|
|
|
|
const handleOpenAddRelationDialog = useCallback(() => {
|
|
setOpenAddRelationDialog(true)
|
|
}, [setOpenAddRelationDialog])
|
|
|
|
const handleOpenMergeDialog = useCallback(() => {
|
|
setOpenMergeDialog(true)
|
|
}, [setOpenMergeDialog])
|
|
|
|
const handleToggleSelector = useCallback(() => {
|
|
setIsSelectorModeActive(!isSelectorModeActive)
|
|
}, [setIsSelectorModeActive, isSelectorModeActive])
|
|
|
|
const handleSelectMode = useCallback(
|
|
(mode: 'lasso' | 'rectangle') => {
|
|
setSelectionMode(mode)
|
|
if (!isSelectorModeActive) {
|
|
setIsSelectorModeActive(true)
|
|
}
|
|
},
|
|
[setSelectionMode, isSelectorModeActive, setIsSelectorModeActive]
|
|
)
|
|
|
|
const handleExport = useCallback(
|
|
async (format: 'json' | 'png') => {
|
|
if (!sketchId) return
|
|
try {
|
|
if (format === 'png') {
|
|
await exportToPNG('null', 'null')
|
|
} else {
|
|
await sketchService.exportSketch(sketchId, format)
|
|
}
|
|
toast.success(`Sketch exported as ${format.toUpperCase()}`)
|
|
} catch (error) {
|
|
toast.error(
|
|
`Failed to export sketch: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
)
|
|
}
|
|
},
|
|
[sketchId, exportToPNG]
|
|
)
|
|
|
|
const areExactlyTwoSelected = selectedNodes.length === 2
|
|
const areMergeable =
|
|
selectedNodes.length > 1 && selectedNodes.every((n) => n.nodeType === selectedNodes[0].nodeType)
|
|
const hasFilters = !(
|
|
filters.types.every((t) => t.checked) || filters.types.every((t) => !t.checked)
|
|
)
|
|
|
|
return (
|
|
<>
|
|
{/* Bottom-left: Zoom controls */}
|
|
{(view === 'graph' || view === 'map') && (
|
|
<FloatingBar className="bottom-3 left-3">
|
|
<ToolbarButton
|
|
icon={<ZoomIn className="h-4 w-4 opacity-70" />}
|
|
tooltip="Zoom In"
|
|
onClick={zoomIn}
|
|
disabled={(view !== 'graph' && view !== 'map') || isSelectorModeActive || !zoomIn}
|
|
/>
|
|
<ToolbarButton
|
|
icon={<Minus className="h-4 w-4 opacity-70" />}
|
|
tooltip="Zoom Out"
|
|
onClick={zoomOut}
|
|
disabled={(view !== 'graph' && view !== 'map') || isSelectorModeActive}
|
|
/>
|
|
<ToolbarButton
|
|
icon={<Maximize className="h-4 w-4 opacity-70" />}
|
|
tooltip="Fit to View"
|
|
onClick={zoomToFit}
|
|
disabled={(view !== 'graph' && view !== 'map') || isSelectorModeActive}
|
|
/>
|
|
<ToolbarButton
|
|
icon={<Focus className="h-4 w-4 opacity-70" />}
|
|
tooltip="Zoom to Selection"
|
|
onClick={zoomToSelection}
|
|
disabled={view !== 'graph' || isSelectorModeActive || selectedNodes.length < 2}
|
|
/>
|
|
</FloatingBar>
|
|
)}
|
|
|
|
{/* Left middle: Selection, Actions & Layout */}
|
|
{view === 'graph' && (
|
|
<FloatingBar className="left-3 top-1/2 -translate-y-1/2 flex-col">
|
|
<div className="flex flex-col items-center">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleToggleSelector}
|
|
disabled={view !== 'graph'}
|
|
className={cn(
|
|
'h-7 w-7 rounded relative items-center shadow-none',
|
|
isSelectorModeActive &&
|
|
'bg-primary/30 border-primary/40 text-primary hover:bg-primary/40 hover:text-primary'
|
|
)}
|
|
>
|
|
{selectionMode === 'lasso' ? (
|
|
<LassoSelect className="h-4 w-4 opacity-70" />
|
|
) : (
|
|
<SquareDashed className="h-4 w-4 opacity-70" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Select (hold S)</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<div>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
disabled={view !== 'graph'}
|
|
className={cn(
|
|
'h-3! w-5 px-0 rounded relative items-center shadow-none',
|
|
isSelectorModeActive &&
|
|
'bg-primary/30 border-primary/40 text-primary hover:bg-primary/40 hover:text-primary'
|
|
)}
|
|
>
|
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Selection mode</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="min-w-[140px]">
|
|
<DropdownMenuItem
|
|
onClick={() => handleSelectMode('lasso')}
|
|
className={cn(selectionMode === 'lasso' && 'bg-accent')}
|
|
>
|
|
<LassoSelect className="h-4 w-4 mr-2" />
|
|
Lasso
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => handleSelectMode('rectangle')}
|
|
className={cn(selectionMode === 'rectangle' && 'bg-accent')}
|
|
>
|
|
<SquareDashed className="h-4 w-4 mr-2" />
|
|
Rectangle
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
<Separator className="w-full" />
|
|
<ToolbarButton
|
|
icon={<GitPullRequestArrow className="h-4 w-4 opacity-70" />}
|
|
tooltip="Connect"
|
|
onClick={handleOpenAddRelationDialog}
|
|
disabled={!areExactlyTwoSelected}
|
|
badge={areExactlyTwoSelected ? 2 : null}
|
|
/>
|
|
<ToolbarButton
|
|
icon={<Merge className="h-4 w-4 opacity-70" />}
|
|
tooltip="Merge"
|
|
onClick={handleOpenMergeDialog}
|
|
disabled={!areMergeable}
|
|
badge={areMergeable ? selectedNodes.length : null}
|
|
/>
|
|
<PathFinder />
|
|
<Separator className="w-full" />
|
|
<ToolbarButton
|
|
icon={<NetworkIcon className="h-4 w-4 opacity-70" />}
|
|
tooltip="Force layout"
|
|
onClick={handleApplyForceLayout}
|
|
disabled={isLoading || view !== 'graph'}
|
|
/>
|
|
<ToolbarButton
|
|
icon={<GitFork strokeWidth={1.4} className="h-4 w-4 opacity-70 rotate-180" />}
|
|
tooltip="Hierarchy layout"
|
|
onClick={handleApplyHierarchyLayout}
|
|
disabled={isLoading || view !== 'graph'}
|
|
/>
|
|
</FloatingBar>
|
|
)}
|
|
|
|
<FloatingBar className="top-3 left-1/2 -translate-x-1/2">
|
|
<ViewToggle view={view} setView={setView} />
|
|
<Separator orientation="vertical" className="h-5" />
|
|
<Filters>
|
|
<ToolbarButton
|
|
disabled={isLoading}
|
|
icon={<FunnelPlus className={cn('h-4 w-4 opacity-70')} />}
|
|
tooltip="Filters"
|
|
toggled={hasFilters}
|
|
/>
|
|
</Filters>
|
|
</FloatingBar>
|
|
|
|
{/* Top right: Status, Export, Reload */}
|
|
<FloatingBar className="top-3 right-3">
|
|
<SaveStatusIndicator />
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<div>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
disabled={isLoading}
|
|
className="h-7 w-7 relative items-center shadow-none"
|
|
>
|
|
<Download className="h-4 w-4 opacity-70" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Export</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="min-w-[140px]">
|
|
<DropdownMenuItem onClick={() => handleExport('json')}>Export as JSON</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleExport('png')}>Export as PNG</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<ToolbarButton
|
|
onClick={handleRefresh}
|
|
disabled={isLoading}
|
|
icon={<RotateCw className={cn('h-4 w-4 opacity-70', isLoading && 'animate-spin')} />}
|
|
tooltip="Refresh"
|
|
/>
|
|
</FloatingBar>
|
|
</>
|
|
)
|
|
})
|