@veltra/compositions 1.3.8 → 1.3.9
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/dist/use-dnd/index.d.ts +44 -14
- package/dist/use-dnd/index.js +83 -17
- package/dist/use-dnd/index.js.map +1 -1
- package/dist/use-dnd/merge-view.js +43 -0
- package/dist/use-dnd/merge-view.js.map +1 -0
- package/package.json +2 -2
- package/src/use-dnd/__test__/use-dnd.test.ts +176 -0
- package/src/use-dnd/index.ts +139 -20
- package/src/use-dnd/merge-view.ts +48 -0
package/dist/use-dnd/index.d.ts
CHANGED
|
@@ -1,21 +1,41 @@
|
|
|
1
|
-
import { Ref } from "vue";
|
|
1
|
+
import { MaybeRefOrGetter, Ref } from "vue";
|
|
2
2
|
import { BaseDragState, DNDPlugin, DNDPluginData, DragState, DragendEvent, DragendEventData, DragstartEvent, DragstartEventData, DropSwapConfig, InsertConfig, InsertEvent, NativeDragEffects, Node, NodeData, NodeDragEventData, NodeEventData, NodePointerEventData, NodeRecord, NodeTargetData, ParentConfig, ParentData, ParentDragEventData, ParentEventData, ParentRecord, ParentTargetData, PointeroverNodeEvent, PointeroverParentEvent, SetupNode, SetupNodeData, ShouldSwapData, SortEvent, SortEventData, SynthDragState, TearDownNode, TearDownNodeData, TransferEvent, TransferEventData, animations, dragValues, dropOrSwap, insert, isBrowser, isDragState, isSynthDragState, parentValues, performSort, performTransfer, remapNodes, setParentValues, tearDown as tearDown$1, updateConfig } from "@formkit/drag-and-drop";
|
|
3
3
|
import { VueDragAndDropData, VueElement, VueParentConfig, VueParentConfig as VueParentConfig$1, dragAndDrop as dragAndDrop$1, useDragAndDrop } from "@formkit/drag-and-drop/vue";
|
|
4
4
|
//#region src/use-dnd/index.d.ts
|
|
5
5
|
interface UseDnDOptions<T> extends VueParentConfig<T> {
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* 列表数据。排序/跨容器转移后自动写回:
|
|
8
|
+
* - `Ref`:直接替换 `.value`
|
|
9
|
+
* - 响应式数组(如 props 上的数组):原地 splice 写回
|
|
10
|
+
* - 非响应式纯数组:作为初始值由内部响应式副本持有,写回时顺带同步原数组
|
|
11
|
+
* - getter / 只读 computed:只读数据源,通过 `onReorder` 接收写回结果
|
|
12
|
+
* - 不传:内部创建数据源,通过返回的 `values` 读写
|
|
9
13
|
*/
|
|
10
|
-
values?:
|
|
14
|
+
values?: MaybeRefOrGetter<T[]>;
|
|
15
|
+
/**
|
|
16
|
+
* 数据过滤:仅命中的项参与拖拽排序(须与 DOM 中实际渲染的可拖拽项一一对应)。
|
|
17
|
+
* 排序/转移结果自动合并回原数组,未命中的项保持相对顺序。
|
|
18
|
+
*/
|
|
19
|
+
filter?: (item: T) => boolean;
|
|
20
|
+
/**
|
|
21
|
+
* 容器元素。支持 ref / getter(如 `() => btnRef.value?.parentElement`),
|
|
22
|
+
* 元素出现、替换、移除时自动初始化 / 重建 / 销毁拖拽实例。
|
|
23
|
+
* 不传时通过返回的 `parentRef` 以模板引用绑定。
|
|
24
|
+
*/
|
|
25
|
+
parent?: MaybeRefOrGetter<HTMLElement | undefined>;
|
|
26
|
+
/**
|
|
27
|
+
* `values` 为只读数据源(getter / 只读 computed)时,
|
|
28
|
+
* 排序/转移结果通过该回调写回,收到的是合并后的完整数组
|
|
29
|
+
*/
|
|
30
|
+
onReorder?: (values: T[]) => void;
|
|
11
31
|
}
|
|
12
32
|
interface UseDnDResult<T> {
|
|
13
|
-
/**
|
|
33
|
+
/** 拖拽容器元素;未传 `parent` 选项时通过模板引用绑定到列表容器上 */
|
|
14
34
|
parentRef: Ref<HTMLElement | undefined>;
|
|
15
|
-
/**
|
|
35
|
+
/** 拖拽数据。`filter` 模式下为参与拖拽的视图;排序/转移后自动更新 */
|
|
16
36
|
values: Ref<T[]>;
|
|
17
37
|
/**
|
|
18
|
-
*
|
|
38
|
+
* 运行时更新拖拽配置(整体替换,不含 `values` / `filter` / `parent` / `onReorder`)
|
|
19
39
|
* @param config 新配置
|
|
20
40
|
*/
|
|
21
41
|
updateConfig: (config?: VueParentConfig<T>) => void;
|
|
@@ -23,16 +43,14 @@ interface UseDnDResult<T> {
|
|
|
23
43
|
/**
|
|
24
44
|
* 拖拽排序组合式方法,基于 `@formkit/drag-and-drop` 的 Vue 适配层封装。
|
|
25
45
|
*
|
|
26
|
-
*
|
|
46
|
+
* 排序/跨容器转移结果自动写回数据,无需手动处理 `onSort`;
|
|
47
|
+
* `filter` 支持只对数据的可见子集排序并自动合并回原数组;
|
|
48
|
+
* `parent` 支持动态容器(元素替换/重建时自动重新初始化)。
|
|
27
49
|
* 组件卸载时自动销毁拖拽实例。
|
|
28
50
|
*
|
|
29
|
-
* @example
|
|
51
|
+
* @example 基础用法:列表即数据,排序自动写回
|
|
30
52
|
* ```ts
|
|
31
|
-
* const { parentRef, values } = useDnD({
|
|
32
|
-
* values: list,
|
|
33
|
-
* plugins: [animations()],
|
|
34
|
-
* onSort: ({ previousValues, values }) => {}
|
|
35
|
-
* })
|
|
53
|
+
* const { parentRef, values } = useDnD({ values: list, plugins: [animations()] })
|
|
36
54
|
* ```
|
|
37
55
|
*
|
|
38
56
|
* ```vue
|
|
@@ -40,6 +58,18 @@ interface UseDnDResult<T> {
|
|
|
40
58
|
* <li v-for="item in values" :key="item.id">{{ item.label }}</li>
|
|
41
59
|
* </ul>
|
|
42
60
|
* ```
|
|
61
|
+
*
|
|
62
|
+
* @example 可见子集排序 + 动态容器
|
|
63
|
+
* ```ts
|
|
64
|
+
* useDnD({
|
|
65
|
+
* values: props.items, // 完整数据(含隐藏项),原地写回
|
|
66
|
+
* filter: (item) => item.visible, // 仅可见项参与拖拽,排序自动合并回原数组
|
|
67
|
+
* parent: () => addBtnRef.value?.parentElement ?? undefined, // 容器动态获取
|
|
68
|
+
* dragHandle: '.u-form-item__label',
|
|
69
|
+
* draggable: (el) => el.classList.contains('setting-trigger'), // 排除“新增”按钮
|
|
70
|
+
* plugins: [animations()]
|
|
71
|
+
* })
|
|
72
|
+
* ```
|
|
43
73
|
*/
|
|
44
74
|
declare function useDnD<T>(options?: UseDnDOptions<T>): UseDnDResult<T>;
|
|
45
75
|
//#endregion
|
package/dist/use-dnd/index.js
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mergeView } from "./merge-view.js";
|
|
2
|
+
import { computed, isReactive, isReadonly, isRef, onBeforeUnmount, ref, toValue, watch } from "vue";
|
|
2
3
|
import { animations, dragValues, dropOrSwap, insert, isBrowser, isDragState, isSynthDragState, parentValues, performSort, performTransfer, remapNodes, setParentValues, tearDown, tearDown as tearDown$1, updateConfig } from "@formkit/drag-and-drop";
|
|
3
4
|
import { dragAndDrop, dragAndDrop as dragAndDrop$1, useDragAndDrop } from "@formkit/drag-and-drop/vue";
|
|
4
5
|
//#region src/use-dnd/index.ts
|
|
5
6
|
/**
|
|
6
7
|
* 拖拽排序组合式方法,基于 `@formkit/drag-and-drop` 的 Vue 适配层封装。
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
+
* 排序/跨容器转移结果自动写回数据,无需手动处理 `onSort`;
|
|
10
|
+
* `filter` 支持只对数据的可见子集排序并自动合并回原数组;
|
|
11
|
+
* `parent` 支持动态容器(元素替换/重建时自动重新初始化)。
|
|
9
12
|
* 组件卸载时自动销毁拖拽实例。
|
|
10
13
|
*
|
|
11
|
-
* @example
|
|
14
|
+
* @example 基础用法:列表即数据,排序自动写回
|
|
12
15
|
* ```ts
|
|
13
|
-
* const { parentRef, values } = useDnD({
|
|
14
|
-
* values: list,
|
|
15
|
-
* plugins: [animations()],
|
|
16
|
-
* onSort: ({ previousValues, values }) => {}
|
|
17
|
-
* })
|
|
16
|
+
* const { parentRef, values } = useDnD({ values: list, plugins: [animations()] })
|
|
18
17
|
* ```
|
|
19
18
|
*
|
|
20
19
|
* ```vue
|
|
@@ -22,25 +21,92 @@ import { dragAndDrop, dragAndDrop as dragAndDrop$1, useDragAndDrop } from "@form
|
|
|
22
21
|
* <li v-for="item in values" :key="item.id">{{ item.label }}</li>
|
|
23
22
|
* </ul>
|
|
24
23
|
* ```
|
|
24
|
+
*
|
|
25
|
+
* @example 可见子集排序 + 动态容器
|
|
26
|
+
* ```ts
|
|
27
|
+
* useDnD({
|
|
28
|
+
* values: props.items, // 完整数据(含隐藏项),原地写回
|
|
29
|
+
* filter: (item) => item.visible, // 仅可见项参与拖拽,排序自动合并回原数组
|
|
30
|
+
* parent: () => addBtnRef.value?.parentElement ?? undefined, // 容器动态获取
|
|
31
|
+
* dragHandle: '.u-form-item__label',
|
|
32
|
+
* draggable: (el) => el.classList.contains('setting-trigger'), // 排除“新增”按钮
|
|
33
|
+
* plugins: [animations()]
|
|
34
|
+
* })
|
|
35
|
+
* ```
|
|
25
36
|
*/
|
|
26
37
|
function useDnD(options = {}) {
|
|
27
|
-
const { values:
|
|
38
|
+
const { values: valuesOption, filter, parent: parentOption, onReorder, ...initialConfig } = options;
|
|
28
39
|
const parentRef = ref();
|
|
29
|
-
const
|
|
30
|
-
|
|
40
|
+
const internalValues = ref([]);
|
|
41
|
+
let currentConfig = initialConfig;
|
|
42
|
+
const useInternal = valuesOption === void 0 || Array.isArray(valuesOption) && !isReactive(valuesOption) && !isReadonly(valuesOption);
|
|
43
|
+
if (useInternal && valuesOption) internalValues.value = valuesOption;
|
|
44
|
+
function readSource() {
|
|
45
|
+
if (useInternal) return internalValues.value;
|
|
46
|
+
return toValue(valuesOption) ?? [];
|
|
47
|
+
}
|
|
48
|
+
function writeSource(next) {
|
|
49
|
+
if (useInternal) {
|
|
50
|
+
internalValues.value = next;
|
|
51
|
+
if (valuesOption) valuesOption.splice(0, valuesOption.length, ...next);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (isRef(valuesOption)) {
|
|
55
|
+
if (isReadonly(valuesOption)) onReorder?.(next);
|
|
56
|
+
else valuesOption.value = next;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(valuesOption)) {
|
|
60
|
+
if (isReadonly(valuesOption)) onReorder?.(next);
|
|
61
|
+
else valuesOption.splice(0, valuesOption.length, ...next);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
onReorder?.(next);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 交给拖拽库的数据(视图)。库排序/转移后通过 setter 写回,
|
|
68
|
+
* setter 将视图结果合并回完整数据源。
|
|
69
|
+
*/
|
|
70
|
+
const dndValues = computed({
|
|
71
|
+
get: () => {
|
|
72
|
+
const source = readSource();
|
|
73
|
+
return filter ? source.filter(filter) : source;
|
|
74
|
+
},
|
|
75
|
+
set: (nextView) => {
|
|
76
|
+
if (!filter) {
|
|
77
|
+
writeSource(nextView);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const source = readSource();
|
|
81
|
+
writeSource(mergeView(source, source.filter(filter), nextView));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
/** 当前生效的容器元素:`parent` 选项优先,否则用模板引用 */
|
|
85
|
+
const resolvedParent = computed(() => parentOption === void 0 ? parentRef.value : toValue(parentOption));
|
|
86
|
+
function init(parent) {
|
|
31
87
|
dragAndDrop({
|
|
32
|
-
parent
|
|
33
|
-
values,
|
|
34
|
-
...
|
|
88
|
+
parent,
|
|
89
|
+
values: dndValues,
|
|
90
|
+
...currentConfig
|
|
35
91
|
});
|
|
36
92
|
}
|
|
37
|
-
updateConfig(
|
|
93
|
+
function updateConfig(newConfig = {}) {
|
|
94
|
+
currentConfig = newConfig;
|
|
95
|
+
if (resolvedParent.value) init(resolvedParent.value);
|
|
96
|
+
}
|
|
97
|
+
watch(resolvedParent, (parent, prev) => {
|
|
98
|
+
if (prev && prev !== parent) tearDown(prev);
|
|
99
|
+
if (parent) init(parent);
|
|
100
|
+
}, {
|
|
101
|
+
flush: "post",
|
|
102
|
+
immediate: true
|
|
103
|
+
});
|
|
38
104
|
onBeforeUnmount(() => {
|
|
39
|
-
|
|
105
|
+
if (resolvedParent.value) tearDown(resolvedParent.value);
|
|
40
106
|
});
|
|
41
107
|
return {
|
|
42
108
|
parentRef,
|
|
43
|
-
values,
|
|
109
|
+
values: dndValues,
|
|
44
110
|
updateConfig
|
|
45
111
|
};
|
|
46
112
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/use-dnd/index.ts"],"sourcesContent":["import { tearDown } from '@formkit/drag-and-drop'\nimport { dragAndDrop, type VueParentConfig } from '@formkit/drag-and-drop/vue'\nimport {
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/use-dnd/index.ts"],"sourcesContent":["import { tearDown } from '@formkit/drag-and-drop'\nimport { dragAndDrop, type VueParentConfig } from '@formkit/drag-and-drop/vue'\nimport {\n computed,\n isReactive,\n isReadonly,\n isRef,\n onBeforeUnmount,\n ref,\n toValue,\n watch,\n type MaybeRefOrGetter,\n type Ref\n} from 'vue'\n\nimport { mergeView } from './merge-view'\n\nexport interface UseDnDOptions<T> extends VueParentConfig<T> {\n /**\n * 列表数据。排序/跨容器转移后自动写回:\n * - `Ref`:直接替换 `.value`\n * - 响应式数组(如 props 上的数组):原地 splice 写回\n * - 非响应式纯数组:作为初始值由内部响应式副本持有,写回时顺带同步原数组\n * - getter / 只读 computed:只读数据源,通过 `onReorder` 接收写回结果\n * - 不传:内部创建数据源,通过返回的 `values` 读写\n */\n values?: MaybeRefOrGetter<T[]>\n /**\n * 数据过滤:仅命中的项参与拖拽排序(须与 DOM 中实际渲染的可拖拽项一一对应)。\n * 排序/转移结果自动合并回原数组,未命中的项保持相对顺序。\n */\n filter?: (item: T) => boolean\n /**\n * 容器元素。支持 ref / getter(如 `() => btnRef.value?.parentElement`),\n * 元素出现、替换、移除时自动初始化 / 重建 / 销毁拖拽实例。\n * 不传时通过返回的 `parentRef` 以模板引用绑定。\n */\n parent?: MaybeRefOrGetter<HTMLElement | undefined>\n /**\n * `values` 为只读数据源(getter / 只读 computed)时,\n * 排序/转移结果通过该回调写回,收到的是合并后的完整数组\n */\n onReorder?: (values: T[]) => void\n}\n\nexport interface UseDnDResult<T> {\n /** 拖拽容器元素;未传 `parent` 选项时通过模板引用绑定到列表容器上 */\n parentRef: Ref<HTMLElement | undefined>\n /** 拖拽数据。`filter` 模式下为参与拖拽的视图;排序/转移后自动更新 */\n values: Ref<T[]>\n /**\n * 运行时更新拖拽配置(整体替换,不含 `values` / `filter` / `parent` / `onReorder`)\n * @param config 新配置\n */\n updateConfig: (config?: VueParentConfig<T>) => void\n}\n\n/**\n * 拖拽排序组合式方法,基于 `@formkit/drag-and-drop` 的 Vue 适配层封装。\n *\n * 排序/跨容器转移结果自动写回数据,无需手动处理 `onSort`;\n * `filter` 支持只对数据的可见子集排序并自动合并回原数组;\n * `parent` 支持动态容器(元素替换/重建时自动重新初始化)。\n * 组件卸载时自动销毁拖拽实例。\n *\n * @example 基础用法:列表即数据,排序自动写回\n * ```ts\n * const { parentRef, values } = useDnD({ values: list, plugins: [animations()] })\n * ```\n *\n * ```vue\n * <ul ref=\"parentRef\">\n * <li v-for=\"item in values\" :key=\"item.id\">{{ item.label }}</li>\n * </ul>\n * ```\n *\n * @example 可见子集排序 + 动态容器\n * ```ts\n * useDnD({\n * values: props.items, // 完整数据(含隐藏项),原地写回\n * filter: (item) => item.visible, // 仅可见项参与拖拽,排序自动合并回原数组\n * parent: () => addBtnRef.value?.parentElement ?? undefined, // 容器动态获取\n * dragHandle: '.u-form-item__label',\n * draggable: (el) => el.classList.contains('setting-trigger'), // 排除“新增”按钮\n * plugins: [animations()]\n * })\n * ```\n */\nexport function useDnD<T>(options: UseDnDOptions<T> = {}): UseDnDResult<T> {\n const {\n values: valuesOption,\n filter,\n parent: parentOption,\n onReorder,\n ...initialConfig\n } = options\n\n const parentRef = ref<HTMLElement>()\n const internalValues = ref<T[]>([]) as Ref<T[]>\n let currentConfig = initialConfig\n\n // 非响应式纯数组无法被追踪:由内部 ref 持有数据驱动视图,写回时顺带同步原数组\n const useInternal =\n valuesOption === undefined ||\n (Array.isArray(valuesOption) && !isReactive(valuesOption) && !isReadonly(valuesOption))\n if (useInternal && valuesOption) internalValues.value = valuesOption\n\n function readSource(): T[] {\n if (useInternal) return internalValues.value\n return toValue(valuesOption as MaybeRefOrGetter<T[]>) ?? []\n }\n\n function writeSource(next: T[]) {\n if (useInternal) {\n internalValues.value = next\n if (valuesOption) (valuesOption as T[]).splice(0, (valuesOption as T[]).length, ...next)\n return\n }\n if (isRef(valuesOption)) {\n // 可写 ref / 可写 computed 直接写回;只读 computed 走 onReorder\n if (isReadonly(valuesOption)) onReorder?.(next)\n else (valuesOption as Ref<T[]>).value = next\n return\n }\n if (Array.isArray(valuesOption)) {\n if (isReadonly(valuesOption)) onReorder?.(next)\n else valuesOption.splice(0, valuesOption.length, ...next)\n return\n }\n onReorder?.(next)\n }\n\n /**\n * 交给拖拽库的数据(视图)。库排序/转移后通过 setter 写回,\n * setter 将视图结果合并回完整数据源。\n */\n const dndValues = computed<T[]>({\n get: () => {\n const source = readSource()\n return filter ? source.filter(filter) : source\n },\n set: (nextView) => {\n if (!filter) {\n writeSource(nextView)\n return\n }\n const source = readSource()\n writeSource(mergeView(source, source.filter(filter), nextView))\n }\n })\n\n /** 当前生效的容器元素:`parent` 选项优先,否则用模板引用 */\n const resolvedParent = computed<HTMLElement | undefined>(() =>\n parentOption === undefined ? parentRef.value : toValue(parentOption)\n )\n\n function init(parent: HTMLElement) {\n dragAndDrop({ parent, values: dndValues, ...currentConfig })\n }\n\n function updateConfig(newConfig: VueParentConfig<T> = {}) {\n currentConfig = newConfig\n if (resolvedParent.value) init(resolvedParent.value)\n }\n\n // 容器出现 / 替换 / 移除时自动初始化 / 重建 / 销毁;\n // flush: 'post' 保证模板引用与 DOM 已更新\n watch(\n resolvedParent,\n (parent, prev) => {\n if (prev && prev !== parent) tearDown(prev)\n if (parent) init(parent)\n },\n { flush: 'post', immediate: true }\n )\n\n onBeforeUnmount(() => {\n if (resolvedParent.value) tearDown(resolvedParent.value)\n })\n\n return { parentRef, values: dndValues, updateConfig }\n}\n\n// 重导出 `@formkit/drag-and-drop`,下游项目统一从 `@veltra/compositions` 获取,\n// 避免本库与下游各自安装导致版本不一致(体积膨胀 / 兼容问题)。\nexport {\n // Vue 适配层原始 API\n dragAndDrop,\n useDragAndDrop,\n type VueDragAndDropData,\n type VueElement,\n type VueParentConfig\n} from '@formkit/drag-and-drop/vue'\n\nexport {\n // 插件\n animations,\n dropOrSwap,\n insert,\n // 操作与状态工具\n performSort,\n performTransfer,\n remapNodes,\n updateConfig,\n parentValues,\n setParentValues,\n dragValues,\n isDragState,\n isSynthDragState,\n isBrowser,\n tearDown,\n // 类型\n type ParentConfig,\n type ParentData,\n type ParentRecord,\n type Node,\n type NodeData,\n type NodeRecord,\n type NodeTargetData,\n type ParentTargetData,\n type BaseDragState,\n type DragState,\n type SynthDragState,\n type NativeDragEffects,\n type DNDPlugin,\n type DNDPluginData,\n type NodeEventData,\n type NodeDragEventData,\n type NodePointerEventData,\n type ParentEventData,\n type ParentDragEventData,\n type SortEvent,\n type SortEventData,\n type TransferEvent,\n type TransferEventData,\n type DragstartEvent,\n type DragstartEventData,\n type DragendEvent,\n type DragendEventData,\n type ShouldSwapData,\n type DropSwapConfig,\n type InsertConfig,\n type InsertEvent,\n type SetupNode,\n type SetupNodeData,\n type TearDownNode,\n type TearDownNodeData,\n type PointeroverNodeEvent,\n type PointeroverParentEvent\n} from '@formkit/drag-and-drop'\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwFA,SAAgB,OAAU,UAA4B,CAAC,GAAoB;CACzE,MAAM,EACJ,QAAQ,cACR,QACA,QAAQ,cACR,WACA,GAAG,kBACD;CAEJ,MAAM,YAAY,IAAiB;CACnC,MAAM,iBAAiB,IAAS,CAAC,CAAC;CAClC,IAAI,gBAAgB;CAGpB,MAAM,cACJ,iBAAiB,KAAA,KAChB,MAAM,QAAQ,YAAY,KAAK,CAAC,WAAW,YAAY,KAAK,CAAC,WAAW,YAAY;CACvF,IAAI,eAAe,cAAc,eAAe,QAAQ;CAExD,SAAS,aAAkB;EACzB,IAAI,aAAa,OAAO,eAAe;EACvC,OAAO,QAAQ,YAAqC,KAAK,CAAC;CAC5D;CAEA,SAAS,YAAY,MAAW;EAC9B,IAAI,aAAa;GACf,eAAe,QAAQ;GACvB,IAAI,cAAc,aAAsB,OAAO,GAAI,aAAqB,QAAQ,GAAG,IAAI;GACvF;EACF;EACA,IAAI,MAAM,YAAY,GAAG;GAEvB,IAAI,WAAW,YAAY,GAAG,YAAY,IAAI;QACzC,aAA2B,QAAQ;GACxC;EACF;EACA,IAAI,MAAM,QAAQ,YAAY,GAAG;GAC/B,IAAI,WAAW,YAAY,GAAG,YAAY,IAAI;QACzC,aAAa,OAAO,GAAG,aAAa,QAAQ,GAAG,IAAI;GACxD;EACF;EACA,YAAY,IAAI;CAClB;;;;;CAMA,MAAM,YAAY,SAAc;EAC9B,WAAW;GACT,MAAM,SAAS,WAAW;GAC1B,OAAO,SAAS,OAAO,OAAO,MAAM,IAAI;EAC1C;EACA,MAAM,aAAa;GACjB,IAAI,CAAC,QAAQ;IACX,YAAY,QAAQ;IACpB;GACF;GACA,MAAM,SAAS,WAAW;GAC1B,YAAY,UAAU,QAAQ,OAAO,OAAO,MAAM,GAAG,QAAQ,CAAC;EAChE;CACF,CAAC;;CAGD,MAAM,iBAAiB,eACrB,iBAAiB,KAAA,IAAY,UAAU,QAAQ,QAAQ,YAAY,CACrE;CAEA,SAAS,KAAK,QAAqB;EACjC,YAAY;GAAE;GAAQ,QAAQ;GAAW,GAAG;EAAc,CAAC;CAC7D;CAEA,SAAS,aAAa,YAAgC,CAAC,GAAG;EACxD,gBAAgB;EAChB,IAAI,eAAe,OAAO,KAAK,eAAe,KAAK;CACrD;CAIA,MACE,iBACC,QAAQ,SAAS;EAChB,IAAI,QAAQ,SAAS,QAAQ,SAAS,IAAI;EAC1C,IAAI,QAAQ,KAAK,MAAM;CACzB,GACA;EAAE,OAAO;EAAQ,WAAW;CAAK,CACnC;CAEA,sBAAsB;EACpB,IAAI,eAAe,OAAO,SAAS,eAAe,KAAK;CACzD,CAAC;CAED,OAAO;EAAE;EAAW,QAAQ;EAAW;CAAa;AACtD"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/use-dnd/merge-view.ts
|
|
2
|
+
/**
|
|
3
|
+
* 将“拖拽视图”的新顺序稳定合并回完整数组。
|
|
4
|
+
*
|
|
5
|
+
* 视图是完整数组中参与拖拽排序的项(`filter` 命中的项),与 DOM 中可拖拽节点一一对应。
|
|
6
|
+
* 合并规则:
|
|
7
|
+
* - 未参与拖拽的项保持相对顺序(锚定在其前驱可见项之后)
|
|
8
|
+
* - 旧视图成员按新视图顺序重排(排序)
|
|
9
|
+
* - 新视图中缺少的旧成员被移除(跨容器移出)
|
|
10
|
+
* - 新视图中多出的成员按相对位置插入(跨容器移入)
|
|
11
|
+
*
|
|
12
|
+
* @param source 完整数组
|
|
13
|
+
* @param prevView 排序前的视图
|
|
14
|
+
* @param nextView 排序/转移后的视图
|
|
15
|
+
* @returns 合并后的新数组(不修改入参)
|
|
16
|
+
*/
|
|
17
|
+
function mergeView(source, prevView, nextView) {
|
|
18
|
+
const prevSet = new Set(prevView);
|
|
19
|
+
const nextSet = new Set(nextView);
|
|
20
|
+
const placed = /* @__PURE__ */ new Set();
|
|
21
|
+
const queue = [...nextView];
|
|
22
|
+
const result = [];
|
|
23
|
+
for (const item of source) {
|
|
24
|
+
if (!prevSet.has(item)) {
|
|
25
|
+
result.push(item);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (!nextSet.has(item) || placed.has(item)) continue;
|
|
29
|
+
while (queue.length > 0 && queue[0] !== item) {
|
|
30
|
+
const head = queue.shift();
|
|
31
|
+
result.push(head);
|
|
32
|
+
if (prevSet.has(head)) placed.add(head);
|
|
33
|
+
}
|
|
34
|
+
queue.shift();
|
|
35
|
+
placed.add(item);
|
|
36
|
+
result.push(item);
|
|
37
|
+
}
|
|
38
|
+
return result.concat(queue);
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { mergeView };
|
|
42
|
+
|
|
43
|
+
//# sourceMappingURL=merge-view.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"merge-view.js","names":[],"sources":["../../src/use-dnd/merge-view.ts"],"sourcesContent":["/**\n * 将“拖拽视图”的新顺序稳定合并回完整数组。\n *\n * 视图是完整数组中参与拖拽排序的项(`filter` 命中的项),与 DOM 中可拖拽节点一一对应。\n * 合并规则:\n * - 未参与拖拽的项保持相对顺序(锚定在其前驱可见项之后)\n * - 旧视图成员按新视图顺序重排(排序)\n * - 新视图中缺少的旧成员被移除(跨容器移出)\n * - 新视图中多出的成员按相对位置插入(跨容器移入)\n *\n * @param source 完整数组\n * @param prevView 排序前的视图\n * @param nextView 排序/转移后的视图\n * @returns 合并后的新数组(不修改入参)\n */\nexport function mergeView<T>(\n source: readonly T[],\n prevView: readonly T[],\n nextView: readonly T[]\n): T[] {\n const prevSet = new Set(prevView)\n const nextSet = new Set(nextView)\n const placed = new Set<T>()\n const queue = [...nextView]\n const result: T[] = []\n\n for (const item of source) {\n // 未参与拖拽的项原位保留\n if (!prevSet.has(item)) {\n result.push(item)\n continue\n }\n // 已从新视图移除(跨容器移出),或已按新顺序放置过\n if (!nextSet.has(item) || placed.has(item)) continue\n // 先放出新视图中排在它前面的项(含跨容器移入的新成员)\n while (queue.length > 0 && queue[0] !== item) {\n const head = queue.shift()!\n result.push(head)\n if (prevSet.has(head)) placed.add(head)\n }\n queue.shift()\n placed.add(item)\n result.push(item)\n }\n\n // 新视图尾部多出的成员追加到末尾\n return result.concat(queue)\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAeA,SAAgB,UACd,QACA,UACA,UACK;CACL,MAAM,UAAU,IAAI,IAAI,QAAQ;CAChC,MAAM,UAAU,IAAI,IAAI,QAAQ;CAChC,MAAM,yBAAS,IAAI,IAAO;CAC1B,MAAM,QAAQ,CAAC,GAAG,QAAQ;CAC1B,MAAM,SAAc,CAAC;CAErB,KAAK,MAAM,QAAQ,QAAQ;EAEzB,IAAI,CAAC,QAAQ,IAAI,IAAI,GAAG;GACtB,OAAO,KAAK,IAAI;GAChB;EACF;EAEA,IAAI,CAAC,QAAQ,IAAI,IAAI,KAAK,OAAO,IAAI,IAAI,GAAG;EAE5C,OAAO,MAAM,SAAS,KAAK,MAAM,OAAO,MAAM;GAC5C,MAAM,OAAO,MAAM,MAAM;GACzB,OAAO,KAAK,IAAI;GAChB,IAAI,QAAQ,IAAI,IAAI,GAAG,OAAO,IAAI,IAAI;EACxC;EACA,MAAM,MAAM;EACZ,OAAO,IAAI,IAAI;EACf,OAAO,KAAK,IAAI;CAClB;CAGA,OAAO,OAAO,OAAO,KAAK;AAC5B"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veltra/compositions",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.9",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist",
|
|
6
6
|
"src"
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"@cat-kit/core": ">=1.1.8",
|
|
24
24
|
"@cat-kit/fe": ">=1.1.8",
|
|
25
|
-
"@veltra/utils": "1.3.
|
|
25
|
+
"@veltra/utils": "1.3.9",
|
|
26
26
|
"vue": ">=3.5.40"
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { computed, createApp, reactive, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
import { useDnD, type UseDnDResult } from '../index'
|
|
5
|
+
import { mergeView } from '../merge-view'
|
|
6
|
+
|
|
7
|
+
/** 在组件 setup 上下文中执行组合式函数(提供生命周期与 effect scope) */
|
|
8
|
+
function withSetup<T>(fn: () => T): T {
|
|
9
|
+
let result!: T
|
|
10
|
+
const app = createApp({
|
|
11
|
+
setup() {
|
|
12
|
+
result = fn()
|
|
13
|
+
return () => null
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
app.mount(document.createElement('div'))
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('mergeView', () => {
|
|
21
|
+
it('排序:视图重排合并回完整数组,未参与项保持相对顺序', () => {
|
|
22
|
+
const [a, b, c, h1, h2] = ['a', 'b', 'c', 'h1', 'h2']
|
|
23
|
+
const source = [h1, a, b, h2, c]
|
|
24
|
+
// 隐藏项锚定在其前驱可见项之后:h2 跟随 b
|
|
25
|
+
expect(mergeView(source, [a, b, c], [c, a, b])).toEqual([h1, c, a, b, h2])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('完全逆序', () => {
|
|
29
|
+
const [a, b] = ['a', 'b']
|
|
30
|
+
expect(mergeView([a, b], [a, b], [b, a])).toEqual([b, a])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('跨容器移出:新视图缺少的成员从源数组移除', () => {
|
|
34
|
+
const [a, b, c, h] = ['a', 'b', 'c', 'h']
|
|
35
|
+
expect(mergeView([a, h, b, c], [a, b, c], [a, c])).toEqual([a, h, c])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('跨容器移入:新成员按相对位置插入(头部 / 中间 / 末尾)', () => {
|
|
39
|
+
const [a, b, x, y, z, h] = ['a', 'b', 'x', 'y', 'z', 'h']
|
|
40
|
+
expect(mergeView([a, b], [a, b], [x, a, b])).toEqual([x, a, b])
|
|
41
|
+
expect(mergeView([a, h, b], [a, b], [a, y, b])).toEqual([a, h, y, b])
|
|
42
|
+
expect(mergeView([a, b], [a, b], [a, b, z])).toEqual([a, b, z])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('不修改入参', () => {
|
|
46
|
+
const [a, b] = ['a', 'b']
|
|
47
|
+
const source = [a, b]
|
|
48
|
+
mergeView(source, [a, b], [b, a])
|
|
49
|
+
expect(source).toEqual([a, b])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('useDnD 数据写回', () => {
|
|
54
|
+
function sortBySetter<T>(dnd: UseDnDResult<T>, next: T[]) {
|
|
55
|
+
// 模拟库排序后通过 setValues 写回
|
|
56
|
+
dnd.values.value = next
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it('ref 数据源:排序结果直接写回 ref', () => {
|
|
60
|
+
const list = ref([{ id: 'a' }, { id: 'b' }])
|
|
61
|
+
const dnd = withSetup(() => useDnD({ values: list }))
|
|
62
|
+
|
|
63
|
+
const [a, b] = dnd.values.value
|
|
64
|
+
sortBySetter(dnd, [b!, a!])
|
|
65
|
+
|
|
66
|
+
expect(list.value.map((i) => i.id)).toEqual(['b', 'a'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('响应式数组数据源:原地 splice 写回', () => {
|
|
70
|
+
const items = reactive([{ id: 'a' }, { id: 'b' }])
|
|
71
|
+
const dnd = withSetup(() => useDnD({ values: items }))
|
|
72
|
+
|
|
73
|
+
const [a, b] = dnd.values.value
|
|
74
|
+
sortBySetter(dnd, [b!, a!])
|
|
75
|
+
|
|
76
|
+
expect(items.map((i) => i.id)).toEqual(['b', 'a'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('filter 模式:仅可见项参与排序,结果自动合并回原数组', () => {
|
|
80
|
+
const items = reactive([
|
|
81
|
+
{ id: 'h1', visible: false },
|
|
82
|
+
{ id: 'a', visible: true },
|
|
83
|
+
{ id: 'h2', visible: false },
|
|
84
|
+
{ id: 'b', visible: true }
|
|
85
|
+
])
|
|
86
|
+
const dnd = withSetup(() => useDnD({ values: items, filter: (i) => i.visible }))
|
|
87
|
+
|
|
88
|
+
expect(dnd.values.value.map((i) => i.id)).toEqual(['a', 'b'])
|
|
89
|
+
|
|
90
|
+
const [a, b] = dnd.values.value
|
|
91
|
+
sortBySetter(dnd, [b!, a!])
|
|
92
|
+
|
|
93
|
+
expect(items.map((i) => i.id)).toEqual(['h1', 'b', 'a', 'h2'])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('filter 模式:跨容器移出时移除对应源数据项', () => {
|
|
97
|
+
const items = reactive([
|
|
98
|
+
{ id: 'a', visible: true },
|
|
99
|
+
{ id: 'h', visible: false },
|
|
100
|
+
{ id: 'b', visible: true }
|
|
101
|
+
])
|
|
102
|
+
const dnd = withSetup(() => useDnD({ values: items, filter: (i) => i.visible }))
|
|
103
|
+
|
|
104
|
+
const [, b] = dnd.values.value
|
|
105
|
+
// b 被移出到其他容器
|
|
106
|
+
sortBySetter(dnd, [b!].slice(0, 0).concat(dnd.values.value.filter((i) => i.id === 'a')))
|
|
107
|
+
|
|
108
|
+
expect(items.map((i) => i.id)).toEqual(['a', 'h'])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('非响应式纯数组数据源:内部副本驱动视图,原数组保持同步', () => {
|
|
112
|
+
const items = [{ id: 'a' }, { id: 'b' }]
|
|
113
|
+
const dnd = withSetup(() => useDnD({ values: items }))
|
|
114
|
+
|
|
115
|
+
const [a, b] = dnd.values.value
|
|
116
|
+
sortBySetter(dnd, [b!, a!])
|
|
117
|
+
|
|
118
|
+
// 返回的 values 已更新(内部响应式副本)
|
|
119
|
+
expect(dnd.values.value.map((i) => i.id)).toEqual(['b', 'a'])
|
|
120
|
+
// 原数组同步
|
|
121
|
+
expect(items.map((i) => i.id)).toEqual(['b', 'a'])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('getter 数据源:通过 onReorder 接收合并后的完整数组', () => {
|
|
125
|
+
const items = reactive([
|
|
126
|
+
{ id: 'a', visible: true },
|
|
127
|
+
{ id: 'h', visible: false },
|
|
128
|
+
{ id: 'b', visible: true }
|
|
129
|
+
])
|
|
130
|
+
const onReorder = vi.fn()
|
|
131
|
+
const dnd = withSetup(() =>
|
|
132
|
+
useDnD({ values: () => items, filter: (i) => i.visible, onReorder })
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const [a, b] = dnd.values.value
|
|
136
|
+
sortBySetter(dnd, [b!, a!])
|
|
137
|
+
|
|
138
|
+
expect(onReorder).toHaveBeenCalledTimes(1)
|
|
139
|
+
expect(onReorder.mock.calls[0]![0].map((i: { id: string }) => i.id)).toEqual(['b', 'a', 'h'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('只读 computed 数据源:通过 onReorder 写回', () => {
|
|
143
|
+
const list = ref([{ id: 'a' }, { id: 'b' }])
|
|
144
|
+
const onReorder = vi.fn()
|
|
145
|
+
const dnd = withSetup(() => useDnD({ values: computed(() => list.value), onReorder }))
|
|
146
|
+
|
|
147
|
+
const [a, b] = dnd.values.value
|
|
148
|
+
sortBySetter(dnd, [b!, a!])
|
|
149
|
+
|
|
150
|
+
expect(onReorder).toHaveBeenCalledTimes(1)
|
|
151
|
+
expect(list.value.map((i) => i.id)).toEqual(['a', 'b'])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('可写 computed 数据源:排序结果进入其 setter', () => {
|
|
155
|
+
const list = ref([{ id: 'a' }, { id: 'b' }])
|
|
156
|
+
const setter = vi.fn()
|
|
157
|
+
const dnd = withSetup(() =>
|
|
158
|
+
useDnD({ values: computed({ get: () => list.value, set: setter }) })
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const [a, b] = dnd.values.value
|
|
162
|
+
sortBySetter(dnd, [b!, a!])
|
|
163
|
+
|
|
164
|
+
expect(setter).toHaveBeenCalledTimes(1)
|
|
165
|
+
expect(setter.mock.calls[0]![0].map((i: { id: string }) => i.id)).toEqual(['b', 'a'])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('外部数据变化时视图自动更新', () => {
|
|
169
|
+
const items = reactive([{ id: 'a', visible: true }])
|
|
170
|
+
const dnd = withSetup(() => useDnD({ values: items, filter: (i) => i.visible }))
|
|
171
|
+
|
|
172
|
+
items.push({ id: 'b', visible: true })
|
|
173
|
+
|
|
174
|
+
expect(dnd.values.value.map((i) => i.id)).toEqual(['a', 'b'])
|
|
175
|
+
})
|
|
176
|
+
})
|
package/src/use-dnd/index.ts
CHANGED
|
@@ -1,22 +1,55 @@
|
|
|
1
1
|
import { tearDown } from '@formkit/drag-and-drop'
|
|
2
2
|
import { dragAndDrop, type VueParentConfig } from '@formkit/drag-and-drop/vue'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
computed,
|
|
5
|
+
isReactive,
|
|
6
|
+
isReadonly,
|
|
7
|
+
isRef,
|
|
8
|
+
onBeforeUnmount,
|
|
9
|
+
ref,
|
|
10
|
+
toValue,
|
|
11
|
+
watch,
|
|
12
|
+
type MaybeRefOrGetter,
|
|
13
|
+
type Ref
|
|
14
|
+
} from 'vue'
|
|
15
|
+
|
|
16
|
+
import { mergeView } from './merge-view'
|
|
4
17
|
|
|
5
18
|
export interface UseDnDOptions<T> extends VueParentConfig<T> {
|
|
6
19
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
20
|
+
* 列表数据。排序/跨容器转移后自动写回:
|
|
21
|
+
* - `Ref`:直接替换 `.value`
|
|
22
|
+
* - 响应式数组(如 props 上的数组):原地 splice 写回
|
|
23
|
+
* - 非响应式纯数组:作为初始值由内部响应式副本持有,写回时顺带同步原数组
|
|
24
|
+
* - getter / 只读 computed:只读数据源,通过 `onReorder` 接收写回结果
|
|
25
|
+
* - 不传:内部创建数据源,通过返回的 `values` 读写
|
|
26
|
+
*/
|
|
27
|
+
values?: MaybeRefOrGetter<T[]>
|
|
28
|
+
/**
|
|
29
|
+
* 数据过滤:仅命中的项参与拖拽排序(须与 DOM 中实际渲染的可拖拽项一一对应)。
|
|
30
|
+
* 排序/转移结果自动合并回原数组,未命中的项保持相对顺序。
|
|
31
|
+
*/
|
|
32
|
+
filter?: (item: T) => boolean
|
|
33
|
+
/**
|
|
34
|
+
* 容器元素。支持 ref / getter(如 `() => btnRef.value?.parentElement`),
|
|
35
|
+
* 元素出现、替换、移除时自动初始化 / 重建 / 销毁拖拽实例。
|
|
36
|
+
* 不传时通过返回的 `parentRef` 以模板引用绑定。
|
|
37
|
+
*/
|
|
38
|
+
parent?: MaybeRefOrGetter<HTMLElement | undefined>
|
|
39
|
+
/**
|
|
40
|
+
* `values` 为只读数据源(getter / 只读 computed)时,
|
|
41
|
+
* 排序/转移结果通过该回调写回,收到的是合并后的完整数组
|
|
9
42
|
*/
|
|
10
|
-
|
|
43
|
+
onReorder?: (values: T[]) => void
|
|
11
44
|
}
|
|
12
45
|
|
|
13
46
|
export interface UseDnDResult<T> {
|
|
14
|
-
/**
|
|
47
|
+
/** 拖拽容器元素;未传 `parent` 选项时通过模板引用绑定到列表容器上 */
|
|
15
48
|
parentRef: Ref<HTMLElement | undefined>
|
|
16
|
-
/**
|
|
49
|
+
/** 拖拽数据。`filter` 模式下为参与拖拽的视图;排序/转移后自动更新 */
|
|
17
50
|
values: Ref<T[]>
|
|
18
51
|
/**
|
|
19
|
-
*
|
|
52
|
+
* 运行时更新拖拽配置(整体替换,不含 `values` / `filter` / `parent` / `onReorder`)
|
|
20
53
|
* @param config 新配置
|
|
21
54
|
*/
|
|
22
55
|
updateConfig: (config?: VueParentConfig<T>) => void
|
|
@@ -25,16 +58,14 @@ export interface UseDnDResult<T> {
|
|
|
25
58
|
/**
|
|
26
59
|
* 拖拽排序组合式方法,基于 `@formkit/drag-and-drop` 的 Vue 适配层封装。
|
|
27
60
|
*
|
|
28
|
-
*
|
|
61
|
+
* 排序/跨容器转移结果自动写回数据,无需手动处理 `onSort`;
|
|
62
|
+
* `filter` 支持只对数据的可见子集排序并自动合并回原数组;
|
|
63
|
+
* `parent` 支持动态容器(元素替换/重建时自动重新初始化)。
|
|
29
64
|
* 组件卸载时自动销毁拖拽实例。
|
|
30
65
|
*
|
|
31
|
-
* @example
|
|
66
|
+
* @example 基础用法:列表即数据,排序自动写回
|
|
32
67
|
* ```ts
|
|
33
|
-
* const { parentRef, values } = useDnD({
|
|
34
|
-
* values: list,
|
|
35
|
-
* plugins: [animations()],
|
|
36
|
-
* onSort: ({ previousValues, values }) => {}
|
|
37
|
-
* })
|
|
68
|
+
* const { parentRef, values } = useDnD({ values: list, plugins: [animations()] })
|
|
38
69
|
* ```
|
|
39
70
|
*
|
|
40
71
|
* ```vue
|
|
@@ -42,24 +73,112 @@ export interface UseDnDResult<T> {
|
|
|
42
73
|
* <li v-for="item in values" :key="item.id">{{ item.label }}</li>
|
|
43
74
|
* </ul>
|
|
44
75
|
* ```
|
|
76
|
+
*
|
|
77
|
+
* @example 可见子集排序 + 动态容器
|
|
78
|
+
* ```ts
|
|
79
|
+
* useDnD({
|
|
80
|
+
* values: props.items, // 完整数据(含隐藏项),原地写回
|
|
81
|
+
* filter: (item) => item.visible, // 仅可见项参与拖拽,排序自动合并回原数组
|
|
82
|
+
* parent: () => addBtnRef.value?.parentElement ?? undefined, // 容器动态获取
|
|
83
|
+
* dragHandle: '.u-form-item__label',
|
|
84
|
+
* draggable: (el) => el.classList.contains('setting-trigger'), // 排除“新增”按钮
|
|
85
|
+
* plugins: [animations()]
|
|
86
|
+
* })
|
|
87
|
+
* ```
|
|
45
88
|
*/
|
|
46
89
|
export function useDnD<T>(options: UseDnDOptions<T> = {}): UseDnDResult<T> {
|
|
47
|
-
const {
|
|
90
|
+
const {
|
|
91
|
+
values: valuesOption,
|
|
92
|
+
filter,
|
|
93
|
+
parent: parentOption,
|
|
94
|
+
onReorder,
|
|
95
|
+
...initialConfig
|
|
96
|
+
} = options
|
|
48
97
|
|
|
49
98
|
const parentRef = ref<HTMLElement>()
|
|
50
|
-
const
|
|
99
|
+
const internalValues = ref<T[]>([]) as Ref<T[]>
|
|
100
|
+
let currentConfig = initialConfig
|
|
101
|
+
|
|
102
|
+
// 非响应式纯数组无法被追踪:由内部 ref 持有数据驱动视图,写回时顺带同步原数组
|
|
103
|
+
const useInternal =
|
|
104
|
+
valuesOption === undefined ||
|
|
105
|
+
(Array.isArray(valuesOption) && !isReactive(valuesOption) && !isReadonly(valuesOption))
|
|
106
|
+
if (useInternal && valuesOption) internalValues.value = valuesOption
|
|
107
|
+
|
|
108
|
+
function readSource(): T[] {
|
|
109
|
+
if (useInternal) return internalValues.value
|
|
110
|
+
return toValue(valuesOption as MaybeRefOrGetter<T[]>) ?? []
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writeSource(next: T[]) {
|
|
114
|
+
if (useInternal) {
|
|
115
|
+
internalValues.value = next
|
|
116
|
+
if (valuesOption) (valuesOption as T[]).splice(0, (valuesOption as T[]).length, ...next)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (isRef(valuesOption)) {
|
|
120
|
+
// 可写 ref / 可写 computed 直接写回;只读 computed 走 onReorder
|
|
121
|
+
if (isReadonly(valuesOption)) onReorder?.(next)
|
|
122
|
+
else (valuesOption as Ref<T[]>).value = next
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (Array.isArray(valuesOption)) {
|
|
126
|
+
if (isReadonly(valuesOption)) onReorder?.(next)
|
|
127
|
+
else valuesOption.splice(0, valuesOption.length, ...next)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
onReorder?.(next)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 交给拖拽库的数据(视图)。库排序/转移后通过 setter 写回,
|
|
135
|
+
* setter 将视图结果合并回完整数据源。
|
|
136
|
+
*/
|
|
137
|
+
const dndValues = computed<T[]>({
|
|
138
|
+
get: () => {
|
|
139
|
+
const source = readSource()
|
|
140
|
+
return filter ? source.filter(filter) : source
|
|
141
|
+
},
|
|
142
|
+
set: (nextView) => {
|
|
143
|
+
if (!filter) {
|
|
144
|
+
writeSource(nextView)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
const source = readSource()
|
|
148
|
+
writeSource(mergeView(source, source.filter(filter), nextView))
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
/** 当前生效的容器元素:`parent` 选项优先,否则用模板引用 */
|
|
153
|
+
const resolvedParent = computed<HTMLElement | undefined>(() =>
|
|
154
|
+
parentOption === undefined ? parentRef.value : toValue(parentOption)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
function init(parent: HTMLElement) {
|
|
158
|
+
dragAndDrop({ parent, values: dndValues, ...currentConfig })
|
|
159
|
+
}
|
|
51
160
|
|
|
52
161
|
function updateConfig(newConfig: VueParentConfig<T> = {}) {
|
|
53
|
-
|
|
162
|
+
currentConfig = newConfig
|
|
163
|
+
if (resolvedParent.value) init(resolvedParent.value)
|
|
54
164
|
}
|
|
55
165
|
|
|
56
|
-
|
|
166
|
+
// 容器出现 / 替换 / 移除时自动初始化 / 重建 / 销毁;
|
|
167
|
+
// flush: 'post' 保证模板引用与 DOM 已更新
|
|
168
|
+
watch(
|
|
169
|
+
resolvedParent,
|
|
170
|
+
(parent, prev) => {
|
|
171
|
+
if (prev && prev !== parent) tearDown(prev)
|
|
172
|
+
if (parent) init(parent)
|
|
173
|
+
},
|
|
174
|
+
{ flush: 'post', immediate: true }
|
|
175
|
+
)
|
|
57
176
|
|
|
58
177
|
onBeforeUnmount(() => {
|
|
59
|
-
|
|
178
|
+
if (resolvedParent.value) tearDown(resolvedParent.value)
|
|
60
179
|
})
|
|
61
180
|
|
|
62
|
-
return { parentRef, values, updateConfig }
|
|
181
|
+
return { parentRef, values: dndValues, updateConfig }
|
|
63
182
|
}
|
|
64
183
|
|
|
65
184
|
// 重导出 `@formkit/drag-and-drop`,下游项目统一从 `@veltra/compositions` 获取,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 将“拖拽视图”的新顺序稳定合并回完整数组。
|
|
3
|
+
*
|
|
4
|
+
* 视图是完整数组中参与拖拽排序的项(`filter` 命中的项),与 DOM 中可拖拽节点一一对应。
|
|
5
|
+
* 合并规则:
|
|
6
|
+
* - 未参与拖拽的项保持相对顺序(锚定在其前驱可见项之后)
|
|
7
|
+
* - 旧视图成员按新视图顺序重排(排序)
|
|
8
|
+
* - 新视图中缺少的旧成员被移除(跨容器移出)
|
|
9
|
+
* - 新视图中多出的成员按相对位置插入(跨容器移入)
|
|
10
|
+
*
|
|
11
|
+
* @param source 完整数组
|
|
12
|
+
* @param prevView 排序前的视图
|
|
13
|
+
* @param nextView 排序/转移后的视图
|
|
14
|
+
* @returns 合并后的新数组(不修改入参)
|
|
15
|
+
*/
|
|
16
|
+
export function mergeView<T>(
|
|
17
|
+
source: readonly T[],
|
|
18
|
+
prevView: readonly T[],
|
|
19
|
+
nextView: readonly T[]
|
|
20
|
+
): T[] {
|
|
21
|
+
const prevSet = new Set(prevView)
|
|
22
|
+
const nextSet = new Set(nextView)
|
|
23
|
+
const placed = new Set<T>()
|
|
24
|
+
const queue = [...nextView]
|
|
25
|
+
const result: T[] = []
|
|
26
|
+
|
|
27
|
+
for (const item of source) {
|
|
28
|
+
// 未参与拖拽的项原位保留
|
|
29
|
+
if (!prevSet.has(item)) {
|
|
30
|
+
result.push(item)
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
// 已从新视图移除(跨容器移出),或已按新顺序放置过
|
|
34
|
+
if (!nextSet.has(item) || placed.has(item)) continue
|
|
35
|
+
// 先放出新视图中排在它前面的项(含跨容器移入的新成员)
|
|
36
|
+
while (queue.length > 0 && queue[0] !== item) {
|
|
37
|
+
const head = queue.shift()!
|
|
38
|
+
result.push(head)
|
|
39
|
+
if (prevSet.has(head)) placed.add(head)
|
|
40
|
+
}
|
|
41
|
+
queue.shift()
|
|
42
|
+
placed.add(item)
|
|
43
|
+
result.push(item)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 新视图尾部多出的成员追加到末尾
|
|
47
|
+
return result.concat(queue)
|
|
48
|
+
}
|