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:
parent
94d3b28be6
commit
9ef2f63d57
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)])
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<SceneNode, 'width' | 'height'>, 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
29
tests/engine/scene-graph/layout-guides.test.ts
Normal file
29
tests/engine/scene-graph/layout-guides.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue