import { useRef, useState, type ReactNode } from 'react' import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, type CollisionDetection, type DragEndEvent, type DragStartEvent, type KeyboardCoordinateGetter, } from '@dnd-kit/core' import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import { Box, Typography } from '@mui/material' import { getLegendStore, selectLayersByGroup, selectOrderedGroups, useLegendId, useLegendShallow, useLegendStore, } from '../../stores' import { LegendSortableContext } from './contexts' import { parseSortableId, toSortableId } from './sortable-ids' import { styles } from './styles' export interface LegendSortableProps { /** Panel content (`Legend.Group` / ungrouped `Legend.Row` children). */ children?: ReactNode } /** * Opt-in DnD reordering for legend groups and layers (within bucket only). * * @experimental This API is new and may change in a future release. */ export function LegendSortable({ children }: LegendSortableProps) { const id = useLegendId() const [activeId, setActiveId] = useState(null) // Cached on drag start — avoid re-reading groupId on every collision candidate. const activeLayerGroupIdRef = useRef(undefined) const topLevelIds = useLegendShallow(id, (s) => [ ...selectOrderedGroups(s).map((g) => toSortableId('group', g.id)), ...selectLayersByGroup(s, undefined).map((l) => toSortableId('layer', l.id), ), ]) // Same-kind + same-bucket legality — shared by collision detection and keyboard moves. const isLegalTarget = (activeId: string, candidateId: string): boolean => { const from = parseSortableId(activeId) const to = parseSortableId(candidateId) if (!from || to?.kind !== from.kind) return false if (from.kind === 'layer') { const state = getLegendStore(id).getState() return state.layers[to.id]?.groupId === activeLayerGroupIdRef.current } return true } // Stock keyboard getter steps through nested member rows inside a group's rect. const keyboardCoordinates: KeyboardCoordinateGetter = ( event, { context }, ) => { const { active, collisionRect, droppableRects, droppableContainers } = context if (!active || !collisionRect) return undefined if (event.code !== 'ArrowDown' && event.code !== 'ArrowUp') return undefined event.preventDefault() const down = event.code === 'ArrowDown' const candidates = droppableContainers .getEnabled() .filter( (container) => container.id !== active.id && isLegalTarget(String(active.id), String(container.id)), ) .flatMap((container) => { const rect = droppableRects.get(container.id) return rect ? [rect] : [] }) .filter((rect) => down ? rect.top > collisionRect.top : rect.top < collisionRect.top, ) .sort((a, b) => (down ? a.top - b.top : b.top - a.top)) const target = candidates[0] return target ? { x: target.left, y: target.top } : undefined } const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: keyboardCoordinates }), ) // Nested droppables: filter to legal targets before closestCenter. const collisionDetection: CollisionDetection = (args) => { const droppableContainers = args.droppableContainers.filter((container) => isLegalTarget(String(args.active.id), String(container.id)), ) return closestCenter({ ...args, droppableContainers }) } const handleDragStart = (event: DragStartEvent) => { const nextId = String(event.active.id) setActiveId(nextId) const parsed = parseSortableId(nextId) activeLayerGroupIdRef.current = parsed?.kind === 'layer' ? getLegendStore(id).getState().layers[parsed.id]?.groupId : undefined } const handleDragEnd = (event: DragEndEvent) => { setActiveId(null) const { active, over } = event if (!over || active.id === over.id) return const from = parseSortableId(String(active.id)) const to = parseSortableId(String(over.id)) if (!from || from.kind !== to?.kind) return const state = getLegendStore(id).getState() if (from.kind === 'group') { const index = selectOrderedGroups(state).findIndex((g) => g.id === to.id) if (index !== -1) state.moveGroup(from.id, index) return } const fromLayer = state.layers[from.id] const toLayer = state.layers[to.id] if (!fromLayer || !toLayer || fromLayer.groupId !== toLayer.groupId) return const bucket = selectLayersByGroup(state, fromLayer.groupId) const index = bucket.findIndex((l) => l.id === to.id) if (index !== -1) state.moveLayer(from.id, index) } return ( setActiveId(null)} > {children} {activeId && } ) } function OverlayCard({ activeId }: { activeId: string }) { const id = useLegendId() const parsed = parseSortableId(activeId) const label = useLegendStore(id, (s) => parsed ? parsed.kind === 'group' ? s.groups[parsed.id]?.label : s.layers[parsed.id]?.name : undefined, ) const groupIcon = useLegendStore(id, (s) => parsed?.kind === 'group' ? s.groups[parsed.id]?.icon : undefined, ) if (!parsed || label === undefined) return null return ( {parsed.kind === 'group' && groupIcon != null && ( {groupIcon} )} {label} ) }