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 {
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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)])
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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)
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue