@splicetree/plugin-dnd 0.2.0 → 0.3.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 +13 -0
- package/README.md +51 -16
- package/dist/index.d.ts +47 -56
- package/dist/index.js +217 -48
- package/package.json +2 -2
- package/src/index.ts +234 -124
- package/src/position.ts +22 -0
- package/src/types.ts +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @splicetree/plugin-dnd
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 多节点拖拽增强:
|
|
8
|
+
- 当拖拽源节点属于当前选择集合时,启用“组拖拽”,保持被选节点的相对顺序。
|
|
9
|
+
- 悬停与释放阶段同时校验整组节点的 draggable / nestable / sortable 规则;任一节点不满足则整组操作禁用。
|
|
10
|
+
- 防止将节点拖入自身或其后代,确保结构合法性。
|
|
11
|
+
- 在 AFTER 场景按逆序处理,被选节点相对顺序保持正确。
|
|
12
|
+
|
|
13
|
+
影响与迁移:
|
|
14
|
+
- 若需要为部分节点放开“入内/前后排序”,请在 v-bind 拖拽行为中设置 nestable / sortable。
|
|
15
|
+
|
|
3
16
|
## 0.2.0
|
|
4
17
|
|
|
5
18
|
## 0.1.1
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @splicetree/plugin-dnd
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
为树节点提供拖拽移动能力,支持插入到目标之前、之后或作为子节点。新增工程化的规则引擎,可精细控制「谁可以拖」「拖到哪里」「允许的落点(前/内/后)」。
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@splicetree/plugin-dnd)
|
|
6
6
|
[](https://npmcharts.com/compare/%40splicetree%2Fplugin-dnd?minimal=true)
|
|
@@ -16,10 +16,31 @@
|
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
18
|
import { createSpliceTree } from '@splicetree/core'
|
|
19
|
-
import dnd from '@splicetree/plugin-dnd'
|
|
19
|
+
import dnd, { DropPosition } from '@splicetree/plugin-dnd'
|
|
20
20
|
|
|
21
21
|
const tree = createSpliceTree(data, {
|
|
22
22
|
plugins: [dnd],
|
|
23
|
+
configuration: {
|
|
24
|
+
dnd: {
|
|
25
|
+
// 控制可拖拽
|
|
26
|
+
disabledDrag: (n) => n.level === 2, // 例如:禁用所有 2 级节点的拖拽
|
|
27
|
+
// 控制可放置
|
|
28
|
+
levelMatrix: {
|
|
29
|
+
// 例如:3 级可以放到 2 级「内」,但 2 级不能放到 1 级「内」
|
|
30
|
+
3: { 2: { inside: true } },
|
|
31
|
+
2: { 1: { inside: false } },
|
|
32
|
+
},
|
|
33
|
+
// 显式禁止某些 ID 对的放置
|
|
34
|
+
denyDropPairs: [
|
|
35
|
+
{ fromId: 'a', toId: 'b', positions: [DropPosition.INSIDE] },
|
|
36
|
+
],
|
|
37
|
+
// 完全自定义:覆盖所有规则(高优先级)
|
|
38
|
+
canDrop: (src, tgt, pos) => {
|
|
39
|
+
if (src.id === 'a' && tgt.id === 'b') return false
|
|
40
|
+
return true
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
23
44
|
})
|
|
24
45
|
```
|
|
25
46
|
|
|
@@ -27,10 +48,16 @@ const tree = createSpliceTree(data, {
|
|
|
27
48
|
|
|
28
49
|
### Options
|
|
29
50
|
|
|
30
|
-
| 选项 | 类型
|
|
31
|
-
| ------------------ |
|
|
32
|
-
| `autoUpdateParent` | `boolean`
|
|
33
|
-
| `autoExpandOnDrop` | `boolean`
|
|
51
|
+
| 选项 | 类型 | 默认值 | 说明 |
|
|
52
|
+
| ------------------ | ------------------------------------------------------------ | ------ | -------------------------------------------------------------------- |
|
|
53
|
+
| `autoUpdateParent` | `boolean` | `true` | 拖拽后自动更新源节点的父字段;为 `false` 时不写回且不移动 |
|
|
54
|
+
| `autoExpandOnDrop` | `boolean` | `true` | 拖入后自动展开目标节点 |
|
|
55
|
+
| `readonly` | `boolean` | `false`| 只读模式,禁用拖拽与放置 |
|
|
56
|
+
| `reorderOnly` | `boolean` | `false`| 仅允许同层级排序(禁用 `INSIDE` 放入子级) |
|
|
57
|
+
| `disabledDrag` | `boolean \| string[] \| (node: DndNode) => boolean` | `无` | 禁用拖拽:布尔、ID 列表或方法 |
|
|
58
|
+
| `levelMatrix` | `Record<level, Record<level, { before?: boolean; inside?: boolean; after?: boolean }>>` | `无` | 基于层级的允许矩阵。未显式配置的组合默认允许 |
|
|
59
|
+
| `denyDropPairs` | `{ fromId: string; toId: string; positions?: DropPosition[] }[]` | `无` | 显式禁止某些源-目标的放置;未指定 `positions` 时,禁止所有落点 |
|
|
60
|
+
| `canDrop` | `(src: DndNode, tgt: DndNode, position: DropPosition) => boolean` | `无` | 自定义总规则(高优先级,返回 `true/false` 覆盖其它规则) |
|
|
34
61
|
|
|
35
62
|
### Events
|
|
36
63
|
|
|
@@ -40,16 +67,16 @@ const tree = createSpliceTree(data, {
|
|
|
40
67
|
|
|
41
68
|
### 实例方法
|
|
42
69
|
|
|
43
|
-
| 名称 | 参数 | 说明
|
|
44
|
-
| ---------------- | --------------------------------------------------------- |
|
|
45
|
-
| `drop` | `srcId: string; targetId: string; position: DropPosition` | 执行移动
|
|
46
|
-
| `onDragStart` | `id: string` | 标记拖拽源
|
|
47
|
-
| `onDragOver` | `id: string; el: HTMLElement; e: DragEvent \| MouseEvent` | 更新目标悬停位置
|
|
48
|
-
| `onDragLeave` | `id: string` | 清理悬停位置
|
|
49
|
-
| `onDrop` | `targetId: string` | 根据当前悬停位置执行移动
|
|
50
|
-
| `hoverPositions` | `无` | 目标悬停位置缓存
|
|
51
|
-
| `draggingId` | `无` | 当前正在拖拽的节点 id
|
|
52
|
-
| `dragProps` |
|
|
70
|
+
| 名称 | 参数 | 说明 |
|
|
71
|
+
| ---------------- | --------------------------------------------------------- | -------------------------- |
|
|
72
|
+
| `drop` | `srcId: string; targetId: string; position: DropPosition` | 执行移动 |
|
|
73
|
+
| `onDragStart` | `id: string` | 标记拖拽源 |
|
|
74
|
+
| `onDragOver` | `id: string; el: HTMLElement; e: DragEvent \| MouseEvent` | 更新目标悬停位置 |
|
|
75
|
+
| `onDragLeave` | `id: string` | 清理悬停位置 |
|
|
76
|
+
| `onDrop` | `targetId: string` | 根据当前悬停位置执行移动 |
|
|
77
|
+
| `hoverPositions` | `无` | 目标悬停位置缓存 |
|
|
78
|
+
| `draggingId` | `无` | 当前正在拖拽的节点 id |
|
|
79
|
+
| `dragProps` | `id: string` | DOM 事件绑定集合(逐节点) |
|
|
53
80
|
|
|
54
81
|
### 节点方法
|
|
55
82
|
|
|
@@ -57,3 +84,11 @@ const tree = createSpliceTree(data, {
|
|
|
57
84
|
| ------------------- | ---- | -------------------- |
|
|
58
85
|
| `getDropPosition()` | `无` | 当前节点的悬停位置 |
|
|
59
86
|
| `isDragging()` | `无` | 当前节点是否为拖拽源 |
|
|
87
|
+
| `isDisabled()` | `无` | 当前节点是否禁用拖拽 |
|
|
88
|
+
|
|
89
|
+
## 规则优先级
|
|
90
|
+
- `readonly` → 全面禁止
|
|
91
|
+
- `canDrop` → 优先级最高,返回值直接决定是否允许
|
|
92
|
+
- `denyDropPairs` → 其次,命中即禁止
|
|
93
|
+
- `levelMatrix` → 再次,根据层级组合与落点决策
|
|
94
|
+
- 默认允许(除去基础非法场景:拖到自己或到父级)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
import { SpliceTreePlugin } from "@splicetree/core";
|
|
2
2
|
|
|
3
|
-
//#region src/
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
/** dnd types */
|
|
5
|
+
interface DndNode {
|
|
6
|
+
id: string;
|
|
7
|
+
level: number;
|
|
8
|
+
parentId?: string;
|
|
9
|
+
root?: boolean;
|
|
10
|
+
}
|
|
11
|
+
interface DragBehavior {
|
|
12
|
+
draggable?: boolean;
|
|
13
|
+
sortable?: boolean;
|
|
14
|
+
nestable?: boolean;
|
|
15
|
+
}
|
|
11
16
|
declare enum DropPosition {
|
|
12
17
|
BEFORE = -1,
|
|
13
18
|
INSIDE = 0,
|
|
14
19
|
AFTER = 1,
|
|
15
20
|
}
|
|
21
|
+
interface DndOptions {
|
|
22
|
+
/** 执行拖拽后是否自动写回父字段并同步树结构 */
|
|
23
|
+
autoUpdateParent?: boolean;
|
|
24
|
+
/** 拖入为子节点后是否自动展开目标节点 */
|
|
25
|
+
autoExpandOnDrop?: boolean;
|
|
26
|
+
/** 全局只读,禁用所有拖拽与排序 */
|
|
27
|
+
readonly?: boolean;
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/index.d.ts
|
|
16
31
|
declare module '@splicetree/core' {
|
|
17
32
|
interface SpliceTreeConfiguration {
|
|
18
|
-
dnd?:
|
|
19
|
-
autoUpdateParent?: boolean;
|
|
20
|
-
autoExpandOnDrop?: boolean;
|
|
21
|
-
};
|
|
33
|
+
dnd?: DndOptions;
|
|
22
34
|
}
|
|
23
35
|
interface SpliceTreeEventPayloadMap {
|
|
24
36
|
/**
|
|
@@ -43,70 +55,49 @@ declare module '@splicetree/core' {
|
|
|
43
55
|
* @param position 落点位置(前/内/后)
|
|
44
56
|
*/
|
|
45
57
|
drop: (srcId: string, targetId: string, position: DropPosition) => void;
|
|
46
|
-
/**
|
|
47
|
-
* 当前拖拽源节点 id
|
|
48
|
-
*/
|
|
58
|
+
/** 当前拖拽源节点 id */
|
|
49
59
|
draggingId?: string;
|
|
50
|
-
/**
|
|
51
|
-
* 目标节点的悬停位置映射
|
|
52
|
-
*/
|
|
60
|
+
/** 目标节点的悬停位置映射 */
|
|
53
61
|
hoverPositions: Map<string, DropPosition>;
|
|
54
|
-
/**
|
|
55
|
-
* DOM 事件:开始拖拽
|
|
56
|
-
*/
|
|
62
|
+
/** DOM 事件:开始拖拽 */
|
|
57
63
|
onDragStart: (id: string) => void;
|
|
58
|
-
/**
|
|
59
|
-
* DOM 事件:悬停计算并更新位置
|
|
60
|
-
*/
|
|
64
|
+
/** DOM 事件:悬停计算并更新位置 */
|
|
61
65
|
onDragOver: (id: string, el: HTMLElement, e: DragEvent | MouseEvent) => void;
|
|
62
|
-
/**
|
|
63
|
-
* DOM 事件:离开目标,清理悬停状态
|
|
64
|
-
*/
|
|
66
|
+
/** DOM 事件:离开目标,清理悬停状态 */
|
|
65
67
|
onDragLeave: (id: string) => void;
|
|
66
|
-
/**
|
|
67
|
-
* DOM 事件:在目标上释放后执行移动
|
|
68
|
-
*/
|
|
68
|
+
/** DOM 事件:在目标上释放后执行移动 */
|
|
69
69
|
onDrop: (targetId: string) => void;
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
dragProps: {
|
|
74
|
-
/**
|
|
75
|
-
* 是否可拖拽
|
|
76
|
-
*/
|
|
70
|
+
/** 可直接 v-bind 到节点的拖拽属性集合(逐节点) */
|
|
71
|
+
dragProps: (id: string, behavior?: DragBehavior) => {
|
|
72
|
+
/** 是否可拖拽 */
|
|
77
73
|
draggable: boolean;
|
|
78
|
-
/**
|
|
79
|
-
* 原生拖拽开始事件处理
|
|
80
|
-
*/
|
|
74
|
+
/** 原生拖拽开始事件处理 */
|
|
81
75
|
onDragstart: (e: DragEvent) => void;
|
|
82
|
-
/**
|
|
83
|
-
* 原生拖拽悬停事件处理
|
|
84
|
-
*/
|
|
76
|
+
/** 原生拖拽悬停事件处理 */
|
|
85
77
|
onDragover: (e: DragEvent) => void;
|
|
86
|
-
/**
|
|
87
|
-
* 原生拖拽离开事件处理
|
|
88
|
-
*/
|
|
78
|
+
/** 原生拖拽离开事件处理 */
|
|
89
79
|
onDragleave: (e: DragEvent) => void;
|
|
90
|
-
/**
|
|
91
|
-
* 原生拖拽释放事件处理
|
|
92
|
-
*/
|
|
80
|
+
/** 原生拖拽释放事件处理 */
|
|
93
81
|
onDrop: (e: DragEvent) => void;
|
|
94
82
|
};
|
|
83
|
+
/** 统一占位样式绑定对象 */
|
|
84
|
+
ghostStyle: (opts?: {
|
|
85
|
+
padding?: boolean;
|
|
86
|
+
margin?: boolean;
|
|
87
|
+
}) => Record<string, any>;
|
|
95
88
|
}
|
|
96
89
|
/**
|
|
97
90
|
* 节点扩展(DnD)
|
|
98
91
|
*/
|
|
99
92
|
interface SpliceTreeNode {
|
|
100
|
-
/**
|
|
101
|
-
* 获取当前节点的悬停落点位置
|
|
102
|
-
*/
|
|
93
|
+
/** 获取当前节点的悬停落点位置 */
|
|
103
94
|
getDropPosition: () => (DropPosition | undefined);
|
|
104
|
-
/**
|
|
105
|
-
* 当前节点是否为拖拽源
|
|
106
|
-
*/
|
|
95
|
+
/** 当前节点是否为拖拽源 */
|
|
107
96
|
isDragging: () => boolean;
|
|
97
|
+
/** 当前节点是否被禁用 */
|
|
98
|
+
isDisabled?: () => boolean;
|
|
108
99
|
}
|
|
109
100
|
}
|
|
110
101
|
declare const dnd: SpliceTreePlugin;
|
|
111
102
|
//#endregion
|
|
112
|
-
export { DropPosition, dnd as default, dnd };
|
|
103
|
+
export { type DndNode, type DndOptions, DropPosition, dnd as default, dnd };
|
package/dist/index.js
CHANGED
|
@@ -1,28 +1,61 @@
|
|
|
1
|
-
//#region src/
|
|
2
|
-
/**
|
|
3
|
-
* 拖拽落点位置
|
|
4
|
-
* - BEFORE: 目标之前插入
|
|
5
|
-
* - INSIDE: 作为目标的子节点插入
|
|
6
|
-
* - AFTER: 目标之后插入
|
|
7
|
-
*/
|
|
1
|
+
//#region src/types.ts
|
|
8
2
|
let DropPosition = /* @__PURE__ */ function(DropPosition$1) {
|
|
9
3
|
DropPosition$1[DropPosition$1["BEFORE"] = -1] = "BEFORE";
|
|
10
4
|
DropPosition$1[DropPosition$1["INSIDE"] = 0] = "INSIDE";
|
|
11
5
|
DropPosition$1[DropPosition$1["AFTER"] = 1] = "AFTER";
|
|
12
6
|
return DropPosition$1;
|
|
13
7
|
}({});
|
|
8
|
+
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/position.ts
|
|
11
|
+
function computePosition(targetId, el, e, src) {
|
|
12
|
+
const rect = el.getBoundingClientRect();
|
|
13
|
+
const y = ("clientY" in e ? e.clientY : 0) - rect.top;
|
|
14
|
+
const ratio = Math.max(0, Math.min(1, rect.height ? y / rect.height : 0));
|
|
15
|
+
if (ratio < .33) return DropPosition.BEFORE;
|
|
16
|
+
if (ratio > .66) return DropPosition.AFTER;
|
|
17
|
+
if (src?.id === targetId) return;
|
|
18
|
+
if (src?.getParent()?.id === targetId) return;
|
|
19
|
+
return DropPosition.INSIDE;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/index.ts
|
|
14
24
|
const dnd = {
|
|
15
25
|
name: "dnd",
|
|
16
26
|
setup(ctx) {
|
|
17
|
-
const {
|
|
18
|
-
const parentField = ctx.tree.options?.configuration?.parentField ??
|
|
27
|
+
const { autoExpandOnDrop = true, autoUpdateParent = true, readonly = false } = ctx.options?.configuration?.dnd ?? {};
|
|
28
|
+
const parentField = ctx.tree.options?.configuration?.parentField ?? "parent";
|
|
19
29
|
let draggingId;
|
|
20
30
|
const hoverPositions = /* @__PURE__ */ new Map();
|
|
31
|
+
let ghostTop = 0;
|
|
32
|
+
let ghostHeight = 0;
|
|
33
|
+
let ghostPos;
|
|
34
|
+
let ghostInsetLeft = 0;
|
|
35
|
+
let ghostInsetRight = 0;
|
|
36
|
+
let ghostMarginLeft = 0;
|
|
37
|
+
let ghostMarginRight = 0;
|
|
38
|
+
const behaviors = /* @__PURE__ */ new Map();
|
|
39
|
+
const isDisabledById = (_id) => !!readonly;
|
|
40
|
+
const getDraggedNodeIds = (primaryId) => {
|
|
41
|
+
const tree = ctx.tree;
|
|
42
|
+
if (tree.selectedKeys && tree.selectedKeys.has(primaryId)) {
|
|
43
|
+
const selected = new Set(tree.selectedKeys);
|
|
44
|
+
const validIds = Array.from(selected).filter((id) => {
|
|
45
|
+
if (isDisabledById(id)) return false;
|
|
46
|
+
if (behaviors.get(id)?.draggable === false) return false;
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
49
|
+
return ctx.tree.items().filter((node) => validIds.includes(node.id)).map((n) => n.id);
|
|
50
|
+
}
|
|
51
|
+
return [primaryId];
|
|
52
|
+
};
|
|
21
53
|
/**
|
|
22
54
|
* 开始拖拽:记录拖拽源并通知视图刷新(用于高亮拖拽源)
|
|
23
55
|
* @param id 源节点 id
|
|
24
56
|
*/
|
|
25
57
|
const onDragStart = (id) => {
|
|
58
|
+
if (isDisabledById(id)) return;
|
|
26
59
|
draggingId = id;
|
|
27
60
|
ctx.events.emit({
|
|
28
61
|
name: "visibility",
|
|
@@ -37,16 +70,6 @@ const dnd = {
|
|
|
37
70
|
* @param el 目标节点元素
|
|
38
71
|
* @param e 拖拽/鼠标事件
|
|
39
72
|
*/
|
|
40
|
-
const computePosition = (id, el, e) => {
|
|
41
|
-
const rect = el.getBoundingClientRect();
|
|
42
|
-
const y = ("clientY" in e ? e.clientY : 0) - rect.top;
|
|
43
|
-
const ratio = Math.max(0, Math.min(1, y / rect.height));
|
|
44
|
-
if (ratio < .33) return DropPosition.BEFORE;
|
|
45
|
-
if (ratio > .66) return DropPosition.AFTER;
|
|
46
|
-
if (draggingId === id) return;
|
|
47
|
-
if (ctx.tree.getNode(draggingId)?.getParent()?.id === id) return;
|
|
48
|
-
return DropPosition.INSIDE;
|
|
49
|
-
};
|
|
50
73
|
/**
|
|
51
74
|
* 悬停:更新目标的悬停位置映射并刷新视图
|
|
52
75
|
* @param id 目标节点 id
|
|
@@ -54,9 +77,86 @@ const dnd = {
|
|
|
54
77
|
* @param e 拖拽/鼠标事件
|
|
55
78
|
*/
|
|
56
79
|
const onDragOver = (id, el, e) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
80
|
+
if (!draggingId) return;
|
|
81
|
+
const draggedIds = getDraggedNodeIds(draggingId);
|
|
82
|
+
const targetNode = ctx.tree.getNode(id);
|
|
83
|
+
if (draggedIds.includes(id)) {
|
|
84
|
+
hoverPositions.delete(id);
|
|
85
|
+
ctx.events.emit({
|
|
86
|
+
name: "visibility",
|
|
87
|
+
keys: ctx.tree.expandedKeys()
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let ancestor = targetNode?.getParent();
|
|
92
|
+
while (ancestor) {
|
|
93
|
+
if (draggedIds.includes(ancestor.id)) {
|
|
94
|
+
hoverPositions.delete(id);
|
|
95
|
+
ctx.events.emit({
|
|
96
|
+
name: "visibility",
|
|
97
|
+
keys: ctx.tree.expandedKeys()
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
ancestor = ancestor.getParent();
|
|
102
|
+
}
|
|
103
|
+
const pos = computePosition(id, el, e, void 0);
|
|
104
|
+
if (!targetNode || pos === void 0) {
|
|
105
|
+
hoverPositions.delete(id);
|
|
106
|
+
ctx.events.emit({
|
|
107
|
+
name: "visibility",
|
|
108
|
+
keys: ctx.tree.expandedKeys()
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (pos === DropPosition.INSIDE) {
|
|
113
|
+
if (draggedIds.some((dId) => {
|
|
114
|
+
return ctx.tree.getNode(dId)?.getParent()?.id === id;
|
|
115
|
+
})) {
|
|
116
|
+
hoverPositions.delete(id);
|
|
117
|
+
ctx.events.emit({
|
|
118
|
+
name: "visibility",
|
|
119
|
+
keys: ctx.tree.expandedKeys()
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!draggedIds.every((dId) => {
|
|
124
|
+
return behaviors.get(dId)?.nestable !== false;
|
|
125
|
+
})) {
|
|
126
|
+
hoverPositions.delete(id);
|
|
127
|
+
ctx.events.emit({
|
|
128
|
+
name: "visibility",
|
|
129
|
+
keys: ctx.tree.expandedKeys()
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
} else if (!draggedIds.every((dId) => {
|
|
134
|
+
return behaviors.get(dId)?.sortable !== false;
|
|
135
|
+
})) {
|
|
136
|
+
hoverPositions.delete(id);
|
|
137
|
+
ctx.events.emit({
|
|
138
|
+
name: "visibility",
|
|
139
|
+
keys: ctx.tree.expandedKeys()
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
hoverPositions.set(id, pos);
|
|
144
|
+
ghostTop = el.offsetTop;
|
|
145
|
+
ghostHeight = el.offsetHeight;
|
|
146
|
+
ghostPos = pos;
|
|
147
|
+
const parent = el.parentElement;
|
|
148
|
+
if (parent) {
|
|
149
|
+
const styles = getComputedStyle(parent);
|
|
150
|
+
ghostInsetLeft = Number.parseFloat(styles.paddingLeft || "0");
|
|
151
|
+
ghostInsetRight = Number.parseFloat(styles.paddingRight || "0");
|
|
152
|
+
ghostMarginLeft = Number.parseFloat(styles.marginLeft || "0");
|
|
153
|
+
ghostMarginRight = Number.parseFloat(styles.marginRight || "0");
|
|
154
|
+
} else {
|
|
155
|
+
ghostInsetLeft = 0;
|
|
156
|
+
ghostInsetRight = 0;
|
|
157
|
+
ghostMarginLeft = 0;
|
|
158
|
+
ghostMarginRight = 0;
|
|
159
|
+
}
|
|
60
160
|
ctx.events.emit({
|
|
61
161
|
name: "visibility",
|
|
62
162
|
keys: ctx.tree.expandedKeys()
|
|
@@ -85,10 +185,15 @@ const dnd = {
|
|
|
85
185
|
const src = ctx.tree.getNode(srcId);
|
|
86
186
|
const target = ctx.tree.getNode(targetId);
|
|
87
187
|
if (!src || !target) return;
|
|
188
|
+
if (readonly) return;
|
|
189
|
+
const ov = behaviors.get(src.id);
|
|
190
|
+
if (ov?.draggable === false) return;
|
|
191
|
+
if (position === DropPosition.INSIDE && ov?.nestable === false) return;
|
|
192
|
+
if (position !== DropPosition.INSIDE && ov?.sortable === false) return;
|
|
88
193
|
if (srcId === targetId) return;
|
|
89
194
|
const srcParentId = src.getParent()?.id;
|
|
195
|
+
if (position === DropPosition.INSIDE && srcParentId === targetId) return;
|
|
90
196
|
if (position === DropPosition.INSIDE) {
|
|
91
|
-
if (targetId === srcParentId) return;
|
|
92
197
|
if (autoUpdateParent) {
|
|
93
198
|
ctx.tree.moveNode(srcId, targetId);
|
|
94
199
|
Reflect.set(src.original, parentField, targetId);
|
|
@@ -151,9 +256,19 @@ const dnd = {
|
|
|
151
256
|
const onDrop = (targetId) => {
|
|
152
257
|
if (!draggingId) return;
|
|
153
258
|
const pos = hoverPositions.get(targetId);
|
|
154
|
-
if (pos ===
|
|
155
|
-
|
|
156
|
-
|
|
259
|
+
if (pos === void 0) {
|
|
260
|
+
hoverPositions.clear();
|
|
261
|
+
draggingId = void 0;
|
|
262
|
+
ctx.events.emit({
|
|
263
|
+
name: "visibility",
|
|
264
|
+
keys: ctx.tree.expandedKeys()
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const draggedIds = getDraggedNodeIds(draggingId);
|
|
269
|
+
let idsToProcess = draggedIds;
|
|
270
|
+
if (pos === DropPosition.AFTER) idsToProcess = [...draggedIds].reverse();
|
|
271
|
+
for (const id of idsToProcess) drop(id, targetId, pos);
|
|
157
272
|
hoverPositions.clear();
|
|
158
273
|
draggingId = void 0;
|
|
159
274
|
ctx.events.emit({
|
|
@@ -161,21 +276,17 @@ const dnd = {
|
|
|
161
276
|
keys: ctx.tree.expandedKeys()
|
|
162
277
|
});
|
|
163
278
|
};
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
},
|
|
174
|
-
dragProps: {
|
|
175
|
-
draggable: true,
|
|
279
|
+
/**
|
|
280
|
+
* 绑定到节点的拖拽属性集,便于直接 v-bind
|
|
281
|
+
* @param id 节点 ID
|
|
282
|
+
*/
|
|
283
|
+
const dragProps = (id, behavior) => {
|
|
284
|
+
if (behavior) behaviors.set(id, behavior);
|
|
285
|
+
const canDrag = behavior?.draggable === false ? false : !isDisabledById(id);
|
|
286
|
+
return {
|
|
287
|
+
draggable: canDrag,
|
|
176
288
|
onDragstart: (e) => {
|
|
177
|
-
|
|
178
|
-
if (id) {
|
|
289
|
+
if (canDrag) {
|
|
179
290
|
e.dataTransfer?.setData("text/plain", id);
|
|
180
291
|
onDragStart(id);
|
|
181
292
|
}
|
|
@@ -183,24 +294,82 @@ const dnd = {
|
|
|
183
294
|
onDragover: (e) => {
|
|
184
295
|
e.preventDefault();
|
|
185
296
|
const el = e.currentTarget;
|
|
186
|
-
|
|
187
|
-
if (id) onDragOver(id, el, e);
|
|
297
|
+
onDragOver(id, el, e);
|
|
188
298
|
},
|
|
189
|
-
onDragleave: (
|
|
190
|
-
|
|
191
|
-
if (id) onDragLeave(id);
|
|
299
|
+
onDragleave: (_e) => {
|
|
300
|
+
onDragLeave(id);
|
|
192
301
|
},
|
|
193
302
|
onDrop: (e) => {
|
|
194
303
|
e.preventDefault();
|
|
195
|
-
|
|
196
|
-
if (id) onDrop(id);
|
|
304
|
+
onDrop(id);
|
|
197
305
|
}
|
|
198
|
-
}
|
|
306
|
+
};
|
|
307
|
+
};
|
|
308
|
+
const ghostStyle = (opts) => {
|
|
309
|
+
if (!draggingId || ghostPos === void 0) return { style: { display: "none" } };
|
|
310
|
+
const usePadding = opts?.padding ?? true;
|
|
311
|
+
const useMargin = opts?.margin ?? true;
|
|
312
|
+
const leftInset = usePadding ? ghostInsetLeft : 0;
|
|
313
|
+
const rightInset = usePadding ? ghostInsetRight : 0;
|
|
314
|
+
const leftMargin = useMargin ? ghostMarginLeft : 0;
|
|
315
|
+
const rightMargin = useMargin ? ghostMarginRight : 0;
|
|
316
|
+
const base = {
|
|
317
|
+
position: "absolute",
|
|
318
|
+
left: `${leftInset + leftMargin}px`,
|
|
319
|
+
right: `${rightInset + rightMargin}px`,
|
|
320
|
+
pointerEvents: "none"
|
|
321
|
+
};
|
|
322
|
+
if (ghostPos === DropPosition.BEFORE) return {
|
|
323
|
+
"style": {
|
|
324
|
+
...base,
|
|
325
|
+
top: `${ghostTop}px`,
|
|
326
|
+
height: "2px",
|
|
327
|
+
background: "var(--vp-code-color)",
|
|
328
|
+
borderRadius: "2px"
|
|
329
|
+
},
|
|
330
|
+
"data-drop-position": -1
|
|
331
|
+
};
|
|
332
|
+
if (ghostPos === DropPosition.AFTER) return {
|
|
333
|
+
"style": {
|
|
334
|
+
...base,
|
|
335
|
+
top: `${ghostTop + ghostHeight}px`,
|
|
336
|
+
height: "2px",
|
|
337
|
+
background: "var(--vp-code-color)",
|
|
338
|
+
borderRadius: "2px"
|
|
339
|
+
},
|
|
340
|
+
"data-drop-position": 1
|
|
341
|
+
};
|
|
342
|
+
return {
|
|
343
|
+
"style": {
|
|
344
|
+
...base,
|
|
345
|
+
top: `${ghostTop}px`,
|
|
346
|
+
height: `${ghostHeight}px`,
|
|
347
|
+
background: "var(--vp-code-color)",
|
|
348
|
+
opacity: .15,
|
|
349
|
+
borderRadius: "4px"
|
|
350
|
+
},
|
|
351
|
+
"data-drop-position": 0
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
return {
|
|
355
|
+
drop,
|
|
356
|
+
onDragStart,
|
|
357
|
+
onDragOver,
|
|
358
|
+
onDragLeave,
|
|
359
|
+
onDrop,
|
|
360
|
+
hoverPositions,
|
|
361
|
+
get draggingId() {
|
|
362
|
+
return draggingId;
|
|
363
|
+
},
|
|
364
|
+
dragProps,
|
|
365
|
+
ghostStyle
|
|
199
366
|
};
|
|
200
367
|
},
|
|
201
368
|
extendNode(node, ctx) {
|
|
202
369
|
node.getDropPosition = () => ctx.tree.hoverPositions.get(node.id);
|
|
203
370
|
node.isDragging = () => ctx.tree.draggingId === node.id;
|
|
371
|
+
const opts = ctx.options?.configuration?.dnd ?? {};
|
|
372
|
+
node.isDisabled = () => !!opts.readonly;
|
|
204
373
|
}
|
|
205
374
|
};
|
|
206
375
|
var src_default = dnd;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@splicetree/plugin-dnd",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.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": "0.
|
|
26
|
+
"@splicetree/core": "0.3.0"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"dev": "tsdown --watch",
|
package/src/index.ts
CHANGED
|
@@ -1,23 +1,12 @@
|
|
|
1
1
|
import type { SpliceTreePlugin, SpliceTreePluginContext } from '@splicetree/core'
|
|
2
|
+
import type { DndNode, DndOptions, DragBehavior } from './types'
|
|
3
|
+
import { computePosition } from './position'
|
|
4
|
+
import { DropPosition } from './types'
|
|
2
5
|
import '@splicetree/core'
|
|
3
6
|
|
|
4
|
-
/**
|
|
5
|
-
* 拖拽落点位置
|
|
6
|
-
* - BEFORE: 目标之前插入
|
|
7
|
-
* - INSIDE: 作为目标的子节点插入
|
|
8
|
-
* - AFTER: 目标之后插入
|
|
9
|
-
*/
|
|
10
|
-
export enum DropPosition {
|
|
11
|
-
BEFORE = -1,
|
|
12
|
-
INSIDE = 0,
|
|
13
|
-
AFTER = 1,
|
|
14
|
-
}
|
|
15
7
|
declare module '@splicetree/core' {
|
|
16
8
|
interface SpliceTreeConfiguration {
|
|
17
|
-
dnd?:
|
|
18
|
-
autoUpdateParent?: boolean
|
|
19
|
-
autoExpandOnDrop?: boolean
|
|
20
|
-
}
|
|
9
|
+
dnd?: DndOptions
|
|
21
10
|
}
|
|
22
11
|
interface SpliceTreeEventPayloadMap {
|
|
23
12
|
/**
|
|
@@ -38,69 +27,46 @@ declare module '@splicetree/core' {
|
|
|
38
27
|
* @param position 落点位置(前/内/后)
|
|
39
28
|
*/
|
|
40
29
|
drop: (srcId: string, targetId: string, position: DropPosition) => void
|
|
41
|
-
/**
|
|
42
|
-
* 当前拖拽源节点 id
|
|
43
|
-
*/
|
|
30
|
+
/** 当前拖拽源节点 id */
|
|
44
31
|
draggingId?: string
|
|
45
|
-
/**
|
|
46
|
-
* 目标节点的悬停位置映射
|
|
47
|
-
*/
|
|
32
|
+
/** 目标节点的悬停位置映射 */
|
|
48
33
|
hoverPositions: Map<string, DropPosition>
|
|
49
|
-
/**
|
|
50
|
-
* DOM 事件:开始拖拽
|
|
51
|
-
*/
|
|
34
|
+
/** DOM 事件:开始拖拽 */
|
|
52
35
|
onDragStart: (id: string) => void
|
|
53
|
-
/**
|
|
54
|
-
* DOM 事件:悬停计算并更新位置
|
|
55
|
-
*/
|
|
36
|
+
/** DOM 事件:悬停计算并更新位置 */
|
|
56
37
|
onDragOver: (id: string, el: HTMLElement, e: DragEvent | MouseEvent) => void
|
|
57
|
-
/**
|
|
58
|
-
* DOM 事件:离开目标,清理悬停状态
|
|
59
|
-
*/
|
|
38
|
+
/** DOM 事件:离开目标,清理悬停状态 */
|
|
60
39
|
onDragLeave: (id: string) => void
|
|
61
|
-
/**
|
|
62
|
-
* DOM 事件:在目标上释放后执行移动
|
|
63
|
-
*/
|
|
40
|
+
/** DOM 事件:在目标上释放后执行移动 */
|
|
64
41
|
onDrop: (targetId: string) => void
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
dragProps: {
|
|
69
|
-
/**
|
|
70
|
-
* 是否可拖拽
|
|
71
|
-
*/
|
|
42
|
+
/** 可直接 v-bind 到节点的拖拽属性集合(逐节点) */
|
|
43
|
+
dragProps: (id: string, behavior?: DragBehavior) => {
|
|
44
|
+
/** 是否可拖拽 */
|
|
72
45
|
draggable: boolean
|
|
73
|
-
/**
|
|
74
|
-
* 原生拖拽开始事件处理
|
|
75
|
-
*/
|
|
46
|
+
/** 原生拖拽开始事件处理 */
|
|
76
47
|
onDragstart: (e: DragEvent) => void
|
|
77
|
-
/**
|
|
78
|
-
* 原生拖拽悬停事件处理
|
|
79
|
-
*/
|
|
48
|
+
/** 原生拖拽悬停事件处理 */
|
|
80
49
|
onDragover: (e: DragEvent) => void
|
|
81
|
-
/**
|
|
82
|
-
* 原生拖拽离开事件处理
|
|
83
|
-
*/
|
|
50
|
+
/** 原生拖拽离开事件处理 */
|
|
84
51
|
onDragleave: (e: DragEvent) => void
|
|
85
|
-
/**
|
|
86
|
-
* 原生拖拽释放事件处理
|
|
87
|
-
*/
|
|
52
|
+
/** 原生拖拽释放事件处理 */
|
|
88
53
|
onDrop: (e: DragEvent) => void
|
|
89
54
|
}
|
|
55
|
+
/** 统一占位样式绑定对象 */
|
|
56
|
+
ghostStyle: (opts?: { padding?: boolean, margin?: boolean }) => Record<string, any>
|
|
90
57
|
}
|
|
91
58
|
|
|
92
59
|
/**
|
|
93
60
|
* 节点扩展(DnD)
|
|
94
61
|
*/
|
|
95
62
|
interface SpliceTreeNode {
|
|
96
|
-
/**
|
|
97
|
-
* 获取当前节点的悬停落点位置
|
|
98
|
-
*/
|
|
63
|
+
/** 获取当前节点的悬停落点位置 */
|
|
99
64
|
getDropPosition: () => (DropPosition | undefined)
|
|
100
|
-
/**
|
|
101
|
-
* 当前节点是否为拖拽源
|
|
102
|
-
*/
|
|
65
|
+
/** 当前节点是否为拖拽源 */
|
|
103
66
|
isDragging: () => boolean
|
|
67
|
+
|
|
68
|
+
/** 当前节点是否被禁用 */
|
|
69
|
+
isDisabled?: () => boolean
|
|
104
70
|
}
|
|
105
71
|
}
|
|
106
72
|
|
|
@@ -113,20 +79,56 @@ export const dnd: SpliceTreePlugin = {
|
|
|
113
79
|
* - 通过 events 派发 move 与 visibility 事件驱动视图刷新
|
|
114
80
|
*/
|
|
115
81
|
setup(ctx: SpliceTreePluginContext) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
autoExpandOnDrop
|
|
119
|
-
|
|
120
|
-
|
|
82
|
+
const opts = (ctx.options?.configuration?.dnd ?? {}) as DndOptions
|
|
83
|
+
const {
|
|
84
|
+
autoExpandOnDrop = true,
|
|
85
|
+
autoUpdateParent = true,
|
|
86
|
+
readonly = false,
|
|
87
|
+
} = opts
|
|
121
88
|
const parentField = ctx.tree.options?.configuration?.parentField ?? 'parent'
|
|
122
89
|
let draggingId: string | undefined
|
|
123
90
|
const hoverPositions = new Map<string, DropPosition>()
|
|
91
|
+
let ghostTop = 0
|
|
92
|
+
let ghostHeight = 0
|
|
93
|
+
let ghostPos: DropPosition | undefined
|
|
94
|
+
let ghostInsetLeft = 0
|
|
95
|
+
let ghostInsetRight = 0
|
|
96
|
+
let ghostMarginLeft = 0
|
|
97
|
+
let ghostMarginRight = 0
|
|
98
|
+
const behaviors = new Map<string, DragBehavior>()
|
|
99
|
+
|
|
100
|
+
const isDisabledById = (_id: string | undefined): boolean => !!readonly
|
|
101
|
+
|
|
102
|
+
const getDraggedNodeIds = (primaryId: string): string[] => {
|
|
103
|
+
const tree = ctx.tree as any
|
|
104
|
+
if (tree.selectedKeys && tree.selectedKeys.has(primaryId)) {
|
|
105
|
+
const selected = new Set(tree.selectedKeys as Set<string>)
|
|
106
|
+
const validIds = Array.from(selected).filter((id) => {
|
|
107
|
+
if (isDisabledById(id)) {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
const ov = behaviors.get(id as string)
|
|
111
|
+
if (ov?.draggable === false) {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
return true
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const allItems = ctx.tree.items()
|
|
118
|
+
const sorted = allItems.filter(node => validIds.includes(node.id)).map(n => n.id)
|
|
119
|
+
return sorted
|
|
120
|
+
}
|
|
121
|
+
return [primaryId]
|
|
122
|
+
}
|
|
124
123
|
|
|
125
124
|
/**
|
|
126
125
|
* 开始拖拽:记录拖拽源并通知视图刷新(用于高亮拖拽源)
|
|
127
126
|
* @param id 源节点 id
|
|
128
127
|
*/
|
|
129
128
|
const onDragStart = (id: string) => {
|
|
129
|
+
if (isDisabledById(id)) {
|
|
130
|
+
return
|
|
131
|
+
}
|
|
130
132
|
draggingId = id
|
|
131
133
|
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
132
134
|
}
|
|
@@ -139,25 +141,7 @@ export const dnd: SpliceTreePlugin = {
|
|
|
139
141
|
* @param el 目标节点元素
|
|
140
142
|
* @param e 拖拽/鼠标事件
|
|
141
143
|
*/
|
|
142
|
-
|
|
143
|
-
const rect = el.getBoundingClientRect()
|
|
144
|
-
const y = ('clientY' in e ? e.clientY : 0) - rect.top
|
|
145
|
-
const ratio = Math.max(0, Math.min(1, y / rect.height))
|
|
146
|
-
if (ratio < 0.33) {
|
|
147
|
-
return DropPosition.BEFORE
|
|
148
|
-
}
|
|
149
|
-
if (ratio > 0.66) {
|
|
150
|
-
return DropPosition.AFTER
|
|
151
|
-
}
|
|
152
|
-
if (draggingId === id) {
|
|
153
|
-
return undefined
|
|
154
|
-
}
|
|
155
|
-
const parentId = ctx.tree.getNode(draggingId!)?.getParent()?.id
|
|
156
|
-
if (parentId === id) {
|
|
157
|
-
return undefined
|
|
158
|
-
}
|
|
159
|
-
return DropPosition.INSIDE
|
|
160
|
-
}
|
|
144
|
+
// 使用独立位置计算模块
|
|
161
145
|
|
|
162
146
|
/**
|
|
163
147
|
* 悬停:更新目标的悬停位置映射并刷新视图
|
|
@@ -166,11 +150,92 @@ export const dnd: SpliceTreePlugin = {
|
|
|
166
150
|
* @param e 拖拽/鼠标事件
|
|
167
151
|
*/
|
|
168
152
|
const onDragOver = (id: string, el: HTMLElement, e: DragEvent | MouseEvent) => {
|
|
169
|
-
|
|
170
|
-
if (
|
|
153
|
+
// 如果没有拖拽源,直接返回
|
|
154
|
+
if (!draggingId) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const draggedIds = getDraggedNodeIds(draggingId)
|
|
159
|
+
const targetNode = ctx.tree.getNode(id)
|
|
160
|
+
|
|
161
|
+
// 1. 目标节点不能是拖拽节点之一
|
|
162
|
+
if (draggedIds.includes(id)) {
|
|
163
|
+
hoverPositions.delete(id)
|
|
164
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 2. 目标节点不能是任何拖拽节点的后代(防止循环)
|
|
169
|
+
let ancestor = targetNode?.getParent()
|
|
170
|
+
while (ancestor) {
|
|
171
|
+
if (draggedIds.includes(ancestor.id)) {
|
|
172
|
+
hoverPositions.delete(id)
|
|
173
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
ancestor = ancestor.getParent()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 这里的 src 参数传 undefined,因为我们已经手动检查了 draggedIds
|
|
180
|
+
const pos = computePosition(id, el, e, undefined)
|
|
181
|
+
|
|
182
|
+
if (!targetNode || pos === undefined) {
|
|
171
183
|
hoverPositions.delete(id)
|
|
184
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 3. 检查所有拖拽节点的行为约束
|
|
189
|
+
if (pos === DropPosition.INSIDE) {
|
|
190
|
+
// 如果目标已经是某个拖拽节点的父节点,则 INSIDE 无意义(或者是 no-op)
|
|
191
|
+
// computePosition 原逻辑是 parentId === targetId 返回 undefined
|
|
192
|
+
const isParentOfAny = draggedIds.some((dId) => {
|
|
193
|
+
const node = ctx.tree.getNode(dId)
|
|
194
|
+
return node?.getParent()?.id === id
|
|
195
|
+
})
|
|
196
|
+
if (isParentOfAny) {
|
|
197
|
+
hoverPositions.delete(id)
|
|
198
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const allNestable = draggedIds.every((dId) => {
|
|
203
|
+
const ov = behaviors.get(dId)
|
|
204
|
+
return ov?.nestable !== false
|
|
205
|
+
})
|
|
206
|
+
if (!allNestable) {
|
|
207
|
+
hoverPositions.delete(id)
|
|
208
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
209
|
+
return
|
|
210
|
+
}
|
|
172
211
|
} else {
|
|
173
|
-
|
|
212
|
+
const allSortable = draggedIds.every((dId) => {
|
|
213
|
+
const ov = behaviors.get(dId)
|
|
214
|
+
return ov?.sortable !== false
|
|
215
|
+
})
|
|
216
|
+
if (!allSortable) {
|
|
217
|
+
hoverPositions.delete(id)
|
|
218
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
hoverPositions.set(id, pos)
|
|
224
|
+
ghostTop = el.offsetTop
|
|
225
|
+
ghostHeight = el.offsetHeight
|
|
226
|
+
ghostPos = pos
|
|
227
|
+
const parent = el.parentElement
|
|
228
|
+
if (parent) {
|
|
229
|
+
const styles = getComputedStyle(parent)
|
|
230
|
+
ghostInsetLeft = Number.parseFloat(styles.paddingLeft || '0')
|
|
231
|
+
ghostInsetRight = Number.parseFloat(styles.paddingRight || '0')
|
|
232
|
+
ghostMarginLeft = Number.parseFloat(styles.marginLeft || '0')
|
|
233
|
+
ghostMarginRight = Number.parseFloat(styles.marginRight || '0')
|
|
234
|
+
} else {
|
|
235
|
+
ghostInsetLeft = 0
|
|
236
|
+
ghostInsetRight = 0
|
|
237
|
+
ghostMarginLeft = 0
|
|
238
|
+
ghostMarginRight = 0
|
|
174
239
|
}
|
|
175
240
|
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
176
241
|
}
|
|
@@ -198,15 +263,29 @@ export const dnd: SpliceTreePlugin = {
|
|
|
198
263
|
if (!src || !target) {
|
|
199
264
|
return
|
|
200
265
|
}
|
|
266
|
+
if (readonly) {
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const ov = behaviors.get(src.id)
|
|
270
|
+
if (ov?.draggable === false) {
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
if (position === DropPosition.INSIDE && ov?.nestable === false) {
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
if (position !== DropPosition.INSIDE && ov?.sortable === false) {
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
// 基础非法:不能拖到自身或其父
|
|
201
280
|
if (srcId === targetId) {
|
|
202
281
|
return
|
|
203
282
|
}
|
|
204
283
|
const srcParentId = src.getParent()?.id
|
|
284
|
+
if (position === DropPosition.INSIDE && srcParentId === targetId) {
|
|
285
|
+
return
|
|
286
|
+
}
|
|
205
287
|
|
|
206
288
|
if (position === DropPosition.INSIDE) {
|
|
207
|
-
if (targetId === srcParentId) {
|
|
208
|
-
return
|
|
209
|
-
}
|
|
210
289
|
if (autoUpdateParent) {
|
|
211
290
|
ctx.tree.moveNode(srcId, targetId)
|
|
212
291
|
Reflect.set(src.original, parentField, targetId)
|
|
@@ -252,13 +331,23 @@ export const dnd: SpliceTreePlugin = {
|
|
|
252
331
|
return
|
|
253
332
|
}
|
|
254
333
|
const pos = hoverPositions.get(targetId)
|
|
255
|
-
if (pos ===
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
334
|
+
if (pos === undefined) {
|
|
335
|
+
hoverPositions.clear()
|
|
336
|
+
draggingId = undefined
|
|
337
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const draggedIds = getDraggedNodeIds(draggingId)
|
|
342
|
+
let idsToProcess = draggedIds
|
|
343
|
+
if (pos === DropPosition.AFTER) {
|
|
344
|
+
idsToProcess = [...draggedIds].reverse()
|
|
261
345
|
}
|
|
346
|
+
|
|
347
|
+
for (const id of idsToProcess) {
|
|
348
|
+
drop(id, targetId, pos)
|
|
349
|
+
}
|
|
350
|
+
|
|
262
351
|
hoverPositions.clear()
|
|
263
352
|
draggingId = undefined
|
|
264
353
|
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
@@ -266,40 +355,56 @@ export const dnd: SpliceTreePlugin = {
|
|
|
266
355
|
|
|
267
356
|
/**
|
|
268
357
|
* 绑定到节点的拖拽属性集,便于直接 v-bind
|
|
358
|
+
* @param id 节点 ID
|
|
269
359
|
*/
|
|
270
|
-
const dragProps = {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
360
|
+
const dragProps = (id: string, behavior?: DragBehavior) => {
|
|
361
|
+
if (behavior) {
|
|
362
|
+
behaviors.set(id, behavior)
|
|
363
|
+
}
|
|
364
|
+
const canDrag = behavior?.draggable === false ? false : !isDisabledById(id)
|
|
365
|
+
return {
|
|
366
|
+
draggable: canDrag,
|
|
367
|
+
onDragstart: (e: DragEvent) => {
|
|
368
|
+
if (canDrag) {
|
|
369
|
+
e.dataTransfer?.setData('text/plain', id)
|
|
370
|
+
onDragStart(id)
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
onDragover: (e: DragEvent) => {
|
|
374
|
+
e.preventDefault()
|
|
375
|
+
const el = e.currentTarget as HTMLElement
|
|
285
376
|
onDragOver(id, el, e)
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
onDragleave: (e: DragEvent) => {
|
|
289
|
-
const el = e.currentTarget as HTMLElement
|
|
290
|
-
const id = el?.dataset?.id
|
|
291
|
-
if (id) {
|
|
377
|
+
},
|
|
378
|
+
onDragleave: (_e: DragEvent) => {
|
|
292
379
|
onDragLeave(id)
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
e.preventDefault()
|
|
297
|
-
const el = e.currentTarget as HTMLElement
|
|
298
|
-
const id = el?.dataset?.id
|
|
299
|
-
if (id) {
|
|
380
|
+
},
|
|
381
|
+
onDrop: (e: DragEvent) => {
|
|
382
|
+
e.preventDefault()
|
|
300
383
|
onDrop(id)
|
|
301
|
-
}
|
|
302
|
-
}
|
|
384
|
+
},
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const ghostStyle = (opts?: { padding?: boolean, margin?: boolean }) => {
|
|
389
|
+
if (!draggingId || ghostPos === undefined) {
|
|
390
|
+
return { style: { display: 'none' } }
|
|
391
|
+
}
|
|
392
|
+
const usePadding = opts?.padding ?? true
|
|
393
|
+
const useMargin = opts?.margin ?? true
|
|
394
|
+
const leftInset = usePadding ? ghostInsetLeft : 0
|
|
395
|
+
const rightInset = usePadding ? ghostInsetRight : 0
|
|
396
|
+
const leftMargin = useMargin ? ghostMarginLeft : 0
|
|
397
|
+
const rightMargin = useMargin ? ghostMarginRight : 0
|
|
398
|
+
const left = `${leftInset + leftMargin}px`
|
|
399
|
+
const right = `${rightInset + rightMargin}px`
|
|
400
|
+
const base: Record<string, any> = { position: 'absolute', left, right, pointerEvents: 'none' }
|
|
401
|
+
if (ghostPos === DropPosition.BEFORE) {
|
|
402
|
+
return { 'style': { ...base, top: `${ghostTop}px`, height: '2px', background: 'var(--vp-code-color)', borderRadius: '2px' }, 'data-drop-position': -1 }
|
|
403
|
+
}
|
|
404
|
+
if (ghostPos === DropPosition.AFTER) {
|
|
405
|
+
return { 'style': { ...base, top: `${ghostTop + ghostHeight}px`, height: '2px', background: 'var(--vp-code-color)', borderRadius: '2px' }, 'data-drop-position': 1 }
|
|
406
|
+
}
|
|
407
|
+
return { 'style': { ...base, top: `${ghostTop}px`, height: `${ghostHeight}px`, background: 'var(--vp-code-color)', opacity: 0.15, borderRadius: '4px' }, 'data-drop-position': 0 }
|
|
303
408
|
}
|
|
304
409
|
|
|
305
410
|
return {
|
|
@@ -317,6 +422,7 @@ export const dnd: SpliceTreePlugin = {
|
|
|
317
422
|
},
|
|
318
423
|
// 通用 DOM 绑定集合
|
|
319
424
|
dragProps,
|
|
425
|
+
ghostStyle,
|
|
320
426
|
}
|
|
321
427
|
},
|
|
322
428
|
/**
|
|
@@ -327,7 +433,11 @@ export const dnd: SpliceTreePlugin = {
|
|
|
327
433
|
extendNode(node, ctx) {
|
|
328
434
|
node.getDropPosition = () => ctx.tree.hoverPositions.get(node.id)
|
|
329
435
|
node.isDragging = () => ctx.tree.draggingId === node.id
|
|
436
|
+
const opts = (ctx.options?.configuration?.dnd ?? {}) as DndOptions
|
|
437
|
+
node.isDisabled = () => !!opts.readonly
|
|
330
438
|
},
|
|
331
439
|
}
|
|
332
440
|
|
|
333
441
|
export default dnd
|
|
442
|
+
export { DropPosition }
|
|
443
|
+
export type { DndNode, DndOptions }
|
package/src/position.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SpliceTreeNode } from '@splicetree/core'
|
|
2
|
+
import { DropPosition } from './types'
|
|
3
|
+
|
|
4
|
+
export function computePosition(targetId: string, el: HTMLElement, e: DragEvent | MouseEvent, src?: SpliceTreeNode<any>): DropPosition | undefined {
|
|
5
|
+
const rect = el.getBoundingClientRect()
|
|
6
|
+
const y = ('clientY' in e ? e.clientY : 0) - rect.top
|
|
7
|
+
const ratio = Math.max(0, Math.min(1, rect.height ? y / rect.height : 0))
|
|
8
|
+
if (ratio < 0.33) {
|
|
9
|
+
return DropPosition.BEFORE
|
|
10
|
+
}
|
|
11
|
+
if (ratio > 0.66) {
|
|
12
|
+
return DropPosition.AFTER
|
|
13
|
+
}
|
|
14
|
+
if (src?.id === targetId) {
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
const parentId = src?.getParent()?.id
|
|
18
|
+
if (parentId === targetId) {
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
return DropPosition.INSIDE
|
|
22
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** dnd types */
|
|
2
|
+
|
|
3
|
+
export interface DndNode {
|
|
4
|
+
id: string
|
|
5
|
+
level: number
|
|
6
|
+
parentId?: string
|
|
7
|
+
root?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DragBehavior {
|
|
11
|
+
draggable?: boolean
|
|
12
|
+
sortable?: boolean
|
|
13
|
+
nestable?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export enum DropPosition {
|
|
17
|
+
BEFORE = -1,
|
|
18
|
+
INSIDE = 0,
|
|
19
|
+
AFTER = 1,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DndOptions {
|
|
23
|
+
/** 执行拖拽后是否自动写回父字段并同步树结构 */
|
|
24
|
+
autoUpdateParent?: boolean
|
|
25
|
+
/** 拖入为子节点后是否自动展开目标节点 */
|
|
26
|
+
autoExpandOnDrop?: boolean
|
|
27
|
+
/** 全局只读,禁用所有拖拽与排序 */
|
|
28
|
+
readonly?: boolean
|
|
29
|
+
}
|