@splicetree/plugin-dnd 0.0.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 +25 -0
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +208 -0
- package/package.json +32 -0
- package/src/index.ts +340 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +11 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# @splicetree/plugin-dnd
|
|
2
|
+
|
|
3
|
+
## 0.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- chore: 搭建 SpliceTree 框架基础架构与工程体系
|
|
8
|
+
|
|
9
|
+
框架架构:
|
|
10
|
+
- 建立无头树(Headless Tree)核心模型与数据结构
|
|
11
|
+
- 定义节点操作 API、事件系统和状态管理机制
|
|
12
|
+
- 设计并实现插件体系(生命周期、注册机制、能力扩展)
|
|
13
|
+
- 引入模块化架构,明确 core / plugins / adapters 的边界
|
|
14
|
+
- 预留扩展点与内部协议,构建可插拔式架构基础
|
|
15
|
+
|
|
16
|
+
适配层与生态:
|
|
17
|
+
- 添加 Vue 3 适配层(渲染无关、纯接口绑定)
|
|
18
|
+
- 设计独立的 UI 层解耦策略,确保跨框架可迁移
|
|
19
|
+
- 预留未来 React/Svelte/WebComponents 的适配接口
|
|
20
|
+
|
|
21
|
+
工程化与工具链:
|
|
22
|
+
- 初始化 monorepo 工程结构(packages + docs)
|
|
23
|
+
- 配置构建工具链 tsdown(多包构建、类型输出)
|
|
24
|
+
- 构建文档系统(VitePress)与基础导航结构
|
|
25
|
+
- 设置开发环境、代码规范(ESLint)、格式化流程
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Michael Cocova
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @splicetree/plugin-dnd
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@splicetree/plugin-dnd)
|
|
4
|
+
[](https://npmcharts.com/compare/%40splicetree%2Fplugin-dnd?minimal=true)
|
|
5
|
+
[](https://www.npmjs.com/package/@splicetree/plugin-dnd)
|
|
6
|
+
[](https://www.splicetree.dev)
|
|
7
|
+
[](https://github.com/michaelcocova/splicetree)
|
|
8
|
+
|
|
9
|
+
为树节点提供拖拽移动能力,支持插入到目标之前、之后或作为子节点。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
`pnpm add @splicetree/plugin-dnd`
|
|
14
|
+
|
|
15
|
+
## 使用
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createSpliceTree } from '@splicetree/core'
|
|
19
|
+
import dnd from '@splicetree/plugin-dnd'
|
|
20
|
+
|
|
21
|
+
const tree = createSpliceTree(data, {
|
|
22
|
+
plugins: [dnd],
|
|
23
|
+
})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Api
|
|
27
|
+
|
|
28
|
+
### Options
|
|
29
|
+
|
|
30
|
+
| 选项 | 类型 | 默认值 | 说明 |
|
|
31
|
+
| ------------------ | --------- | ------ | --------------------------------------------------------- |
|
|
32
|
+
| `autoUpdateParent` | `boolean` | `true` | 拖拽后自动更新源节点的父字段;为 `false` 时不写回且不移动 |
|
|
33
|
+
| `autoExpandOnDrop` | `boolean` | `true` | 拖入后自动展开目标节点 |
|
|
34
|
+
|
|
35
|
+
### Events
|
|
36
|
+
|
|
37
|
+
| 事件 | 负载 | 说明 |
|
|
38
|
+
| ------ | ------------------------------------------------------------------------------ | -------- |
|
|
39
|
+
| `move` | `{ id: string, parentId?: string, position: DropPosition, beforeId?: string }` | 节点移动 |
|
|
40
|
+
|
|
41
|
+
### 实例方法
|
|
42
|
+
|
|
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` | `无` | DOM 事件绑定集合 |
|
|
53
|
+
|
|
54
|
+
### 节点方法
|
|
55
|
+
|
|
56
|
+
| 名称 | 参数 | 说明 |
|
|
57
|
+
| ------------------- | ---- | -------------------- |
|
|
58
|
+
| `getDropPosition()` | `无` | 当前节点的悬停位置 |
|
|
59
|
+
| `isDragging()` | `无` | 当前节点是否为拖拽源 |
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { SpliceTreePlugin } from "@splicetree/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 拖拽落点位置
|
|
7
|
+
* - BEFORE: 目标之前插入
|
|
8
|
+
* - INSIDE: 作为目标的子节点插入
|
|
9
|
+
* - AFTER: 目标之后插入
|
|
10
|
+
*/
|
|
11
|
+
declare enum DropPosition {
|
|
12
|
+
BEFORE = -1,
|
|
13
|
+
INSIDE = 0,
|
|
14
|
+
AFTER = 1,
|
|
15
|
+
}
|
|
16
|
+
declare module '@splicetree/core' {
|
|
17
|
+
/**
|
|
18
|
+
* 插件配置项扩展(DnD)
|
|
19
|
+
*/
|
|
20
|
+
interface UseSpliceTreeOptions {
|
|
21
|
+
/**
|
|
22
|
+
* 自动更新父节点
|
|
23
|
+
* 如果为 false,则不会自动更新父节点
|
|
24
|
+
* 需要监听 move 事件,手动更新父节点
|
|
25
|
+
* @default true
|
|
26
|
+
*/
|
|
27
|
+
autoUpdateParent?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* 拖入后自动展开目标节点
|
|
30
|
+
* @default true
|
|
31
|
+
*/
|
|
32
|
+
autoExpandOnDrop?: boolean;
|
|
33
|
+
}
|
|
34
|
+
interface SpliceTreeEventPayloadMap {
|
|
35
|
+
/**
|
|
36
|
+
* 节点移动事件负载
|
|
37
|
+
* @property id 源节点 id
|
|
38
|
+
* @property parentId 新父级节点 id(INSIDE/BEFORE/AFTER 场景下可能不同)
|
|
39
|
+
* @property position 落点位置(BEFORE/INSIDE/AFTER)
|
|
40
|
+
* @property beforeId 插入到谁之前(AFTER/BEFORE 场景下提供)
|
|
41
|
+
*/
|
|
42
|
+
move: {
|
|
43
|
+
id: string;
|
|
44
|
+
parentId?: string;
|
|
45
|
+
position: DropPosition;
|
|
46
|
+
beforeId?: string;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
interface SpliceTreeInstance {
|
|
50
|
+
/**
|
|
51
|
+
* 执行拖拽移动
|
|
52
|
+
* @param srcId 源节点 id
|
|
53
|
+
* @param targetId 目标节点 id
|
|
54
|
+
* @param position 落点位置(前/内/后)
|
|
55
|
+
*/
|
|
56
|
+
drop: (srcId: string, targetId: string, position: DropPosition) => void;
|
|
57
|
+
/**
|
|
58
|
+
* 当前拖拽源节点 id
|
|
59
|
+
*/
|
|
60
|
+
draggingId?: string;
|
|
61
|
+
/**
|
|
62
|
+
* 目标节点的悬停位置映射
|
|
63
|
+
*/
|
|
64
|
+
hoverPositions: Map<string, DropPosition>;
|
|
65
|
+
/**
|
|
66
|
+
* DOM 事件:开始拖拽
|
|
67
|
+
*/
|
|
68
|
+
onDragStart: (id: string) => void;
|
|
69
|
+
/**
|
|
70
|
+
* DOM 事件:悬停计算并更新位置
|
|
71
|
+
*/
|
|
72
|
+
onDragOver: (id: string, el: HTMLElement, e: DragEvent | MouseEvent) => void;
|
|
73
|
+
/**
|
|
74
|
+
* DOM 事件:离开目标,清理悬停状态
|
|
75
|
+
*/
|
|
76
|
+
onDragLeave: (id: string) => void;
|
|
77
|
+
/**
|
|
78
|
+
* DOM 事件:在目标上释放后执行移动
|
|
79
|
+
*/
|
|
80
|
+
onDrop: (targetId: string) => void;
|
|
81
|
+
/**
|
|
82
|
+
* 可直接 v-bind 到节点的拖拽属性集合
|
|
83
|
+
*/
|
|
84
|
+
dragProps: {
|
|
85
|
+
/**
|
|
86
|
+
* 是否可拖拽
|
|
87
|
+
*/
|
|
88
|
+
draggable: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* 原生拖拽开始事件处理
|
|
91
|
+
*/
|
|
92
|
+
onDragstart: (e: DragEvent) => void;
|
|
93
|
+
/**
|
|
94
|
+
* 原生拖拽悬停事件处理
|
|
95
|
+
*/
|
|
96
|
+
onDragover: (e: DragEvent) => void;
|
|
97
|
+
/**
|
|
98
|
+
* 原生拖拽离开事件处理
|
|
99
|
+
*/
|
|
100
|
+
onDragleave: (e: DragEvent) => void;
|
|
101
|
+
/**
|
|
102
|
+
* 原生拖拽释放事件处理
|
|
103
|
+
*/
|
|
104
|
+
onDrop: (e: DragEvent) => void;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 节点扩展(DnD)
|
|
109
|
+
*/
|
|
110
|
+
interface SpliceTreeNode {
|
|
111
|
+
/**
|
|
112
|
+
* 获取当前节点的悬停落点位置
|
|
113
|
+
*/
|
|
114
|
+
getDropPosition: () => (DropPosition | undefined);
|
|
115
|
+
/**
|
|
116
|
+
* 当前节点是否为拖拽源
|
|
117
|
+
*/
|
|
118
|
+
isDragging: () => boolean;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
declare const dnd: SpliceTreePlugin;
|
|
122
|
+
//#endregion
|
|
123
|
+
export { DropPosition, dnd as default, dnd };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* 拖拽落点位置
|
|
4
|
+
* - BEFORE: 目标之前插入
|
|
5
|
+
* - INSIDE: 作为目标的子节点插入
|
|
6
|
+
* - AFTER: 目标之后插入
|
|
7
|
+
*/
|
|
8
|
+
let DropPosition = /* @__PURE__ */ function(DropPosition$1) {
|
|
9
|
+
DropPosition$1[DropPosition$1["BEFORE"] = -1] = "BEFORE";
|
|
10
|
+
DropPosition$1[DropPosition$1["INSIDE"] = 0] = "INSIDE";
|
|
11
|
+
DropPosition$1[DropPosition$1["AFTER"] = 1] = "AFTER";
|
|
12
|
+
return DropPosition$1;
|
|
13
|
+
}({});
|
|
14
|
+
const dnd = {
|
|
15
|
+
name: "dnd",
|
|
16
|
+
setup(ctx) {
|
|
17
|
+
const { autoUpdateParent = true, autoExpandOnDrop = true } = ctx.tree.options || {};
|
|
18
|
+
const parentField = ctx.tree.options?.parentField ?? "parent";
|
|
19
|
+
let draggingId;
|
|
20
|
+
const hoverPositions = /* @__PURE__ */ new Map();
|
|
21
|
+
/**
|
|
22
|
+
* 开始拖拽:记录拖拽源并通知视图刷新(用于高亮拖拽源)
|
|
23
|
+
* @param id 源节点 id
|
|
24
|
+
*/
|
|
25
|
+
const onDragStart = (id) => {
|
|
26
|
+
draggingId = id;
|
|
27
|
+
ctx.events.emit({
|
|
28
|
+
name: "visibility",
|
|
29
|
+
keys: ctx.tree.expandedKeys()
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* 依据指针相对目标节点的垂直比例计算落点
|
|
34
|
+
* - 上 1/3:BEFORE;下 1/3:AFTER;中间:INSIDE
|
|
35
|
+
* - 过滤自身与其父级为目标的情况(返回 undefined)
|
|
36
|
+
* @param id 目标节点 id
|
|
37
|
+
* @param el 目标节点元素
|
|
38
|
+
* @param e 拖拽/鼠标事件
|
|
39
|
+
*/
|
|
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
|
+
/**
|
|
51
|
+
* 悬停:更新目标的悬停位置映射并刷新视图
|
|
52
|
+
* @param id 目标节点 id
|
|
53
|
+
* @param el 目标节点元素
|
|
54
|
+
* @param e 拖拽/鼠标事件
|
|
55
|
+
*/
|
|
56
|
+
const onDragOver = (id, el, e) => {
|
|
57
|
+
const pos = computePosition(id, el, e);
|
|
58
|
+
if (pos === void 0) hoverPositions.delete(id);
|
|
59
|
+
else hoverPositions.set(id, pos);
|
|
60
|
+
ctx.events.emit({
|
|
61
|
+
name: "visibility",
|
|
62
|
+
keys: ctx.tree.expandedKeys()
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* 离开目标:清理该目标的悬停位置并刷新视图
|
|
67
|
+
* @param id 目标节点 id
|
|
68
|
+
*/
|
|
69
|
+
const onDragLeave = (id) => {
|
|
70
|
+
hoverPositions.delete(id);
|
|
71
|
+
ctx.events.emit({
|
|
72
|
+
name: "visibility",
|
|
73
|
+
keys: ctx.tree.expandedKeys()
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* 执行移动:依据落点位置更新树结构,必要时写回父字段并派发事件
|
|
78
|
+
* - INSIDE:成为子节点;可选自动展开目标
|
|
79
|
+
* - BEFORE/AFTER:保持同父级,调整顺序
|
|
80
|
+
* @param srcId 源节点 id
|
|
81
|
+
* @param targetId 目标节点 id
|
|
82
|
+
* @param position 落点位置
|
|
83
|
+
*/
|
|
84
|
+
const drop = (srcId, targetId, position) => {
|
|
85
|
+
const src = ctx.tree.getNode(srcId);
|
|
86
|
+
const target = ctx.tree.getNode(targetId);
|
|
87
|
+
if (!src || !target) return;
|
|
88
|
+
if (srcId === targetId) return;
|
|
89
|
+
const srcParentId = src.getParent()?.id;
|
|
90
|
+
if (position === DropPosition.INSIDE) {
|
|
91
|
+
if (targetId === srcParentId) return;
|
|
92
|
+
if (autoUpdateParent) {
|
|
93
|
+
ctx.tree.moveNode(srcId, targetId);
|
|
94
|
+
Reflect.set(src.original, parentField, targetId);
|
|
95
|
+
if (autoExpandOnDrop) ctx.tree.expand(targetId);
|
|
96
|
+
ctx.events.emit({
|
|
97
|
+
name: "visibility",
|
|
98
|
+
keys: ctx.tree.expandedKeys()
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
ctx.events.emit({
|
|
102
|
+
name: "move",
|
|
103
|
+
id: srcId,
|
|
104
|
+
parentId: targetId,
|
|
105
|
+
position
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const parentId = target.getParent()?.id;
|
|
110
|
+
if (position === DropPosition.BEFORE) {
|
|
111
|
+
if (autoUpdateParent) {
|
|
112
|
+
ctx.tree.moveNode(srcId, parentId, targetId);
|
|
113
|
+
Reflect.set(src.original, parentField, parentId);
|
|
114
|
+
ctx.events.emit({
|
|
115
|
+
name: "visibility",
|
|
116
|
+
keys: ctx.tree.expandedKeys()
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
ctx.events.emit({
|
|
120
|
+
name: "move",
|
|
121
|
+
id: srcId,
|
|
122
|
+
parentId,
|
|
123
|
+
position,
|
|
124
|
+
beforeId: targetId
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const siblings = parentId ? ctx.tree.getNode(parentId).getChildren() : ctx.tree.items().filter((n) => !n.getParent());
|
|
129
|
+
const afterSibling = siblings[siblings.findIndex((n) => n.id === targetId) + 1]?.id;
|
|
130
|
+
if (autoUpdateParent) {
|
|
131
|
+
ctx.tree.moveNode(srcId, parentId, afterSibling);
|
|
132
|
+
Reflect.set(src.original, parentField, parentId);
|
|
133
|
+
ctx.events.emit({
|
|
134
|
+
name: "visibility",
|
|
135
|
+
keys: ctx.tree.expandedKeys()
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
ctx.events.emit({
|
|
139
|
+
name: "move",
|
|
140
|
+
id: srcId,
|
|
141
|
+
parentId,
|
|
142
|
+
position,
|
|
143
|
+
beforeId: afterSibling
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* 完成拖拽:依据最后一次悬停位置执行移动并清理状态
|
|
148
|
+
* @param targetId 释放时所处的目标节点 id
|
|
149
|
+
*/
|
|
150
|
+
const onDrop = (targetId) => {
|
|
151
|
+
if (!draggingId) return;
|
|
152
|
+
const pos = hoverPositions.get(targetId);
|
|
153
|
+
if (pos === DropPosition.BEFORE) drop(draggingId, targetId, DropPosition.BEFORE);
|
|
154
|
+
else if (pos === DropPosition.AFTER) drop(draggingId, targetId, DropPosition.AFTER);
|
|
155
|
+
else if (pos === DropPosition.INSIDE) drop(draggingId, targetId, DropPosition.INSIDE);
|
|
156
|
+
hoverPositions.clear();
|
|
157
|
+
draggingId = void 0;
|
|
158
|
+
ctx.events.emit({
|
|
159
|
+
name: "visibility",
|
|
160
|
+
keys: ctx.tree.expandedKeys()
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
drop,
|
|
165
|
+
onDragStart,
|
|
166
|
+
onDragOver,
|
|
167
|
+
onDragLeave,
|
|
168
|
+
onDrop,
|
|
169
|
+
hoverPositions,
|
|
170
|
+
get draggingId() {
|
|
171
|
+
return draggingId;
|
|
172
|
+
},
|
|
173
|
+
dragProps: {
|
|
174
|
+
draggable: true,
|
|
175
|
+
onDragstart: (e) => {
|
|
176
|
+
const id = e.currentTarget?.dataset?.id;
|
|
177
|
+
if (id) {
|
|
178
|
+
e.dataTransfer?.setData("text/plain", id);
|
|
179
|
+
onDragStart(id);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
onDragover: (e) => {
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
const el = e.currentTarget;
|
|
185
|
+
const id = el?.dataset?.id;
|
|
186
|
+
if (id) onDragOver(id, el, e);
|
|
187
|
+
},
|
|
188
|
+
onDragleave: (e) => {
|
|
189
|
+
const id = e.currentTarget?.dataset?.id;
|
|
190
|
+
if (id) onDragLeave(id);
|
|
191
|
+
},
|
|
192
|
+
onDrop: (e) => {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
const id = e.currentTarget?.dataset?.id;
|
|
195
|
+
if (id) onDrop(id);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
extendNode(node, ctx) {
|
|
201
|
+
node.getDropPosition = () => ctx.tree.hoverPositions.get(node.id);
|
|
202
|
+
node.isDragging = () => ctx.tree.draggingId === node.id;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
var src_default = dnd;
|
|
206
|
+
|
|
207
|
+
//#endregion
|
|
208
|
+
export { DropPosition, src_default as default, dnd };
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@splicetree/plugin-dnd",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"author": {
|
|
6
|
+
"email": "michael.cocova@gmail.com",
|
|
7
|
+
"name": "Michael Cocova"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"homepage": "https://www.splicetree.dev",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/michaelcocova/splicetree"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./dist/index.js",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"module": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@splicetree/core": "0.0.1"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "tsdown --watch",
|
|
30
|
+
"build": "tsdown"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type { SpliceTreePlugin, SpliceTreePluginContext } from '@splicetree/core'
|
|
2
|
+
import '@splicetree/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 拖拽落点位置
|
|
6
|
+
* - BEFORE: 目标之前插入
|
|
7
|
+
* - INSIDE: 作为目标的子节点插入
|
|
8
|
+
* - AFTER: 目标之后插入
|
|
9
|
+
*/
|
|
10
|
+
export enum DropPosition {
|
|
11
|
+
BEFORE = -1,
|
|
12
|
+
INSIDE = 0,
|
|
13
|
+
AFTER = 1,
|
|
14
|
+
}
|
|
15
|
+
declare module '@splicetree/core' {
|
|
16
|
+
/**
|
|
17
|
+
* 插件配置项扩展(DnD)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface UseSpliceTreeOptions {
|
|
21
|
+
/**
|
|
22
|
+
* 自动更新父节点
|
|
23
|
+
* 如果为 false,则不会自动更新父节点
|
|
24
|
+
* 需要监听 move 事件,手动更新父节点
|
|
25
|
+
* @default true
|
|
26
|
+
*/
|
|
27
|
+
autoUpdateParent?: boolean
|
|
28
|
+
/**
|
|
29
|
+
* 拖入后自动展开目标节点
|
|
30
|
+
* @default true
|
|
31
|
+
*/
|
|
32
|
+
autoExpandOnDrop?: boolean
|
|
33
|
+
}
|
|
34
|
+
interface SpliceTreeEventPayloadMap {
|
|
35
|
+
/**
|
|
36
|
+
* 节点移动事件负载
|
|
37
|
+
* @property id 源节点 id
|
|
38
|
+
* @property parentId 新父级节点 id(INSIDE/BEFORE/AFTER 场景下可能不同)
|
|
39
|
+
* @property position 落点位置(BEFORE/INSIDE/AFTER)
|
|
40
|
+
* @property beforeId 插入到谁之前(AFTER/BEFORE 场景下提供)
|
|
41
|
+
*/
|
|
42
|
+
move: { id: string, parentId?: string, position: DropPosition, beforeId?: string }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SpliceTreeInstance {
|
|
46
|
+
/**
|
|
47
|
+
* 执行拖拽移动
|
|
48
|
+
* @param srcId 源节点 id
|
|
49
|
+
* @param targetId 目标节点 id
|
|
50
|
+
* @param position 落点位置(前/内/后)
|
|
51
|
+
*/
|
|
52
|
+
drop: (srcId: string, targetId: string, position: DropPosition) => void
|
|
53
|
+
/**
|
|
54
|
+
* 当前拖拽源节点 id
|
|
55
|
+
*/
|
|
56
|
+
draggingId?: string
|
|
57
|
+
/**
|
|
58
|
+
* 目标节点的悬停位置映射
|
|
59
|
+
*/
|
|
60
|
+
hoverPositions: Map<string, DropPosition>
|
|
61
|
+
/**
|
|
62
|
+
* DOM 事件:开始拖拽
|
|
63
|
+
*/
|
|
64
|
+
onDragStart: (id: string) => void
|
|
65
|
+
/**
|
|
66
|
+
* DOM 事件:悬停计算并更新位置
|
|
67
|
+
*/
|
|
68
|
+
onDragOver: (id: string, el: HTMLElement, e: DragEvent | MouseEvent) => void
|
|
69
|
+
/**
|
|
70
|
+
* DOM 事件:离开目标,清理悬停状态
|
|
71
|
+
*/
|
|
72
|
+
onDragLeave: (id: string) => void
|
|
73
|
+
/**
|
|
74
|
+
* DOM 事件:在目标上释放后执行移动
|
|
75
|
+
*/
|
|
76
|
+
onDrop: (targetId: string) => void
|
|
77
|
+
/**
|
|
78
|
+
* 可直接 v-bind 到节点的拖拽属性集合
|
|
79
|
+
*/
|
|
80
|
+
dragProps: {
|
|
81
|
+
/**
|
|
82
|
+
* 是否可拖拽
|
|
83
|
+
*/
|
|
84
|
+
draggable: boolean
|
|
85
|
+
/**
|
|
86
|
+
* 原生拖拽开始事件处理
|
|
87
|
+
*/
|
|
88
|
+
onDragstart: (e: DragEvent) => void
|
|
89
|
+
/**
|
|
90
|
+
* 原生拖拽悬停事件处理
|
|
91
|
+
*/
|
|
92
|
+
onDragover: (e: DragEvent) => void
|
|
93
|
+
/**
|
|
94
|
+
* 原生拖拽离开事件处理
|
|
95
|
+
*/
|
|
96
|
+
onDragleave: (e: DragEvent) => void
|
|
97
|
+
/**
|
|
98
|
+
* 原生拖拽释放事件处理
|
|
99
|
+
*/
|
|
100
|
+
onDrop: (e: DragEvent) => void
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 节点扩展(DnD)
|
|
106
|
+
*/
|
|
107
|
+
interface SpliceTreeNode {
|
|
108
|
+
/**
|
|
109
|
+
* 获取当前节点的悬停落点位置
|
|
110
|
+
*/
|
|
111
|
+
getDropPosition: () => (DropPosition | undefined)
|
|
112
|
+
/**
|
|
113
|
+
* 当前节点是否为拖拽源
|
|
114
|
+
*/
|
|
115
|
+
isDragging: () => boolean
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const dnd: SpliceTreePlugin = {
|
|
120
|
+
name: 'dnd',
|
|
121
|
+
/**
|
|
122
|
+
* 拖拽插件
|
|
123
|
+
* - 计算目标悬停位置:前(BEFORE)/内(INSIDE)/后(AFTER)
|
|
124
|
+
* - 根据配置自动更新父字段与目标展开
|
|
125
|
+
* - 通过 events 派发 move 与 visibility 事件驱动视图刷新
|
|
126
|
+
*/
|
|
127
|
+
setup(ctx: SpliceTreePluginContext) {
|
|
128
|
+
const { autoUpdateParent = true, autoExpandOnDrop = true } = ctx.tree.options || {}
|
|
129
|
+
const parentField = ctx.tree.options?.parentField ?? 'parent'
|
|
130
|
+
let draggingId: string | undefined
|
|
131
|
+
const hoverPositions = new Map<string, DropPosition>()
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 开始拖拽:记录拖拽源并通知视图刷新(用于高亮拖拽源)
|
|
135
|
+
* @param id 源节点 id
|
|
136
|
+
*/
|
|
137
|
+
const onDragStart = (id: string) => {
|
|
138
|
+
draggingId = id
|
|
139
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 依据指针相对目标节点的垂直比例计算落点
|
|
144
|
+
* - 上 1/3:BEFORE;下 1/3:AFTER;中间:INSIDE
|
|
145
|
+
* - 过滤自身与其父级为目标的情况(返回 undefined)
|
|
146
|
+
* @param id 目标节点 id
|
|
147
|
+
* @param el 目标节点元素
|
|
148
|
+
* @param e 拖拽/鼠标事件
|
|
149
|
+
*/
|
|
150
|
+
const computePosition = (id: string, el: HTMLElement, e: DragEvent | MouseEvent): DropPosition | undefined => {
|
|
151
|
+
const rect = el.getBoundingClientRect()
|
|
152
|
+
const y = ('clientY' in e ? e.clientY : 0) - rect.top
|
|
153
|
+
const ratio = Math.max(0, Math.min(1, y / rect.height))
|
|
154
|
+
if (ratio < 0.33) {
|
|
155
|
+
return DropPosition.BEFORE
|
|
156
|
+
}
|
|
157
|
+
if (ratio > 0.66) {
|
|
158
|
+
return DropPosition.AFTER
|
|
159
|
+
}
|
|
160
|
+
if (draggingId === id) {
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
const parentId = ctx.tree.getNode(draggingId!)?.getParent()?.id
|
|
164
|
+
if (parentId === id) {
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
return DropPosition.INSIDE
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 悬停:更新目标的悬停位置映射并刷新视图
|
|
172
|
+
* @param id 目标节点 id
|
|
173
|
+
* @param el 目标节点元素
|
|
174
|
+
* @param e 拖拽/鼠标事件
|
|
175
|
+
*/
|
|
176
|
+
const onDragOver = (id: string, el: HTMLElement, e: DragEvent | MouseEvent) => {
|
|
177
|
+
const pos = computePosition(id, el, e)
|
|
178
|
+
if (pos === undefined) {
|
|
179
|
+
hoverPositions.delete(id)
|
|
180
|
+
} else {
|
|
181
|
+
hoverPositions.set(id, pos)
|
|
182
|
+
}
|
|
183
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 离开目标:清理该目标的悬停位置并刷新视图
|
|
188
|
+
* @param id 目标节点 id
|
|
189
|
+
*/
|
|
190
|
+
const onDragLeave = (id: string) => {
|
|
191
|
+
hoverPositions.delete(id)
|
|
192
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 执行移动:依据落点位置更新树结构,必要时写回父字段并派发事件
|
|
197
|
+
* - INSIDE:成为子节点;可选自动展开目标
|
|
198
|
+
* - BEFORE/AFTER:保持同父级,调整顺序
|
|
199
|
+
* @param srcId 源节点 id
|
|
200
|
+
* @param targetId 目标节点 id
|
|
201
|
+
* @param position 落点位置
|
|
202
|
+
*/
|
|
203
|
+
const drop = (srcId: string, targetId: string, position: DropPosition) => {
|
|
204
|
+
const src = ctx.tree.getNode(srcId)
|
|
205
|
+
const target = ctx.tree.getNode(targetId)
|
|
206
|
+
if (!src || !target) {
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
if (srcId === targetId) {
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
const srcParentId = src.getParent()?.id
|
|
213
|
+
|
|
214
|
+
if (position === DropPosition.INSIDE) {
|
|
215
|
+
if (targetId === srcParentId) {
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (autoUpdateParent) {
|
|
219
|
+
ctx.tree.moveNode(srcId, targetId)
|
|
220
|
+
Reflect.set(src.original, parentField, targetId)
|
|
221
|
+
if (autoExpandOnDrop) {
|
|
222
|
+
ctx.tree.expand(targetId)
|
|
223
|
+
}
|
|
224
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
225
|
+
}
|
|
226
|
+
ctx.events.emit({ name: 'move', id: srcId, parentId: targetId, position })
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const parent = target.getParent()
|
|
231
|
+
const parentId = parent?.id
|
|
232
|
+
if (position === DropPosition.BEFORE) {
|
|
233
|
+
if (autoUpdateParent) {
|
|
234
|
+
ctx.tree.moveNode(srcId, parentId, targetId)
|
|
235
|
+
Reflect.set(src.original, parentField, parentId)
|
|
236
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
237
|
+
}
|
|
238
|
+
ctx.events.emit({ name: 'move', id: srcId, parentId, position, beforeId: targetId })
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const siblings = parentId ? ctx.tree.getNode(parentId)!.getChildren() : ctx.tree.items().filter(n => !n.getParent())
|
|
243
|
+
const idx = siblings.findIndex(n => n.id === targetId)
|
|
244
|
+
const afterSibling = siblings[idx + 1]?.id
|
|
245
|
+
if (autoUpdateParent) {
|
|
246
|
+
ctx.tree.moveNode(srcId, parentId, afterSibling)
|
|
247
|
+
Reflect.set(src.original, parentField, parentId)
|
|
248
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
249
|
+
}
|
|
250
|
+
ctx.events.emit({ name: 'move', id: srcId, parentId, position, beforeId: afterSibling })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 完成拖拽:依据最后一次悬停位置执行移动并清理状态
|
|
255
|
+
* @param targetId 释放时所处的目标节点 id
|
|
256
|
+
*/
|
|
257
|
+
const onDrop = (targetId: string) => {
|
|
258
|
+
if (!draggingId) {
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
const pos = hoverPositions.get(targetId)
|
|
262
|
+
if (pos === DropPosition.BEFORE) {
|
|
263
|
+
drop(draggingId, targetId, DropPosition.BEFORE)
|
|
264
|
+
} else if (pos === DropPosition.AFTER) {
|
|
265
|
+
drop(draggingId, targetId, DropPosition.AFTER)
|
|
266
|
+
} else if (pos === DropPosition.INSIDE) {
|
|
267
|
+
drop(draggingId, targetId, DropPosition.INSIDE)
|
|
268
|
+
}
|
|
269
|
+
hoverPositions.clear()
|
|
270
|
+
draggingId = undefined
|
|
271
|
+
ctx.events.emit({ name: 'visibility', keys: ctx.tree.expandedKeys() })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 绑定到节点的拖拽属性集,便于直接 v-bind
|
|
276
|
+
*/
|
|
277
|
+
const dragProps = {
|
|
278
|
+
draggable: true,
|
|
279
|
+
onDragstart: (e: DragEvent) => {
|
|
280
|
+
const el = e.currentTarget as HTMLElement
|
|
281
|
+
const id = el?.dataset?.id
|
|
282
|
+
if (id) {
|
|
283
|
+
e.dataTransfer?.setData('text/plain', id)
|
|
284
|
+
onDragStart(id)
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
onDragover: (e: DragEvent) => {
|
|
288
|
+
e.preventDefault()
|
|
289
|
+
const el = e.currentTarget as HTMLElement
|
|
290
|
+
const id = el?.dataset?.id
|
|
291
|
+
if (id) {
|
|
292
|
+
onDragOver(id, el, e)
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
onDragleave: (e: DragEvent) => {
|
|
296
|
+
const el = e.currentTarget as HTMLElement
|
|
297
|
+
const id = el?.dataset?.id
|
|
298
|
+
if (id) {
|
|
299
|
+
onDragLeave(id)
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
onDrop: (e: DragEvent) => {
|
|
303
|
+
e.preventDefault()
|
|
304
|
+
const el = e.currentTarget as HTMLElement
|
|
305
|
+
const id = el?.dataset?.id
|
|
306
|
+
if (id) {
|
|
307
|
+
onDrop(id)
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
// 公开 API:直接编程操作拖拽移动
|
|
314
|
+
drop,
|
|
315
|
+
// 事件处理:用于绑定到节点的 DOM 上
|
|
316
|
+
onDragStart,
|
|
317
|
+
onDragOver,
|
|
318
|
+
onDragLeave,
|
|
319
|
+
onDrop,
|
|
320
|
+
// 状态与查询:悬停位置与拖拽源
|
|
321
|
+
hoverPositions,
|
|
322
|
+
get draggingId() {
|
|
323
|
+
return draggingId
|
|
324
|
+
},
|
|
325
|
+
// 通用 DOM 绑定集合
|
|
326
|
+
dragProps,
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
/**
|
|
330
|
+
* 为节点扩展拖拽相关方法
|
|
331
|
+
* - getDropPosition:查询当前节点的悬停位置
|
|
332
|
+
* - isDragging:查询是否为当前拖拽源
|
|
333
|
+
*/
|
|
334
|
+
extendNode(node, ctx) {
|
|
335
|
+
node.getDropPosition = () => ctx.tree.hoverPositions.get(node.id)
|
|
336
|
+
node.isDragging = () => ctx.tree.draggingId === node.id
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export default dnd
|
package/tsconfig.json
ADDED