| 1 | "use client"; |
| 2 | |
| 3 | import { |
| 4 | closestCenter, |
| 5 | DndContext, |
| 6 | PointerSensor, |
| 7 | useSensor, |
| 8 | useSensors, |
| 9 | type DragEndEvent, |
| 10 | } from "@dnd-kit/core"; |
| 11 | import { |
| 12 | arrayMove, |
| 13 | SortableContext, |
| 14 | useSortable, |
| 15 | verticalListSortingStrategy, |
| 16 | } from "@dnd-kit/sortable"; |
| 17 | import { CSS } from "@dnd-kit/utilities"; |
| 18 | import { GripVertical, ListTree, Plus, Trash2 } from "lucide-react"; |
| 19 | |
| 20 | import { Button } from "@/components/ui/button"; |
| 21 | import { Input } from "@/components/ui/input"; |
| 22 | import { Textarea } from "@/components/ui/textarea"; |
| 23 | import { cn } from "@/lib/utils"; |
| 24 | import { |
| 25 | type EditableInfographicItem, |
| 26 | type InfographicItemPatch, |
| 27 | } from "./types"; |
| 28 | import { createEditorId, normalizeEditableValue } from "./utils"; |
| 29 | |
| 30 | interface InfographicTreeEditorProps { |
| 31 | items: EditableInfographicItem[]; |
| 32 | variant?: TreeEditorVariant; |
| 33 | onChange: (items: EditableInfographicItem[]) => void; |
| 34 | } |
| 35 | |
| 36 | interface TreeLevelProps { |
| 37 | items: EditableInfographicItem[]; |
| 38 | level: number; |
| 39 | variant: TreeEditorVariant; |
| 40 | onChange: (items: EditableInfographicItem[]) => void; |
| 41 | } |
| 42 | |
| 43 | interface TreeNodeRowProps { |
| 44 | item: EditableInfographicItem; |
| 45 | level: number; |
| 46 | variant: TreeEditorVariant; |
| 47 | onAddChild: () => void; |
| 48 | onChildrenChange: (children: EditableInfographicItem[]) => void; |
| 49 | onInsertAfter: () => void; |
| 50 | onRemove: () => void; |
| 51 | onUpdate: (patch: InfographicItemPatch) => void; |
| 52 | } |
| 53 | |
| 54 | type TreeEditorVariant = "compare" | "hierarchy"; |
| 55 | |
| 56 | function createTreeItem(label = "New item"): EditableInfographicItem { |
| 57 | return { |
| 58 | editorId: createEditorId(), |
| 59 | label, |
| 60 | desc: "", |
| 61 | value: undefined, |
| 62 | }; |
| 63 | } |
| 64 | |
| 65 | function SortableTreeNode({ |
| 66 | item, |
| 67 | level, |
| 68 | variant, |
| 69 | onAddChild, |
| 70 | onChildrenChange, |
| 71 | onInsertAfter, |
| 72 | onRemove, |
| 73 | onUpdate, |
| 74 | }: TreeNodeRowProps) { |
| 75 | const { |
| 76 | attributes, |
| 77 | isDragging, |
| 78 | listeners, |
| 79 | setNodeRef, |
| 80 | transform, |
| 81 | transition, |
| 82 | } = useSortable({ id: item.editorId }); |
| 83 | |
| 84 | const style = { |
| 85 | transform: CSS.Transform.toString(transform), |
| 86 | transition, |
| 87 | }; |
| 88 | const children = item.children ?? []; |
| 89 | const isCompareGroup = variant === "compare" && level === 0; |
| 90 | const childCountLabel = `${children.length} item${children.length === 1 ? "" : "s"}`; |
| 91 | |
| 92 | return ( |
| 93 | <div ref={setNodeRef} style={style} className="space-y-2"> |
| 94 | <div |
| 95 | className={cn( |
| 96 | "group relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 rounded-xl border border-border/50 bg-background p-3 transition-colors hover:bg-muted/30", |
| 97 | isDragging && "z-20 opacity-80 shadow-xl ring-1 ring-ring", |
| 98 | )} |
| 99 | > |
| 100 | <button |
| 101 | type="button" |
| 102 | className="mt-1 flex size-7 cursor-grab items-center justify-center rounded-md text-muted-foreground opacity-50 transition-all hover:bg-muted hover:opacity-100 active:cursor-grabbing group-hover:opacity-100" |
| 103 | aria-label="Reorder hierarchy item" |
| 104 | {...attributes} |
| 105 | {...listeners} |
| 106 | > |
| 107 | <GripVertical className="size-4" /> |
| 108 | </button> |
| 109 | |
| 110 | <div className="min-w-0 space-y-3"> |
| 111 | {isCompareGroup ? ( |
| 112 | <div className="rounded-lg border border-dashed bg-muted/20 px-3 py-2.5 transition-colors group-hover:bg-muted/40"> |
| 113 | <p className="text-sm font-semibold tracking-tight text-foreground"> |
| 114 | {item.label ?? "Group"} |
| 115 | </p> |
| 116 | <p className="text-xs text-muted-foreground mt-0.5"> |
| 117 | {childCountLabel} |
| 118 | </p> |
| 119 | </div> |
| 120 | ) : ( |
| 121 | <div className="space-y-3"> |
| 122 | <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_8rem]"> |
| 123 | <Input |
| 124 | value={item.label ?? ""} |
| 125 | onChange={(event) => onUpdate({ label: event.target.value })} |
| 126 | placeholder={level === 0 ? "Root title" : "Item title"} |
| 127 | className="bg-transparent font-medium shadow-none focus-visible:bg-background" |
| 128 | /> |
| 129 | <Input |
| 130 | value={item.value ?? ""} |
| 131 | onChange={(event) => |
| 132 | onUpdate({ |
| 133 | value: normalizeEditableValue(event.target.value), |
| 134 | }) |
| 135 | } |
| 136 | placeholder="Value" |
| 137 | className="bg-transparent shadow-none focus-visible:bg-background" |
| 138 | /> |
| 139 | </div> |
| 140 | <Textarea |
| 141 | value={item.desc ?? ""} |
| 142 | onChange={(event) => onUpdate({ desc: event.target.value })} |
| 143 | placeholder="Description" |
| 144 | className="min-h-16 resize-none bg-transparent shadow-none focus-visible:bg-background leading-relaxed" |
| 145 | /> |
| 146 | </div> |
| 147 | )} |
| 148 | |
| 149 | <div className="flex items-center gap-2 pt-1 opacity-60 transition-opacity focus-within:opacity-100 group-hover:opacity-100"> |
| 150 | <Button |
| 151 | type="button" |
| 152 | variant="secondary" |
| 153 | size="sm" |
| 154 | className="h-7 gap-1.5 px-2.5 text-xs" |
| 155 | onClick={onAddChild} |
| 156 | > |
| 157 | <Plus className="size-3.5" /> |
| 158 | {isCompareGroup ? "Add Group Item" : "Add Child"} |
| 159 | </Button> |
| 160 | <Button |
| 161 | type="button" |
| 162 | variant="ghost" |
| 163 | size="sm" |
| 164 | className="h-7 gap-1.5 px-2.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" |
| 165 | onClick={onInsertAfter} |
| 166 | > |
| 167 | <Plus className="size-3.5" /> |
| 168 | {isCompareGroup ? "Insert Group Below" : "Insert Sibling Below"} |
| 169 | </Button> |
| 170 | </div> |
| 171 | </div> |
| 172 | |
| 173 | <Button |
| 174 | type="button" |
| 175 | variant="ghost" |
| 176 | size="icon" |
| 177 | className="mt-1 size-8 text-muted-foreground opacity-50 transition-opacity hover:text-destructive group-hover:opacity-100" |
| 178 | onClick={onRemove} |
| 179 | aria-label="Remove hierarchy item" |
| 180 | > |
| 181 | <Trash2 className="size-4" /> |
| 182 | </Button> |
| 183 | </div> |
| 184 | |
| 185 | {children.length > 0 ? ( |
| 186 | <div className="ml-5 mt-2 border-l-2 border-border/40 pl-4 transition-colors hover:border-border/80"> |
| 187 | <TreeLevel |
| 188 | items={children} |
| 189 | level={level + 1} |
| 190 | variant={variant} |
| 191 | onChange={onChildrenChange} |
| 192 | /> |
| 193 | </div> |
| 194 | ) : null} |
| 195 | </div> |
| 196 | ); |
| 197 | } |
| 198 | |
| 199 | function TreeLevel({ items, level, variant, onChange }: TreeLevelProps) { |
| 200 | const sensors = useSensors( |
| 201 | useSensor(PointerSensor, { |
| 202 | activationConstraint: { distance: 6 }, |
| 203 | }), |
| 204 | ); |
| 205 | |
| 206 | const handleDragEnd = (event: DragEndEvent) => { |
| 207 | const { active, over } = event; |
| 208 | if (!over || active.id === over.id) return; |
| 209 | |
| 210 | const oldIndex = items.findIndex((item) => item.editorId === active.id); |
| 211 | const newIndex = items.findIndex((item) => item.editorId === over.id); |
| 212 | if (oldIndex === -1 || newIndex === -1) return; |
| 213 | |
| 214 | onChange(arrayMove(items, oldIndex, newIndex)); |
| 215 | }; |
| 216 | |
| 217 | const updateItem = (editorId: string, patch: InfographicItemPatch) => { |
| 218 | onChange( |
| 219 | items.map((item) => |
| 220 | item.editorId === editorId ? { ...item, ...patch } : item, |
| 221 | ), |
| 222 | ); |
| 223 | }; |
| 224 | |
| 225 | const updateChildren = ( |
| 226 | editorId: string, |
| 227 | children: EditableInfographicItem[], |
| 228 | ) => { |
| 229 | onChange( |
| 230 | items.map((item) => |
| 231 | item.editorId === editorId |
| 232 | ? { |
| 233 | ...item, |
| 234 | children, |
| 235 | value: |
| 236 | variant === "compare" && level === 0 |
| 237 | ? children.length |
| 238 | : item.value, |
| 239 | } |
| 240 | : item, |
| 241 | ), |
| 242 | ); |
| 243 | }; |
| 244 | |
| 245 | const addChild = (editorId: string) => { |
| 246 | onChange( |
| 247 | items.map((item) => |
| 248 | item.editorId === editorId |
| 249 | ? { |
| 250 | ...item, |
| 251 | children: [ |
| 252 | ...(item.children ?? []), |
| 253 | createTreeItem( |
| 254 | variant === "compare" ? "New item" : "New child", |
| 255 | ), |
| 256 | ], |
| 257 | value: |
| 258 | variant === "compare" |
| 259 | ? (item.children?.length ?? 0) + 1 |
| 260 | : item.value, |
| 261 | } |
| 262 | : item, |
| 263 | ), |
| 264 | ); |
| 265 | }; |
| 266 | |
| 267 | const removeItem = (editorId: string) => { |
| 268 | onChange(items.filter((item) => item.editorId !== editorId)); |
| 269 | }; |
| 270 | |
| 271 | const insertItem = (index: number) => { |
| 272 | const nextItems = [...items]; |
| 273 | nextItems.splice( |
| 274 | index, |
| 275 | 0, |
| 276 | createTreeItem( |
| 277 | variant === "compare" && level === 0 |
| 278 | ? "New group" |
| 279 | : index === 0 |
| 280 | ? "New item" |
| 281 | : "New sibling", |
| 282 | ), |
| 283 | ); |
| 284 | onChange(nextItems); |
| 285 | }; |
| 286 | |
| 287 | return ( |
| 288 | <DndContext |
| 289 | sensors={sensors} |
| 290 | collisionDetection={closestCenter} |
| 291 | onDragEnd={handleDragEnd} |
| 292 | > |
| 293 | <SortableContext |
| 294 | items={items.map((item) => item.editorId)} |
| 295 | strategy={verticalListSortingStrategy} |
| 296 | > |
| 297 | <div className="space-y-2"> |
| 298 | {items.length > 0 ? ( |
| 299 | <Button |
| 300 | type="button" |
| 301 | variant="outline" |
| 302 | size="sm" |
| 303 | className="h-8 w-full border-dashed text-xs" |
| 304 | onClick={() => insertItem(0)} |
| 305 | > |
| 306 | <Plus className="size-3.5" /> |
| 307 | Insert item at start |
| 308 | </Button> |
| 309 | ) : null} |
| 310 | {items.map((item, index) => ( |
| 311 | <SortableTreeNode |
| 312 | key={item.editorId} |
| 313 | item={item} |
| 314 | level={level} |
| 315 | variant={variant} |
| 316 | onAddChild={() => addChild(item.editorId)} |
| 317 | onChildrenChange={(children) => |
| 318 | updateChildren(item.editorId, children) |
| 319 | } |
| 320 | onInsertAfter={() => insertItem(index + 1)} |
| 321 | onRemove={() => removeItem(item.editorId)} |
| 322 | onUpdate={(patch) => updateItem(item.editorId, patch)} |
| 323 | /> |
| 324 | ))} |
| 325 | </div> |
| 326 | </SortableContext> |
| 327 | </DndContext> |
| 328 | ); |
| 329 | } |
| 330 | |
| 331 | export function InfographicTreeEditor({ |
| 332 | items, |
| 333 | variant = "hierarchy", |
| 334 | onChange, |
| 335 | }: InfographicTreeEditorProps) { |
| 336 | const addRoot = () => { |
| 337 | onChange([ |
| 338 | ...items, |
| 339 | createTreeItem( |
| 340 | variant === "compare" |
| 341 | ? `Group ${String.fromCharCode(65 + items.length)}` |
| 342 | : items.length === 0 |
| 343 | ? "Root" |
| 344 | : "New root", |
| 345 | ), |
| 346 | ]); |
| 347 | }; |
| 348 | const isCompare = variant === "compare"; |
| 349 | |
| 350 | return ( |
| 351 | <div className="space-y-4"> |
| 352 | <div className="flex items-center justify-between gap-3 border-b border-border/50 pb-3"> |
| 353 | <div> |
| 354 | <p className="flex items-center gap-2 text-sm font-semibold tracking-tight text-foreground"> |
| 355 | <ListTree className="size-4" /> |
| 356 | {isCompare ? "Compare Groups" : "Hierarchy"} |
| 357 | </p> |
| 358 | <p className="text-xs text-muted-foreground mt-1"> |
| 359 | {isCompare |
| 360 | ? "Edit each compare side and the items shown inside it." |
| 361 | : "Nest items to shape tree, mind-map, and hierarchy infographics."} |
| 362 | </p> |
| 363 | </div> |
| 364 | <Button |
| 365 | type="button" |
| 366 | variant="secondary" |
| 367 | size="sm" |
| 368 | className="gap-2" |
| 369 | onClick={addRoot} |
| 370 | > |
| 371 | <Plus className="size-3.5" /> |
| 372 | {isCompare ? "Add Group" : "Add Root"} |
| 373 | </Button> |
| 374 | </div> |
| 375 | |
| 376 | <TreeLevel |
| 377 | items={items} |
| 378 | level={0} |
| 379 | variant={variant} |
| 380 | onChange={onChange} |
| 381 | /> |
| 382 | |
| 383 | {items.length === 0 ? ( |
| 384 | <div className="rounded-xl border border-dashed border-border/60 bg-muted/10 p-8 text-center"> |
| 385 | <p className="text-sm font-medium text-foreground"> |
| 386 | {isCompare ? "No compare groups yet" : "No hierarchy yet"} |
| 387 | </p> |
| 388 | <p className="mt-1 text-xs text-muted-foreground"> |
| 389 | {isCompare |
| 390 | ? "Add a compare group, then add the items inside it." |
| 391 | : "Add a root item, then add children below it."} |
| 392 | </p> |
| 393 | </div> |
| 394 | ) : null} |
| 395 | </div> |
| 396 | ); |
| 397 | } |
| 398 |