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:
parent
91e759d7fd
commit
921cd702f8
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -85,13 +85,13 @@
|
|||
"settingsDescription": "Gérez les intégrations et les préférences de l’application.",
|
||||
"settingsGeneral": "Général",
|
||||
"settingsEditing": "Édition",
|
||||
"settingsSnappingDescription": "Contrôlez l’alignement des points vectoriels lors de la modification des tracés.",
|
||||
"settingsSnappingDescription": "Contrôlez l’alignement 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 l’alignement.",
|
||||
"settingsAIAndAgents": "IA et agents",
|
||||
"models": "Modèles",
|
||||
|
|
|
|||
|
|
@ -85,13 +85,13 @@
|
|||
"settingsDescription": "Gestisci integrazioni e preferenze dell’app.",
|
||||
"settingsGeneral": "Generali",
|
||||
"settingsEditing": "Modifica",
|
||||
"settingsSnappingDescription": "Controlla come si allineano i punti vettoriali durante la modifica dei tracciati.",
|
||||
"settingsSnappingDescription": "Controlla l’allineamento 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 l’aggancio.",
|
||||
"settingsAIAndAgents": "IA e agenti",
|
||||
"models": "Modelli",
|
||||
|
|
|
|||
|
|
@ -85,13 +85,13 @@
|
|||
"settingsDescription": "連携サービスとアプリの設定を管理します。",
|
||||
"settingsGeneral": "一般",
|
||||
"settingsEditing": "編集",
|
||||
"settingsSnappingDescription": "パス編集中のベクターポイントの整列方法を設定します。",
|
||||
"settingsSnappingDescription": "パス編集、レイヤーの移動、サイズ変更時の整列方法を設定します。",
|
||||
"snapToGeometry": "ジオメトリにスナップ",
|
||||
"snapToGeometryDescription": "ドラッグしたベクターポイントをパス内の他のポイントに揃えます。",
|
||||
"snapToObjects": "オブジェクトにスナップ",
|
||||
"snapToObjectsDescription": "ベクターポイントを近くのレイヤーの端や中心に揃えます。",
|
||||
"snapToObjectsDescription": "ベクターポイントとレイヤー境界を近くのレイヤーの端や中心に揃えます。",
|
||||
"snapToPixelGrid": "ピクセルグリッドにスナップ",
|
||||
"snapToPixelGridDescription": "ベクターポイントを整数のピクセル座標に揃えます。",
|
||||
"snapToPixelGridDescription": "ベクターポイント、移動したレイヤー、サイズ変更した端を整数ピクセルに揃えます。",
|
||||
"snapTemporaryDisableHint": "ドラッグ中にControlキーを押すと、スナップを一時的に無効にできます。",
|
||||
"settingsAIAndAgents": "AIとエージェント",
|
||||
"models": "モデル",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -85,13 +85,13 @@
|
|||
"settingsDescription": "Управление интеграциями и настройками приложения.",
|
||||
"settingsGeneral": "Основные",
|
||||
"settingsEditing": "Редактирование",
|
||||
"settingsSnappingDescription": "Настройте выравнивание векторных точек при редактировании контуров.",
|
||||
"settingsSnappingDescription": "Настройте выравнивание при редактировании контуров, перемещении и изменении размеров слоёв.",
|
||||
"snapToGeometry": "Привязка к геометрии",
|
||||
"snapToGeometryDescription": "Выравнивать перетаскиваемые векторные точки по другим точкам контура.",
|
||||
"snapToObjects": "Привязка к объектам",
|
||||
"snapToObjectsDescription": "Выравнивать векторные точки по краям и центрам соседних слоёв.",
|
||||
"snapToObjectsDescription": "Выравнивать векторные точки и границы слоёв по краям и центрам соседних слоёв.",
|
||||
"snapToPixelGrid": "Привязка к пиксельной сетке",
|
||||
"snapToPixelGridDescription": "Выравнивать векторные точки по целым пиксельным координатам.",
|
||||
"snapToPixelGridDescription": "Выравнивать векторные точки, перемещаемые слои и изменяемые края по целым пикселям.",
|
||||
"snapTemporaryDisableHint": "Удерживайте Control при перетаскивании, чтобы временно отключить привязку.",
|
||||
"settingsAIAndAgents": "ИИ и агенты",
|
||||
"models": "Модели",
|
||||
|
|
|
|||
|
|
@ -85,13 +85,13 @@
|
|||
"settingsDescription": "管理集成和应用偏好设置。",
|
||||
"settingsGeneral": "通用",
|
||||
"settingsEditing": "编辑",
|
||||
"settingsSnappingDescription": "控制编辑路径时矢量点的对齐方式。",
|
||||
"settingsSnappingDescription": "控制编辑路径以及移动或调整图层大小时的对齐方式。",
|
||||
"snapToGeometry": "吸附到几何图形",
|
||||
"snapToGeometryDescription": "将拖动的矢量点与路径中的其他点对齐。",
|
||||
"snapToObjects": "吸附到对象",
|
||||
"snapToObjectsDescription": "将矢量点与附近图层的边缘和中心对齐。",
|
||||
"snapToObjectsDescription": "将矢量点和图层边界与附近图层的边缘和中心对齐。",
|
||||
"snapToPixelGrid": "吸附到像素网格",
|
||||
"snapToPixelGridDescription": "将矢量点与整数像素坐标对齐。",
|
||||
"snapToPixelGridDescription": "将矢量点、移动的图层和调整大小的边缘与整数像素对齐。",
|
||||
"snapTemporaryDisableHint": "拖动时按住 Control 可暂时禁用吸附。",
|
||||
"settingsAIAndAgents": "AI 和代理",
|
||||
"models": "模型",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: [] }
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue