@splicetree/plugin-dnd 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @splicetree/plugin-dnd
2
2
 
3
+ ## 1.0.0
4
+
5
+ ## 0.3.0
6
+
7
+ ### Minor Changes
8
+
9
+ - 多节点拖拽增强:
10
+ - 当拖拽源节点属于当前选择集合时,启用“组拖拽”,保持被选节点的相对顺序。
11
+ - 悬停与释放阶段同时校验整组节点的 draggable / nestable / sortable 规则;任一节点不满足则整组操作禁用。
12
+ - 防止将节点拖入自身或其后代,确保结构合法性。
13
+ - 在 AFTER 场景按逆序处理,被选节点相对顺序保持正确。
14
+
15
+ 影响与迁移:
16
+ - 若需要为部分节点放开“入内/前后排序”,请在 v-bind 拖拽行为中设置 nestable / sortable。
17
+
3
18
  ## 0.2.0
4
19
 
5
20
  ## 0.1.1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @splicetree/plugin-dnd
2
2
 
3
- 为树节点提供拖拽移动能力,支持插入到目标之前、之后或作为子节点。
3
+ 为树节点提供拖拽移动能力,支持插入到目标之前、之后或作为子节点。新增工程化的规则引擎,可精细控制「谁可以拖」「拖到哪里」「允许的落点(前/内/后)」。
4
4
 
5
5
  [![version](https://img.shields.io/npm/v/@splicetree/plugin-dnd.svg?label=version)](https://www.npmjs.com/package/@splicetree/plugin-dnd)
6
6
  [![downloads](https://img.shields.io/npm/dm/@splicetree/plugin-dnd.svg)](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` | `true` | 拖拽后自动更新源节点的父字段;为 `false` 时不写回且不移动 |
33
- | `autoExpandOnDrop` | `boolean` | `true` | 拖入后自动展开目标节点 |
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` | `无` | DOM 事件绑定集合 |
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/index.d.ts
4
-
5
- /**
6
- * 拖拽落点位置
7
- * - BEFORE: 目标之前插入
8
- * - INSIDE: 作为目标的子节点插入
9
- * - AFTER: 目标之后插入
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
- * 可直接 v-bind 到节点的拖拽属性集合
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/index.ts
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 { autoUpdateParent = true, autoExpandOnDrop = true } = ctx.options?.configuration?.dnd ?? {};
18
- const parentField = ctx.tree.options?.configuration?.parentField ?? ctx.tree.options?.parentField ?? "parent";
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
- const pos = computePosition(id, el, e);
58
- if (pos === void 0) hoverPositions.delete(id);
59
- else hoverPositions.set(id, pos);
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 === DropPosition.BEFORE) drop(draggingId, targetId, DropPosition.BEFORE);
155
- else if (pos === DropPosition.AFTER) drop(draggingId, targetId, DropPosition.AFTER);
156
- else if (pos === DropPosition.INSIDE) drop(draggingId, targetId, DropPosition.INSIDE);
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
- return {
165
- drop,
166
- onDragStart,
167
- onDragOver,
168
- onDragLeave,
169
- onDrop,
170
- hoverPositions,
171
- get draggingId() {
172
- return draggingId;
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
- const id = e.currentTarget?.dataset?.id;
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
- const id = el?.dataset?.id;
187
- if (id) onDragOver(id, el, e);
297
+ onDragOver(id, el, e);
188
298
  },
189
- onDragleave: (e) => {
190
- const id = e.currentTarget?.dataset?.id;
191
- if (id) onDragLeave(id);
299
+ onDragleave: (_e) => {
300
+ onDragLeave(id);
192
301
  },
193
302
  onDrop: (e) => {
194
303
  e.preventDefault();
195
- const id = e.currentTarget?.dataset?.id;
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.2.0",
4
+ "version": "1.0.0",
5
5
  "author": {
6
6
  "email": "michael.cocova@gmail.com",
7
7
  "name": "Michael Cocova"
@@ -23,7 +23,7 @@
23
23
  "access": "public"
24
24
  },
25
25
  "devDependencies": {
26
- "@splicetree/core": "0.2.0"
26
+ "@splicetree/core": "1.0.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
- * 可直接 v-bind 到节点的拖拽属性集合
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 cfg = (ctx.options?.configuration?.dnd ?? {}) as {
117
- autoUpdateParent?: boolean
118
- autoExpandOnDrop?: boolean
119
- }
120
- const { autoUpdateParent = true, autoExpandOnDrop = true } = cfg
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
- const computePosition = (id: string, el: HTMLElement, e: DragEvent | MouseEvent): DropPosition | undefined => {
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
- const pos = computePosition(id, el, e)
170
- if (pos === undefined) {
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
- hoverPositions.set(id, pos)
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 === DropPosition.BEFORE) {
256
- drop(draggingId, targetId, DropPosition.BEFORE)
257
- } else if (pos === DropPosition.AFTER) {
258
- drop(draggingId, targetId, DropPosition.AFTER)
259
- } else if (pos === DropPosition.INSIDE) {
260
- drop(draggingId, targetId, DropPosition.INSIDE)
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
- draggable: true,
272
- onDragstart: (e: DragEvent) => {
273
- const el = e.currentTarget as HTMLElement
274
- const id = el?.dataset?.id
275
- if (id) {
276
- e.dataTransfer?.setData('text/plain', id)
277
- onDragStart(id)
278
- }
279
- },
280
- onDragover: (e: DragEvent) => {
281
- e.preventDefault()
282
- const el = e.currentTarget as HTMLElement
283
- const id = el?.dataset?.id
284
- if (id) {
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
- onDrop: (e: DragEvent) => {
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 }
@@ -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
+ }