@splicetree/plugin-dnd 3.0.2 → 3.1.1

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,17 @@
1
1
  # @splicetree/plugin-dnd
2
2
 
3
+ ## 3.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Core/Adapter/Lazy-Load/DnD:稳定性修复与小幅改进
8
+ - DnD:为 `draggable/sortable/nestable` 增加函数/异步判定;在悬停与释放阶段严格校验并在不允许时隐藏 ghost;增加去抖与离开清理以消除偶发 ghost 闪烁
9
+ - Adapter-Vue:抽取数据同步工具;字段变更走 `updateOriginal` 合并,结构变更再 `syncData`
10
+ - Core:节点扩展应用于追加节点;补充若干示例与文档更新
11
+ - Lazy-Load:覆盖 `syncData`,在重建后恢复已加载子树与覆盖
12
+
13
+ ## 3.1.0
14
+
3
15
  ## 3.0.2
4
16
 
5
17
  ## 3.0.1
package/dist/index.d.ts CHANGED
@@ -1,17 +1,32 @@
1
- import { SpliceTreePlugin } from "@splicetree/core";
1
+ import { SpliceTreeNode as SpliceTreeNode$1, SpliceTreePlugin } from "@splicetree/core";
2
2
 
3
3
  //#region src/types.d.ts
4
- /** dnd types */
5
4
  interface DndNode {
6
5
  id: string;
7
6
  level: number;
8
7
  parentId?: string;
9
8
  root?: boolean;
10
9
  }
11
- interface DragBehavior {
12
- draggable?: boolean;
13
- sortable?: boolean;
14
- nestable?: boolean;
10
+ type BehaviorPredicate<T = unknown> = boolean | ((target: SpliceTreeNode$1<T>) => boolean | Promise<boolean>);
11
+ interface DragBehavior<T = unknown> {
12
+ /**
13
+ * 控制是否允许作为拖拽源在当前目标上执行拖放
14
+ * - 布尔:true/false 全局允许或禁止
15
+ * - 函数:基于目标节点动态判断;可返回 Promise<boolean>
16
+ */
17
+ draggable?: BehaviorPredicate<T>;
18
+ /**
19
+ * 控制是否允许在目标节点的前后排序
20
+ * - 布尔:true/false 全局允许或禁止
21
+ * - 函数:基于目标节点动态判断;可返回 Promise<boolean>
22
+ */
23
+ sortable?: BehaviorPredicate<T>;
24
+ /**
25
+ * 控制能否作为子节点拖入
26
+ * - 布尔:true/false 全局允许或禁止
27
+ * - 函数:基于目标节点动态判断;可返回 Promise<boolean>
28
+ */
29
+ nestable?: BehaviorPredicate<T>;
15
30
  }
16
31
  declare enum DropPosition {
17
32
  BEFORE = -1,
@@ -36,13 +51,13 @@ declare module '@splicetree/core' {
36
51
  interface SpliceTreeConfiguration {
37
52
  dnd?: DndOptions;
38
53
  }
39
- interface SpliceTreeEventPayloadMap {
54
+ interface SpliceTreeEventPayloadMap<T = unknown> {
40
55
  move: {
41
56
  index: number;
42
- parent: SpliceTreeNode<any> | undefined;
57
+ parent: SpliceTreeNode<T> | undefined;
43
58
  oldIndex: number;
44
- oldParent: SpliceTreeNode<any> | undefined;
45
- node: SpliceTreeNode<any>;
59
+ oldParent: SpliceTreeNode<T> | undefined;
60
+ node: SpliceTreeNode<T>;
46
61
  event?: DragEvent | MouseEvent;
47
62
  };
48
63
  }
package/dist/index.js CHANGED
@@ -43,6 +43,8 @@ const dnd = {
43
43
  let containerHoverParentId;
44
44
  let containerHoverPos;
45
45
  let lastHoverTargetId;
46
+ let evalToken = 0;
47
+ let evaluating = false;
46
48
  const refresh = () => ctx.events.emit({
47
49
  name: "visibility",
48
50
  keys: ctx.tree.expandedKeys()
@@ -109,12 +111,21 @@ const dnd = {
109
111
  * @param el 目标节点元素
110
112
  * @param e 拖拽/鼠标事件
111
113
  */
112
- const onDragOver = (id, el, e) => {
114
+ const onDragOver = async (id, el, e) => {
113
115
  if (!draggingId) return;
116
+ evalToken++;
117
+ const myToken = evalToken;
118
+ evaluating = true;
119
+ ghostPos = void 0;
120
+ ctx.events.emit({
121
+ name: "visibility",
122
+ keys: ctx.tree.expandedKeys()
123
+ });
114
124
  const draggedIds = getDraggedNodeIds(draggingId);
115
125
  const targetNode = ctx.tree.getNode(id);
116
126
  if (draggedIds.includes(id)) {
117
127
  hoverPositions.delete(id);
128
+ ghostPos = void 0;
118
129
  ctx.events.emit({
119
130
  name: "visibility",
120
131
  keys: ctx.tree.expandedKeys()
@@ -125,6 +136,7 @@ const dnd = {
125
136
  while (ancestor) {
126
137
  if (draggedIds.includes(ancestor.id)) {
127
138
  hoverPositions.delete(id);
139
+ ghostPos = void 0;
128
140
  ctx.events.emit({
129
141
  name: "visibility",
130
142
  keys: ctx.tree.expandedKeys()
@@ -136,10 +148,12 @@ const dnd = {
136
148
  const pos = computePosition(id, el, e, void 0);
137
149
  if (!targetNode || pos === void 0) {
138
150
  hoverPositions.delete(id);
151
+ ghostPos = void 0;
139
152
  ctx.events.emit({
140
153
  name: "visibility",
141
154
  keys: ctx.tree.expandedKeys()
142
155
  });
156
+ evaluating = false;
143
157
  return;
144
158
  }
145
159
  if (pos === DropPosition.INSIDE) {
@@ -153,24 +167,83 @@ const dnd = {
153
167
  });
154
168
  return;
155
169
  }
156
- if (!draggedIds.every((dId) => {
157
- return behaviors.get(dId)?.nestable !== false;
158
- })) {
170
+ let allNestable = true;
171
+ for (const dId of draggedIds) {
172
+ const rule = behaviors.get(dId)?.nestable;
173
+ if (rule === false) {
174
+ allNestable = false;
175
+ break;
176
+ }
177
+ if (typeof rule === "function") try {
178
+ const ret = rule(targetNode);
179
+ if (!(ret instanceof Promise ? await ret : ret)) {
180
+ allNestable = false;
181
+ break;
182
+ }
183
+ } catch {
184
+ allNestable = false;
185
+ break;
186
+ }
187
+ }
188
+ if (!allNestable) {
159
189
  hoverPositions.delete(id);
190
+ ghostPos = void 0;
160
191
  ctx.events.emit({
161
192
  name: "visibility",
162
193
  keys: ctx.tree.expandedKeys()
163
194
  });
195
+ evaluating = false;
164
196
  return;
165
197
  }
166
- } else if (!draggedIds.every((dId) => {
167
- return behaviors.get(dId)?.sortable !== false;
168
- })) {
169
- hoverPositions.delete(id);
170
- ctx.events.emit({
171
- name: "visibility",
172
- keys: ctx.tree.expandedKeys()
173
- });
198
+ } else {
199
+ let allSortable = true;
200
+ for (const dId of draggedIds) {
201
+ const ov = behaviors.get(dId);
202
+ const sRule = ov?.sortable;
203
+ if (typeof sRule === "boolean" && sRule === false) {
204
+ allSortable = false;
205
+ break;
206
+ }
207
+ if (typeof sRule === "function") try {
208
+ const ret = sRule(targetNode);
209
+ if (!(ret instanceof Promise ? await ret : ret)) {
210
+ allSortable = false;
211
+ break;
212
+ }
213
+ } catch {
214
+ allSortable = false;
215
+ break;
216
+ }
217
+ const dRuleRaw = ov?.draggable;
218
+ if (typeof dRuleRaw !== "function") {
219
+ if (dRuleRaw === false) {
220
+ allSortable = false;
221
+ break;
222
+ }
223
+ } else try {
224
+ const ret = dRuleRaw(targetNode);
225
+ if (!(ret instanceof Promise ? await ret : ret)) {
226
+ allSortable = false;
227
+ break;
228
+ }
229
+ } catch {
230
+ allSortable = false;
231
+ break;
232
+ }
233
+ }
234
+ if (!allSortable) {
235
+ hoverPositions.delete(id);
236
+ ghostPos = void 0;
237
+ ctx.events.emit({
238
+ name: "visibility",
239
+ keys: ctx.tree.expandedKeys()
240
+ });
241
+ evaluating = false;
242
+ return;
243
+ }
244
+ }
245
+ if (myToken !== evalToken || !draggingId) {
246
+ evaluating = false;
174
247
  return;
175
248
  }
176
249
  hoverPositions.set(id, pos);
@@ -189,34 +262,18 @@ const dnd = {
189
262
  ghostMarginLeft = 0;
190
263
  ghostMarginRight = 0;
191
264
  }
265
+ evaluating = false;
192
266
  refresh();
193
267
  };
194
268
  /**
195
269
  * 离开目标:清理该目标的悬停位置并刷新视图
196
270
  * @param id 目标节点 id
197
271
  */
198
- const onDragLeave = (id, e) => {
272
+ const onDragLeave = (id, _e) => {
199
273
  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;
274
+ hoverPositions.delete(id);
275
+ if (lastHoverTargetId === id) lastHoverTargetId = void 0;
276
+ ghostPos = void 0;
220
277
  refresh();
221
278
  };
222
279
  /**
@@ -227,15 +284,44 @@ const dnd = {
227
284
  * @param targetId 目标节点 id
228
285
  * @param position 落点位置
229
286
  */
230
- const drop = (srcId, targetId, position, e) => {
287
+ const drop = async (srcId, targetId, position, e) => {
231
288
  const src = ctx.tree.getNode(srcId);
232
289
  const target = ctx.tree.getNode(targetId);
233
290
  if (!src || !target) return;
234
291
  if (readonly) return;
235
292
  const ov = behaviors.get(src.id);
236
293
  if (ov?.draggable === false) return;
237
- if (position === DropPosition.INSIDE && ov?.nestable === false) return;
238
- if (position !== DropPosition.INSIDE && ov?.sortable === false) return;
294
+ if (ov?.sortable === false) return;
295
+ if (position === DropPosition.INSIDE) {
296
+ const rule = ov?.nestable;
297
+ if (rule === false) return;
298
+ if (typeof rule === "function") try {
299
+ const ret = rule(target);
300
+ if (!(ret instanceof Promise ? await ret : ret)) return;
301
+ } catch {
302
+ return;
303
+ }
304
+ }
305
+ if (position !== DropPosition.INSIDE) {
306
+ const sRule = ov?.sortable;
307
+ if (typeof sRule !== "function") {
308
+ if (sRule === false) return;
309
+ } else try {
310
+ const ret = sRule(target);
311
+ if (!(ret instanceof Promise ? await ret : ret)) return;
312
+ } catch {
313
+ return;
314
+ }
315
+ const dRuleRaw = ov?.draggable;
316
+ if (typeof dRuleRaw !== "function") {
317
+ if (dRuleRaw === false) return;
318
+ } else try {
319
+ const ret = dRuleRaw(target);
320
+ if (!(ret instanceof Promise ? await ret : ret)) return;
321
+ } catch {
322
+ return;
323
+ }
324
+ }
239
325
  if (srcId === targetId) return;
240
326
  const srcParentId = src.getParent()?.id;
241
327
  if (position === DropPosition.INSIDE && srcParentId === targetId) return;
@@ -445,6 +531,20 @@ const dnd = {
445
531
  onDragover: (e) => {
446
532
  if (!draggingId) return;
447
533
  e.preventDefault();
534
+ const draggedIds = getDraggedNodeIds(draggingId);
535
+ for (const did of draggedIds) {
536
+ const ov = behaviors.get(did);
537
+ if (ov?.sortable === false || ov?.draggable === false) {
538
+ containerHoverParentId = void 0;
539
+ containerHoverPos = void 0;
540
+ ghostPos = void 0;
541
+ ctx.events.emit({
542
+ name: "visibility",
543
+ keys: ctx.tree.expandedKeys()
544
+ });
545
+ return;
546
+ }
547
+ }
448
548
  const el = e.currentTarget;
449
549
  const rect = el.getBoundingClientRect();
450
550
  const y = ("clientY" in e ? e.clientY : 0) - rect.top;
@@ -479,9 +579,25 @@ const dnd = {
479
579
  return;
480
580
  }
481
581
  const draggedIds = getDraggedNodeIds(draggingId);
582
+ for (const did of draggedIds) {
583
+ const ov = behaviors.get(did);
584
+ if (ov?.sortable === false || ov?.draggable === false) {
585
+ hoverPositions.clear();
586
+ draggingId = void 0;
587
+ containerHoverParentId = void 0;
588
+ containerHoverPos = void 0;
589
+ ghostPos = void 0;
590
+ ctx.events.emit({
591
+ name: "visibility",
592
+ keys: ctx.tree.expandedKeys()
593
+ });
594
+ return;
595
+ }
596
+ }
597
+ const draggedIds2 = getDraggedNodeIds(draggingId);
482
598
  const parentId2 = containerHoverParentId;
483
599
  const rawSiblings = parentId2 ? ctx.tree.getNode(parentId2).getChildren() : ctx.tree.items().filter((n) => !n.getParent());
484
- const processIds = containerHoverPos === DropPosition.AFTER ? [...draggedIds].reverse() : draggedIds;
600
+ const processIds = containerHoverPos === DropPosition.AFTER ? [...draggedIds2].reverse() : draggedIds2;
485
601
  for (const id of processIds) {
486
602
  const siblings = rawSiblings.filter((n) => n.id !== id);
487
603
  const firstId = siblings[0]?.id;
@@ -523,7 +639,23 @@ const dnd = {
523
639
  };
524
640
  };
525
641
  const ghostStyle = (opts$1) => {
526
- if (!draggingId || ghostPos === void 0) return { style: { display: "none" } };
642
+ if (evaluating || !draggingId || ghostPos === void 0) return { style: { display: "none" } };
643
+ try {
644
+ const draggedIds = getDraggedNodeIds(draggingId);
645
+ const targetId = lastHoverTargetId;
646
+ if (targetId ? ctx.tree.getNode(targetId) : void 0) if (ghostPos === DropPosition.INSIDE) {
647
+ for (const did of draggedIds) if (behaviors.get(did)?.nestable === false) return { style: { display: "none" } };
648
+ } else for (const did of draggedIds) {
649
+ const ov = behaviors.get(did);
650
+ if (ov?.sortable === false || ov?.draggable === false) return { style: { display: "none" } };
651
+ }
652
+ else for (const did of draggedIds) {
653
+ const ov = behaviors.get(did);
654
+ if (ov?.sortable === false || ov?.draggable === false) return { style: { display: "none" } };
655
+ }
656
+ } catch {
657
+ return { style: { display: "none" } };
658
+ }
527
659
  const usePadding = opts$1?.padding ?? true;
528
660
  const useMargin = opts$1?.margin ?? true;
529
661
  const leftInset = usePadding ? ghostInsetLeft : 0;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@splicetree/plugin-dnd",
3
3
  "type": "module",
4
- "version": "3.0.2",
4
+ "version": "3.1.1",
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": "3.0.2"
26
+ "@splicetree/core": "3.1.1"
27
27
  },
28
28
  "scripts": {
29
29
  "dev": "tsdown --watch",
package/src/index.ts CHANGED
@@ -8,13 +8,13 @@ declare module '@splicetree/core' {
8
8
  interface SpliceTreeConfiguration {
9
9
  dnd?: DndOptions
10
10
  }
11
- interface SpliceTreeEventPayloadMap {
11
+ interface SpliceTreeEventPayloadMap<T = unknown> {
12
12
  move: {
13
13
  index: number
14
- parent: SpliceTreeNode<any> | undefined
14
+ parent: SpliceTreeNode<T> | undefined
15
15
  oldIndex: number
16
- oldParent: SpliceTreeNode<any> | undefined
17
- node: SpliceTreeNode<any>
16
+ oldParent: SpliceTreeNode<T> | undefined
17
+ node: SpliceTreeNode<T>
18
18
  event?: DragEvent | MouseEvent
19
19
  }
20
20
  }
@@ -110,6 +110,8 @@ export const dnd: SpliceTreePlugin = {
110
110
  let containerHoverParentId: string | undefined
111
111
  let containerHoverPos: DropPosition | undefined
112
112
  let lastHoverTargetId: string | undefined
113
+ let evalToken = 0
114
+ let evaluating = false
113
115
  const refresh = () => ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
114
116
  const setGhostByEl = (el: HTMLElement, pos: DropPosition) => {
115
117
  ghostTop = el.offsetTop
@@ -193,11 +195,17 @@ export const dnd: SpliceTreePlugin = {
193
195
  * @param el 目标节点元素
194
196
  * @param e 拖拽/鼠标事件
195
197
  */
196
- const onDragOver = (id: string, el: HTMLElement, e: DragEvent | MouseEvent) => {
198
+ const onDragOver = async (id: string, el: HTMLElement, e: DragEvent | MouseEvent) => {
197
199
  // 如果没有拖拽源,直接返回
198
200
  if (!draggingId) {
199
201
  return
200
202
  }
203
+ // 临时隐藏 ghost,直到当前目标判定通过,避免异步判定期间的闪烁
204
+ evalToken++
205
+ const myToken = evalToken
206
+ evaluating = true
207
+ ghostPos = undefined
208
+ ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
201
209
 
202
210
  const draggedIds = getDraggedNodeIds(draggingId)
203
211
  const targetNode = ctx.tree.getNode(id)
@@ -205,6 +213,7 @@ export const dnd: SpliceTreePlugin = {
205
213
  // 1. 目标节点不能是拖拽节点之一
206
214
  if (draggedIds.includes(id)) {
207
215
  hoverPositions.delete(id)
216
+ ghostPos = undefined
208
217
  ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
209
218
  return
210
219
  }
@@ -214,6 +223,7 @@ export const dnd: SpliceTreePlugin = {
214
223
  while (ancestor) {
215
224
  if (draggedIds.includes(ancestor.id)) {
216
225
  hoverPositions.delete(id)
226
+ ghostPos = undefined
217
227
  ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
218
228
  return
219
229
  }
@@ -225,7 +235,9 @@ export const dnd: SpliceTreePlugin = {
225
235
 
226
236
  if (!targetNode || pos === undefined) {
227
237
  hoverPositions.delete(id)
238
+ ghostPos = undefined
228
239
  ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
240
+ evaluating = false
229
241
  return
230
242
  }
231
243
 
@@ -243,27 +255,93 @@ export const dnd: SpliceTreePlugin = {
243
255
  return
244
256
  }
245
257
 
246
- const allNestable = draggedIds.every((dId) => {
258
+ let allNestable = true
259
+ for (const dId of draggedIds) {
247
260
  const ov = behaviors.get(dId)
248
- return ov?.nestable !== false
249
- })
261
+ const rule = ov?.nestable
262
+ if (rule === false) {
263
+ allNestable = false
264
+ break
265
+ }
266
+ if (typeof rule === 'function') {
267
+ try {
268
+ const ret = rule(targetNode)
269
+ const ok = ret instanceof Promise ? await ret : ret
270
+ if (!ok) {
271
+ allNestable = false
272
+ break
273
+ }
274
+ } catch {
275
+ allNestable = false
276
+ break
277
+ }
278
+ }
279
+ }
250
280
  if (!allNestable) {
251
281
  hoverPositions.delete(id)
282
+ ghostPos = undefined
252
283
  ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
284
+ evaluating = false
253
285
  return
254
286
  }
255
287
  } else {
256
- const allSortable = draggedIds.every((dId) => {
288
+ // BEFORE/AFTER:检查 sortable draggable 的目标相关规则
289
+ let allSortable = true
290
+ for (const dId of draggedIds) {
257
291
  const ov = behaviors.get(dId)
258
- return ov?.sortable !== false
259
- })
292
+ const sRule = ov?.sortable
293
+ if (typeof sRule === 'boolean' && sRule === false) {
294
+ allSortable = false
295
+ break
296
+ }
297
+ if (typeof sRule === 'function') {
298
+ try {
299
+ const ret = sRule(targetNode!)
300
+ const ok = ret instanceof Promise ? await ret : ret
301
+ if (!ok) {
302
+ allSortable = false
303
+ break
304
+ }
305
+ } catch {
306
+ allSortable = false
307
+ break
308
+ }
309
+ }
310
+ const dRuleRaw = ov?.draggable
311
+ if (typeof dRuleRaw !== 'function') {
312
+ const dBool = dRuleRaw as boolean | undefined
313
+ if (dBool === false) {
314
+ allSortable = false
315
+ break
316
+ }
317
+ } else {
318
+ try {
319
+ const ret = dRuleRaw(targetNode!)
320
+ const ok = ret instanceof Promise ? await ret : ret
321
+ if (!ok) {
322
+ allSortable = false
323
+ break
324
+ }
325
+ } catch {
326
+ allSortable = false
327
+ break
328
+ }
329
+ }
330
+ }
260
331
  if (!allSortable) {
261
332
  hoverPositions.delete(id)
333
+ ghostPos = undefined
262
334
  ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
335
+ evaluating = false
263
336
  return
264
337
  }
265
338
  }
266
339
 
340
+ // 若期间目标已变化或被新的悬停替代,则忽略当前结果
341
+ if (myToken !== evalToken || !draggingId) {
342
+ evaluating = false
343
+ return
344
+ }
267
345
  hoverPositions.set(id, pos)
268
346
  setGhostByEl(el, pos)
269
347
  lastHoverTargetId = id
@@ -280,6 +358,7 @@ export const dnd: SpliceTreePlugin = {
280
358
  ghostMarginLeft = 0
281
359
  ghostMarginRight = 0
282
360
  }
361
+ evaluating = false
283
362
  refresh()
284
363
  }
285
364
 
@@ -287,32 +366,16 @@ export const dnd: SpliceTreePlugin = {
287
366
  * 离开目标:清理该目标的悬停位置并刷新视图
288
367
  * @param id 目标节点 id
289
368
  */
290
- const onDragLeave = (id: string, e?: DragEvent) => {
369
+ const onDragLeave = (id: string, _e?: DragEvent) => {
291
370
  if (!draggingId) {
292
371
  return
293
372
  }
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
- }
373
+ // 离开目标时直接清理该目标的悬停与 ghost,避免跨节点移动时的占位闪烁
374
+ hoverPositions.delete(id)
375
+ if (lastHoverTargetId === id) {
376
+ lastHoverTargetId = undefined as any
314
377
  }
315
- lastHoverTargetId = id
378
+ ghostPos = undefined
316
379
  refresh()
317
380
  }
318
381
 
@@ -324,7 +387,7 @@ export const dnd: SpliceTreePlugin = {
324
387
  * @param targetId 目标节点 id
325
388
  * @param position 落点位置
326
389
  */
327
- const drop = (srcId: string, targetId: string, position: DropPosition, e?: DragEvent | MouseEvent) => {
390
+ const drop = async (srcId: string, targetId: string, position: DropPosition, e?: DragEvent | MouseEvent) => {
328
391
  const src = ctx.tree.getNode(srcId)
329
392
  const target = ctx.tree.getNode(targetId)
330
393
  if (!src || !target) {
@@ -337,11 +400,62 @@ export const dnd: SpliceTreePlugin = {
337
400
  if (ov?.draggable === false) {
338
401
  return
339
402
  }
340
- if (position === DropPosition.INSIDE && ov?.nestable === false) {
403
+ if (ov?.sortable === false) {
341
404
  return
342
405
  }
343
- if (position !== DropPosition.INSIDE && ov?.sortable === false) {
344
- return
406
+ if (position === DropPosition.INSIDE) {
407
+ const rule = ov?.nestable
408
+ if (rule === false) {
409
+ return
410
+ }
411
+ if (typeof rule === 'function') {
412
+ try {
413
+ const ret = rule(target)
414
+ const ok = ret instanceof Promise ? await ret : ret
415
+ if (!ok) {
416
+ return
417
+ }
418
+ } catch {
419
+ return
420
+ }
421
+ }
422
+ }
423
+ // BEFORE/AFTER:再次检查 sortable 与 draggable 的目标相关规则,防止误投放
424
+ if (position !== DropPosition.INSIDE) {
425
+ const sRule = ov?.sortable
426
+ if (typeof sRule !== 'function') {
427
+ const sBool = sRule as boolean | undefined
428
+ if (sBool === false) {
429
+ return
430
+ }
431
+ } else {
432
+ try {
433
+ const ret = sRule(target)
434
+ const ok = ret instanceof Promise ? await ret : ret
435
+ if (!ok) {
436
+ return
437
+ }
438
+ } catch {
439
+ return
440
+ }
441
+ }
442
+ const dRuleRaw = ov?.draggable
443
+ if (typeof dRuleRaw !== 'function') {
444
+ const dBool = dRuleRaw as boolean | undefined
445
+ if (dBool === false) {
446
+ return
447
+ }
448
+ } else {
449
+ try {
450
+ const ret = dRuleRaw(target)
451
+ const ok = ret instanceof Promise ? await ret : ret
452
+ if (!ok) {
453
+ return
454
+ }
455
+ } catch {
456
+ return
457
+ }
458
+ }
345
459
  }
346
460
  // 基础非法:不能拖到自身或其父
347
461
  if (srcId === targetId) {
@@ -554,6 +668,18 @@ export const dnd: SpliceTreePlugin = {
554
668
  return
555
669
  }
556
670
  e.preventDefault()
671
+ // 顶级容器处悬停:若有任何被拖节点声明了布尔 false 的 sortable/draggable,则不显示 ghost
672
+ const draggedIds = getDraggedNodeIds(draggingId)
673
+ for (const did of draggedIds) {
674
+ const ov = behaviors.get(did)
675
+ if (ov?.sortable === false || ov?.draggable === false) {
676
+ containerHoverParentId = undefined
677
+ containerHoverPos = undefined
678
+ ghostPos = undefined
679
+ ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
680
+ return
681
+ }
682
+ }
557
683
  const el = e.currentTarget as HTMLElement
558
684
  const rect = el.getBoundingClientRect()
559
685
  const y = ('clientY' in e ? e.clientY : 0) - rect.top
@@ -585,10 +711,24 @@ export const dnd: SpliceTreePlugin = {
585
711
  ghostPos = undefined
586
712
  return
587
713
  }
714
+ // 顶级释放:布尔 false 的 sortable/draggable 禁止投放
588
715
  const draggedIds = getDraggedNodeIds(draggingId)
716
+ for (const did of draggedIds) {
717
+ const ov = behaviors.get(did)
718
+ if (ov?.sortable === false || ov?.draggable === false) {
719
+ hoverPositions.clear()
720
+ draggingId = undefined
721
+ containerHoverParentId = undefined
722
+ containerHoverPos = undefined
723
+ ghostPos = undefined
724
+ ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
725
+ return
726
+ }
727
+ }
728
+ const draggedIds2 = getDraggedNodeIds(draggingId)
589
729
  const parentId2 = containerHoverParentId
590
730
  const rawSiblings = parentId2 ? ctx.tree.getNode(parentId2)!.getChildren() : ctx.tree.items().filter(n => !n.getParent())
591
- const processIds = containerHoverPos === DropPosition.AFTER ? [...draggedIds].reverse() : draggedIds
731
+ const processIds = containerHoverPos === DropPosition.AFTER ? [...draggedIds2].reverse() : draggedIds2
592
732
  for (const id of processIds) {
593
733
  const siblings = rawSiblings.filter(n => n.id !== id)
594
734
  const firstId = siblings[0]?.id
@@ -621,7 +761,41 @@ export const dnd: SpliceTreePlugin = {
621
761
  }
622
762
 
623
763
  const ghostStyle = (opts?: { padding?: boolean, margin?: boolean }) => {
624
- if (!draggingId || ghostPos === undefined) {
764
+ // 当被拖拽节点在当前上下文被禁止(sortable/draggable/nestable false)时,不显示 ghost
765
+ if (evaluating || !draggingId || ghostPos === undefined) {
766
+ return { style: { display: 'none' } }
767
+ }
768
+ try {
769
+ const draggedIds = getDraggedNodeIds(draggingId)
770
+ const targetId = lastHoverTargetId
771
+ const targetNode = targetId ? ctx.tree.getNode(targetId) : undefined
772
+ // Node 目标场景:根据落点类型进行规则校验(仅快速检查布尔 false;函数规则已在 onDragOver 中处理为不设置 ghostPos)
773
+ if (targetNode) {
774
+ if (ghostPos === DropPosition.INSIDE) {
775
+ for (const did of draggedIds) {
776
+ const ov = behaviors.get(did)
777
+ if (ov?.nestable === false) {
778
+ return { style: { display: 'none' } }
779
+ }
780
+ }
781
+ } else {
782
+ for (const did of draggedIds) {
783
+ const ov = behaviors.get(did)
784
+ if (ov?.sortable === false || ov?.draggable === false) {
785
+ return { style: { display: 'none' } }
786
+ }
787
+ }
788
+ }
789
+ } else {
790
+ // 容器场景:无目标节点,只对布尔 false 做拦截(函数规则需节点上下文)
791
+ for (const did of draggedIds) {
792
+ const ov = behaviors.get(did)
793
+ if (ov?.sortable === false || ov?.draggable === false) {
794
+ return { style: { display: 'none' } }
795
+ }
796
+ }
797
+ }
798
+ } catch {
625
799
  return { style: { display: 'none' } }
626
800
  }
627
801
  const usePadding = opts?.padding ?? true
package/src/types.ts CHANGED
@@ -1,16 +1,35 @@
1
1
  /** dnd types */
2
2
 
3
+ import type { SpliceTreeNode } from '@splicetree/core'
4
+
3
5
  export interface DndNode {
4
6
  id: string
5
7
  level: number
6
8
  parentId?: string
7
9
  root?: boolean
8
10
  }
9
-
10
- export interface DragBehavior {
11
- draggable?: boolean
12
- sortable?: boolean
13
- nestable?: boolean
11
+ export type BehaviorPredicate<T = unknown>
12
+ = | boolean
13
+ | ((target: SpliceTreeNode<T>) => boolean | Promise<boolean>)
14
+ export interface DragBehavior<T = unknown> {
15
+ /**
16
+ * 控制是否允许作为拖拽源在当前目标上执行拖放
17
+ * - 布尔:true/false 全局允许或禁止
18
+ * - 函数:基于目标节点动态判断;可返回 Promise<boolean>
19
+ */
20
+ draggable?: BehaviorPredicate<T>
21
+ /**
22
+ * 控制是否允许在目标节点的前后排序
23
+ * - 布尔:true/false 全局允许或禁止
24
+ * - 函数:基于目标节点动态判断;可返回 Promise<boolean>
25
+ */
26
+ sortable?: BehaviorPredicate<T>
27
+ /**
28
+ * 控制能否作为子节点拖入
29
+ * - 布尔:true/false 全局允许或禁止
30
+ * - 函数:基于目标节点动态判断;可返回 Promise<boolean>
31
+ */
32
+ nestable?: BehaviorPredicate<T>
14
33
  }
15
34
 
16
35
  export enum DropPosition {