openpencil/packages/scene-graph/src/snap.ts
Danila Poyarkov 91e759d7fd feat(editor): add configurable snapping and guides
- Snap vector points, moved layers, and resized edges to geometry, objects, guides, and pixels
- Add persistent snapping preferences with browser and native menu controls
- Normalize canvas and layout guides across Scene Graph and .fig conversion
- Clear transient snapping feedback across interrupted interactions
2026-08-18 19:11:48 +03:00

130 lines
3.7 KiB
TypeScript

import type { SceneNode } from './'
import { rotatedBBox } from './geometry'
import type { Rect } from './primitives'
const DEFAULT_SNAP_THRESHOLD = 5
export interface SnapGuide {
axis: 'x' | 'y'
position: number
from: number
to: number
}
export interface SnapResult {
dx: number
dy: number
guides: SnapGuide[]
}
function getEdges(node: SceneNode) {
return rotatedBBox(node.x, node.y, node.width, node.height, node.rotation)
}
// Pairwise edge/center matching intentionally keeps both axes in one pass.
// eslint-disable-next-line complexity
export function computeSnap(
movingIds: Set<string>,
movingBounds: Rect,
allNodes: SceneNode[],
threshold = DEFAULT_SNAP_THRESHOLD
): SnapResult {
const targets = allNodes.filter((n) => !movingIds.has(n.id))
if (targets.length === 0) return { dx: 0, dy: 0, guides: [] }
const m = {
left: movingBounds.x,
right: movingBounds.x + movingBounds.width,
centerX: movingBounds.x + movingBounds.width / 2,
top: movingBounds.y,
bottom: movingBounds.y + movingBounds.height,
centerY: movingBounds.y + movingBounds.height / 2
}
let bestDx = Infinity
let bestDy = Infinity
const guides: SnapGuide[] = []
for (const target of targets) {
const t = getEdges(target)
// X-axis snapping: left-to-left, left-to-right, right-to-left, right-to-right, center-to-center
const xPairs: [number, number][] = [
[m.left, t.left],
[m.left, t.right],
[m.right, t.left],
[m.right, t.right],
[m.centerX, t.centerX]
]
for (const [mVal, tVal] of xPairs) {
const d = tVal - mVal
if (Math.abs(d) < threshold && Math.abs(d) <= Math.abs(bestDx)) {
if (Math.abs(d) < Math.abs(bestDx)) {
bestDx = d
guides.length = guides.filter((g) => g.axis === 'y').length
? guides.length
: guides.length
// Remove old x guides if we found a closer snap
for (let i = guides.length - 1; i >= 0; i--) {
if (guides[i].axis === 'x') guides.splice(i, 1)
}
}
if (Math.abs(d) === Math.abs(bestDx)) {
const minY = Math.min(m.top, t.top)
const maxY = Math.max(m.bottom, t.bottom)
guides.push({ axis: 'x', position: tVal, from: minY, to: maxY })
}
}
}
// Y-axis snapping
const yPairs: [number, number][] = [
[m.top, t.top],
[m.top, t.bottom],
[m.bottom, t.top],
[m.bottom, t.bottom],
[m.centerY, t.centerY]
]
for (const [mVal, tVal] of yPairs) {
const d = tVal - mVal
if (Math.abs(d) < threshold && Math.abs(d) <= Math.abs(bestDy)) {
if (Math.abs(d) < Math.abs(bestDy)) {
for (let i = guides.length - 1; i >= 0; i--) {
if (guides[i].axis === 'y') guides.splice(i, 1)
}
bestDy = d
}
if (Math.abs(d) === Math.abs(bestDy)) {
const minX = Math.min(m.left, t.left)
const maxX = Math.max(m.right, t.right)
guides.push({ axis: 'y', position: tVal, from: minX, to: maxX })
}
}
}
}
return {
dx: Math.abs(bestDx) <= threshold ? bestDx : 0,
dy: Math.abs(bestDy) <= threshold ? bestDy : 0,
guides
}
}
export function computeSelectionBounds(nodes: SceneNode[]): Rect | null {
if (nodes.length === 0) return null
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
const edges = getEdges(n)
minX = Math.min(minX, edges.left)
minY = Math.min(minY, edges.top)
maxX = Math.max(maxX, edges.right)
maxY = Math.max(maxY, edges.bottom)
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}