@motor-cms/ui-admin 4.11.0 → 4.12.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.
@@ -10,6 +10,10 @@ interface CategoryItem {
10
10
  }
11
11
 
12
12
  const TEMP_NEW_ID = -1
13
+ // Horizontal pixels per nesting level. Dragging the item this far to the
14
+ // right/left while moving changes the target depth by one — this is what makes
15
+ // the nesting level directly visible and controllable.
16
+ const INDENT = 24
13
17
 
14
18
  const props = defineProps<{
15
19
  treeId: string
@@ -38,7 +42,6 @@ const activeItemId = computed(() => props.currentId ?? (isCreateMode.value ? TEM
38
42
 
39
43
  // The parent_id of root-level categories (their parent is outside the returned list)
40
44
  const rootParentId = ref<number | null>(null)
41
- const isDragging = ref(false)
42
45
  const expandedIds = ref(new Set<number>())
43
46
 
44
47
  // IDs that can't be a drop/select target (current item + its descendants)
@@ -84,7 +87,6 @@ function buildTree(cats: CategoryItem[]): TreeNode[] {
84
87
  map.get(cat.parent_id)!.children.push(node)
85
88
  } else {
86
89
  roots.push(node)
87
- // Capture the parent_id of root-level categories
88
90
  if (rootParentId.value === null && cat.parent_id !== null) {
89
91
  rootParentId.value = cat.parent_id
90
92
  }
@@ -93,7 +95,6 @@ function buildTree(cats: CategoryItem[]): TreeNode[] {
93
95
  return roots
94
96
  }
95
97
 
96
- // Find ancestor IDs from root to target (not including target)
97
98
  function findPathTo(nodes: TreeNode[], targetId: number): number[] | null {
98
99
  for (const node of nodes) {
99
100
  if (node.id === targetId) return []
@@ -108,15 +109,12 @@ function findPathTo(nodes: TreeNode[], targetId: number): number[] | null {
108
109
  watch(categories, (cats) => {
109
110
  tree.value = buildTree(cats)
110
111
 
111
- // Empty tree: default rootParentId to the tree's own ID (root node)
112
112
  if (rootParentId.value === null) {
113
113
  rootParentId.value = Number(props.treeId)
114
114
  }
115
115
 
116
- // Create mode: insert virtual "new" node at the start of the root list
117
116
  if (isCreateMode.value) {
118
117
  tree.value.unshift({ id: TEMP_NEW_ID, name: props.newItemName || '', children: [] })
119
- // Emit initial position (first child at root level)
120
118
  emitPosition()
121
119
  }
122
120
 
@@ -129,7 +127,6 @@ watch(categories, (cats) => {
129
127
  }
130
128
  }, { immediate: true })
131
129
 
132
- // Keep the active node's name in sync with the prop
133
130
  watch(() => props.newItemName, (name) => {
134
131
  const id = activeItemId.value
135
132
  if (id === undefined) return
@@ -139,16 +136,13 @@ watch(() => props.newItemName, (name) => {
139
136
 
140
137
  function toggleNode(id: number) {
141
138
  const next = new Set(expandedIds.value)
142
- if (next.has(id)) {
143
- next.delete(id)
144
- } else {
145
- next.add(id)
146
- }
139
+ if (next.has(id)) next.delete(id)
140
+ else next.add(id)
147
141
  expandedIds.value = next
148
142
  }
149
143
 
150
144
  // =============================================
151
- // Tree mutation (edit mode drag & drop)
145
+ // Tree data helpers (move + emit position)
152
146
  // =============================================
153
147
 
154
148
  function findAndRemove(nodes: TreeNode[], id: number): TreeNode | null {
@@ -207,7 +201,6 @@ function moveItem(itemId: number, newParentId: number | null, newIndex: number)
207
201
 
208
202
  targetList.splice(newIndex, 0, item)
209
203
 
210
- // Auto-expand the target parent so the user sees the result
211
204
  if (newParentId !== null && !expandedIds.value.has(newParentId)) {
212
205
  const next = new Set(expandedIds.value)
213
206
  next.add(newParentId)
@@ -218,114 +211,164 @@ function moveItem(itemId: number, newParentId: number | null, newIndex: number)
218
211
  }
219
212
 
220
213
  // =============================================
221
- // Auto-expand collapsed parents on drag hover
214
+ // Flatten the visible tree into a single list with depth
222
215
  // =============================================
223
216
 
224
- const autoExpandedIds = new Set<number>()
225
- let hoverTimer: ReturnType<typeof setTimeout> | null = null
226
- let hoverTargetId: number | null = null
227
-
228
- function isDescendantOf(nodeId: number, ancestorId: number): boolean {
229
- const catMap = new Map(categories.value.map(c => [c.id, c]))
230
- let current = catMap.get(nodeId)
231
- while (current) {
232
- if (current.parent_id === ancestorId) return true
233
- if (current.parent_id === null) return false
234
- current = current.parent_id !== null ? catMap.get(current.parent_id) : undefined
235
- }
236
- return false
217
+ interface FlatItem {
218
+ id: number
219
+ name: string
220
+ depth: number
221
+ parentId: number | null
222
+ hasChildren: boolean
237
223
  }
238
224
 
239
- function collapseStaleAutoExpanded(currentItemId: number) {
240
- if (autoExpandedIds.size === 0) return
241
- const toRemove: number[] = []
242
- for (const id of autoExpandedIds) {
243
- if (id === currentItemId || isDescendantOf(currentItemId, id)) continue
244
- toRemove.push(id)
245
- }
246
- if (toRemove.length === 0) return
247
- const next = new Set(expandedIds.value)
248
- for (const id of toRemove) {
249
- next.delete(id)
250
- autoExpandedIds.delete(id)
225
+ const flattened = computed<FlatItem[]>(() => {
226
+ const out: FlatItem[] = []
227
+ function walk(nodes: TreeNode[], depth: number, parentId: number | null) {
228
+ for (const n of nodes) {
229
+ out.push({ id: n.id, name: n.name, depth, parentId, hasChildren: n.children.length > 0 })
230
+ if (n.children.length && expandedIds.value.has(n.id)) walk(n.children, depth + 1, n.id)
231
+ }
251
232
  }
252
- expandedIds.value = next
253
- }
233
+ walk(tree.value, 0, rootParentId.value)
234
+ return out
235
+ })
254
236
 
255
- function cancelHoverExpand() {
256
- if (hoverTimer) {
257
- clearTimeout(hoverTimer)
258
- hoverTimer = null
259
- hoverTargetId = null
260
- }
237
+ function isExpanded(id: number): boolean {
238
+ return expandedIds.value.has(id)
261
239
  }
262
240
 
263
- function startHoverExpand(id: number) {
264
- if (id === hoverTargetId) return
265
- cancelHoverExpand()
266
- hoverTargetId = id
267
- hoverTimer = setTimeout(() => {
268
- const next = new Set(expandedIds.value)
269
- next.add(id)
270
- expandedIds.value = next
271
- autoExpandedIds.add(id)
272
- hoverTargetId = null
273
- hoverTimer = null
274
- }, 500)
275
- }
241
+ // =============================================
242
+ // Pointer-based drag with horizontal level control
243
+ // =============================================
276
244
 
277
- /** Called from @dragover on each tree item during drag */
278
- function handleDragOver(itemId: number) {
279
- collapseStaleAutoExpanded(itemId)
245
+ const containerRef = ref<HTMLElement | null>(null)
246
+ const dragActive = ref(false)
247
+ const isDragging = computed(() => dragActive.value)
248
+ const pointer = ref({ x: 0, y: 0 })
249
+ let startX = 0
250
+ let activeDepth = 0
280
251
 
281
- if (itemId === activeItemId.value) {
282
- cancelHoverExpand()
283
- return
284
- }
285
- if (nonSelectableIds.value.has(itemId)) {
286
- cancelHoverExpand()
287
- return
288
- }
289
- if (expandedIds.value.has(itemId)) {
290
- cancelHoverExpand()
291
- return
252
+ // Item above the insertion point (null = insert at very top)
253
+ const overItemId = ref<number | null>(null)
254
+ const offsetLeft = ref(0)
255
+
256
+ // Descendants of the active item — excluded from the list while dragging
257
+ const activeDescendantIds = computed<Set<number>>(() => {
258
+ const ids = new Set<number>()
259
+ if (activeItemId.value === undefined) return ids
260
+ const node = findNode(tree.value, activeItemId.value)
261
+ if (node) {
262
+ (function collect(n: TreeNode) {
263
+ for (const c of n.children) { ids.add(c.id); collect(c) }
264
+ })(node)
292
265
  }
266
+ return ids
267
+ })
293
268
 
294
- const node = findNode(tree.value, itemId)
295
- if (!node || node.children.length === 0) {
296
- cancelHoverExpand()
297
- return
269
+ // What we render: during drag the active item + its subtree lift out
270
+ const displayItems = computed<FlatItem[]>(() => {
271
+ if (!dragActive.value) return flattened.value
272
+ return flattened.value.filter(i => i.id !== activeItemId.value && !activeDescendantIds.value.has(i.id))
273
+ })
274
+
275
+ const activeItem = computed(() => flattened.value.find(i => i.id === activeItemId.value) ?? null)
276
+
277
+ const projected = computed(() => {
278
+ if (!dragActive.value) return null
279
+ const items = displayItems.value
280
+ const overIndex = overItemId.value === null ? -1 : items.findIndex(i => i.id === overItemId.value)
281
+ const prevItem = overIndex >= 0 ? items[overIndex] : null
282
+ const nextItem = items[overIndex + 1] ?? null
283
+
284
+ const dragDepth = Math.round(offsetLeft.value / INDENT)
285
+ const projectedDepth = activeDepth + dragDepth
286
+ const maxDepth = prevItem ? prevItem.depth + 1 : 0
287
+ const minDepth = nextItem ? nextItem.depth : 0
288
+ const depth = Math.max(minDepth, Math.min(projectedDepth, maxDepth))
289
+
290
+ let parentId: number | null
291
+ if (depth === 0 || !prevItem) {
292
+ parentId = rootParentId.value
293
+ } else if (depth === prevItem.depth) {
294
+ parentId = prevItem.parentId
295
+ } else if (depth > prevItem.depth) {
296
+ parentId = prevItem.id
297
+ } else {
298
+ const ancestor = items.slice(0, overIndex + 1).reverse().find(i => i.depth === depth)
299
+ parentId = ancestor?.parentId ?? rootParentId.value
298
300
  }
299
301
 
300
- startHoverExpand(itemId)
302
+ return { depth, parentId, overIndex, overItemId: overItemId.value }
303
+ })
304
+
305
+ const projectedParentName = computed(() => {
306
+ const p = projected.value
307
+ if (!p) return null
308
+ if (p.parentId === null || p.parentId === rootParentId.value) return null
309
+ return findNode(tree.value, p.parentId)?.name ?? null
310
+ })
311
+
312
+ function rowsInDom(): HTMLElement[] {
313
+ return [...(containerRef.value?.querySelectorAll<HTMLElement>('[data-flat-id]') ?? [])]
301
314
  }
302
315
 
303
- /** Called after drop — keeps drop target expanded, collapses the rest */
304
- function cleanupAutoExpand(dropParentId: number | null) {
305
- cancelHoverExpand()
306
- if (dropParentId !== null) {
307
- autoExpandedIds.delete(dropParentId)
308
- }
309
- if (autoExpandedIds.size === 0) return
310
- const next = new Set(expandedIds.value)
311
- for (const id of autoExpandedIds) {
312
- next.delete(id)
316
+ function updateOver(clientY: number) {
317
+ let over: number | null = null
318
+ for (const row of rowsInDom()) {
319
+ const r = row.getBoundingClientRect()
320
+ if (clientY >= r.top + r.height / 2) over = Number(row.dataset.flatId)
321
+ else break
313
322
  }
314
- expandedIds.value = next
315
- autoExpandedIds.clear()
323
+ overItemId.value = over
324
+ }
325
+
326
+ function onHandlePointerDown(e: PointerEvent) {
327
+ if (activeItemId.value === undefined) return
328
+ e.preventDefault()
329
+ e.stopPropagation()
330
+ dragActive.value = true
331
+ startX = e.clientX
332
+ offsetLeft.value = 0
333
+ activeDepth = activeItem.value?.depth ?? 0
334
+ pointer.value = { x: e.clientX, y: e.clientY }
335
+ nextTick(() => updateOver(e.clientY))
336
+ window.addEventListener('pointermove', onPointerMove)
337
+ window.addEventListener('pointerup', onPointerUp)
338
+ }
339
+
340
+ function onPointerMove(e: PointerEvent) {
341
+ if (!dragActive.value) return
342
+ pointer.value = { x: e.clientX, y: e.clientY }
343
+ offsetLeft.value = e.clientX - startX
344
+ updateOver(e.clientY)
316
345
  }
317
346
 
318
- // Provide shared state for the recursive NestedDraggable components
319
- provide('categoryTree', {
320
- currentId: computed(() => activeItemId.value),
321
- nonSelectableIds,
322
- moveItem,
323
- isEditMode,
324
- isDragging,
325
- expandedIds: computed(() => expandedIds.value),
326
- toggleNode,
327
- handleDragOver,
328
- cleanupAutoExpand
347
+ function onPointerUp() {
348
+ window.removeEventListener('pointermove', onPointerMove)
349
+ window.removeEventListener('pointerup', onPointerUp)
350
+ const p = projected.value
351
+ const id = activeItemId.value
352
+ dragActive.value = false
353
+ if (!p || id === undefined) return
354
+
355
+ // Index among the target parent's children
356
+ const items = displayItems.value
357
+ const upto = items.slice(0, p.overIndex + 1)
358
+ const prevSibling = [...upto].reverse().find(i => i.parentId === p.parentId && i.depth === p.depth)
359
+ const targetChildren = (p.parentId === null || p.parentId === rootParentId.value)
360
+ ? tree.value
361
+ : (findNode(tree.value, p.parentId)?.children ?? [])
362
+ const newIndex = prevSibling
363
+ ? targetChildren.findIndex(c => c.id === prevSibling.id) + 1
364
+ : 0
365
+
366
+ moveItem(id, p.parentId, newIndex)
367
+ }
368
+
369
+ onBeforeUnmount(() => {
370
+ window.removeEventListener('pointermove', onPointerMove)
371
+ window.removeEventListener('pointerup', onPointerUp)
329
372
  })
330
373
  </script>
331
374
 
@@ -343,13 +386,126 @@ provide('categoryTree', {
343
386
  </div>
344
387
  <div
345
388
  v-else
346
- class="text-sm"
389
+ ref="containerRef"
390
+ class="relative text-sm select-none"
391
+ :class="dragActive ? 'cursor-grabbing' : ''"
347
392
  >
348
- <FormInputsNestedDraggable
349
- :items="tree"
350
- :parent-id="rootParentId"
351
- :depth="0"
352
- />
393
+ <!-- Insertion line at the very top -->
394
+ <div
395
+ v-if="dragActive && projected && projected.overItemId === null"
396
+ class="pointer-events-none py-0.5"
397
+ :style="{ paddingLeft: (projected.depth * INDENT + 8) + 'px' }"
398
+ >
399
+ <span class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 border-2 border-dashed border-[var(--ui-primary)] bg-[var(--ui-primary)]/5 text-[var(--ui-primary)]/70 text-sm">
400
+ <UIcon
401
+ name="i-lucide-grip-vertical"
402
+ class="size-4 opacity-40"
403
+ />
404
+ {{ activeItem?.name || '…' }}
405
+ </span>
406
+ </div>
407
+
408
+ <template
409
+ v-for="item in displayItems"
410
+ :key="item.id"
411
+ >
412
+ <!-- Active (current) item rendered in place as a grabbable pill -->
413
+ <div
414
+ v-if="item.id === activeItemId"
415
+ :data-flat-id="item.id"
416
+ class="flex items-center gap-1 px-2 py-1.5 my-0.5"
417
+ :style="{ paddingLeft: (item.depth * INDENT + 8) + 'px' }"
418
+ >
419
+ <span class="shrink-0 size-5 sm:size-4" />
420
+ <span
421
+ class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 -my-0.5 ring-2 ring-[var(--ui-primary)] bg-[var(--ui-primary)]/10 font-semibold text-[var(--ui-primary)] cursor-grab active:cursor-grabbing touch-none"
422
+ @pointerdown="onHandlePointerDown"
423
+ >
424
+ <UIcon
425
+ name="i-lucide-grip-vertical"
426
+ class="shrink-0 size-4 opacity-60"
427
+ />
428
+ {{ item.name || '…' }}
429
+ </span>
430
+ </div>
431
+ <div
432
+ v-else
433
+ :data-flat-id="item.id"
434
+ class="flex items-center gap-1 px-2 py-1.5 my-0.5 rounded-md transition-colors"
435
+ :class="[
436
+ nonSelectableIds.has(item.id) ? 'opacity-40' : '',
437
+ dragActive && projected && projected.parentId === item.id ? 'ring-2 ring-[var(--ui-primary)] bg-[var(--ui-primary)]/10' : ''
438
+ ]"
439
+ :style="{ paddingLeft: (item.depth * INDENT + 8) + 'px' }"
440
+ >
441
+ <button
442
+ v-if="item.hasChildren"
443
+ class="shrink-0 size-5 sm:size-4 flex items-center justify-center rounded hover:bg-[var(--ui-bg-elevated)] transition-colors cursor-pointer"
444
+ type="button"
445
+ @click.stop="toggleNode(item.id)"
446
+ >
447
+ <UIcon
448
+ name="i-lucide-chevron-right"
449
+ class="size-3.5 transition-transform duration-200"
450
+ :class="{ 'rotate-90': isExpanded(item.id) }"
451
+ />
452
+ </button>
453
+ <span
454
+ v-else
455
+ class="shrink-0 size-5 sm:size-4"
456
+ />
457
+ <UIcon
458
+ :name="item.hasChildren
459
+ ? ((isExpanded(item.id) || (dragActive && projected && projected.parentId === item.id)) ? 'i-lucide-folder-open' : 'i-lucide-folder')
460
+ : 'i-lucide-file'"
461
+ class="shrink-0 size-4"
462
+ :class="dragActive && projected && projected.parentId === item.id ? 'text-[var(--ui-primary)]' : 'opacity-60'"
463
+ />
464
+ <span class="truncate">{{ item.name }}</span>
465
+ </div>
466
+
467
+ <!-- Insertion line after this row -->
468
+ <div
469
+ v-if="dragActive && projected && projected.overItemId === item.id"
470
+ class="pointer-events-none py-0.5"
471
+ :style="{ paddingLeft: (projected.depth * INDENT + 8) + 'px' }"
472
+ >
473
+ <span class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 border-2 border-dashed border-[var(--ui-primary)] bg-[var(--ui-primary)]/5 text-[var(--ui-primary)]/70 text-sm">
474
+ <UIcon
475
+ name="i-lucide-grip-vertical"
476
+ class="size-4 opacity-40"
477
+ />
478
+ {{ activeItem?.name || '…' }}
479
+ </span>
480
+ </div>
481
+ </template>
482
+
483
+ <!-- Floating chip + level hint following the cursor while dragging -->
484
+ <Teleport to="body">
485
+ <div
486
+ v-if="dragActive && activeItem"
487
+ class="fixed z-50 pointer-events-none -translate-y-1/2"
488
+ :style="{ left: pointer.x + 14 + 'px', top: pointer.y + 'px' }"
489
+ >
490
+ <span class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 ring-2 ring-[var(--ui-primary)] bg-[var(--ui-bg-default,#fff)] shadow-lg font-semibold text-[var(--ui-primary)] text-sm">
491
+ <UIcon
492
+ name="i-lucide-grip-vertical"
493
+ class="shrink-0 size-4 opacity-60"
494
+ />
495
+ {{ activeItem.name || '…' }}
496
+ </span>
497
+ <span
498
+ v-if="projected"
499
+ class="mt-1 flex items-center gap-1 w-fit rounded px-1.5 py-0.5 bg-[var(--ui-primary)] text-white text-[11px] shadow"
500
+ >
501
+ <UIcon
502
+ name="i-lucide-corner-down-right"
503
+ class="size-3"
504
+ />
505
+ {{ projectedParentName || '—' }}
506
+ </span>
507
+ </div>
508
+ </Teleport>
353
509
  </div>
354
510
  </ClientOnly>
355
511
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motor-cms/ui-admin",
3
- "version": "4.11.0",
3
+ "version": "4.12.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [