@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.
@@ -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
- * 列表数据。传入 ref 时与外部共享同一个引用,拖拽排序/转移后直接写回;
8
- * 传入普通数组或不传时内部创建 ref。
7
+ * 列表数据。排序/跨容器转移后自动写回:
8
+ * - `Ref`:直接替换 `.value`
9
+ * - 响应式数组(如 props 上的数组):原地 splice 写回
10
+ * - 非响应式纯数组:作为初始值由内部响应式副本持有,写回时顺带同步原数组
11
+ * - getter / 只读 computed:只读数据源,通过 `onReorder` 接收写回结果
12
+ * - 不传:内部创建数据源,通过返回的 `values` 读写
9
13
  */
10
- values?: T[] | Ref<T[]>;
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
- /** 拖拽容器元素,通过 `ref` 绑定到列表容器元素上 */
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
- * 相比官方 `useDragAndDrop` 的元组返回,这里使用 options 对象传参并返回命名结果。
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
@@ -1,20 +1,19 @@
1
- import { isRef, onBeforeUnmount, ref } from "vue";
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
- * 相比官方 `useDragAndDrop` 的元组返回,这里使用 options 对象传参并返回命名结果。
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: initialValues, ...config } = options;
38
+ const { values: valuesOption, filter, parent: parentOption, onReorder, ...initialConfig } = options;
28
39
  const parentRef = ref();
29
- const values = isRef(initialValues) ? initialValues : ref(initialValues ?? []);
30
- function updateConfig(newConfig = {}) {
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: parentRef,
33
- values,
34
- ...newConfig
88
+ parent,
89
+ values: dndValues,
90
+ ...currentConfig
35
91
  });
36
92
  }
37
- updateConfig(config);
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
- parentRef.value && tearDown(parentRef.value);
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 { isRef, onBeforeUnmount, ref, type Ref } from 'vue'\n\nexport interface UseDnDOptions<T> extends VueParentConfig<T> {\n /**\n * 列表数据。传入 ref 时与外部共享同一个引用,拖拽排序/转移后直接写回;\n * 传入普通数组或不传时内部创建 ref。\n */\n values?: T[] | Ref<T[]>\n}\n\nexport interface UseDnDResult<T> {\n /** 拖拽容器元素,通过 `ref` 绑定到列表容器元素上 */\n parentRef: Ref<HTMLElement | undefined>\n /** 列表数据,排序/跨容器转移后自动更新 */\n values: Ref<T[]>\n /**\n * 运行时更新拖拽配置(整体替换,不与初始配置合并)\n * @param config 新配置\n */\n updateConfig: (config?: VueParentConfig<T>) => void\n}\n\n/**\n * 拖拽排序组合式方法,基于 `@formkit/drag-and-drop` 的 Vue 适配层封装。\n *\n * 相比官方 `useDragAndDrop` 的元组返回,这里使用 options 对象传参并返回命名结果。\n * 组件卸载时自动销毁拖拽实例。\n *\n * @example\n * ```ts\n * const { parentRef, values } = useDnD({\n * values: list,\n * plugins: [animations()],\n * onSort: ({ previousValues, values }) => {}\n * })\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 */\nexport function useDnD<T>(options: UseDnDOptions<T> = {}): UseDnDResult<T> {\n const { values: initialValues, ...config } = options\n\n const parentRef = ref<HTMLElement>()\n const values = isRef(initialValues) ? initialValues : (ref(initialValues ?? []) as Ref<T[]>)\n\n function updateConfig(newConfig: VueParentConfig<T> = {}) {\n dragAndDrop({ parent: parentRef, values, ...newConfig })\n }\n\n updateConfig(config)\n\n onBeforeUnmount(() => {\n parentRef.value && tearDown(parentRef.value)\n })\n\n return { parentRef, values, 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":";;;;;;;;;;;;;;;;;;;;;;;;;AA6CA,SAAgB,OAAU,UAA4B,CAAC,GAAoB;CACzE,MAAM,EAAE,QAAQ,eAAe,GAAG,WAAW;CAE7C,MAAM,YAAY,IAAiB;CACnC,MAAM,SAAS,MAAM,aAAa,IAAI,gBAAiB,IAAI,iBAAiB,CAAC,CAAC;CAE9E,SAAS,aAAa,YAAgC,CAAC,GAAG;EACxD,YAAY;GAAE,QAAQ;GAAW;GAAQ,GAAG;EAAU,CAAC;CACzD;CAEA,aAAa,MAAM;CAEnB,sBAAsB;EACpB,UAAU,SAAS,SAAS,UAAU,KAAK;CAC7C,CAAC;CAED,OAAO;EAAE;EAAW;EAAQ;CAAa;AAC3C"}
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.8",
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.8",
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
+ })
@@ -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 { isRef, onBeforeUnmount, ref, type Ref } from 'vue'
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
- * 列表数据。传入 ref 时与外部共享同一个引用,拖拽排序/转移后直接写回;
8
- * 传入普通数组或不传时内部创建 ref。
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
- values?: T[] | Ref<T[]>
43
+ onReorder?: (values: T[]) => void
11
44
  }
12
45
 
13
46
  export interface UseDnDResult<T> {
14
- /** 拖拽容器元素,通过 `ref` 绑定到列表容器元素上 */
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
- * 相比官方 `useDragAndDrop` 的元组返回,这里使用 options 对象传参并返回命名结果。
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 { values: initialValues, ...config } = options
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 values = isRef(initialValues) ? initialValues : (ref(initialValues ?? []) as Ref<T[]>)
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
- dragAndDrop({ parent: parentRef, values, ...newConfig })
162
+ currentConfig = newConfig
163
+ if (resolvedParent.value) init(resolvedParent.value)
54
164
  }
55
165
 
56
- updateConfig(config)
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
- parentRef.value && tearDown(parentRef.value)
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
+ }