From 0b0697d1658445503973ea10d549dbb67c0fd0f7 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 02:19:40 +0000 Subject: [PATCH 1/2] =?UTF-8?q?refactor(frontend):=20Vue=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EC=A0=95=EB=A0=AC=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=EB=A5=BC=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-frontend/e2e/npcPolicy.spec.ts | 1 + app/game-frontend/package.json | 2 +- .../src/components/ui/SortableStringList.ts | 107 +++++++++++++++--- app/game-frontend/src/views/MyPageView.vue | 2 +- .../src/views/NpcControlView.vue | 4 +- pnpm-lock.yaml | 37 +++--- 6 files changed, 118 insertions(+), 35 deletions(-) diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts index 97e0b2ab..d4b2fe43 100644 --- a/app/game-frontend/e2e/npcPolicy.spec.ts +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -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), diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 45ecd77a..19180b39 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -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" }, diff --git a/app/game-frontend/src/components/ui/SortableStringList.ts b/app/game-frontend/src/components/ui/SortableStringList.ts index 12973226..30f17229 100644 --- a/app/game-frontend/src/components/ui/SortableStringList.ts +++ b/app/game-frontend/src/components/ui/SortableStringList.ts @@ -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) => { + 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(null); + let initialChildren: ChildNode[] | null = null; + + const options = computed>(() => ({ + 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]); + }; }, }); diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index bb6e5512..4c4cdf5a 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -691,7 +691,7 @@ onMounted(() => {

항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.

diff --git a/app/game-frontend/src/views/NpcControlView.vue b/app/game-frontend/src/views/NpcControlView.vue index 4629dcb6..69fe3665 100644 --- a/app/game-frontend/src/views/NpcControlView.vue +++ b/app/game-frontend/src/views/NpcControlView.vue @@ -448,7 +448,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
비활성
{
활성
=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: From 349a9d367386946f217fab14e1bbde3b507655ab Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 02:34:41 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test(frontend):=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=ED=84=B0=EC=B9=98=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=A2=8C=ED=91=9C=EB=A5=BC=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-frontend/e2e/npcPolicy.spec.ts | 1 - app/game-frontend/e2e/touchDrag.ts | 114 +++++++++++++----- .../src/components/ui/SortableStringList.ts | 2 - 3 files changed, 83 insertions(+), 34 deletions(-) diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts index d4b2fe43..97e0b2ab 100644 --- a/app/game-frontend/e2e/npcPolicy.spec.ts +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -344,7 +344,6 @@ 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), diff --git a/app/game-frontend/e2e/touchDrag.ts b/app/game-frontend/e2e/touchDrag.ts index c79bcb99..11b607fb 100644 --- a/app/game-frontend/e2e/touchDrag.ts +++ b/app/game-frontend/e2e/touchDrag.ts @@ -9,15 +9,27 @@ type TouchDragOptions = { targetYRatio?: number; }; -const pointIn = async (locator: Locator, yRatio = 0.5): Promise => { - const box = await locator.boundingBox(); - if (!box) { - throw new Error('Touch drag target has no visible bounding box'); +const pointInStable = async (locator: Locator, yRatio = 0.5): Promise => { + let previous: TouchPoint | null = null; + for (let attempt = 0; attempt < 5; attempt += 1) { + await locator.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + ); + const box = await locator.boundingBox(); + if (!box) { + throw new Error('Touch drag target has no visible bounding box'); + } + const point = { + x: box.x + box.width / 2, + y: box.y + box.height * yRatio, + }; + if (previous && Math.abs(previous.x - point.x) < 0.25 && Math.abs(previous.y - point.y) < 0.25) { + return point; + } + previous = point; } - return { - x: box.x + box.width / 2, - y: box.y + box.height * yRatio, - }; + if (!previous) throw new Error('Touch drag target did not produce a stable point'); + return previous; }; export const touchDrag = async ( @@ -26,25 +38,66 @@ export const touchDrag = async ( target: Locator, options: TouchDragOptions = {} ): Promise => { - await source.scrollIntoViewIfNeeded(); - await target.scrollIntoViewIfNeeded(); - const from = await pointIn(source); - const to = await pointIn(target, options.targetYRatio); const cdp = await page.context().newCDPSession(page); - await page.evaluate(() => { - document.documentElement.removeAttribute('data-playwright-touch-trusted'); - document.addEventListener( - 'touchstart', - (event) => document.documentElement.setAttribute('data-playwright-touch-trusted', String(event.isTrusted)), - { capture: true, once: true } - ); - }); + let from: TouchPoint | null = null; + let to: TouchPoint | null = null; + + for (let attempt = 0; attempt < 2; attempt += 1) { + await source.scrollIntoViewIfNeeded(); + await target.scrollIntoViewIfNeeded(); + from = await pointInStable(source); + to = await pointInStable(target, options.targetYRatio); + await page.evaluate(() => { + for (const element of document.querySelectorAll('[data-playwright-touch-source]')) { + element.removeAttribute('data-playwright-touch-source'); + } + }); + await source.evaluate((element) => element.setAttribute('data-playwright-touch-source', '')); + await page.evaluate(() => { + document.documentElement.removeAttribute('data-playwright-touch-trusted'); + document.documentElement.removeAttribute('data-playwright-touch-source-hit'); + document.addEventListener( + 'touchstart', + (event) => { + const sourceElement = document.querySelector('[data-playwright-touch-source]'); + document.documentElement.setAttribute('data-playwright-touch-trusted', String(event.isTrusted)); + document.documentElement.setAttribute( + 'data-playwright-touch-source-hit', + String(event.target instanceof Node && sourceElement?.contains(event.target)) + ); + }, + { capture: true, once: true } + ); + }); + + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ ...from, id: 0, radiusX: 1, radiusY: 1, force: 1 }], + }); + await page.waitForTimeout(50); + const startState = await page.evaluate(() => ({ + trusted: document.documentElement.getAttribute('data-playwright-touch-trusted') === 'true', + sourceHit: document.documentElement.getAttribute('data-playwright-touch-source-hit') === 'true', + })); + if (!startState.trusted) { + throw new Error('Chromium did not dispatch a trusted touchstart event'); + } + if (startState.sourceHit) break; + + await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] }); + await page.evaluate(() => { + for (const element of document.querySelectorAll('[data-playwright-touch-source]')) { + element.removeAttribute('data-playwright-touch-source'); + } + }); + from = null; + to = null; + } + + if (!from || !to) { + throw new Error('Trusted touchstart did not land on the requested drag source'); + } - await cdp.send('Input.dispatchTouchEvent', { - type: 'touchStart', - touchPoints: [{ ...from, id: 0, radiusX: 1, radiusY: 1, force: 1 }], - }); - await page.waitForTimeout(50); const dispatchMove = async (ratio: number) => { await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', @@ -68,10 +121,9 @@ export const touchDrag = async ( } await page.waitForTimeout(50); await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); - const trusted = await page.evaluate( - () => document.documentElement.getAttribute('data-playwright-touch-trusted') === 'true' - ); - if (!trusted) { - throw new Error('Chromium did not dispatch a trusted touchstart event'); - } + await page.evaluate(() => { + for (const element of document.querySelectorAll('[data-playwright-touch-source]')) { + element.removeAttribute('data-playwright-touch-source'); + } + }); }; diff --git a/app/game-frontend/src/components/ui/SortableStringList.ts b/app/game-frontend/src/components/ui/SortableStringList.ts index 30f17229..d5e4cbe3 100644 --- a/app/game-frontend/src/components/ui/SortableStringList.ts +++ b/app/game-frontend/src/components/ui/SortableStringList.ts @@ -105,8 +105,6 @@ export default defineComponent({ 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' }], }) ) );