@kyro-cms/admin 0.10.19 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,21 @@
1
1
  import React from "react";
2
2
  import type { Field } from "@kyro-cms/core/client";
3
3
  import RelationshipField from "./RelationshipField";
4
- import { ChevronDown, ChevronUp } from "../ui/icons";
4
+ import { ChevronDown, ChevronUp, GripVertical } from "../ui/icons";
5
+ import {
6
+ DndContext,
7
+ closestCenter,
8
+ PointerSensor,
9
+ useSensor,
10
+ useSensors,
11
+ type DragEndEvent,
12
+ } from "@dnd-kit/core";
13
+ import {
14
+ SortableContext,
15
+ useSortable,
16
+ verticalListSortingStrategy,
17
+ } from "@dnd-kit/sortable";
18
+ import { CSS } from "@dnd-kit/utilities";
5
19
 
6
20
  const SIMPLE_TYPES = new Set(["text", "textarea", "number", "checkbox", "select", "radio", "color", "email", "password", "code", "markdown", "upload"]);
7
21
 
@@ -23,6 +37,161 @@ function isCompactArray(field: Field): boolean {
23
37
  return subFields.every((f: Field) => SIMPLE_TYPES.has(f.type));
24
38
  }
25
39
 
40
+ // Sortable item wrapper
41
+ interface SortableArrayItemProps {
42
+ id: string;
43
+ index: number;
44
+ isOpen: boolean;
45
+ setOpenIndex: (index: number | null) => void;
46
+ item: Record<string, unknown>;
47
+ field: Field;
48
+ renderField: any;
49
+ onChangeItem: (newItem: any) => void;
50
+ onRemove: () => void;
51
+ disabled?: boolean;
52
+ compact: boolean;
53
+ getItemLabel: (item: any) => string;
54
+ }
55
+
56
+ function SortableArrayItem({
57
+ id,
58
+ index,
59
+ isOpen,
60
+ setOpenIndex,
61
+ item,
62
+ field,
63
+ renderField,
64
+ onChangeItem,
65
+ onRemove,
66
+ disabled,
67
+ compact,
68
+ getItemLabel,
69
+ }: SortableArrayItemProps) {
70
+ const {
71
+ attributes,
72
+ listeners,
73
+ setNodeRef,
74
+ transform,
75
+ transition,
76
+ isDragging,
77
+ } = useSortable({ id });
78
+
79
+ const style = {
80
+ transform: CSS.Transform.toString(transform),
81
+ transition,
82
+ zIndex: isDragging ? 10 : 1,
83
+ opacity: isDragging ? 0.8 : 1,
84
+ };
85
+
86
+ if (compact) {
87
+ return (
88
+ <div
89
+ ref={setNodeRef}
90
+ style={style}
91
+ className={`flex items-start gap-2 px-3 py-1.5 border-b border-[var(--kyro-border)] last:border-b-0 hover:bg-[var(--kyro-sidebar-active)]/5 transition-colors ${
92
+ isDragging ? "bg-[var(--kyro-surface-accent)]/50" : ""
93
+ }`}
94
+ >
95
+ <div
96
+ {...attributes}
97
+ {...listeners}
98
+ className="p-1 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] hover:bg-[var(--kyro-surface-accent)] rounded flex-shrink-0 mt-1"
99
+ >
100
+ <GripVertical className="w-3.5 h-3.5" />
101
+ </div>
102
+ <span className="text-[10px] font-bold text-[var(--kyro-text-muted)] pt-2.5 min-w-[18px] text-center">
103
+ {index + 1}
104
+ </span>
105
+ <div className="flex-1 flex items-start gap-1.5 min-w-0">
106
+ {((field as any).fields || []).map((f: Field) => (
107
+ <div key={f.name} className="flex-1 min-w-0">
108
+ {renderField(f, item, onChangeItem)}
109
+ </div>
110
+ ))}
111
+ </div>
112
+ <button
113
+ type="button"
114
+ disabled={disabled}
115
+ onClick={onRemove}
116
+ className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-error)] transition-colors disabled:opacity-30 p-0.5 mt-1.5 flex-shrink-0"
117
+ title="Remove"
118
+ >
119
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
120
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
121
+ </svg>
122
+ </button>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ return (
128
+ <div
129
+ ref={setNodeRef}
130
+ style={style}
131
+ className={`border border-[var(--kyro-border)] rounded-lg overflow-hidden group bg-[var(--kyro-surface)] ${
132
+ isDragging ? "border-[var(--kyro-primary)] shadow-md" : ""
133
+ }`}
134
+ >
135
+ <div className="w-full flex items-center justify-between p-3 bg-[var(--kyro-surface-accent)] hover:bg-[var(--kyro-sidebar-active)]/10 transition-colors">
136
+ <div className="flex items-center gap-2 flex-1 min-w-0">
137
+ <div
138
+ {...attributes}
139
+ {...listeners}
140
+ className="p-1 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] hover:bg-[var(--kyro-border)] rounded flex-shrink-0"
141
+ >
142
+ <GripVertical className="w-4 h-4" />
143
+ </div>
144
+ <span
145
+ role="button"
146
+ tabIndex={0}
147
+ onClick={() => setOpenIndex(isOpen ? null : index)}
148
+ onKeyDown={(e) => {
149
+ if (e.key === "Enter" || e.key === " ") {
150
+ e.preventDefault();
151
+ setOpenIndex(isOpen ? null : index);
152
+ }
153
+ }}
154
+ className="text-xs font-bold tracking-widest text-[var(--kyro-text-muted)] truncate cursor-pointer flex-1 py-1 text-left"
155
+ >
156
+ {getItemLabel(item) || `Item ${index + 1}`}
157
+ </span>
158
+ </div>
159
+ <div className="flex items-center gap-1">
160
+ <button
161
+ type="button"
162
+ disabled={disabled}
163
+ onClick={(e) => {
164
+ e.stopPropagation();
165
+ onRemove();
166
+ }}
167
+ className="text-[11px] font-bold text-[var(--kyro-error)] opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-30 hover:bg-[var(--kyro-danger-bg)] rounded px-1.5 py-0.5"
168
+ >
169
+ Remove
170
+ </button>
171
+ <button
172
+ type="button"
173
+ onClick={() => setOpenIndex(isOpen ? null : index)}
174
+ className="p-1 hover:bg-[var(--kyro-surface-accent)] rounded"
175
+ >
176
+ {isOpen ? (
177
+ <ChevronUp className="w-4 h-4 text-[var(--kyro-text-muted)]" />
178
+ ) : (
179
+ <ChevronDown className="w-4 h-4 text-[var(--kyro-text-muted)]" />
180
+ )}
181
+ </button>
182
+ </div>
183
+ </div>
184
+ {isOpen && (
185
+ <div className="p-4 bg-[var(--kyro-surface)] space-y-4">
186
+ {((field as any).fields || []).map((f: Field) =>
187
+ renderField(f, item, onChangeItem),
188
+ )}
189
+ </div>
190
+ )}
191
+ </div>
192
+ );
193
+ }
194
+
26
195
  export function ArrayLayout({
27
196
  field,
28
197
  value,
@@ -37,6 +206,32 @@ export function ArrayLayout({
37
206
  const isRelationship = firstField?.type === "relationship";
38
207
  const [openIndex, setOpenIndex] = React.useState<number | null>(0);
39
208
 
209
+ // Sync stable IDs and heal bad data
210
+ React.useEffect(() => {
211
+ let needsUpdate = false;
212
+ const updated = items
213
+ .filter((item: any) => {
214
+ if (typeof item !== "object" || item === null) {
215
+ needsUpdate = true;
216
+ return false;
217
+ }
218
+ return true;
219
+ })
220
+ .map((item: any) => {
221
+ if (!item.id && !item._key) {
222
+ needsUpdate = true;
223
+ return {
224
+ ...item,
225
+ id: Math.random().toString(36).substr(2, 9),
226
+ };
227
+ }
228
+ return item;
229
+ });
230
+ if (needsUpdate) {
231
+ onChange(updated);
232
+ }
233
+ }, [value, onChange]);
234
+
40
235
  function getItemLabel(item: Record<string, unknown>): string {
41
236
  for (const key of ["label", "title", "name"]) {
42
237
  const val = item[key];
@@ -59,12 +254,41 @@ export function ArrayLayout({
59
254
  }
60
255
  return "";
61
256
  }
257
+
62
258
  const compact = isCompactArray(field);
63
259
 
64
- return (
65
- <div className="kyro-form-field">
66
- <label className="kyro-form-label">{field.label || field.name}</label>
67
- {isRelationship ? (
260
+ // dnd-kit sensors setup
261
+ const sensors = useSensors(
262
+ useSensor(PointerSensor, {
263
+ activationConstraint: {
264
+ distance: 3,
265
+ },
266
+ })
267
+ );
268
+
269
+ const handleDragEnd = (event: DragEndEvent) => {
270
+ const { active, over } = event;
271
+ if (!over || active.id === over.id) return;
272
+
273
+ const oldIndex = items.findIndex((item: any) => (item.id || item._key) === active.id);
274
+ const newIndex = items.findIndex((item: any) => (item.id || item._key) === over.id);
275
+
276
+ if (oldIndex !== -1 && newIndex !== -1) {
277
+ const newItems = [...items];
278
+ const [movedItem] = newItems.splice(oldIndex, 1);
279
+ newItems.splice(newIndex, 0, movedItem);
280
+ onChange(newItems);
281
+ }
282
+ };
283
+
284
+ const itemIds = React.useMemo(() => {
285
+ return items.map((item: any) => item?.id || item?._key || "");
286
+ }, [items]);
287
+
288
+ if (isRelationship) {
289
+ return (
290
+ <div className="kyro-form-field">
291
+ <label className="kyro-form-label">{field.label || field.name}</label>
68
292
  <RelationshipField
69
293
  field={{
70
294
  name: labelField,
@@ -76,118 +300,109 @@ export function ArrayLayout({
76
300
  onChange={(newValue) => {
77
301
  const newItems = (newValue || []).map((id: string) => ({
78
302
  [labelField]: id,
303
+ id: Math.random().toString(36).substr(2, 9),
79
304
  }));
80
305
  onChange(newItems);
81
306
  }}
82
307
  disabled={disabled}
83
308
  />
84
- ) : compact ? (
85
- <div className="kyro-form-array kyro-form-array--compact border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)]/30 rounded-md overflow-hidden">
86
- {(items as Record<string, unknown>[]).map((item, index) => (
87
- <div
88
- key={index}
89
- className="flex items-start gap-2 px-3 py-1.5 border-b border-[var(--kyro-border)] last:border-b-0 hover:bg-[var(--kyro-sidebar-active)]/5 transition-colors"
309
+ </div>
310
+ );
311
+ }
312
+
313
+ return (
314
+ <div className="kyro-form-field">
315
+ <label className="kyro-form-label">{field.label || field.name}</label>
316
+ <DndContext
317
+ sensors={sensors}
318
+ collisionDetection={closestCenter}
319
+ onDragEnd={handleDragEnd}
320
+ >
321
+ {compact ? (
322
+ <div className="kyro-form-array kyro-form-array--compact border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)]/30 rounded-md overflow-hidden">
323
+ <SortableContext
324
+ items={itemIds}
325
+ strategy={verticalListSortingStrategy}
326
+ >
327
+ {(items as Record<string, unknown>[]).map((item, index) => (
328
+ <SortableArrayItem
329
+ key={item.id as string || item._key as string || index}
330
+ id={item.id as string || item._key as string || `idx-${index}`}
331
+ index={index}
332
+ isOpen={false}
333
+ setOpenIndex={() => {}}
334
+ item={item}
335
+ field={field}
336
+ renderField={renderField}
337
+ onChangeItem={(newItem) => {
338
+ const newItems = [...items];
339
+ newItems[index] = newItem;
340
+ onChange(newItems);
341
+ }}
342
+ onRemove={() => onChange(items.filter((_: unknown, i: number) => i !== index))}
343
+ disabled={disabled}
344
+ compact={true}
345
+ getItemLabel={getItemLabel}
346
+ />
347
+ ))}
348
+ </SortableContext>
349
+ <button
350
+ type="button"
351
+ className="w-full py-2 border-2 border-dashed border-[var(--kyro-border)] rounded-none text-xs font-bold text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)] hover:border-[var(--kyro-primary)] transition-all disabled:opacity-50"
352
+ disabled={disabled}
353
+ onClick={() => onChange([...items, { id: Math.random().toString(36).substr(2, 9) }])}
90
354
  >
91
- <span className="text-[10px] font-bold text-[var(--kyro-text-muted)] pt-2 min-w-[18px] text-center">
92
- {index + 1}
93
- </span>
94
- <div className="flex-1 flex items-start gap-1.5 min-w-0">
95
- {(field as Field & { fields?: Field[] }).fields.map((f: Field) => {
355
+ + Add Item
356
+ </button>
357
+ </div>
358
+ ) : (
359
+ <div className="kyro-form-array border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)]/30 rounded-md p-3 space-y-4">
360
+ <SortableContext
361
+ items={itemIds}
362
+ strategy={verticalListSortingStrategy}
363
+ >
364
+ <div className="space-y-2">
365
+ {(items as Record<string, unknown>[]).map((item, index) => {
366
+ const isOpen = openIndex === index;
96
367
  return (
97
- <div key={f.name} className="flex-1 min-w-0">
98
- {renderField(f, item, (newItem) => {
368
+ <SortableArrayItem
369
+ key={item.id as string || item._key as string || index}
370
+ id={item.id as string || item._key as string || `idx-${index}`}
371
+ index={index}
372
+ isOpen={isOpen}
373
+ setOpenIndex={setOpenIndex}
374
+ item={item}
375
+ field={field}
376
+ renderField={renderField}
377
+ onChangeItem={(newItem) => {
99
378
  const newItems = [...items];
100
379
  newItems[index] = newItem;
101
380
  onChange(newItems);
102
- })}
103
- </div>
381
+ }}
382
+ onRemove={() => onChange(items.filter((_: unknown, i: number) => i !== index))}
383
+ disabled={disabled}
384
+ compact={false}
385
+ getItemLabel={getItemLabel}
386
+ />
104
387
  );
105
388
  })}
106
389
  </div>
107
- <button
108
- type="button"
109
- disabled={disabled}
110
- onClick={() => onChange(items.filter((_: unknown, i: number) => i !== index))}
111
- className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-error)] transition-colors disabled:opacity-30 p-0.5 mt-1.5 flex-shrink-0"
112
- title="Remove"
113
- >
114
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
115
- <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
116
- </svg>
117
- </button>
118
- </div>
119
- ))}
120
- <button
121
- type="button"
122
- className="w-full py-2 border-2 border-dashed border-[var(--kyro-border)] rounded-none text-xs font-bold text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)] hover:border-[var(--kyro-primary)] transition-all disabled:opacity-50"
123
- disabled={disabled}
124
- onClick={() => onChange([...items, {}])}
125
- >
126
- + Add Item
127
- </button>
128
- </div>
129
- ) : (
130
- <div className="kyro-form-array border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)]/30 rounded-md p-3 space-y-4">
131
- {(items as Record<string, unknown>[]).map((item, index) => {
132
- const isOpen = openIndex === index;
133
- return (
134
- <div
135
- key={index}
136
- className="border border-[var(--kyro-border)] rounded-lg overflow-hidden group"
137
- >
138
- <div
139
- role="button"
140
- tabIndex={0}
141
- onClick={() => setOpenIndex(isOpen ? null : index)}
142
- onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpenIndex(isOpen ? null : index); } }}
143
- className="w-full flex items-center justify-between p-3 bg-[var(--kyro-surface-accent)] hover:bg-[var(--kyro-sidebar-active)]/10 transition-colors cursor-pointer"
144
- >
145
- <span className="text-xs font-bold tracking-widest text-[var(--kyro-text-muted)] truncate">
146
- {getItemLabel(item) || `Item ${index + 1}`}
147
- </span>
148
- <div className="flex items-center gap-1">
149
- <button
150
- type="button"
151
- disabled={disabled}
152
- onClick={(e) => {
153
- e.stopPropagation();
154
- onChange(items.filter((_: unknown, i: number) => i !== index));
155
- }}
156
- className="text-[11px] font-bold text-[var(--kyro-error)] opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-30 hover:bg-[var(--kyro-danger-bg)] rounded px-1.5 py-0.5"
157
- >
158
- Remove
159
- </button>
160
- {isOpen ? (
161
- <ChevronUp className="w-4 h-4 text-[var(--kyro-text-muted)]" />
162
- ) : (
163
- <ChevronDown className="w-4 h-4 text-[var(--kyro-text-muted)]" />
164
- )}
165
- </div>
166
- </div>
167
- {isOpen && (
168
- <div className="p-4 bg-[var(--kyro-surface)] space-y-4">
169
- {(field as Field & { fields?: Field[] }).fields.map((f: Field) =>
170
- renderField(f, item, (newItem) => {
171
- const newItems = [...items];
172
- newItems[index] = newItem;
173
- onChange(newItems);
174
- }),
175
- )}
176
- </div>
177
- )}
178
- </div>
179
- );
180
- })}
181
- <button
182
- type="button"
183
- className="w-full py-3 border-2 border-dashed border-[var(--kyro-border)] rounded-lg text-xs font-bold text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)] hover:border-[var(--kyro-primary)] transition-all disabled:opacity-50"
184
- disabled={disabled}
185
- onClick={() => onChange([...items, {}])}
186
- >
187
- + Add Item
188
- </button>
189
- </div>
190
- )}
390
+ </SortableContext>
391
+ <button
392
+ type="button"
393
+ className="w-full py-3 border-2 border-dashed border-[var(--kyro-border)] rounded-lg text-xs font-bold text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)] hover:border-[var(--kyro-primary)] transition-all disabled:opacity-50"
394
+ disabled={disabled}
395
+ onClick={() => {
396
+ const newId = Math.random().toString(36).substr(2, 9);
397
+ onChange([...items, { id: newId }]);
398
+ setOpenIndex(items.length);
399
+ }}
400
+ >
401
+ + Add Item
402
+ </button>
403
+ </div>
404
+ )}
405
+ </DndContext>
191
406
  </div>
192
407
  );
193
408
  }
@@ -157,9 +157,7 @@ const SortableBlockComponent = ({
157
157
  />
158
158
  ) : (
159
159
  <span
160
- onClick={(e) => { e.stopPropagation(); setNameDraft((block.name as string) || ""); setEditingName(true); }}
161
- className="font-medium text-[var(--kyro-text-secondary)] truncate max-w-[120px] cursor-text hover:text-[var(--kyro-text-primary)] transition-colors"
162
- title="Click to rename"
160
+ className="font-medium text-[var(--kyro-text-secondary)] truncate max-w-[120px] transition-colors"
163
161
  >
164
162
  {itemLabel}
165
163
  </span>
@@ -187,6 +185,18 @@ const SortableBlockComponent = ({
187
185
  </div>
188
186
  ) : (
189
187
  <div className="flex items-center gap-0.5">
188
+ <button
189
+ type="button"
190
+ onClick={(e) => {
191
+ e.stopPropagation();
192
+ setNameDraft((block.name as string) || "");
193
+ setEditingName(true);
194
+ }}
195
+ className="p-0.5 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
196
+ title="Rename"
197
+ >
198
+ <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
199
+ </button>
190
200
  <button
191
201
  type="button"
192
202
  onClick={(e) => {
@@ -225,15 +235,7 @@ const SortableBlockComponent = ({
225
235
  }
226
236
 
227
237
  return (
228
- <div ref={setNodeRef} style={style} className="relative group md:pl-8 mb-2">
229
- <div
230
- className="hidden md:absolute left-0 top-1/2 -translate-y-1/2 p-1.5 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--kyro-surface-accent)] rounded"
231
- {...attributes}
232
- {...listeners}
233
- >
234
- <GripVertical className="w-4 h-4" />
235
- </div>
236
-
238
+ <div ref={setNodeRef} style={style} className="relative group mb-2">
237
239
  <div
238
240
  onClick={() => setEditingBlockId(block.id as string)}
239
241
  className={`flex items-center gap-3 p-3 bg-[var(--kyro-bg-secondary)] rounded-lg border transition-colors cursor-pointer ${isEditing
@@ -241,6 +243,15 @@ const SortableBlockComponent = ({
241
243
  : "border-[var(--kyro-border)] hover:border-[var(--kyro-primary)]/50 hover:bg-[var(--kyro-primary)]/5"
242
244
  }`}
243
245
  >
246
+ <div
247
+ className="p-1 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] hover:bg-[var(--kyro-surface-accent)] rounded flex-shrink-0"
248
+ {...attributes}
249
+ {...listeners}
250
+ onClick={(e) => e.stopPropagation()}
251
+ >
252
+ <GripVertical className="w-4 h-4" />
253
+ </div>
254
+
244
255
  {blockIcons[block.type as string] && (
245
256
  <span className="text-[var(--kyro-text-secondary)]">
246
257
  {blockIcons[block.type as string]}
@@ -261,15 +272,7 @@ const SortableBlockComponent = ({
261
272
  />
262
273
  ) : (
263
274
  <div
264
- onClick={(e) => {
265
- if (window.innerWidth >= 768) {
266
- e.stopPropagation();
267
- setNameDraft((block.name as string) || "");
268
- setEditingName(true);
269
- }
270
- }}
271
- className="text-xs font-semibold text-[var(--kyro-text-secondary)] truncate md:cursor-text hover:text-[var(--kyro-text-primary)] transition-colors"
272
- title="Click to rename"
275
+ className="text-xs font-semibold text-[var(--kyro-text-secondary)] truncate transition-colors"
273
276
  >
274
277
  {itemLabel}
275
278
  {previewSnippet && typeof previewSnippet === "string" && (
@@ -324,7 +327,7 @@ const SortableBlockComponent = ({
324
327
  setNameDraft((block.name as string) || "");
325
328
  setEditingName(true);
326
329
  }}
327
- className="md:hidden p-1 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
330
+ className="p-1 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
328
331
  title="Rename Block"
329
332
  >
330
333
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
@@ -543,7 +546,7 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
543
546
  const sensors = useSensors(
544
547
  useSensor(PointerSensor, {
545
548
  activationConstraint: {
546
- distance: 8,
549
+ distance: 3,
547
550
  },
548
551
  }),
549
552
  useSensor(KeyboardSensor),
@@ -574,8 +577,13 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
574
577
  });
575
578
  }
576
579
  } else {
577
- // Dropped on root level - add as top-level block
578
- handleAddBlock(blockType);
580
+ // Dropped on root level - check if dropped over a specific block to insert at index
581
+ const overIndex = blocks.findIndex((b) => b.id === over.id);
582
+ if (overIndex !== -1) {
583
+ store.getState().addBlock(blockType, overIndex);
584
+ } else {
585
+ handleAddBlock(blockType);
586
+ }
579
587
  }
580
588
  return;
581
589
  }