@splicetree/plugin-dnd 2.0.0 → 3.0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @splicetree/plugin-dnd
2
2
 
3
+ ## 3.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 拖拽插件(dnd)交互与事件重构:
8
+ - 支持在容器“外部顶部/外部底部”释放也触发移动:新增 containerProps,用于绑定到列表容器;顶部释放移动到顶级 index=0、底部释放移动到顶级末尾
9
+ - 将 move 事件负载重构为:{ index, parent, oldIndex, oldParent, node, event },便于直接按索引/父节点进行写回与业务处理(破坏性变更)
10
+ - 在 dragend 增加回退逻辑:容器外释放也会执行移动并清理状态;ghost 先隐藏再移动,降低延迟感知
11
+ - 支持“上部分包含上外部、下部分包含下外部”:节点 dragleave 根据指针越界方向归并为 BEFORE/AFTER,一致化落点判定
12
+
13
+ 迁移指南:
14
+ - 监听 move 事件的代码需改用新的负载字段;例如在 autoUpdateParent=false 时按 { node, parent, index } 执行 moveNode 写回
15
+ - 若需要顶级外部释放行为,请在顶级列表容器上绑定 containerProps()
16
+
3
17
  ## 2.0.0
4
18
 
5
19
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -37,18 +37,13 @@ declare module '@splicetree/core' {
37
37
  dnd?: DndOptions;
38
38
  }
39
39
  interface SpliceTreeEventPayloadMap {
40
- /**
41
- * 节点移动事件负载
42
- * @property id 源节点 id
43
- * @property parentId 新父级节点 id(INSIDE/BEFORE/AFTER 场景下可能不同)
44
- * @property position 落点位置(BEFORE/INSIDE/AFTER)
45
- * @property beforeId 插入到谁之前(AFTER/BEFORE 场景下提供)
46
- */
47
40
  move: {
48
- id: string;
49
- parentId?: string;
50
- position: DropPosition;
51
- beforeId?: string;
41
+ index: number;
42
+ parent: SpliceTreeNode<any> | undefined;
43
+ oldIndex: number;
44
+ oldParent: SpliceTreeNode<any> | undefined;
45
+ node: SpliceTreeNode<any>;
46
+ event?: DragEvent | MouseEvent;
52
47
  };
53
48
  }
54
49
  interface SpliceTreeInstance {
@@ -58,7 +53,7 @@ declare module '@splicetree/core' {
58
53
  * @param targetId 目标节点 id
59
54
  * @param position 落点位置(前/内/后)
60
55
  */
61
- drop: (srcId: string, targetId: string, position: DropPosition) => void;
56
+ drop: (srcId: string, targetId: string, position: DropPosition, e?: DragEvent | MouseEvent) => void;
62
57
  /** 当前拖拽源节点 id */
63
58
  draggingId?: string;
64
59
  /** 目标节点的悬停位置映射 */
@@ -68,9 +63,9 @@ declare module '@splicetree/core' {
68
63
  /** DOM 事件:悬停计算并更新位置 */
69
64
  onDragOver: (id: string, el: HTMLElement, e: DragEvent | MouseEvent) => void;
70
65
  /** DOM 事件:离开目标,清理悬停状态 */
71
- onDragLeave: (id: string) => void;
66
+ onDragLeave: (id: string, e?: DragEvent) => void;
72
67
  /** DOM 事件:在目标上释放后执行移动 */
73
- onDrop: (targetId: string) => void;
68
+ onDrop: (targetId: string, e?: DragEvent | MouseEvent) => void;
74
69
  /** 可直接 v-bind 到节点的拖拽属性集合(逐节点) */
75
70
  dragProps: (id: string, behavior?: DragBehavior) => {
76
71
  /** 是否可拖拽 */
@@ -93,6 +88,10 @@ declare module '@splicetree/core' {
93
88
  padding?: boolean;
94
89
  margin?: boolean;
95
90
  }) => Record<string, any>;
91
+ containerProps: (parentId?: string) => {
92
+ onDragover: (e: DragEvent) => void;
93
+ onDrop: (e: DragEvent) => void;
94
+ };
96
95
  }
97
96
  /**
98
97
  * 节点扩展(DnD)
package/dist/index.js CHANGED
@@ -40,6 +40,38 @@ const dnd = {
40
40
  let ghostMarginLeft = 0;
41
41
  let ghostMarginRight = 0;
42
42
  const behaviors = /* @__PURE__ */ new Map();
43
+ let containerHoverParentId;
44
+ let containerHoverPos;
45
+ let lastHoverTargetId;
46
+ const refresh = () => ctx.events.emit({
47
+ name: "visibility",
48
+ keys: ctx.tree.expandedKeys()
49
+ });
50
+ const setGhostByEl = (el, pos) => {
51
+ ghostTop = el.offsetTop;
52
+ ghostHeight = el.offsetHeight;
53
+ ghostPos = pos;
54
+ const parent = el.parentElement;
55
+ if (parent) {
56
+ const styles = getComputedStyle(parent);
57
+ ghostInsetLeft = Number.parseFloat(styles.paddingLeft || "0");
58
+ ghostInsetRight = Number.parseFloat(styles.paddingRight || "0");
59
+ ghostMarginLeft = Number.parseFloat(styles.marginLeft || "0");
60
+ ghostMarginRight = Number.parseFloat(styles.marginRight || "0");
61
+ } else {
62
+ ghostInsetLeft = 0;
63
+ ghostInsetRight = 0;
64
+ ghostMarginLeft = 0;
65
+ ghostMarginRight = 0;
66
+ }
67
+ };
68
+ const topLevelItems = () => ctx.tree.items().filter((n) => !n.getParent());
69
+ const siblingsOf = (parentId) => parentId ? ctx.tree.getNode(parentId).getChildren() : topLevelItems();
70
+ const computeOldIndexForNode = (node) => {
71
+ const p = node.getParent();
72
+ if (p) return p.getChildren().findIndex((n) => n.id === node.id);
73
+ return topLevelItems().findIndex((n) => n.id === node.id);
74
+ };
43
75
  const isDisabledById = (_id) => !!readonly;
44
76
  const getDraggedNodeIds = (primaryId) => {
45
77
  const tree = ctx.tree;
@@ -61,10 +93,7 @@ const dnd = {
61
93
  const onDragStart = (id) => {
62
94
  if (isDisabledById(id)) return;
63
95
  draggingId = id;
64
- ctx.events.emit({
65
- name: "visibility",
66
- keys: ctx.tree.expandedKeys()
67
- });
96
+ refresh();
68
97
  };
69
98
  /**
70
99
  * 依据指针相对目标节点的垂直比例计算落点
@@ -145,9 +174,8 @@ const dnd = {
145
174
  return;
146
175
  }
147
176
  hoverPositions.set(id, pos);
148
- ghostTop = el.offsetTop;
149
- ghostHeight = el.offsetHeight;
150
- ghostPos = pos;
177
+ setGhostByEl(el, pos);
178
+ lastHoverTargetId = id;
151
179
  const parent = el.parentElement;
152
180
  if (parent) {
153
181
  const styles = getComputedStyle(parent);
@@ -161,21 +189,35 @@ const dnd = {
161
189
  ghostMarginLeft = 0;
162
190
  ghostMarginRight = 0;
163
191
  }
164
- ctx.events.emit({
165
- name: "visibility",
166
- keys: ctx.tree.expandedKeys()
167
- });
192
+ refresh();
168
193
  };
169
194
  /**
170
195
  * 离开目标:清理该目标的悬停位置并刷新视图
171
196
  * @param id 目标节点 id
172
197
  */
173
- const onDragLeave = (id) => {
174
- hoverPositions.delete(id);
175
- ctx.events.emit({
176
- name: "visibility",
177
- keys: ctx.tree.expandedKeys()
178
- });
198
+ const onDragLeave = (id, e) => {
199
+ if (!draggingId) return;
200
+ const el = e?.currentTarget ?? document.querySelector(`[data-id="${id}"]`);
201
+ if (!el) {
202
+ ctx.events.emit({
203
+ name: "visibility",
204
+ keys: ctx.tree.expandedKeys()
205
+ });
206
+ return;
207
+ }
208
+ const rect = el.getBoundingClientRect();
209
+ const cy = e?.clientY;
210
+ if (typeof cy === "number") {
211
+ if (cy < rect.top) {
212
+ hoverPositions.set(id, DropPosition.BEFORE);
213
+ setGhostByEl(el, DropPosition.BEFORE);
214
+ } else if (cy > rect.bottom) {
215
+ hoverPositions.set(id, DropPosition.AFTER);
216
+ setGhostByEl(el, DropPosition.AFTER);
217
+ }
218
+ }
219
+ lastHoverTargetId = id;
220
+ refresh();
179
221
  };
180
222
  /**
181
223
  * 执行移动:依据落点位置更新树结构,必要时写回父字段并派发事件
@@ -185,7 +227,7 @@ const dnd = {
185
227
  * @param targetId 目标节点 id
186
228
  * @param position 落点位置
187
229
  */
188
- const drop = (srcId, targetId, position) => {
230
+ const drop = (srcId, targetId, position, e) => {
189
231
  const src = ctx.tree.getNode(srcId);
190
232
  const target = ctx.tree.getNode(targetId);
191
233
  if (!src || !target) return;
@@ -197,25 +239,30 @@ const dnd = {
197
239
  if (srcId === targetId) return;
198
240
  const srcParentId = src.getParent()?.id;
199
241
  if (position === DropPosition.INSIDE && srcParentId === targetId) return;
242
+ const oldParent = src.getParent();
243
+ const oldIndex = computeOldIndexForNode(src);
200
244
  if (position === DropPosition.INSIDE) {
201
245
  if (autoUpdateParent) {
202
246
  ctx.tree.moveNode(srcId, targetId);
203
247
  Reflect.set(src.original, parentField, targetId);
204
248
  if (autoExpandOnDrop) ctx.tree.expand(targetId);
205
- ctx.events.emit({
206
- name: "visibility",
207
- keys: ctx.tree.expandedKeys()
208
- });
249
+ refresh();
209
250
  }
251
+ const newParent$1 = ctx.tree.getNode(targetId);
252
+ const newIndex$1 = autoUpdateParent ? newParent$1.getChildren().findIndex((n) => n.id === srcId) : newParent$1.getChildren().length;
210
253
  ctx.events.emit({
211
254
  name: "move",
212
- id: srcId,
213
- parentId: targetId,
214
- position
255
+ index: newIndex$1,
256
+ parent: newParent$1,
257
+ oldIndex,
258
+ oldParent,
259
+ node: src,
260
+ event: e
215
261
  });
216
262
  return;
217
263
  }
218
- const parentId = target.getParent()?.id;
264
+ const parent = target.getParent();
265
+ const parentId = parent?.id;
219
266
  if (position === DropPosition.BEFORE) {
220
267
  if (autoUpdateParent) {
221
268
  ctx.tree.moveNode(srcId, parentId, targetId);
@@ -225,39 +272,46 @@ const dnd = {
225
272
  keys: ctx.tree.expandedKeys()
226
273
  });
227
274
  }
275
+ const idx$1 = siblingsOf(parentId).filter((n) => n.id !== srcId).findIndex((n) => n.id === targetId);
276
+ const newParent$1 = parent;
277
+ const newIndex$1 = autoUpdateParent ? newParent$1 ? newParent$1.getChildren().findIndex((n) => n.id === srcId) : ctx.tree.items().filter((n) => !n.getParent()).findIndex((n) => n.id === srcId) : idx$1 >= 0 ? idx$1 : 0;
228
278
  ctx.events.emit({
229
279
  name: "move",
230
- id: srcId,
231
- parentId,
232
- position,
233
- beforeId: targetId
280
+ index: newIndex$1,
281
+ parent: newParent$1,
282
+ oldIndex,
283
+ oldParent,
284
+ node: src,
285
+ event: e
234
286
  });
235
287
  return;
236
288
  }
237
- const siblings = (parentId ? ctx.tree.getNode(parentId).getChildren() : ctx.tree.items().filter((n) => !n.getParent())).filter((n) => n.id !== srcId);
289
+ const siblings = siblingsOf(parentId).filter((n) => n.id !== srcId);
238
290
  const idx = siblings.findIndex((n) => n.id === targetId);
239
291
  const afterSibling = idx >= 0 ? siblings[idx + 1]?.id : void 0;
240
292
  if (autoUpdateParent) {
241
293
  ctx.tree.moveNode(srcId, parentId, afterSibling);
242
294
  Reflect.set(src.original, parentField, parentId);
243
- ctx.events.emit({
244
- name: "visibility",
245
- keys: ctx.tree.expandedKeys()
246
- });
295
+ refresh();
247
296
  }
297
+ const newParent = parent;
298
+ const predictedIndex = afterSibling ? idx + 1 : siblings.length;
299
+ const newIndex = autoUpdateParent ? newParent ? newParent.getChildren().findIndex((n) => n.id === srcId) : ctx.tree.items().filter((n) => !n.getParent()).findIndex((n) => n.id === srcId) : predictedIndex;
248
300
  ctx.events.emit({
249
301
  name: "move",
250
- id: srcId,
251
- parentId,
252
- position,
253
- beforeId: afterSibling
302
+ index: newIndex,
303
+ parent: newParent,
304
+ oldIndex,
305
+ oldParent,
306
+ node: src,
307
+ event: e
254
308
  });
255
309
  };
256
310
  /**
257
311
  * 完成拖拽:依据最后一次悬停位置执行移动并清理状态
258
312
  * @param targetId 释放时所处的目标节点 id
259
313
  */
260
- const onDrop = (targetId) => {
314
+ const onDrop = (targetId, e) => {
261
315
  if (!draggingId) return;
262
316
  const pos = hoverPositions.get(targetId);
263
317
  if (pos === void 0) {
@@ -272,7 +326,7 @@ const dnd = {
272
326
  const draggedIds = getDraggedNodeIds(draggingId);
273
327
  let idsToProcess = draggedIds;
274
328
  if (pos === DropPosition.AFTER) idsToProcess = [...draggedIds].reverse();
275
- for (const id of idsToProcess) drop(id, targetId, pos);
329
+ for (const id of idsToProcess) drop(id, targetId, pos, e);
276
330
  hoverPositions.clear();
277
331
  draggingId = void 0;
278
332
  ctx.events.emit({
@@ -306,24 +360,168 @@ const dnd = {
306
360
  onDragStart(id);
307
361
  }
308
362
  },
309
- onDragend: (_e) => {
363
+ onDragend: (e) => {
310
364
  handleActive.delete(id);
365
+ if (!draggingId) return;
366
+ const draggedIds = getDraggedNodeIds(draggingId);
367
+ ghostPos = void 0;
368
+ ctx.events.emit({
369
+ name: "visibility",
370
+ keys: ctx.tree.expandedKeys()
371
+ });
372
+ if (containerHoverPos !== void 0) {
373
+ const parentId2 = containerHoverParentId;
374
+ const rawSiblings = parentId2 ? ctx.tree.getNode(parentId2).getChildren() : ctx.tree.items().filter((n) => !n.getParent());
375
+ const processIds = containerHoverPos === DropPosition.AFTER ? [...draggedIds].reverse() : draggedIds;
376
+ for (const did of processIds) {
377
+ const siblings = rawSiblings.filter((n) => n.id !== did);
378
+ const firstId = siblings[0]?.id;
379
+ const beforeId = containerHoverPos === DropPosition.BEFORE ? firstId : void 0;
380
+ const src2 = ctx.tree.getNode(did);
381
+ if (!src2) continue;
382
+ const oldParent2 = src2.getParent();
383
+ const oldIndex2 = oldParent2 ? oldParent2.getChildren().findIndex((n) => n.id === did) : ctx.tree.items().filter((n) => !n.getParent()).findIndex((n) => n.id === did);
384
+ if (autoUpdateParent) {
385
+ ctx.tree.moveNode(did, parentId2, beforeId);
386
+ Reflect.set(src2.original, parentField, parentId2);
387
+ ctx.events.emit({
388
+ name: "visibility",
389
+ keys: ctx.tree.expandedKeys()
390
+ });
391
+ }
392
+ const newParent2 = parentId2 ? ctx.tree.getNode(parentId2) : void 0;
393
+ const newIndex2 = autoUpdateParent ? newParent2 ? newParent2.getChildren().findIndex((n) => n.id === did) : ctx.tree.items().filter((n) => !n.getParent()).findIndex((n) => n.id === did) : containerHoverPos === DropPosition.BEFORE ? 0 : siblings.length;
394
+ ctx.events.emit({
395
+ name: "move",
396
+ index: newIndex2,
397
+ parent: newParent2,
398
+ oldIndex: oldIndex2,
399
+ oldParent: oldParent2,
400
+ node: src2,
401
+ event: e
402
+ });
403
+ }
404
+ hoverPositions.clear();
405
+ draggingId = void 0;
406
+ containerHoverParentId = void 0;
407
+ containerHoverPos = void 0;
408
+ ctx.events.emit({
409
+ name: "visibility",
410
+ keys: ctx.tree.expandedKeys()
411
+ });
412
+ return;
413
+ }
414
+ const targetId = lastHoverTargetId;
415
+ const pos = targetId ? hoverPositions.get(targetId) : void 0;
416
+ if (targetId && pos !== void 0) {
417
+ let idsToProcess = draggedIds;
418
+ if (pos === DropPosition.AFTER) idsToProcess = [...draggedIds].reverse();
419
+ for (const did of idsToProcess) drop(did, targetId, pos, e);
420
+ }
421
+ hoverPositions.clear();
422
+ draggingId = void 0;
423
+ ctx.events.emit({
424
+ name: "visibility",
425
+ keys: ctx.tree.expandedKeys()
426
+ });
311
427
  },
312
428
  onDragover: (e) => {
313
429
  e.preventDefault();
314
430
  const el = e.currentTarget;
315
431
  onDragOver(id, el, e);
316
432
  },
317
- onDragleave: (_e) => {
318
- onDragLeave(id);
433
+ onDragleave: (e) => {
434
+ onDragLeave(id, e);
319
435
  },
320
436
  onDrop: (e) => {
321
437
  e.preventDefault();
322
- onDrop(id);
438
+ onDrop(id, e);
323
439
  handleActive.delete(id);
324
440
  }
325
441
  };
326
442
  };
443
+ const containerProps = (parentId) => {
444
+ return {
445
+ onDragover: (e) => {
446
+ if (!draggingId) return;
447
+ e.preventDefault();
448
+ const el = e.currentTarget;
449
+ const rect = el.getBoundingClientRect();
450
+ const y = ("clientY" in e ? e.clientY : 0) - rect.top;
451
+ if (Math.max(0, Math.min(1, rect.height ? y / rect.height : 0)) < .5) {
452
+ containerHoverPos = DropPosition.BEFORE;
453
+ ghostPos = DropPosition.BEFORE;
454
+ ghostTop = el.offsetTop;
455
+ ghostHeight = el.offsetHeight;
456
+ } else {
457
+ containerHoverPos = DropPosition.AFTER;
458
+ ghostPos = DropPosition.AFTER;
459
+ ghostTop = el.offsetTop;
460
+ ghostHeight = el.offsetHeight;
461
+ }
462
+ containerHoverParentId = parentId;
463
+ const styles = getComputedStyle(el);
464
+ ghostInsetLeft = Number.parseFloat(styles.paddingLeft || "0");
465
+ ghostInsetRight = Number.parseFloat(styles.paddingRight || "0");
466
+ ghostMarginLeft = Number.parseFloat(styles.marginLeft || "0");
467
+ ghostMarginRight = Number.parseFloat(styles.marginRight || "0");
468
+ ctx.events.emit({
469
+ name: "visibility",
470
+ keys: ctx.tree.expandedKeys()
471
+ });
472
+ },
473
+ onDrop: (e) => {
474
+ e.preventDefault();
475
+ if (!draggingId || containerHoverPos === void 0) {
476
+ containerHoverParentId = void 0;
477
+ containerHoverPos = void 0;
478
+ ghostPos = void 0;
479
+ return;
480
+ }
481
+ const draggedIds = getDraggedNodeIds(draggingId);
482
+ const parentId2 = containerHoverParentId;
483
+ const rawSiblings = parentId2 ? ctx.tree.getNode(parentId2).getChildren() : ctx.tree.items().filter((n) => !n.getParent());
484
+ const processIds = containerHoverPos === DropPosition.AFTER ? [...draggedIds].reverse() : draggedIds;
485
+ for (const id of processIds) {
486
+ const siblings = rawSiblings.filter((n) => n.id !== id);
487
+ const firstId = siblings[0]?.id;
488
+ const beforeId = containerHoverPos === DropPosition.BEFORE ? firstId : void 0;
489
+ const src = ctx.tree.getNode(id);
490
+ if (!src) continue;
491
+ const oldParent = src.getParent();
492
+ const oldIndex = oldParent ? oldParent.getChildren().findIndex((n) => n.id === id) : ctx.tree.items().filter((n) => !n.getParent()).findIndex((n) => n.id === id);
493
+ if (autoUpdateParent) {
494
+ ctx.tree.moveNode(id, parentId2, beforeId);
495
+ Reflect.set(src.original, parentField, parentId2);
496
+ ctx.events.emit({
497
+ name: "visibility",
498
+ keys: ctx.tree.expandedKeys()
499
+ });
500
+ }
501
+ const newParent = parentId2 ? ctx.tree.getNode(parentId2) : void 0;
502
+ const newIndex = autoUpdateParent ? newParent ? newParent.getChildren().findIndex((n) => n.id === id) : ctx.tree.items().filter((n) => !n.getParent()).findIndex((n) => n.id === id) : containerHoverPos === DropPosition.BEFORE ? 0 : siblings.length;
503
+ ctx.events.emit({
504
+ name: "move",
505
+ index: newIndex,
506
+ parent: newParent,
507
+ oldIndex,
508
+ oldParent,
509
+ node: src,
510
+ event: e
511
+ });
512
+ }
513
+ hoverPositions.clear();
514
+ draggingId = void 0;
515
+ containerHoverParentId = void 0;
516
+ containerHoverPos = void 0;
517
+ ghostPos = void 0;
518
+ ctx.events.emit({
519
+ name: "visibility",
520
+ keys: ctx.tree.expandedKeys()
521
+ });
522
+ }
523
+ };
524
+ };
327
525
  const ghostStyle = (opts$1) => {
328
526
  if (!draggingId || ghostPos === void 0) return { style: { display: "none" } };
329
527
  const usePadding = opts$1?.padding ?? true;
@@ -381,7 +579,8 @@ const dnd = {
381
579
  return draggingId;
382
580
  },
383
581
  dragProps,
384
- ghostStyle
582
+ ghostStyle,
583
+ containerProps
385
584
  };
386
585
  },
387
586
  extendNode(node, ctx) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@splicetree/plugin-dnd",
3
3
  "type": "module",
4
- "version": "2.0.0",
4
+ "version": "3.0.0",
5
5
  "author": {
6
6
  "email": "michael.cocova@gmail.com",
7
7
  "name": "Michael Cocova"
@@ -23,7 +23,7 @@
23
23
  "access": "public"
24
24
  },
25
25
  "devDependencies": {
26
- "@splicetree/core": "2.0.0"
26
+ "@splicetree/core": "3.0.0"
27
27
  },
28
28
  "scripts": {
29
29
  "dev": "tsdown --watch",
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SpliceTreePlugin, SpliceTreePluginContext } from '@splicetree/core'
1
+ import type { SpliceTreeNode, SpliceTreePlugin, SpliceTreePluginContext } from '@splicetree/core'
2
2
  import type { DndNode, DndOptions, DragBehavior } from './types'
3
3
  import { computePosition } from './position'
4
4
  import { DropPosition } from './types'
@@ -9,14 +9,14 @@ declare module '@splicetree/core' {
9
9
  dnd?: DndOptions
10
10
  }
11
11
  interface SpliceTreeEventPayloadMap {
12
- /**
13
- * 节点移动事件负载
14
- * @property id 源节点 id
15
- * @property parentId 新父级节点 id(INSIDE/BEFORE/AFTER 场景下可能不同)
16
- * @property position 落点位置(BEFORE/INSIDE/AFTER)
17
- * @property beforeId 插入到谁之前(AFTER/BEFORE 场景下提供)
18
- */
19
- move: { id: string, parentId?: string, position: DropPosition, beforeId?: string }
12
+ move: {
13
+ index: number
14
+ parent: SpliceTreeNode<any> | undefined
15
+ oldIndex: number
16
+ oldParent: SpliceTreeNode<any> | undefined
17
+ node: SpliceTreeNode<any>
18
+ event?: DragEvent | MouseEvent
19
+ }
20
20
  }
21
21
 
22
22
  interface SpliceTreeInstance {
@@ -26,7 +26,7 @@ declare module '@splicetree/core' {
26
26
  * @param targetId 目标节点 id
27
27
  * @param position 落点位置(前/内/后)
28
28
  */
29
- drop: (srcId: string, targetId: string, position: DropPosition) => void
29
+ drop: (srcId: string, targetId: string, position: DropPosition, e?: DragEvent | MouseEvent) => void
30
30
  /** 当前拖拽源节点 id */
31
31
  draggingId?: string
32
32
  /** 目标节点的悬停位置映射 */
@@ -36,9 +36,9 @@ declare module '@splicetree/core' {
36
36
  /** DOM 事件:悬停计算并更新位置 */
37
37
  onDragOver: (id: string, el: HTMLElement, e: DragEvent | MouseEvent) => void
38
38
  /** DOM 事件:离开目标,清理悬停状态 */
39
- onDragLeave: (id: string) => void
39
+ onDragLeave: (id: string, e?: DragEvent) => void
40
40
  /** DOM 事件:在目标上释放后执行移动 */
41
- onDrop: (targetId: string) => void
41
+ onDrop: (targetId: string, e?: DragEvent | MouseEvent) => void
42
42
  /** 可直接 v-bind 到节点的拖拽属性集合(逐节点) */
43
43
  dragProps: (id: string, behavior?: DragBehavior) => {
44
44
  /** 是否可拖拽 */
@@ -58,6 +58,10 @@ declare module '@splicetree/core' {
58
58
  }
59
59
  /** 统一占位样式绑定对象 */
60
60
  ghostStyle: (opts?: { padding?: boolean, margin?: boolean }) => Record<string, any>
61
+ containerProps: (parentId?: string) => {
62
+ onDragover: (e: DragEvent) => void
63
+ onDrop: (e: DragEvent) => void
64
+ }
61
65
  }
62
66
 
63
67
  /**
@@ -103,6 +107,39 @@ export const dnd: SpliceTreePlugin = {
103
107
  let ghostMarginLeft = 0
104
108
  let ghostMarginRight = 0
105
109
  const behaviors = new Map<string, DragBehavior>()
110
+ let containerHoverParentId: string | undefined
111
+ let containerHoverPos: DropPosition | undefined
112
+ let lastHoverTargetId: string | undefined
113
+ const refresh = () => ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
114
+ const setGhostByEl = (el: HTMLElement, pos: DropPosition) => {
115
+ ghostTop = el.offsetTop
116
+ ghostHeight = el.offsetHeight
117
+ ghostPos = pos
118
+ const parent = el.parentElement
119
+ if (parent) {
120
+ const styles = getComputedStyle(parent)
121
+ ghostInsetLeft = Number.parseFloat(styles.paddingLeft || '0')
122
+ ghostInsetRight = Number.parseFloat(styles.paddingRight || '0')
123
+ ghostMarginLeft = Number.parseFloat(styles.marginLeft || '0')
124
+ ghostMarginRight = Number.parseFloat(styles.marginRight || '0')
125
+ } else {
126
+ ghostInsetLeft = 0
127
+ ghostInsetRight = 0
128
+ ghostMarginLeft = 0
129
+ ghostMarginRight = 0
130
+ }
131
+ }
132
+ const topLevelItems = () => ctx.tree.items().filter(n => !n.getParent())
133
+ const siblingsOf = (parentId?: string) => parentId ? ctx.tree.getNode(parentId)!.getChildren() : topLevelItems()
134
+ const computeOldIndexForNode = (node: SpliceTreeNode<any>) => {
135
+ const p = node.getParent()
136
+ if (p) {
137
+ const list = p.getChildren()
138
+ return list.findIndex(n => n.id === node.id)
139
+ }
140
+ const list = topLevelItems()
141
+ return list.findIndex(n => n.id === node.id)
142
+ }
106
143
 
107
144
  const isDisabledById = (_id: string | undefined): boolean => !!readonly
108
145
 
@@ -137,7 +174,7 @@ export const dnd: SpliceTreePlugin = {
137
174
  return
138
175
  }
139
176
  draggingId = id
140
- ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
177
+ refresh()
141
178
  }
142
179
 
143
180
  /**
@@ -228,9 +265,8 @@ export const dnd: SpliceTreePlugin = {
228
265
  }
229
266
 
230
267
  hoverPositions.set(id, pos)
231
- ghostTop = el.offsetTop
232
- ghostHeight = el.offsetHeight
233
- ghostPos = pos
268
+ setGhostByEl(el, pos)
269
+ lastHoverTargetId = id
234
270
  const parent = el.parentElement
235
271
  if (parent) {
236
272
  const styles = getComputedStyle(parent)
@@ -244,16 +280,40 @@ export const dnd: SpliceTreePlugin = {
244
280
  ghostMarginLeft = 0
245
281
  ghostMarginRight = 0
246
282
  }
247
- ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
283
+ refresh()
248
284
  }
249
285
 
250
286
  /**
251
287
  * 离开目标:清理该目标的悬停位置并刷新视图
252
288
  * @param id 目标节点 id
253
289
  */
254
- const onDragLeave = (id: string) => {
255
- hoverPositions.delete(id)
256
- ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
290
+ const onDragLeave = (id: string, e?: DragEvent) => {
291
+ if (!draggingId) {
292
+ return
293
+ }
294
+ const el = (e?.currentTarget ?? document.querySelector(`[data-id="${id}"]`)) as HTMLElement | null
295
+ if (!el) {
296
+ // 无法定位元素时,保留最后一次悬停位置,避免外部释放丢失落点
297
+ ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
298
+ return
299
+ }
300
+ const rect = el.getBoundingClientRect()
301
+ const cy = e?.clientY
302
+ if (typeof cy === 'number') {
303
+ if (cy < rect.top) {
304
+ // 上外部:归并为 BEFORE
305
+ hoverPositions.set(id, DropPosition.BEFORE)
306
+ setGhostByEl(el, DropPosition.BEFORE)
307
+ } else if (cy > rect.bottom) {
308
+ // 下外部:归并为 AFTER
309
+ hoverPositions.set(id, DropPosition.AFTER)
310
+ setGhostByEl(el, DropPosition.AFTER)
311
+ } else {
312
+ // 水平离开或无法判断:保留原有位置,不删除
313
+ }
314
+ }
315
+ lastHoverTargetId = id
316
+ refresh()
257
317
  }
258
318
 
259
319
  /**
@@ -264,7 +324,7 @@ export const dnd: SpliceTreePlugin = {
264
324
  * @param targetId 目标节点 id
265
325
  * @param position 落点位置
266
326
  */
267
- const drop = (srcId: string, targetId: string, position: DropPosition) => {
327
+ const drop = (srcId: string, targetId: string, position: DropPosition, e?: DragEvent | MouseEvent) => {
268
328
  const src = ctx.tree.getNode(srcId)
269
329
  const target = ctx.tree.getNode(targetId)
270
330
  if (!src || !target) {
@@ -292,6 +352,9 @@ export const dnd: SpliceTreePlugin = {
292
352
  return
293
353
  }
294
354
 
355
+ const oldParent = src.getParent()
356
+ const oldIndex = computeOldIndexForNode(src)
357
+
295
358
  if (position === DropPosition.INSIDE) {
296
359
  if (autoUpdateParent) {
297
360
  ctx.tree.moveNode(srcId, targetId)
@@ -299,9 +362,13 @@ export const dnd: SpliceTreePlugin = {
299
362
  if (autoExpandOnDrop) {
300
363
  ctx.tree.expand(targetId)
301
364
  }
302
- ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
365
+ refresh()
303
366
  }
304
- ctx.events.emit({ name: 'move', id: srcId, parentId: targetId, position })
367
+ const newParent = ctx.tree.getNode(targetId)
368
+ const newIndex = autoUpdateParent
369
+ ? newParent!.getChildren().findIndex(n => n.id === srcId)
370
+ : newParent!.getChildren().length
371
+ ctx.events.emit({ name: 'move', index: newIndex, parent: newParent, oldIndex, oldParent, node: src, event: e })
305
372
  return
306
373
  }
307
374
 
@@ -313,27 +380,38 @@ export const dnd: SpliceTreePlugin = {
313
380
  Reflect.set(src.original, parentField, parentId)
314
381
  ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
315
382
  }
316
- ctx.events.emit({ name: 'move', id: srcId, parentId, position, beforeId: targetId })
383
+ const siblings = siblingsOf(parentId).filter(n => n.id !== srcId)
384
+ const idx = siblings.findIndex(n => n.id === targetId)
385
+ const newParent = parent
386
+ const newIndex = autoUpdateParent
387
+ ? (newParent ? newParent.getChildren().findIndex(n => n.id === srcId) : ctx.tree.items().filter(n => !n.getParent()).findIndex(n => n.id === srcId))
388
+ : (idx >= 0 ? idx : 0)
389
+ ctx.events.emit({ name: 'move', index: newIndex, parent: newParent, oldIndex, oldParent, node: src, event: e })
317
390
  return
318
391
  }
319
392
 
320
- const rawSiblings = parentId ? ctx.tree.getNode(parentId)!.getChildren() : ctx.tree.items().filter(n => !n.getParent())
393
+ const rawSiblings = siblingsOf(parentId)
321
394
  const siblings = rawSiblings.filter(n => n.id !== srcId)
322
395
  const idx = siblings.findIndex(n => n.id === targetId)
323
396
  const afterSibling = idx >= 0 ? siblings[idx + 1]?.id : undefined
324
397
  if (autoUpdateParent) {
325
398
  ctx.tree.moveNode(srcId, parentId, afterSibling)
326
399
  Reflect.set(src.original, parentField, parentId)
327
- ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
400
+ refresh()
328
401
  }
329
- ctx.events.emit({ name: 'move', id: srcId, parentId, position, beforeId: afterSibling })
402
+ const newParent = parent
403
+ const predictedIndex = afterSibling ? (idx + 1) : siblings.length
404
+ const newIndex = autoUpdateParent
405
+ ? (newParent ? newParent.getChildren().findIndex(n => n.id === srcId) : ctx.tree.items().filter(n => !n.getParent()).findIndex(n => n.id === srcId))
406
+ : predictedIndex
407
+ ctx.events.emit({ name: 'move', index: newIndex, parent: newParent, oldIndex, oldParent, node: src, event: e })
330
408
  }
331
409
 
332
410
  /**
333
411
  * 完成拖拽:依据最后一次悬停位置执行移动并清理状态
334
412
  * @param targetId 释放时所处的目标节点 id
335
413
  */
336
- const onDrop = (targetId: string) => {
414
+ const onDrop = (targetId: string, e?: DragEvent | MouseEvent) => {
337
415
  if (!draggingId) {
338
416
  return
339
417
  }
@@ -352,7 +430,7 @@ export const dnd: SpliceTreePlugin = {
352
430
  }
353
431
 
354
432
  for (const id of idsToProcess) {
355
- drop(id, targetId, pos)
433
+ drop(id, targetId, pos, e)
356
434
  }
357
435
 
358
436
  hoverPositions.clear()
@@ -372,7 +450,9 @@ export const dnd: SpliceTreePlugin = {
372
450
  return {
373
451
  draggable: canDrag,
374
452
  onMousedown: (e: MouseEvent) => {
375
- if (!handleSelector) return
453
+ if (!handleSelector) {
454
+ return
455
+ }
376
456
  const path = (e as any).composedPath?.() as Element[] | undefined
377
457
  const allowed = path?.some(el => (el as Element)?.matches?.(handleSelector)) ?? false
378
458
  if (allowed) {
@@ -393,25 +473,153 @@ export const dnd: SpliceTreePlugin = {
393
473
  onDragStart(id)
394
474
  }
395
475
  },
396
- onDragend: (_e: DragEvent) => {
476
+ onDragend: (e: DragEvent) => {
397
477
  handleActive.delete(id)
478
+ if (!draggingId) {
479
+ return
480
+ }
481
+ const draggedIds = getDraggedNodeIds(draggingId)
482
+ // 提前隐藏 ghost,降低感知延迟
483
+ ghostPos = undefined
484
+ ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
485
+
486
+ if (containerHoverPos !== undefined) {
487
+ const parentId2 = containerHoverParentId
488
+ const rawSiblings = parentId2 ? ctx.tree.getNode(parentId2)!.getChildren() : ctx.tree.items().filter(n => !n.getParent())
489
+ const processIds = containerHoverPos === DropPosition.AFTER ? [...draggedIds].reverse() : draggedIds
490
+ for (const did of processIds) {
491
+ const siblings = rawSiblings.filter(n => n.id !== did)
492
+ const firstId = siblings[0]?.id
493
+ const beforeId = containerHoverPos === DropPosition.BEFORE ? firstId : undefined
494
+ const src2 = ctx.tree.getNode(did)
495
+ if (!src2) {
496
+ continue
497
+ }
498
+ const oldParent2 = src2.getParent()
499
+ const oldIndex2 = oldParent2 ? oldParent2.getChildren().findIndex(n => n.id === did) : ctx.tree.items().filter(n => !n.getParent()).findIndex(n => n.id === did)
500
+ if (autoUpdateParent) {
501
+ ctx.tree.moveNode(did, parentId2, beforeId)
502
+ Reflect.set(src2.original, parentField, parentId2)
503
+ ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
504
+ }
505
+ const newParent2 = parentId2 ? ctx.tree.getNode(parentId2)! : undefined
506
+ const newIndex2 = autoUpdateParent
507
+ ? (newParent2 ? newParent2.getChildren().findIndex(n => n.id === did) : ctx.tree.items().filter(n => !n.getParent()).findIndex(n => n.id === did))
508
+ : (containerHoverPos === DropPosition.BEFORE ? 0 : siblings.length)
509
+ ctx.events.emit({ name: 'move', index: newIndex2, parent: newParent2, oldIndex: oldIndex2, oldParent: oldParent2, node: src2!, event: e })
510
+ }
511
+ hoverPositions.clear()
512
+ draggingId = undefined
513
+ containerHoverParentId = undefined
514
+ containerHoverPos = undefined
515
+ ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
516
+ return
517
+ }
518
+
519
+ const targetId = lastHoverTargetId
520
+ const pos = targetId ? hoverPositions.get(targetId) : undefined
521
+ if (targetId && pos !== undefined) {
522
+ let idsToProcess = draggedIds
523
+ if (pos === DropPosition.AFTER) {
524
+ idsToProcess = [...draggedIds].reverse()
525
+ }
526
+ for (const did of idsToProcess) {
527
+ drop(did, targetId, pos, e)
528
+ }
529
+ }
530
+ hoverPositions.clear()
531
+ draggingId = undefined
532
+ ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
398
533
  },
399
534
  onDragover: (e: DragEvent) => {
400
535
  e.preventDefault()
401
536
  const el = e.currentTarget as HTMLElement
402
537
  onDragOver(id, el, e)
403
538
  },
404
- onDragleave: (_e: DragEvent) => {
405
- onDragLeave(id)
539
+ onDragleave: (e: DragEvent) => {
540
+ onDragLeave(id, e)
406
541
  },
407
542
  onDrop: (e: DragEvent) => {
408
543
  e.preventDefault()
409
- onDrop(id)
544
+ onDrop(id, e)
410
545
  handleActive.delete(id)
411
546
  },
412
547
  }
413
548
  }
414
549
 
550
+ const containerProps = (parentId?: string) => {
551
+ return {
552
+ onDragover: (e: DragEvent) => {
553
+ if (!draggingId) {
554
+ return
555
+ }
556
+ e.preventDefault()
557
+ const el = e.currentTarget as HTMLElement
558
+ const rect = el.getBoundingClientRect()
559
+ const y = ('clientY' in e ? e.clientY : 0) - rect.top
560
+ const ratio = Math.max(0, Math.min(1, rect.height ? y / rect.height : 0))
561
+ if (ratio < 0.5) {
562
+ containerHoverPos = DropPosition.BEFORE
563
+ ghostPos = DropPosition.BEFORE
564
+ ghostTop = el.offsetTop
565
+ ghostHeight = el.offsetHeight
566
+ } else {
567
+ containerHoverPos = DropPosition.AFTER
568
+ ghostPos = DropPosition.AFTER
569
+ ghostTop = el.offsetTop
570
+ ghostHeight = el.offsetHeight
571
+ }
572
+ containerHoverParentId = parentId
573
+ const styles = getComputedStyle(el)
574
+ ghostInsetLeft = Number.parseFloat(styles.paddingLeft || '0')
575
+ ghostInsetRight = Number.parseFloat(styles.paddingRight || '0')
576
+ ghostMarginLeft = Number.parseFloat(styles.marginLeft || '0')
577
+ ghostMarginRight = Number.parseFloat(styles.marginRight || '0')
578
+ ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
579
+ },
580
+ onDrop: (e: DragEvent) => {
581
+ e.preventDefault()
582
+ if (!draggingId || containerHoverPos === undefined) {
583
+ containerHoverParentId = undefined
584
+ containerHoverPos = undefined
585
+ ghostPos = undefined
586
+ return
587
+ }
588
+ const draggedIds = getDraggedNodeIds(draggingId)
589
+ const parentId2 = containerHoverParentId
590
+ const rawSiblings = parentId2 ? ctx.tree.getNode(parentId2)!.getChildren() : ctx.tree.items().filter(n => !n.getParent())
591
+ const processIds = containerHoverPos === DropPosition.AFTER ? [...draggedIds].reverse() : draggedIds
592
+ for (const id of processIds) {
593
+ const siblings = rawSiblings.filter(n => n.id !== id)
594
+ const firstId = siblings[0]?.id
595
+ const beforeId = containerHoverPos === DropPosition.BEFORE ? firstId : undefined
596
+ const src = ctx.tree.getNode(id)
597
+ if (!src) {
598
+ continue
599
+ }
600
+ const oldParent = src.getParent()
601
+ const oldIndex = oldParent ? oldParent.getChildren().findIndex(n => n.id === id) : ctx.tree.items().filter(n => !n.getParent()).findIndex(n => n.id === id)
602
+ if (autoUpdateParent) {
603
+ ctx.tree.moveNode(id, parentId2, beforeId)
604
+ Reflect.set(src.original, parentField, parentId2)
605
+ ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
606
+ }
607
+ const newParent = parentId2 ? ctx.tree.getNode(parentId2)! : undefined
608
+ const newIndex = autoUpdateParent
609
+ ? (newParent ? newParent.getChildren().findIndex(n => n.id === id) : ctx.tree.items().filter(n => !n.getParent()).findIndex(n => n.id === id))
610
+ : (containerHoverPos === DropPosition.BEFORE ? 0 : siblings.length)
611
+ ctx.events.emit({ name: 'move', index: newIndex, parent: newParent, oldIndex, oldParent, node: src!, event: e })
612
+ }
613
+ hoverPositions.clear()
614
+ draggingId = undefined
615
+ containerHoverParentId = undefined
616
+ containerHoverPos = undefined
617
+ ghostPos = undefined
618
+ ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
619
+ },
620
+ }
621
+ }
622
+
415
623
  const ghostStyle = (opts?: { padding?: boolean, margin?: boolean }) => {
416
624
  if (!draggingId || ghostPos === undefined) {
417
625
  return { style: { display: 'none' } }
@@ -450,6 +658,7 @@ export const dnd: SpliceTreePlugin = {
450
658
  // 通用 DOM 绑定集合
451
659
  dragProps,
452
660
  ghostStyle,
661
+ containerProps,
453
662
  }
454
663
  },
455
664
  /**