diff --git a/packages/vue/src/controls/position/use.ts b/packages/vue/src/controls/position/use.ts index 44d3b0c9b..6541e77c9 100644 --- a/packages/vue/src/controls/position/use.ts +++ b/packages/vue/src/controls/position/use.ts @@ -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)) diff --git a/packages/vue/src/i18n/locales/de/dialogs.json b/packages/vue/src/i18n/locales/de/dialogs.json index 7b1db8b41..ac10a7a26 100644 --- a/packages/vue/src/i18n/locales/de/dialogs.json +++ b/packages/vue/src/i18n/locales/de/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/es/dialogs.json b/packages/vue/src/i18n/locales/es/dialogs.json index c27228ef2..6b8b69949 100644 --- a/packages/vue/src/i18n/locales/es/dialogs.json +++ b/packages/vue/src/i18n/locales/es/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/fr/dialogs.json b/packages/vue/src/i18n/locales/fr/dialogs.json index 165790fdf..8b12c86ce 100644 --- a/packages/vue/src/i18n/locales/fr/dialogs.json +++ b/packages/vue/src/i18n/locales/fr/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/it/dialogs.json b/packages/vue/src/i18n/locales/it/dialogs.json index e42b69385..7c4f3e664 100644 --- a/packages/vue/src/i18n/locales/it/dialogs.json +++ b/packages/vue/src/i18n/locales/it/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ja/dialogs.json b/packages/vue/src/i18n/locales/ja/dialogs.json index 63b7d9058..31d9475f5 100644 --- a/packages/vue/src/i18n/locales/ja/dialogs.json +++ b/packages/vue/src/i18n/locales/ja/dialogs.json @@ -85,13 +85,13 @@ "settingsDescription": "連携サービスとアプリの設定を管理します。", "settingsGeneral": "一般", "settingsEditing": "編集", - "settingsSnappingDescription": "パス編集中のベクターポイントの整列方法を設定します。", + "settingsSnappingDescription": "パス編集、レイヤーの移動、サイズ変更時の整列方法を設定します。", "snapToGeometry": "ジオメトリにスナップ", "snapToGeometryDescription": "ドラッグしたベクターポイントをパス内の他のポイントに揃えます。", "snapToObjects": "オブジェクトにスナップ", - "snapToObjectsDescription": "ベクターポイントを近くのレイヤーの端や中心に揃えます。", + "snapToObjectsDescription": "ベクターポイントとレイヤー境界を近くのレイヤーの端や中心に揃えます。", "snapToPixelGrid": "ピクセルグリッドにスナップ", - "snapToPixelGridDescription": "ベクターポイントを整数のピクセル座標に揃えます。", + "snapToPixelGridDescription": "ベクターポイント、移動したレイヤー、サイズ変更した端を整数ピクセルに揃えます。", "snapTemporaryDisableHint": "ドラッグ中にControlキーを押すと、スナップを一時的に無効にできます。", "settingsAIAndAgents": "AIとエージェント", "models": "モデル", diff --git a/packages/vue/src/i18n/locales/pl/dialogs.json b/packages/vue/src/i18n/locales/pl/dialogs.json index 716c73a17..f412f3721 100644 --- a/packages/vue/src/i18n/locales/pl/dialogs.json +++ b/packages/vue/src/i18n/locales/pl/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ru/dialogs.json b/packages/vue/src/i18n/locales/ru/dialogs.json index f2ba43cd9..0e12b7e35 100644 --- a/packages/vue/src/i18n/locales/ru/dialogs.json +++ b/packages/vue/src/i18n/locales/ru/dialogs.json @@ -85,13 +85,13 @@ "settingsDescription": "Управление интеграциями и настройками приложения.", "settingsGeneral": "Основные", "settingsEditing": "Редактирование", - "settingsSnappingDescription": "Настройте выравнивание векторных точек при редактировании контуров.", + "settingsSnappingDescription": "Настройте выравнивание при редактировании контуров, перемещении и изменении размеров слоёв.", "snapToGeometry": "Привязка к геометрии", "snapToGeometryDescription": "Выравнивать перетаскиваемые векторные точки по другим точкам контура.", "snapToObjects": "Привязка к объектам", - "snapToObjectsDescription": "Выравнивать векторные точки по краям и центрам соседних слоёв.", + "snapToObjectsDescription": "Выравнивать векторные точки и границы слоёв по краям и центрам соседних слоёв.", "snapToPixelGrid": "Привязка к пиксельной сетке", - "snapToPixelGridDescription": "Выравнивать векторные точки по целым пиксельным координатам.", + "snapToPixelGridDescription": "Выравнивать векторные точки, перемещаемые слои и изменяемые края по целым пикселям.", "snapTemporaryDisableHint": "Удерживайте Control при перетаскивании, чтобы временно отключить привязку.", "settingsAIAndAgents": "ИИ и агенты", "models": "Модели", diff --git a/packages/vue/src/i18n/locales/zh-cn/dialogs.json b/packages/vue/src/i18n/locales/zh-cn/dialogs.json index 1bafefbce..36251e8dd 100644 --- a/packages/vue/src/i18n/locales/zh-cn/dialogs.json +++ b/packages/vue/src/i18n/locales/zh-cn/dialogs.json @@ -85,13 +85,13 @@ "settingsDescription": "管理集成和应用偏好设置。", "settingsGeneral": "通用", "settingsEditing": "编辑", - "settingsSnappingDescription": "控制编辑路径时矢量点的对齐方式。", + "settingsSnappingDescription": "控制编辑路径以及移动或调整图层大小时的对齐方式。", "snapToGeometry": "吸附到几何图形", "snapToGeometryDescription": "将拖动的矢量点与路径中的其他点对齐。", "snapToObjects": "吸附到对象", - "snapToObjectsDescription": "将矢量点与附近图层的边缘和中心对齐。", + "snapToObjectsDescription": "将矢量点和图层边界与附近图层的边缘和中心对齐。", "snapToPixelGrid": "吸附到像素网格", - "snapToPixelGridDescription": "将矢量点与整数像素坐标对齐。", + "snapToPixelGridDescription": "将矢量点、移动的图层和调整大小的边缘与整数像素对齐。", "snapTemporaryDisableHint": "拖动时按住 Control 可暂时禁用吸附。", "settingsAIAndAgents": "AI 和代理", "models": "模型", diff --git a/packages/vue/src/i18n/messages/dialogs.ts b/packages/vue/src/i18n/messages/dialogs.ts index 262c3308b..1b0fdd998 100644 --- a/packages/vue/src/i18n/messages/dialogs.ts +++ b/packages/vue/src/i18n/messages/dialogs.ts @@ -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', diff --git a/packages/vue/src/shared/input/explicit-snap-targets.ts b/packages/vue/src/shared/input/explicit-snap-targets.ts index de9046312..b9049c9dd 100644 --- a/packages/vue/src/shared/input/explicit-snap-targets.ts +++ b/packages/vue/src/shared/input/explicit-snap-targets.ts @@ -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', diff --git a/packages/vue/src/shared/input/move-snap.ts b/packages/vue/src/shared/input/move-snap.ts index b92168615..7ade90bbe 100644 --- a/packages/vue/src/shared/input/move-snap.ts +++ b/packages/vue/src/shared/input/move-snap.ts @@ -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, diff --git a/packages/vue/src/shared/input/node-edit/snap.ts b/packages/vue/src/shared/input/node-edit/snap.ts index 6e14c1309..9e6cd1beb 100644 --- a/packages/vue/src/shared/input/node-edit/snap.ts +++ b/packages/vue/src/shared/input/node-edit/snap.ts @@ -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)) } diff --git a/packages/vue/src/shared/input/resize/snap.ts b/packages/vue/src/shared/input/resize/snap.ts index b9722a696..779a1b5a1 100644 --- a/packages/vue/src/shared/input/resize/snap.ts +++ b/packages/vue/src/shared/input/resize/snap.ts @@ -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) diff --git a/packages/vue/src/shared/input/snap.ts b/packages/vue/src/shared/input/snap.ts index 4825fc58c..a257b9984 100644 --- a/packages/vue/src/shared/input/snap.ts +++ b/packages/vue/src/shared/input/snap.ts @@ -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 { + delta: number + target: T +} + +function nearestPerAxis( + targets: readonly T[], + xAnchors: readonly number[], + yAnchors: readonly number[], + threshold: number +): { x: AxisCandidate | null; y: AxisCandidate | null } { + let x: AxisCandidate | null = null + let y: AxisCandidate | 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, 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: [] } diff --git a/src/app/editor/vector-edit/lifecycle.ts b/src/app/editor/vector-edit/lifecycle.ts index e85a6884e..894b2be4c 100644 --- a/src/app/editor/vector-edit/lifecycle.ts +++ b/src/app/editor/vector-edit/lifecycle.ts @@ -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') { diff --git a/src/app/settings/preferences/apply.ts b/src/app/settings/preferences/apply.ts index 32f80416a..9274968b5 100644 --- a/src/app/settings/preferences/apply.ts +++ b/src/app/settings/preferences/apply.ts @@ -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) + }) } diff --git a/src/app/shell/menu/schema.ts b/src/app/shell/menu/schema.ts index 945773d28..b4ccebdc1 100644 --- a/src/app/shell/menu/schema.ts +++ b/src/app/shell/menu/schema.ts @@ -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', diff --git a/src/app/shell/menu/shell.ts b/src/app/shell/menu/shell.ts index 9b5176a53..d932687e0 100644 --- a/src/app/shell/menu/shell.ts +++ b/src/app/shell/menu/shell.ts @@ -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'), diff --git a/tests/engine/vue/input/move-threshold.test.ts b/tests/engine/vue/input/move-threshold.test.ts index 7f68d9a65..77554c6d2 100644 --- a/tests/engine/vue/input/move-threshold.test.ts +++ b/tests/engine/vue/input/move-threshold.test.ts @@ -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', () => { diff --git a/tests/engine/vue/input/resize/snapping.test.ts b/tests/engine/vue/input/resize/snapping.test.ts index d0ad68f3f..67acf1ea8 100644 --- a/tests/engine/vue/input/resize/snapping.test.ts +++ b/tests/engine/vue/input/resize/snapping.test.ts @@ -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