fix(editor): address snapping review feedback

- Preserve parent-local movement during Control bypass
- Route native snapping menu actions through persisted preferences
- Harden guide alignment, lifecycle rendering, and transformed resize behavior
- Update preference descriptions and fractional position display
This commit is contained in:
Danila Poyarkov 2026-08-18 22:03:53 +03:00
parent 91e759d7fd
commit 921cd702f8
21 changed files with 190 additions and 100 deletions

View file

@ -21,8 +21,8 @@ export function usePosition() {
const isMulti = computed(() => nodes.value.length > 1)
const ids = computed(() => nodes.value.map((n) => n.id))
const x = computed(() => Math.round(node.value?.x ?? 0))
const y = computed(() => Math.round(node.value?.y ?? 0))
const x = computed(() => node.value?.x ?? 0)
const y = computed(() => node.value?.y ?? 0)
const width = computed(() => node.value?.width ?? 0)
const height = computed(() => node.value?.height ?? 0)
const rotation = computed(() => Math.round(node.value?.rotation ?? 0))

View file

@ -85,13 +85,13 @@
"settingsDescription": "Integrationen und App-Einstellungen verwalten.",
"settingsGeneral": "Allgemein",
"settingsEditing": "Bearbeitung",
"settingsSnappingDescription": "Lege fest, wie sich Vektorpunkte beim Bearbeiten von Pfaden ausrichten.",
"settingsSnappingDescription": "Lege die Ausrichtung beim Bearbeiten von Pfaden sowie beim Verschieben und Skalieren von Ebenen fest.",
"snapToGeometry": "An Geometrie ausrichten",
"snapToGeometryDescription": "Gezogene Vektorpunkte an anderen Punkten im Pfad ausrichten.",
"snapToObjects": "An Objekten ausrichten",
"snapToObjectsDescription": "Vektorpunkte an Kanten und Mittelpunkten benachbarter Ebenen ausrichten.",
"snapToObjectsDescription": "Vektorpunkte und Ebenengrenzen an Kanten und Mittelpunkten benachbarter Ebenen ausrichten.",
"snapToPixelGrid": "Am Pixelraster ausrichten",
"snapToPixelGridDescription": "Vektorpunkte an ganzzahligen Pixelkoordinaten ausrichten.",
"snapToPixelGridDescription": "Vektorpunkte, verschobene Ebenen und skalierte Kanten an ganzen Pixeln ausrichten.",
"snapTemporaryDisableHint": "Halte beim Ziehen die Strg-Taste gedrückt, um das Ausrichten vorübergehend zu deaktivieren.",
"settingsAIAndAgents": "KI und Agenten",
"models": "Modelle",

View file

@ -85,13 +85,13 @@
"settingsDescription": "Gestiona integraciones y preferencias de la aplicación.",
"settingsGeneral": "General",
"settingsEditing": "Edición",
"settingsSnappingDescription": "Controla cómo se alinean los puntos vectoriales al editar trazados.",
"settingsSnappingDescription": "Controla la alineación al editar trazados y al mover o redimensionar capas.",
"snapToGeometry": "Ajustar a la geometría",
"snapToGeometryDescription": "Alinea los puntos vectoriales arrastrados con otros puntos del trazado.",
"snapToObjects": "Ajustar a objetos",
"snapToObjectsDescription": "Alinea los puntos vectoriales con los bordes y centros de las capas cercanas.",
"snapToObjectsDescription": "Alinea puntos vectoriales y límites de capas con los bordes y centros de las capas cercanas.",
"snapToPixelGrid": "Ajustar a la cuadrícula de píxeles",
"snapToPixelGridDescription": "Alinea los puntos vectoriales con coordenadas de píxeles enteros.",
"snapToPixelGridDescription": "Alinea puntos vectoriales, capas movidas y bordes redimensionados con píxeles enteros.",
"snapTemporaryDisableHint": "Mantén pulsada la tecla Control mientras arrastras para desactivar temporalmente el ajuste.",
"settingsAIAndAgents": "IA y agentes",
"models": "Modelos",

View file

@ -85,13 +85,13 @@
"settingsDescription": "Gérez les intégrations et les préférences de lapplication.",
"settingsGeneral": "Général",
"settingsEditing": "Édition",
"settingsSnappingDescription": "Contrôlez lalignement des points vectoriels lors de la modification des tracés.",
"settingsSnappingDescription": "Contrôlez lalignement lors de la modification des tracés et du déplacement ou redimensionnement des calques.",
"snapToGeometry": "Aligner sur la géométrie",
"snapToGeometryDescription": "Aligne les points vectoriels déplacés sur les autres points du tracé.",
"snapToObjects": "Aligner sur les objets",
"snapToObjectsDescription": "Aligne les points vectoriels sur les bords et les centres des calques voisins.",
"snapToObjectsDescription": "Aligne les points vectoriels et les limites des calques sur les bords et centres des calques voisins.",
"snapToPixelGrid": "Aligner sur la grille de pixels",
"snapToPixelGridDescription": "Aligne les points vectoriels sur des coordonnées de pixels entières.",
"snapToPixelGridDescription": "Aligne les points vectoriels, les calques déplacés et les bords redimensionnés sur des pixels entiers.",
"snapTemporaryDisableHint": "Maintenez la touche Contrôle pendant le déplacement pour désactiver temporairement lalignement.",
"settingsAIAndAgents": "IA et agents",
"models": "Modèles",

View file

@ -85,13 +85,13 @@
"settingsDescription": "Gestisci integrazioni e preferenze dellapp.",
"settingsGeneral": "Generali",
"settingsEditing": "Modifica",
"settingsSnappingDescription": "Controlla come si allineano i punti vettoriali durante la modifica dei tracciati.",
"settingsSnappingDescription": "Controlla lallineamento durante la modifica dei tracciati e lo spostamento o ridimensionamento dei livelli.",
"snapToGeometry": "Aggancia alla geometria",
"snapToGeometryDescription": "Allinea i punti vettoriali trascinati agli altri punti del tracciato.",
"snapToObjects": "Aggancia agli oggetti",
"snapToObjectsDescription": "Allinea i punti vettoriali ai bordi e ai centri dei livelli vicini.",
"snapToObjectsDescription": "Allinea punti vettoriali e limiti dei livelli ai bordi e ai centri dei livelli vicini.",
"snapToPixelGrid": "Aggancia alla griglia di pixel",
"snapToPixelGridDescription": "Allinea i punti vettoriali a coordinate di pixel intere.",
"snapToPixelGridDescription": "Allinea punti vettoriali, livelli spostati e bordi ridimensionati a pixel interi.",
"snapTemporaryDisableHint": "Tieni premuto Control durante il trascinamento per disattivare temporaneamente laggancio.",
"settingsAIAndAgents": "IA e agenti",
"models": "Modelli",

View file

@ -85,13 +85,13 @@
"settingsDescription": "連携サービスとアプリの設定を管理します。",
"settingsGeneral": "一般",
"settingsEditing": "編集",
"settingsSnappingDescription": "パス編集中のベクターポイントの整列方法を設定します。",
"settingsSnappingDescription": "パス編集、レイヤーの移動、サイズ変更時の整列方法を設定します。",
"snapToGeometry": "ジオメトリにスナップ",
"snapToGeometryDescription": "ドラッグしたベクターポイントをパス内の他のポイントに揃えます。",
"snapToObjects": "オブジェクトにスナップ",
"snapToObjectsDescription": "ベクターポイントを近くのレイヤーの端や中心に揃えます。",
"snapToObjectsDescription": "ベクターポイントとレイヤー境界を近くのレイヤーの端や中心に揃えます。",
"snapToPixelGrid": "ピクセルグリッドにスナップ",
"snapToPixelGridDescription": "ベクターポイントを整数ピクセル座標に揃えます。",
"snapToPixelGridDescription": "ベクターポイント、移動したレイヤー、サイズ変更した端を整数ピクセルに揃えます。",
"snapTemporaryDisableHint": "ドラッグ中にControlキーを押すと、スナップを一時的に無効にできます。",
"settingsAIAndAgents": "AIとエージェント",
"models": "モデル",

View file

@ -85,13 +85,13 @@
"settingsDescription": "Zarządzaj integracjami i preferencjami aplikacji.",
"settingsGeneral": "Ogólne",
"settingsEditing": "Edycja",
"settingsSnappingDescription": "Określ sposób wyrównywania punktów wektorowych podczas edycji ścieżek.",
"settingsSnappingDescription": "Określ wyrównywanie podczas edycji ścieżek oraz przesuwania i zmiany rozmiaru warstw.",
"snapToGeometry": "Przyciągaj do geometrii",
"snapToGeometryDescription": "Wyrównuj przeciągane punkty wektorowe do innych punktów ścieżki.",
"snapToObjects": "Przyciągaj do obiektów",
"snapToObjectsDescription": "Wyrównuj punkty wektorowe do krawędzi i środków pobliskich warstw.",
"snapToObjectsDescription": "Wyrównuj punkty wektorowe i granice warstw do krawędzi i środków pobliskich warstw.",
"snapToPixelGrid": "Przyciągaj do siatki pikseli",
"snapToPixelGridDescription": "Wyrównuj punkty wektorowe do pełnych współrzędnych pikseli.",
"snapToPixelGridDescription": "Wyrównuj punkty wektorowe, przesuwane warstwy i zmieniane krawędzie do pełnych pikseli.",
"snapTemporaryDisableHint": "Przytrzymaj Control podczas przeciągania, aby tymczasowo wyłączyć przyciąganie.",
"settingsAIAndAgents": "AI i agenci",
"models": "Modele",

View file

@ -85,13 +85,13 @@
"settingsDescription": "Управление интеграциями и настройками приложения.",
"settingsGeneral": "Основные",
"settingsEditing": "Редактирование",
"settingsSnappingDescription": "Настройте выравнивание векторных точек при редактировании контуров.",
"settingsSnappingDescription": "Настройте выравнивание при редактировании контуров, перемещении и изменении размеров слоёв.",
"snapToGeometry": "Привязка к геометрии",
"snapToGeometryDescription": "Выравнивать перетаскиваемые векторные точки по другим точкам контура.",
"snapToObjects": "Привязка к объектам",
"snapToObjectsDescription": "Выравнивать векторные точки по краям и центрам соседних слоёв.",
"snapToObjectsDescription": "Выравнивать векторные точки и границы слоёв по краям и центрам соседних слоёв.",
"snapToPixelGrid": "Привязка к пиксельной сетке",
"snapToPixelGridDescription": "Выравнивать векторные точки по целым пиксельным координатам.",
"snapToPixelGridDescription": "Выравнивать векторные точки, перемещаемые слои и изменяемые края по целым пикселям.",
"snapTemporaryDisableHint": "Удерживайте Control при перетаскивании, чтобы временно отключить привязку.",
"settingsAIAndAgents": "ИИ и агенты",
"models": "Модели",

View file

@ -85,13 +85,13 @@
"settingsDescription": "管理集成和应用偏好设置。",
"settingsGeneral": "通用",
"settingsEditing": "编辑",
"settingsSnappingDescription": "控制编辑路径矢量点的对齐方式。",
"settingsSnappingDescription": "控制编辑路径以及移动或调整图层大小时的对齐方式。",
"snapToGeometry": "吸附到几何图形",
"snapToGeometryDescription": "将拖动的矢量点与路径中的其他点对齐。",
"snapToObjects": "吸附到对象",
"snapToObjectsDescription": "将矢量点与附近图层的边缘和中心对齐。",
"snapToObjectsDescription": "将矢量点和图层边界与附近图层的边缘和中心对齐。",
"snapToPixelGrid": "吸附到像素网格",
"snapToPixelGridDescription": "将矢量点与整数像素坐标对齐。",
"snapToPixelGridDescription": "将矢量点、移动的图层和调整大小的边缘与整数像素对齐。",
"snapTemporaryDisableHint": "拖动时按住 Control 可暂时禁用吸附。",
"settingsAIAndAgents": "AI 和代理",
"models": "模型",

View file

@ -173,13 +173,16 @@ export const dialogMessageDefaults = {
settingsDescription: 'Manage integrations and app preferences.',
settingsGeneral: 'General',
settingsEditing: 'Editing',
settingsSnappingDescription: 'Control how vector points align while you edit paths.',
settingsSnappingDescription:
'Control alignment while editing paths, moving, and resizing layers.',
snapToGeometry: 'Snap to geometry',
snapToGeometryDescription: 'Align dragged vector points to other points in the path.',
snapToObjects: 'Snap to objects',
snapToObjectsDescription: 'Align vector points to the edges and centers of nearby layers.',
snapToObjectsDescription:
'Align vector points and layer bounds to nearby layer edges and centers.',
snapToPixelGrid: 'Snap to pixel grid',
snapToPixelGridDescription: 'Align vector points to whole-pixel coordinates.',
snapToPixelGridDescription:
'Align vector points, moved layers, and resized edges to whole pixels.',
snapTemporaryDisableHint: 'Hold Control while dragging to temporarily disable snapping.',
settingsAIAndAgents: 'AI & agents',
models: 'Models',

View file

@ -19,10 +19,12 @@ function pageGuideTargets(editor: Editor): ExplicitSnapTarget[] {
}))
}
const AXIS_ALIGNMENT_EPSILON = 1e-6
function axisAlignedTarget(start: Vector, end: Vector): ExplicitSnapTarget | null {
const dx = Math.abs(end.x - start.x)
const dy = Math.abs(end.y - start.y)
if (dx <= Number.EPSILON * Math.max(1, Math.abs(start.x), Math.abs(end.x))) {
if (dx <= AXIS_ALIGNMENT_EPSILON) {
return {
kind: 'layout-guide',
axis: 'x',
@ -31,7 +33,7 @@ function axisAlignedTarget(start: Vector, end: Vector): ExplicitSnapTarget | nul
to: Math.max(start.y, end.y)
}
}
if (dy <= Number.EPSILON * Math.max(1, Math.abs(start.y), Math.abs(end.y))) {
if (dy <= AXIS_ALIGNMENT_EPSILON) {
return {
kind: 'layout-guide',
axis: 'y',

View file

@ -2,7 +2,6 @@ import type { Editor } from '@open-pencil/core/editor'
import { computeSelectionBounds } from '@open-pencil/scene-graph'
import type { SceneNode } from '@open-pencil/scene-graph'
import { getAxisAlignedWorldBounds } from '@open-pencil/scene-graph/coordinate'
import type { Rect } from '@open-pencil/scene-graph/primitives'
import { explicitSnapTargets } from '#vue/shared/input/explicit-snap-targets'
import {
@ -35,18 +34,17 @@ function movingSelection(drag: DragMove, dx: number, dy: number, editor: Editor)
return selectedNodes
}
function absoluteMoveContext(
drag: DragMove,
bounds: Rect,
editor: Editor
): { bounds: Rect; targets: SceneNode[] } {
function moveParentId(drag: DragMove, editor: Editor): string {
const firstId = drag.originals.keys().next().value
const firstNode = firstId ? editor.graph.getNode(firstId) : undefined
const parentId = firstNode?.parentId ?? editor.state.currentPageId
return {
bounds,
targets: editor.graph.getChildren(parentId).map((node) => worldBoundsNode(node, editor))
return firstNode?.parentId ?? editor.state.currentPageId
}
function parentTargets(parentId: string, editor: Editor): SceneNode[] {
return editor.graph
.getChildren(parentId)
.filter((node) => node.visible)
.map((node) => worldBoundsNode(node, editor))
}
export function applyMoveSnap(
@ -56,25 +54,22 @@ export function applyMoveSnap(
editor: Editor,
disableSnapping = false
): { dx: number; dy: number } {
const parentId = moveParentId(drag, editor)
const localDelta = worldDeltaToParentLocal({ x: dx, y: dy }, parentId, editor)
const selectionBounds = computeSelectionBounds(movingSelection(drag, dx, dy, editor))
if (!selectionBounds || disableSnapping) {
editor.setSnapGuides([])
return { dx, dy }
return { dx: localDelta.x, dy: localDelta.y }
}
const context = absoluteMoveContext(drag, selectionBounds, editor)
const firstId = drag.originals.keys().next().value
const firstNode = firstId ? editor.graph.getNode(firstId) : undefined
const parentId = firstNode?.parentId ?? editor.state.currentPageId
const snap = resolveObjectPixelSnap(
editor.state.selectedIds,
context.bounds,
context.targets,
selectionBounds,
parentTargets(parentId, editor),
editor,
explicitSnapTargets(parentId, editor)
)
editor.setSnapGuides(snap.guides)
const localDelta = worldDeltaToParentLocal({ x: dx, y: dy }, parentId, editor)
const localCorrection = worldDeltaToParentLocal(snap.correction, parentId, editor)
return {
dx: localDelta.x + localCorrection.x,

View file

@ -34,7 +34,7 @@ function siblingSnapTargets(editor: Editor, editedNode: SceneNode): SceneNode[]
const parentId = editedNode.parentId ?? editor.state.currentPageId
return editor.graph
.getChildren(parentId)
.filter((node) => node.id !== editedNode.id)
.filter((node) => node.id !== editedNode.id && node.visible)
.map((node) => worldBoundsNode(node, editor))
}

View file

@ -1,6 +1,6 @@
import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/scene-graph'
import { getAxisAlignedWorldBounds } from '@open-pencil/scene-graph/coordinate'
import { getAxisAlignedWorldBounds, getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import type { Rect } from '@open-pencil/scene-graph/primitives'
import { explicitSnapTargets } from '#vue/shared/input/explicit-snap-targets'
@ -32,7 +32,7 @@ function resizeTargets(drag: DragResize, editor: Editor): SceneNode[] {
const parentId = node?.parentId ?? editor.state.currentPageId
return editor.graph
.getChildren(parentId)
.filter((candidate) => candidate.id !== drag.nodeId)
.filter((candidate) => candidate.id !== drag.nodeId && candidate.visible)
.map((candidate) => worldBoundsNode(candidate, editor))
}
@ -68,6 +68,11 @@ function applyEdgeDelta(handle: HandlePosition, rect: Rect, dx: number, dy: numb
return { x, y, width: Math.max(1, width), height: Math.max(1, height) }
}
function hasAxisAlignedWorldBasis(node: SceneNode, editor: Editor): boolean {
const matrix = getWorldMatrix(node, editor.graph)
return Math.abs(matrix[1]) <= 1e-6 && Math.abs(matrix[3]) <= 1e-6
}
export function applyResizeSnap(
drag: DragResize,
rect: Rect,
@ -81,6 +86,12 @@ export function applyResizeSnap(
const node = editor.graph.getNode(drag.nodeId)
if (!node) return rect
// The current candidate model is axis-aligned. Do not apply a world-axis
// correction as a local width/height delta for rotated nodes or ancestors.
if (!hasAxisAlignedWorldBasis(node, editor)) {
editor.state.snapGuides = []
return rect
}
const candidate = { ...node, ...rect }
const worldBounds = getAxisAlignedWorldBounds(candidate, editor.graph)
const activeBounds = activeEdgeBounds(drag.handle, worldBounds)

View file

@ -87,9 +87,33 @@ function winningCorrection(
return objectMatched ? objectDelta : pixelDelta
}
// Resolving ranked candidates on two axes is branch-heavy by nature; candidate
// collection remains split into focused helpers at each interaction call site.
// eslint-disable-next-line complexity
interface AxisCandidate<T extends SnapGuide> {
delta: number
target: T
}
function nearestPerAxis<T extends SnapGuide>(
targets: readonly T[],
xAnchors: readonly number[],
yAnchors: readonly number[],
threshold: number
): { x: AxisCandidate<T> | null; y: AxisCandidate<T> | null } {
let x: AxisCandidate<T> | null = null
let y: AxisCandidate<T> | null = null
for (const target of targets) {
const anchors = target.axis === 'x' ? xAnchors : yAnchors
for (const anchor of anchors) {
const delta = target.position - anchor
if (Math.abs(delta) >= threshold) continue
const current = target.axis === 'x' ? x : y
if (current && Math.abs(delta) >= Math.abs(current.delta)) continue
if (target.axis === 'x') x = { delta, target }
else y = { delta, target }
}
}
return { x, y }
}
export function resolveObjectPixelSnap(
movingIds: Set<string>,
movingBounds: Rect,
@ -109,36 +133,18 @@ export function resolveObjectPixelSnap(
movingBounds.y + movingBounds.height / 2,
movingBounds.y + movingBounds.height
]
let geometryX: { delta: number; target: GeometrySnapTarget } | null = null
let geometryY: { delta: number; target: GeometrySnapTarget } | null = null
for (const target of geometryTargets) {
const anchors = target.axis === 'x' ? xAnchors : yAnchors
for (const anchor of anchors) {
const delta = target.position - anchor
if (Math.abs(delta) >= threshold) continue
const current = target.axis === 'x' ? geometryX : geometryY
if (!current || Math.abs(delta) < Math.abs(current.delta)) {
const winner = { delta, target }
if (target.axis === 'x') geometryX = winner
else geometryY = winner
}
}
}
let explicitX: { delta: number; target: ExplicitSnapTarget } | null = null
let explicitY: { delta: number; target: ExplicitSnapTarget } | null = null
for (const target of explicitTargets) {
const anchors = target.axis === 'x' ? xAnchors : yAnchors
for (const anchor of anchors) {
const delta = target.position - anchor
if (Math.abs(delta) >= threshold) continue
const current = target.axis === 'x' ? explicitX : explicitY
if (!current || Math.abs(delta) < Math.abs(current.delta)) {
const winner = { delta, target }
if (target.axis === 'x') explicitX = winner
else explicitY = winner
}
}
}
const { x: geometryX, y: geometryY } = nearestPerAxis(
geometryTargets,
xAnchors,
yAnchors,
threshold
)
const { x: explicitX, y: explicitY } = nearestPerAxis(
explicitTargets,
xAnchors,
yAnchors,
threshold
)
const objectSnap = editor.state.snappingPreferences.objects
? computeSnap(movingIds, movingBounds, targets, threshold)
: { dx: 0, dy: 0, guides: [] }

View file

@ -108,6 +108,7 @@ export function createVectorEditLifecycle(editor: Editor, state: VectorEditState
const es = getNodeEditState()
if (!es) return
state.snapGuides = []
editor.requestRender()
const node = editor.graph.getNode(es.nodeId)
if (node?.type !== 'VECTOR') {

View file

@ -14,5 +14,7 @@ export function setSnappingPreference(
for (const tab of getTabsSnapshot()) {
tab.store.state.snappingPreferences = { ...snapping }
}
void syncNativeSnappingMenu(snapping)
void syncNativeSnappingMenu(snapping).catch((error: unknown) => {
console.error('[Settings] Failed to synchronize native snapping preferences:', error)
})
}

View file

@ -140,9 +140,24 @@ export const APP_MENU_SCHEMA = [
id: 'preferences',
label: 'Preferences',
sub: [
{ id: 'snap-geometry', label: 'Snap to Geometry', checkbox: true },
{ id: 'snap-objects', label: 'Snap to Objects', checkbox: true },
{ id: 'snap-pixel-grid', label: 'Snap to Pixel Grid', checkbox: true },
{
id: 'snap-geometry',
label: 'Snap to Geometry',
checkbox: true,
handler: 'shell'
},
{
id: 'snap-objects',
label: 'Snap to Objects',
checkbox: true,
handler: 'shell'
},
{
id: 'snap-pixel-grid',
label: 'Snap to Pixel Grid',
checkbox: true,
handler: 'shell'
},
{ type: 'separator' },
{
id: 'settings',

View file

@ -1,6 +1,7 @@
import { useI18n } from '@open-pencil/vue'
import { openSettingsDialog } from '@/app/settings/dialog'
import { setSnappingPreference } from '@/app/settings/preferences/apply'
import { syncNativeSnappingMenu } from '@/app/settings/preferences/native-menu'
import { appPreferences } from '@/app/settings/preferences/store'
import { useNativeMenuEvents } from '@/app/shell/menu/native-events'
@ -26,7 +27,9 @@ export const SHELL_MENU_IDS = new Set([
export function useShellMenu() {
if (!isTauri()) return
void syncNativeSnappingMenu(appPreferences.value.editing.snapping)
void syncNativeSnappingMenu(appPreferences.value.editing.snapping).catch((error: unknown) => {
console.error('[Menu] Failed to synchronize native snapping preferences:', error)
})
const { setTheme } = useAppTheme()
const { dialogs } = useI18n()
@ -35,6 +38,18 @@ export function useShellMenu() {
void import('@/router').then(({ default: router }) => openStorageWorkspace(router))
},
settings: openSettingsDialog,
'snap-geometry': () => {
const current = appPreferences.value.editing.snapping.geometry
setSnappingPreference('geometry', !current)
},
'snap-objects': () => {
const current = appPreferences.value.editing.snapping.objects
setSnappingPreference('objects', !current)
},
'snap-pixel-grid': () => {
const current = appPreferences.value.editing.snapping.pixelGrid
setSnappingPreference('pixelGrid', !current)
},
'theme-light': () => setTheme('light'),
'theme-dark': () => setTheme('dark'),
'theme-auto': () => setTheme('auto'),

View file

@ -81,6 +81,34 @@ describe('selection move drag threshold', () => {
expect(editor.state.snapGuides).toEqual([])
})
test('Control bypasses object and pixel snapping inside a rotated parent', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
const frame = editor.graph.createNode('FRAME', pageId, {
x: 200,
y: 150,
width: 300,
height: 300,
rotation: 30
})
const moving = editor.graph.createNode('RECTANGLE', frame.id, {
x: 20,
y: 40,
width: 50,
height: 50
})
editor.select([moving.id])
const drag = createSelectionMoveDrag(0, 0, 0, 0, editor, false)
if (drag.type !== 'move') throw new Error('Expected move drag')
handleMoveMove(drag, 74, 43, 100, 100, editor, true)
const angle = (-30 * Math.PI) / 180
expect(drag.appliedDx).toBeCloseTo(74 * Math.cos(angle) - 43 * Math.sin(angle), 3)
expect(drag.appliedDy).toBeCloseTo(74 * Math.sin(angle) + 43 * Math.cos(angle), 3)
expect(editor.state.snapGuides).toEqual([])
})
test('converts world movement into a rotated parent coordinate space', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
@ -97,13 +125,6 @@ describe('selection move drag threshold', () => {
width: 50,
height: 50
})
editor.graph.createNode('RECTANGLE', frame.id, {
x: 140,
y: 40,
width: 60,
height: 50,
rotation: 20
})
editor.select([moving.id])
editor.state.snappingPreferences = { geometry: true, objects: false, pixelGrid: false }
const drag = createSelectionMoveDrag(0, 0, 0, 0, editor, false)
@ -112,8 +133,9 @@ describe('selection move drag threshold', () => {
handleMoveMove(drag, 74, 43, 100, 100, editor)
expect(editor.state.snapGuides).toEqual([])
expect(drag.appliedDx).toBeCloseTo(85.5859, 3)
expect(drag.appliedDy).toBeCloseTo(0.2391, 3)
const angle = (-30 * Math.PI) / 180
expect(drag.appliedDx).toBeCloseTo(74 * Math.cos(angle) - 43 * Math.sin(angle), 3)
expect(drag.appliedDy).toBeCloseTo(74 * Math.sin(angle) + 43 * Math.cos(angle), 3)
})
test('removes duplicate created for alt-click without movement', () => {

View file

@ -69,6 +69,7 @@ describe('resize snapping preferences', () => {
test('Control bypass preserves fractional resize geometry', () => {
const { editor, nodeId, drag } = setup()
editor.state.snappingPreferences = { geometry: true, objects: true, pixelGrid: true }
applyResize(drag, drag.startX + 6.3, drag.startY + 7.4, false, editor, true)
commitResizePreview(drag, editor)
@ -77,6 +78,23 @@ describe('resize snapping preferences', () => {
expect(editor.state.snapGuides).toEqual([])
})
test('does not apply axis-aligned snapping to rotated resize geometry', () => {
const { editor, nodeId, drag } = setup('e')
editor.updateNode(nodeId, { rotation: 30 })
editor.graph.createNode('RECTANGLE', editor.state.currentPageId, {
x: 150,
y: 0,
width: 50,
height: 50
})
applyResize(drag, 148, drag.startY, false, editor)
commitResizePreview(drag, editor)
expect(editor.graph.getNode(nodeId)?.width).toBeCloseTo(137.75)
expect(editor.state.snapGuides).toEqual([])
})
test('object edges take priority over the pixel grid', () => {
const { editor, nodeId, drag } = setup('e')
const pageId = editor.state.currentPageId