refactor(frontend): Vue 드래그 정렬 라이브러리를 교체

This commit is contained in:
2026-08-21 02:19:40 +00:00
parent ac7e2c87fd
commit 0b0697d165
6 changed files with 118 additions and 35 deletions
+1
View File
@@ -344,6 +344,7 @@ test('physical mobile touch reorders NPC priority across active and inactive lis
const nationPanel = mobilePage.locator('.priority-panel').first();
const activeList = nationPanel.locator('.priority-column').nth(1).locator('.priority-list');
const activeRows = activeList.locator('.priority-item');
await expect(activeRows.first()).toHaveCSS('touch-action', 'none');
await touchDrag(
mobilePage,
activeRows.nth(0),
+1 -1
View File
@@ -48,7 +48,7 @@
"mitt": "^3.0.1",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vuedraggable-es": "4.1.1",
"vue-draggable-plus": "0.6.1",
"vue-router": "^4.6.4",
"zod": "^4.3.5"
},
@@ -1,5 +1,25 @@
import { defineComponent, h, type PropType, type SlotsType, type VNode } from 'vue';
import VueDraggable from 'vuedraggable-es';
import { cloneVNode, computed, defineComponent, h, ref, type PropType, type SlotsType, type VNode } from 'vue';
import { useDraggable, type DraggableEvent, type UseDraggableOptions } from 'vue-draggable-plus';
const SORTABLE_ITEM_ATTRIBUTE = 'data-sortable-string-list-item';
// SortableJS allows only one active drag, so this preserves the exact string across grouped lists.
let activeStringDrag: { value: string } | null = null;
const restoreItem = (event: DraggableEvent<string>) => {
if (event.oldIndex === undefined) return;
event.item.remove();
event.from.insertBefore(event.item, event.from.children[event.oldIndex] ?? null);
};
const moveItem = (list: string[], from: number, to: number): string[] => {
if (from === to) return list;
const next = [...list];
const [item] = next.splice(from, 1);
if (item === undefined) return list;
next.splice(to, 0, item);
return next;
};
export default defineComponent({
name: 'SortableStringList',
@@ -18,26 +38,79 @@ export default defineComponent({
default: 'div',
},
},
emits: {
'update:list': (list: string[]) => Array.isArray(list),
},
slots: Object as SlotsType<{
header?: () => VNode[];
item: (props: { element: string; index: number }) => VNode[];
}>,
setup(props, { attrs, slots }) {
return () =>
h(
VueDraggable,
{
...attrs,
list: props.list,
group: props.group,
itemKey: (item: string) => item,
tag: props.tag,
},
{
header: () => slots.header?.(),
item: ({ element, index }: { element: string; index: number }) =>
slots.item({ element, index }),
setup(props, { attrs, emit, slots }) {
const root = ref<HTMLElement | null>(null);
let initialChildren: ChildNode[] | null = null;
const options = computed<UseDraggableOptions<string>>(() => ({
group: props.group,
draggable: `[${SORTABLE_ITEM_ATTRIBUTE}]`,
dataIdAttr: SORTABLE_ITEM_ATTRIBUTE,
onStart: (event) => {
initialChildren = Array.from(event.from.childNodes);
if (event.oldDraggableIndex === undefined) {
activeStringDrag = null;
return;
}
const value = props.list[event.oldDraggableIndex];
activeStringDrag = value === undefined ? null : { value };
},
onUpdate: (event) => {
restoreItem(event);
if (event.oldDraggableIndex === undefined || event.newDraggableIndex === undefined) return;
emit('update:list', moveItem(props.list, event.oldDraggableIndex, event.newDraggableIndex));
},
onRemove: (event) => {
restoreItem(event);
if (event.pullMode === 'clone') {
event.clone.remove();
return;
}
if (event.oldDraggableIndex === undefined) return;
const next = [...props.list];
next.splice(event.oldDraggableIndex, 1);
emit('update:list', next);
},
onAdd: (event) => {
event.item.remove();
if (event.newDraggableIndex === undefined) return;
const value = activeStringDrag?.value ?? event.item.getAttribute(SORTABLE_ITEM_ATTRIBUTE);
if (value === null) return;
const next = [...props.list];
next.splice(event.newDraggableIndex, 0, value);
emit('update:list', next);
},
onEnd: (event) => {
if (event.from === event.to && event.oldIndex === event.newIndex && initialChildren) {
for (const child of initialChildren) event.from.append(child);
}
initialChildren = null;
activeStringDrag = null;
},
}));
useDraggable(root, options);
return () => {
const header = slots.header?.() ?? [];
const items = props.list.flatMap((element, index) =>
slots.item({ element, index }).map((node) =>
cloneVNode(node, {
key: element,
[SORTABLE_ITEM_ATTRIBUTE]: element,
// Prevent a long-list touch gesture from being cancelled as page scrolling.
style: [node.props?.style, { touchAction: 'none' }],
})
)
);
return h(props.tag, { ...attrs, ref: root }, [...header, ...items]);
};
},
});
+1 -1
View File
@@ -691,7 +691,7 @@ onMounted(() => {
</div>
<p>항목을 끌어 놓거나 ·아래 버튼으로 상대 순서를 바꿉니다.</p>
<SortableStringList
:list="mobileLayoutOrder"
v-model:list="mobileLayoutOrder"
tag="ol"
class="mobile-layout-list"
>
@@ -448,7 +448,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
<div class="priority-column">
<div class="sub_bar legacy-bg2">비활성</div>
<SortableStringList
:list="panel.state.inactive"
v-model:list="panel.state.inactive"
:group="`npc-priority-${panel.key}`"
tag="div"
class="priority-list"
@@ -477,7 +477,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
<div class="priority-column">
<div class="sub_bar legacy-bg2">활성</div>
<SortableStringList
:list="panel.state.active"
v-model:list="panel.state.active"
:group="`npc-priority-${panel.key}`"
tag="div"
class="priority-list"
+23 -14
View File
@@ -210,12 +210,12 @@ importers:
vue:
specifier: ^3.5.26
version: 3.5.41(typescript@6.0.3)
vue-draggable-plus:
specifier: 0.6.1
version: 0.6.1(@types/sortablejs@1.15.9)
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.41(typescript@6.0.3))
vuedraggable-es:
specifier: 4.1.1
version: 4.1.1(vue@3.5.41(typescript@6.0.3))
zod:
specifier: ^4.3.5
version: 4.4.3
@@ -2257,6 +2257,9 @@ packages:
'@types/sanitize-html@2.16.1':
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
'@types/sortablejs@1.15.9':
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -4776,6 +4779,15 @@ packages:
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-draggable-plus@0.6.1:
resolution: {integrity: sha512-FbtQ/fuoixiOfTZzG3yoPl4JAo9HJXRHmBQZFB9x2NYCh6pq0TomHf7g5MUmpaDYv+LU2n6BPq2YN9sBO+FbIg==}
peerDependencies:
'@types/sortablejs': ^1.15.0
'@vue/composition-api': '*'
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-eslint-parser@10.4.1:
resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4801,11 +4813,6 @@ packages:
typescript:
optional: true
vuedraggable-es@4.1.1:
resolution: {integrity: sha512-F35pjSwC8HS/lnaOd+B59nYR4FZmwuhWAzccK9xftRuWds8SU1TZh5myKVM86j5dFOI7S26O64Kwe7LUHnXjlA==}
peerDependencies:
vue: ^3.2.31
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
@@ -6247,6 +6254,8 @@ snapshots:
dependencies:
htmlparser2: 10.1.0
'@types/sortablejs@1.15.9': {}
'@types/unist@3.0.3': {}
'@types/web-bluetooth@0.0.21': {}
@@ -8446,7 +8455,8 @@ snapshots:
dependencies:
atomic-sleep: 1.0.0
sortablejs@1.14.0: {}
sortablejs@1.14.0:
optional: true
source-map-js@1.2.1: {}
@@ -8784,6 +8794,10 @@ snapshots:
vscode-uri@3.1.0: {}
vue-draggable-plus@0.6.1(@types/sortablejs@1.15.9):
dependencies:
'@types/sortablejs': 1.15.9
vue-eslint-parser@10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
debug: 4.4.3(supports-color@7.2.0)
@@ -8817,11 +8831,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
vuedraggable-es@4.1.1(vue@3.5.41(typescript@6.0.3)):
dependencies:
sortablejs: 1.14.0
vue: 3.5.41(typescript@6.0.3)
w3c-keyname@2.2.8: {}
which@2.0.2: