From 9ef2f63d5724cc119c06b44e7e32880ee5d22252 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 19 Aug 2026 12:33:32 +0300 Subject: [PATCH] fix(editor): harden snapping edge cases - Preserve rounded auto-layout preview displacement through drop commits - Bound imported layout-guide counts and tiny grid steps - Normalize missing guide arrays in direct graph hydration paths - Add regression coverage for auto-layout movement and guide limits --- packages/core/src/kiwi/fig/parse/transfer.ts | 4 ++- packages/core/src/library/serialization.ts | 8 ++++- packages/scene-graph/src/layout-guides.ts | 28 ++++++++++++++---- packages/vue/src/shared/input/move.ts | 10 ++++++- .../engine/scene-graph/layout-guides.test.ts | 29 +++++++++++++++++++ tests/engine/vue/input/move-threshold.test.ts | 27 +++++++++++++++++ 6 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 tests/engine/scene-graph/layout-guides.test.ts diff --git a/packages/core/src/kiwi/fig/parse/transfer.ts b/packages/core/src/kiwi/fig/parse/transfer.ts index ca26b35fb..8e1283434 100644 --- a/packages/core/src/kiwi/fig/parse/transfer.ts +++ b/packages/core/src/kiwi/fig/parse/transfer.ts @@ -115,7 +115,9 @@ export function cloneSceneGraphForFigExport(graph: SceneGraph): SceneGraph { export function deserializeSceneGraph(data: SerializedSceneGraph): SceneGraph { const graph = new SceneGraph() graph.rootId = data.rootId - graph.nodes = new Map(data.nodes) + graph.nodes = new Map( + data.nodes.map(([id, node]) => [id, { ...node, guides: node.guides ?? [] }]) + ) graph.images = new Map(data.images) graph.variables = new Map(data.variables) graph.variableCollections = new Map(data.variableCollections) diff --git a/packages/core/src/library/serialization.ts b/packages/core/src/library/serialization.ts index 001121284..ab0415de9 100644 --- a/packages/core/src/library/serialization.ts +++ b/packages/core/src/library/serialization.ts @@ -39,7 +39,13 @@ export function deserializeLibraryRevision( ): ComponentLibraryRevision { const graph = new SceneGraph() graph.rootId = revision.graph.rootId - graph.nodes = new Map(revision.graph.nodes.map(([id, node]) => [id, structuredClone(node)])) + graph.nodes = new Map( + revision.graph.nodes.map(([id, node]) => { + const cloned = structuredClone(node) + cloned.guides ??= [] + return [id, cloned] + }) + ) graph.images = new Map( revision.graph.images.map(([hash, bytes]) => [hash, new Uint8Array(bytes)]) ) diff --git a/packages/scene-graph/src/layout-guides.ts b/packages/scene-graph/src/layout-guides.ts index 7810ca117..cc60bc8c5 100644 --- a/packages/scene-graph/src/layout-guides.ts +++ b/packages/scene-graph/src/layout-guides.ts @@ -5,6 +5,14 @@ export interface LayoutGuideLine { position: number } +const MAX_LAYOUT_GUIDE_LINES = 10_000 + +function normalizedCount(grid: LayoutGrid): number { + const value = grid.count ?? grid.numSections ?? 1 + if (!Number.isFinite(value)) return 0 + return Math.min(MAX_LAYOUT_GUIDE_LINES, Math.max(0, Math.floor(value))) +} + function pattern(grid: LayoutGrid): 'COLUMNS' | 'ROWS' | 'GRID' { if (grid.pattern === 'GRID' || grid.pattern === 'ROWS') return grid.pattern if (grid.axis === 'Y') return 'ROWS' @@ -20,14 +28,14 @@ function alignment(grid: LayoutGrid): 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' { function sectionSize(nodeSize: number, grid: LayoutGrid): number { const fixed = grid.sectionSize ?? 0 if (alignment(grid) !== 'STRETCH') return fixed - const count = grid.count ?? grid.numSections ?? 1 + const count = normalizedCount(grid) const gutter = grid.gutterSize ?? 0 const offset = grid.offset ?? 0 return (nodeSize - offset * 2 - Math.max(0, count - 1) * gutter) / count } function start(nodeSize: number, grid: LayoutGrid, size: number): number { - const count = grid.count ?? grid.numSections ?? 1 + const count = normalizedCount(grid) const gutter = grid.gutterSize ?? 0 const offset = grid.offset ?? 0 const span = count * size + Math.max(0, count - 1) * gutter @@ -50,8 +58,8 @@ export function layoutGuideSections( const axis = pattern(grid) === 'ROWS' ? 'y' : 'x' const nodeSize = axis === 'x' ? node.width : node.height const size = sectionSize(nodeSize, grid) - const count = grid.count ?? grid.numSections ?? 1 - if (!Number.isFinite(count) || count <= 0 || size <= 0) return [] + const count = normalizedCount(grid) + if (count <= 0 || size <= 0) return [] const first = start(nodeSize, grid, size) const step = size + (grid.gutterSize ?? 0) return Array.from({ length: count }, (_, index) => { @@ -67,10 +75,18 @@ export function layoutGuideLines(node: Pick, grid if (gridPattern === 'GRID') { const size = grid.sectionSize ?? 0 if (size <= 0) return lines - for (let x = grid.offset ?? 0; x <= node.width; x += size) { + for ( + let index = 0, x = grid.offset ?? 0; + x <= node.width && index < MAX_LAYOUT_GUIDE_LINES; + index++, x += size + ) { lines.push({ axis: 'x', position: x }) } - for (let y = grid.offset ?? 0; y <= node.height; y += size) { + for ( + let index = 0, y = grid.offset ?? 0; + y <= node.height && index < MAX_LAYOUT_GUIDE_LINES; + index++, y += size + ) { lines.push({ axis: 'y', position: y }) } return lines diff --git a/packages/vue/src/shared/input/move.ts b/packages/vue/src/shared/input/move.ts index 82d041f0b..ef2f6dbbc 100644 --- a/packages/vue/src/shared/input/move.ts +++ b/packages/vue/src/shared/input/move.ts @@ -86,8 +86,16 @@ export function handleMoveMove( if (dropParent && dropParent.layoutMode !== 'NONE') { computeAutoLayoutIndicatorForFrame(dropParent, cx, cy, editor) editor.setDropTarget(dropParent.id) + let firstApplied: { dx: number; dy: number } | null = null for (const [id, orig] of d.originals) { - editor.graph.updateNodePositionPreview(id, Math.round(orig.x + dx), Math.round(orig.y + dy)) + const previewX = Math.round(orig.x + dx) + const previewY = Math.round(orig.y + dy) + firstApplied ??= { dx: previewX - orig.x, dy: previewY - orig.y } + editor.graph.updateNodePositionPreview(id, previewX, previewY) + } + if (firstApplied) { + d.appliedDx = firstApplied.dx + d.appliedDy = firstApplied.dy } editor.requestRepaint() return diff --git a/tests/engine/scene-graph/layout-guides.test.ts b/tests/engine/scene-graph/layout-guides.test.ts new file mode 100644 index 000000000..af157545a --- /dev/null +++ b/tests/engine/scene-graph/layout-guides.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' + +import { layoutGuideLines, layoutGuideSections } from '@open-pencil/scene-graph/layout-guides' + +describe('layout guide geometry bounds', () => { + test('normalizes fractional section counts', () => { + expect( + layoutGuideSections( + { width: 100, height: 100 }, + { pattern: 'COLUMNS', count: 2.8, sectionSize: 10 } + ) + ).toHaveLength(2) + }) + + test('caps pathological section and grid line counts', () => { + expect( + layoutGuideSections( + { width: 100, height: 100 }, + { pattern: 'COLUMNS', count: Number.MAX_SAFE_INTEGER, sectionSize: 0.001 } + ) + ).toHaveLength(10_000) + expect( + layoutGuideLines( + { width: 100, height: 100 }, + { pattern: 'GRID', sectionSize: Number.MIN_VALUE } + ) + ).toHaveLength(20_000) + }) +}) diff --git a/tests/engine/vue/input/move-threshold.test.ts b/tests/engine/vue/input/move-threshold.test.ts index 77554c6d2..488ba85b3 100644 --- a/tests/engine/vue/input/move-threshold.test.ts +++ b/tests/engine/vue/input/move-threshold.test.ts @@ -138,6 +138,33 @@ describe('selection move drag threshold', () => { expect(drag.appliedDy).toBeCloseTo(74 * Math.sin(angle) + 43 * Math.cos(angle), 3) }) + test('stores rounded preview displacement when targeting auto-layout', () => { + const editor = createEditor() + const pageId = editor.state.currentPageId + const moving = editor.graph.createNode('RECTANGLE', pageId, { + x: 10.25, + y: 20.25, + width: 20, + height: 20 + }) + editor.graph.createNode('FRAME', pageId, { + x: 100, + y: 100, + width: 200, + height: 100, + layoutMode: 'HORIZONTAL' + }) + editor.select([moving.id]) + const drag = createSelectionMoveDrag(10.25, 20.25, 0, 0, editor, false) + if (drag.type !== 'move') throw new Error('Expected move drag') + + handleMoveMove(drag, 150.6, 130.7, 10, 10, editor) + + expect(drag.appliedDx).toBeCloseTo(140.75) + expect(drag.appliedDy).toBeCloseTo(110.75) + expect(editor.graph.getNode(moving.id)).toMatchObject({ x: 151, y: 131 }) + }) + test('removes duplicate created for alt-click without movement', () => { const editor = createEditor() const pageId = editor.state.currentPageId