From 08b9e56a151ec28a5e68bda7652ab0f0be804f77 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 13 May 2026 04:47:28 +0300 Subject: [PATCH] fix(editor): restore auto-layout instance dragging --- packages/vue/src/shared/input/move.ts | 25 +++++- packages/vue/src/shared/input/select/move.ts | 41 +++++++++- tests/e2e/editor/auto-layout-drag.spec.ts | 84 ++++++++++++++++++++ 3 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/editor/auto-layout-drag.spec.ts diff --git a/packages/vue/src/shared/input/move.ts b/packages/vue/src/shared/input/move.ts index 3a85a44f7..d7d0ce41a 100644 --- a/packages/vue/src/shared/input/move.ts +++ b/packages/vue/src/shared/input/move.ts @@ -11,6 +11,26 @@ import { applyMoveSnap } from '#vue/shared/input/move-snap' import type { DragMove } from '#vue/shared/input/types' const AUTO_LAYOUT_REORDER_CLICK_SLOP = 3 +const AUTO_LAYOUT_CROSS_AXIS_DRAG_TOLERANCE = 96 + +function isInsideAutoLayoutDragBounds(parentId: string, cx: number, cy: number, editor: Editor) { + const parent = editor.graph.getNode(parentId) + if (!parent) return false + const abs = editor.graph.getAbsolutePosition(parentId) + const isRow = parent.layoutMode === 'HORIZONTAL' + const mainStart = isRow ? abs.x : abs.y + const mainSize = isRow ? parent.width : parent.height + const crossStart = isRow ? abs.y : abs.x + const crossSize = isRow ? parent.height : parent.width + const main = isRow ? cx : cy + const cross = isRow ? cy : cx + return ( + main >= mainStart - AUTO_LAYOUT_BREAK_THRESHOLD && + main <= mainStart + mainSize + AUTO_LAYOUT_BREAK_THRESHOLD && + cross >= crossStart - AUTO_LAYOUT_CROSS_AXIS_DRAG_TOLERANCE && + cross <= crossStart + crossSize + AUTO_LAYOUT_CROSS_AXIS_DRAG_TOLERANCE + ) +} export function detectAutoLayoutParent(editor: Editor): string | undefined { if (editor.state.selectedIds.size !== 1) return undefined @@ -32,8 +52,7 @@ export function handleMoveMove(d: DragMove, cx: number, cy: number, editor: Edit let dy = cy - d.startY if (d.autoLayoutParentId && !d.brokeFromAutoLayout) { - const dist = Math.sqrt(dx * dx + dy * dy) - if (dist < AUTO_LAYOUT_BREAK_THRESHOLD) { + if (isInsideAutoLayoutDragBounds(d.autoLayoutParentId, cx, cy, editor)) { computeAutoLayoutIndicator(d, cx, cy, editor) return } @@ -84,7 +103,7 @@ export function handleMoveUp(d: DragMove, editor: Editor) { editor.setDropTarget(null) return } - for (const id of editor.state.selectedIds) { + for (const id of d.originals.keys()) { editor.reorderInAutoLayout(id, indicator.parentId, indicator.index) } editor.setDropTarget(null) diff --git a/packages/vue/src/shared/input/select/move.ts b/packages/vue/src/shared/input/select/move.ts index 07e9b1855..beeabdb63 100644 --- a/packages/vue/src/shared/input/select/move.ts +++ b/packages/vue/src/shared/input/select/move.ts @@ -1,7 +1,6 @@ import type { Editor } from '@open-pencil/core/editor' import { duplicateAndDrag } from '#vue/shared/input/duplicate-drag' -import { detectAutoLayoutParent } from '#vue/shared/input/move' import type { DragState } from '#vue/shared/input/types' type MoveOriginal = { x: number; y: number; parentId: string } @@ -10,9 +9,30 @@ export function selectionIsLocked(editor: Editor) { return [...editor.state.selectedIds].every((id) => editor.graph.getNode(id)?.locked) } +function autoLayoutMoveTarget(id: string, editor: Editor): string { + let current = editor.graph.getNode(id) + let target = current + + while (current?.parentId) { + const parent = editor.graph.getNode(current.parentId) + if (!parent) break + if ( + current.type === 'INSTANCE' && + parent.layoutMode !== 'NONE' && + current.layoutPositioning !== 'ABSOLUTE' + ) { + target = current + } + current = parent + } + + return target?.id ?? id +} + function collectMoveOriginals(editor: Editor) { const originals = new Map() - for (const id of editor.state.selectedIds) { + for (const selectedId of editor.state.selectedIds) { + const id = autoLayoutMoveTarget(selectedId, editor) const node = editor.graph.getNode(id) if (node) { originals.set(id, { @@ -25,6 +45,17 @@ function collectMoveOriginals(editor: Editor) { return originals } +function detectDragAutoLayoutParent(originals: Map, editor: Editor) { + if (originals.size !== 1) return undefined + const [id, original] = [...originals][0] + const node = editor.graph.getNode(id) + const parent = editor.graph.getNode(original.parentId) + if (parent && parent.layoutMode !== 'NONE' && node?.layoutPositioning !== 'ABSOLUTE') { + return parent.id + } + return undefined +} + export function createSelectionMoveDrag( cx: number, cy: number, @@ -33,13 +64,15 @@ export function createSelectionMoveDrag( ): DragState { if (duplicate && editor.state.selectedIds.size > 0) return duplicateAndDrag(cx, cy, editor).drag + const originals = collectMoveOriginals(editor) + return { type: 'move', startX: cx, startY: cy, currentX: cx, currentY: cy, - originals: collectMoveOriginals(editor), - autoLayoutParentId: detectAutoLayoutParent(editor) + originals, + autoLayoutParentId: detectDragAutoLayoutParent(originals, editor) } } diff --git a/tests/e2e/editor/auto-layout-drag.spec.ts b/tests/e2e/editor/auto-layout-drag.spec.ts new file mode 100644 index 000000000..ce8e3a2dd --- /dev/null +++ b/tests/e2e/editor/auto-layout-drag.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from '@playwright/test' + +import { CanvasHelper } from '#tests/helpers/canvas' + +test('dragging selected nested instance content reorders its auto-layout item', async ({ page }) => { + await page.goto('/') + const canvas = new CanvasHelper(page) + await canvas.waitForInit() + canvas.errors.length = 0 + await canvas.clearCanvas() + + const ids = await page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + + store.state.panX = 0 + store.state.panY = 0 + store.state.zoom = 1 + + const pageId = store.state.currentPageId + const frame = store.graph.createNode('FRAME', pageId, { + name: 'Auto Row', + x: 100, + y: 100, + width: 360, + height: 80, + layoutMode: 'HORIZONTAL', + itemSpacing: 20, + paddingTop: 10, + paddingRight: 10, + paddingBottom: 10, + paddingLeft: 10 + }) + + const first = store.graph.createNode('INSTANCE', frame.id, { + name: 'First item', + x: 110, + y: 110, + width: 120, + height: 60 + }) + store.graph.createNode('TEXT', first.id, { + name: 'First label', + text: 'First', + x: 8, + y: 8, + width: 80, + height: 24 + }) + + const second = store.graph.createNode('INSTANCE', frame.id, { + name: 'Second item', + x: 250, + y: 110, + width: 120, + height: 60 + }) + const secondText = store.graph.createNode('TEXT', second.id, { + name: 'Second label', + text: 'Second', + x: 8, + y: 8, + width: 80, + height: 24 + }) + + store.select([secondText.id]) + store.requestRender() + return { frame: frame.id, first: first.id, second: second.id, secondText: secondText.id } + }) + await canvas.waitForRender() + + await canvas.drag(370, 228, 230, 228, 12) + await canvas.waitForRender() + + const childIds = await page.evaluate((frameId) => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + return store.graph.getNode(frameId)?.childIds ?? [] + }, ids.frame) + + expect(childIds).toEqual([ids.second, ids.first]) + canvas.assertNoErrors() +})