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
This commit is contained in:
Danila Poyarkov 2026-08-19 12:33:32 +03:00
parent 94d3b28be6
commit 9ef2f63d57
6 changed files with 97 additions and 9 deletions

View file

@ -115,7 +115,9 @@ export function cloneSceneGraphForFigExport(graph: SceneGraph): SceneGraph {
export function deserializeSceneGraph(data: SerializedSceneGraph): SceneGraph { export function deserializeSceneGraph(data: SerializedSceneGraph): SceneGraph {
const graph = new SceneGraph() const graph = new SceneGraph()
graph.rootId = data.rootId 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.images = new Map(data.images)
graph.variables = new Map(data.variables) graph.variables = new Map(data.variables)
graph.variableCollections = new Map(data.variableCollections) graph.variableCollections = new Map(data.variableCollections)

View file

@ -39,7 +39,13 @@ export function deserializeLibraryRevision(
): ComponentLibraryRevision { ): ComponentLibraryRevision {
const graph = new SceneGraph() const graph = new SceneGraph()
graph.rootId = revision.graph.rootId 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( graph.images = new Map(
revision.graph.images.map(([hash, bytes]) => [hash, new Uint8Array(bytes)]) revision.graph.images.map(([hash, bytes]) => [hash, new Uint8Array(bytes)])
) )

View file

@ -5,6 +5,14 @@ export interface LayoutGuideLine {
position: number 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' { function pattern(grid: LayoutGrid): 'COLUMNS' | 'ROWS' | 'GRID' {
if (grid.pattern === 'GRID' || grid.pattern === 'ROWS') return grid.pattern if (grid.pattern === 'GRID' || grid.pattern === 'ROWS') return grid.pattern
if (grid.axis === 'Y') return 'ROWS' 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 { function sectionSize(nodeSize: number, grid: LayoutGrid): number {
const fixed = grid.sectionSize ?? 0 const fixed = grid.sectionSize ?? 0
if (alignment(grid) !== 'STRETCH') return fixed if (alignment(grid) !== 'STRETCH') return fixed
const count = grid.count ?? grid.numSections ?? 1 const count = normalizedCount(grid)
const gutter = grid.gutterSize ?? 0 const gutter = grid.gutterSize ?? 0
const offset = grid.offset ?? 0 const offset = grid.offset ?? 0
return (nodeSize - offset * 2 - Math.max(0, count - 1) * gutter) / count return (nodeSize - offset * 2 - Math.max(0, count - 1) * gutter) / count
} }
function start(nodeSize: number, grid: LayoutGrid, size: number): number { 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 gutter = grid.gutterSize ?? 0
const offset = grid.offset ?? 0 const offset = grid.offset ?? 0
const span = count * size + Math.max(0, count - 1) * gutter 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 axis = pattern(grid) === 'ROWS' ? 'y' : 'x'
const nodeSize = axis === 'x' ? node.width : node.height const nodeSize = axis === 'x' ? node.width : node.height
const size = sectionSize(nodeSize, grid) const size = sectionSize(nodeSize, grid)
const count = grid.count ?? grid.numSections ?? 1 const count = normalizedCount(grid)
if (!Number.isFinite(count) || count <= 0 || size <= 0) return [] if (count <= 0 || size <= 0) return []
const first = start(nodeSize, grid, size) const first = start(nodeSize, grid, size)
const step = size + (grid.gutterSize ?? 0) const step = size + (grid.gutterSize ?? 0)
return Array.from({ length: count }, (_, index) => { return Array.from({ length: count }, (_, index) => {
@ -67,10 +75,18 @@ export function layoutGuideLines(node: Pick<SceneNode, 'width' | 'height'>, grid
if (gridPattern === 'GRID') { if (gridPattern === 'GRID') {
const size = grid.sectionSize ?? 0 const size = grid.sectionSize ?? 0
if (size <= 0) return lines 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 }) 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 }) lines.push({ axis: 'y', position: y })
} }
return lines return lines

View file

@ -86,8 +86,16 @@ export function handleMoveMove(
if (dropParent && dropParent.layoutMode !== 'NONE') { if (dropParent && dropParent.layoutMode !== 'NONE') {
computeAutoLayoutIndicatorForFrame(dropParent, cx, cy, editor) computeAutoLayoutIndicatorForFrame(dropParent, cx, cy, editor)
editor.setDropTarget(dropParent.id) editor.setDropTarget(dropParent.id)
let firstApplied: { dx: number; dy: number } | null = null
for (const [id, orig] of d.originals) { 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() editor.requestRepaint()
return return

View file

@ -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)
})
})

View file

@ -138,6 +138,33 @@ describe('selection move drag threshold', () => {
expect(drag.appliedDy).toBeCloseTo(74 * Math.sin(angle) + 43 * Math.cos(angle), 3) 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', () => { test('removes duplicate created for alt-click without movement', () => {
const editor = createEditor() const editor = createEditor()
const pageId = editor.state.currentPageId const pageId = editor.state.currentPageId