返回 presentation-ai
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
398 lines Plain Text