Something went wrong. Try again.
An atproto Tilemap editor
Something went wrong. Try again.
13 kB · 399 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400import { useState } from 'react'import { ArrowDownToLine, ArrowDownUp, ArrowUpToLine, Box as BoxIcon, ChevronDown, ChevronRight, Combine, Eye, EyeOff, Folder, FolderPlus, Layers, MapPin, Spline, SquareDashed, Trash2, Trees,} from 'lucide-react'import { type CanvasSort, type DropPosition, findNode, isCollisionNode, type SceneNode, useMapStore,} from '../store/mapStore'import { scopedObjects, useLoadedTilesetsStore } from '../store/loadedTilesetsStore'
/** The canvas sort toggle's per-state icon + label. */const SORT_META: Record<CanvasSort, { icon: typeof ArrowDownUp; label: string }> = { dynamic: { icon: ArrowDownUp, label: 'Dynamic — depth-sorted' }, ground: { icon: ArrowDownToLine, label: 'Ground — behind everything' }, sky: { icon: ArrowUpToLine, label: 'Sky — in front of everything' },}import { Button } from '@/components/ui/button'import { Input } from '@/components/ui/input'import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'import { cn } from '@/lib/utils'
interface DropState { id: string pos: DropPosition}
interface Dnd { dragId: string | null drop: DropState | null setDragId: (id: string | null) => void setDrop: (drop: DropState | null) => void commit: () => void end: () => void}
function ToolbarButton({ label, onClick, disabled, children,}: { label: string onClick: () => void disabled?: boolean children: React.ReactNode}) { return ( <Tooltip> <TooltipTrigger asChild> <Button type="button" variant="ghost" size="icon-sm" disabled={disabled} onClick={onClick}> {children} </Button> </TooltipTrigger> <TooltipContent>{label}</TooltipContent> </Tooltip> )}
/** The scene node graph: add nodes, reorder via drag/drop, rename, hide. The * selected node drives the right-side inspector. */export default function MapNodeTree() { const nodes = useMapStore((s) => s.nodes) const grid = useMapStore((s) => s.grid) const addCanvas = useMapStore((s) => s.addCanvas) const addGroup = useMapStore((s) => s.addGroup) const addArea = useMapStore((s) => s.addArea) const addMarker = useMapStore((s) => s.addMarker) const addLink = useMapStore((s) => s.addLink) const addObject = useMapStore((s) => s.addObject) const removeNode = useMapStore((s) => s.removeNode) const moveNode = useMapStore((s) => s.moveNode) const activeNodeId = useMapStore((s) => s.activeNodeId) // object nodes default to the first object across the loaded tilesets const loadedTilesets = useLoadedTilesetsStore((s) => s.tilesets) const firstObject = scopedObjects(loadedTilesets)[0]
const [editingId, setEditingId] = useState<string | null>(null) const [dragId, setDragId] = useState<string | null>(null) const [drop, setDrop] = useState<DropState | null>(null)
const dnd: Dnd = { dragId, drop, setDragId, setDrop, commit: () => { if (dragId && drop && dragId !== drop.id) moveNode(dragId, drop.id, drop.pos) setDragId(null) setDrop(null) }, end: () => { setDragId(null) setDrop(null) }, }
const areaBox = { x: 0, y: 0, w: grid.tileWidth * 4, h: grid.tileHeight * 4 }
// a new link defaults its source to the selected node when that node can be an // endpoint (anything but a group or another link); the inspector picks the target. const activeNode = activeNodeId ? findNode(nodes, activeNodeId) : null const linkSource = activeNode && activeNode.kind !== 'group' && activeNode.kind !== 'link' ? activeNode.id : ''
return ( <section className="flex flex-col gap-2"> <div className="flex items-center justify-between gap-1.5"> <h2 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Nodes</h2> <div className="flex gap-0.5"> <ToolbarButton label="Add canvas" onClick={() => addCanvas()}> <Layers /> </ToolbarButton> <ToolbarButton label="Add area" onClick={() => addArea(areaBox)}> <SquareDashed /> </ToolbarButton> <ToolbarButton label="Add marker" onClick={() => addMarker()}> <MapPin /> </ToolbarButton> <ToolbarButton label="Add link" onClick={() => addLink(linkSource)}> <Spline /> </ToolbarButton> <ToolbarButton label={firstObject ? 'Add object' : 'Add object (define one in the Tileset editor first)'} disabled={!firstObject} onClick={() => firstObject && addObject(firstObject.tilesetUri, firstObject.object.id)} > <Trees /> </ToolbarButton> <ToolbarButton label="Add group" onClick={() => addGroup()}> <FolderPlus /> </ToolbarButton> <ToolbarButton label="Delete selected node" disabled={!activeNodeId} onClick={() => activeNodeId && removeNode(activeNodeId)} > <Trash2 /> </ToolbarButton> </div> </div>
{nodes.length === 0 ? ( <p className="text-xs text-muted-foreground">No nodes — add one above.</p> ) : ( <ul className="list-none p-0"> {nodes.map((n) => ( <NodeRow key={n.id} node={n} depth={0} editingId={editingId} setEditingId={setEditingId} dnd={dnd} /> ))} </ul> )} </section> )}
function kindIcon(node: SceneNode) { switch (node.kind) { case 'group': return Folder case 'canvas': return Layers case 'object': return Trees case 'area': return isCollisionNode(node) ? BoxIcon : SquareDashed case 'marker': return MapPin case 'link': return Spline }}
function NodeRow({ node, depth, editingId, setEditingId, dnd,}: { node: SceneNode depth: number editingId: string | null setEditingId: (id: string | null) => void dnd: Dnd}) { const activeNodeId = useMapStore((s) => s.activeNodeId) const setActiveNode = useMapStore((s) => s.setActiveNode) const toggleHidden = useMapStore((s) => s.toggleNodeHidden) const toggleCollapsed = useMapStore((s) => s.toggleNodeCollapsed) const renameNode = useMapStore((s) => s.renameNode) const cycleCanvasSort = useMapStore((s) => s.cycleCanvasSort) const toggleComposite = useMapStore((s) => s.toggleComposite)
const isGroup = node.kind === 'group' const active = node.id === activeNodeId const editing = editingId === node.id const dropPos = dnd.drop && dnd.drop.id === node.id ? dnd.drop.pos : null const KindIcon = kindIcon(node) const sortMeta = node.kind === 'canvas' ? SORT_META[node.sort] : null const SortIcon = sortMeta?.icon
const onDragOver = (e: React.DragEvent<HTMLDivElement>) => { if (!dnd.dragId || dnd.dragId === node.id) return e.preventDefault() e.stopPropagation() e.dataTransfer.dropEffect = 'move' const r = e.currentTarget.getBoundingClientRect() const y = (e.clientY - r.top) / r.height const pos: DropPosition = isGroup ? y < 0.25 ? 'before' : y > 0.75 ? 'after' : 'inside' : y < 0.5 ? 'before' : 'after' if (!dnd.drop || dnd.drop.id !== node.id || dnd.drop.pos !== pos) { dnd.setDrop({ id: node.id, pos }) } }
return ( <li> <div className={cn( 'flex cursor-pointer items-center gap-1 rounded-md border border-transparent py-1 pr-1.5 hover:bg-accent', active && 'border-ring bg-accent', dnd.dragId === node.id && 'opacity-40', dropPos === 'before' && 'shadow-[inset_0_2px_0_var(--ring)]', dropPos === 'after' && 'shadow-[inset_0_-2px_0_var(--ring)]', dropPos === 'inside' && 'border-ring bg-accent/40', )} style={{ paddingLeft: 6 + depth * 14 }} onClick={() => setActiveNode(node.id)} draggable={!editing} onDragStart={(e) => { e.stopPropagation() e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', node.id) dnd.setDragId(node.id) }} onDragOver={onDragOver} onDrop={(e) => { e.preventDefault() e.stopPropagation() dnd.commit() }} onDragEnd={(e) => { e.stopPropagation() dnd.end() }} > {isGroup ? ( <button type="button" className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground" title={node.collapsed ? 'Expand' : 'Collapse'} onClick={(e) => { e.stopPropagation() toggleCollapsed(node.id) }} > {node.collapsed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />} </button> ) : ( <span className="size-3.5 shrink-0" /> )}
<button type="button" className="flex size-4 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground" title={node.hidden ? 'Show' : 'Hide'} onClick={(e) => { e.stopPropagation() toggleHidden(node.id) }} > {node.hidden ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />} </button>
<KindIcon className={cn('size-3.5 shrink-0 text-muted-foreground', node.hidden && 'opacity-45')} aria-hidden />
{editing ? ( <Input className="h-6 flex-1 text-sm" autoFocus defaultValue={node.name} onClick={(e) => e.stopPropagation()} onBlur={(e) => { renameNode(node.id, e.target.value) setEditingId(null) }} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() else if (e.key === 'Escape') setEditingId(null) }} /> ) : ( <span className={cn('flex-1 truncate text-sm', node.hidden && 'opacity-45')} title="Double-click to rename" onDoubleClick={(e) => { e.stopPropagation() setEditingId(node.id) }} > {node.name} </span> )}
{isGroup && ( <Tooltip> <TooltipTrigger asChild> <button type="button" className={cn( 'flex size-4 shrink-0 items-center justify-center hover:text-foreground', node.composite ? 'text-amber-400' : 'text-muted-foreground/40', )} onClick={(e) => { e.stopPropagation() toggleComposite(node.id) }} > <Combine className="size-3.5" /> </button> </TooltipTrigger> <TooltipContent> {node.composite ? 'Composite — sorts as one unit. Click to make organisational.' : 'Organisational group. Click to composite (sort as one unit).'} </TooltipContent> </Tooltip> )} {node.kind === 'area' && ( <span className="shrink-0 text-[10px] tabular-nums text-muted-foreground" title={`${node.boxes.length} box${node.boxes.length === 1 ? '' : 'es'}`} > {node.boxes.length} </span> )} {node.kind === 'canvas' && sortMeta && SortIcon && ( <> <Tooltip> <TooltipTrigger asChild> <button type="button" className="flex size-4 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground" onClick={(e) => { e.stopPropagation() cycleCanvasSort(node.id) }} > <SortIcon className="size-3.5" /> </button> </TooltipTrigger> <TooltipContent>{sortMeta.label} — click to cycle</TooltipContent> </Tooltip> <span className="shrink-0 text-[10px] tabular-nums text-muted-foreground" title={`${node.layers.length} layer${node.layers.length === 1 ? '' : 's'}`} > {node.layers.length} </span> </> )} </div>
{isGroup && !node.collapsed && node.children.length > 0 && ( <ul className="list-none p-0"> {node.children.map((c) => ( <NodeRow key={c.id} node={c} depth={depth + 1} editingId={editingId} setEditingId={setEditingId} dnd={dnd} /> ))} </ul> )} </li> )}