diff --git a/CHANGELOG.md b/CHANGELOG.md index 666f72840..d63c77891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Added a vector curve editor and improved drawing experience with the pen tool - Resume pen drawing from existing open path endpoints — click an endpoint to continue the curve - Close open paths by dragging one endpoint to the other +- Align selected anchor points relative to each other in vector edit mode — the standard alignment buttons in the position panel now operate on selected vertices when 2 or more are selected ### Fixes diff --git a/openspec/specs/vector-edit/spec.md b/openspec/specs/vector-edit/spec.md index 1a173c6c8..730a5cf00 100644 --- a/openspec/specs/vector-edit/spec.md +++ b/openspec/specs/vector-edit/spec.md @@ -126,6 +126,48 @@ With Pen active inside curve edit mode, contour insertion, endpoint resume, and - **WHEN** Pen is active and user `Alt/Option`-clicks an anchor in curve edit mode - **THEN** anchor is removed and neighboring segments are reconnected if topology allows +### Requirement: Align selected anchors relative to each other +When two or more anchors are selected in curve edit mode, alignment buttons in the position panel SHALL reposition those anchors relative to each other instead of operating on the parent node. + +#### Scenario: Align left (min X) +- **WHEN** 2+ anchors are selected and user clicks Align Left +- **THEN** all selected anchors move to the X coordinate of the leftmost anchor +- **AND** unselected anchors and tangents are unaffected + +#### Scenario: Align right (max X) +- **WHEN** 2+ anchors are selected and user clicks Align Right +- **THEN** all selected anchors move to the X coordinate of the rightmost anchor +- **AND** unselected anchors and tangents are unaffected + +#### Scenario: Align center horizontally +- **WHEN** 2+ anchors are selected and user clicks Align Center Horizontally +- **THEN** all selected anchors move to the midpoint X between leftmost and rightmost selected anchor +- **AND** unselected anchors and tangents are unaffected + +#### Scenario: Align top (min Y) +- **WHEN** 2+ anchors are selected and user clicks Align Top +- **THEN** all selected anchors move to the Y coordinate of the topmost anchor +- **AND** unselected anchors and tangents are unaffected + +#### Scenario: Align bottom (max Y) +- **WHEN** 2+ anchors are selected and user clicks Align Bottom +- **THEN** all selected anchors move to the Y coordinate of the bottommost anchor +- **AND** unselected anchors and tangents are unaffected + +#### Scenario: Align center vertically +- **WHEN** 2+ anchors are selected and user clicks Align Center Vertically +- **THEN** all selected anchors move to the midpoint Y between topmost and bottommost selected anchor +- **AND** unselected anchors and tangents are unaffected + +#### Scenario: Single anchor selected — no vertex alignment +- **WHEN** exactly 1 anchor is selected and user clicks any alignment button +- **THEN** alignment operates on the parent node as in normal selection mode +- **AND** no vertex repositioning occurs + +#### Scenario: No anchors selected — no vertex alignment +- **WHEN** no anchors are selected and user clicks any alignment button +- **THEN** alignment operates on the parent node as in normal selection mode + ### Requirement: Visual preview correctness during curve edit Interactive preview in curve edit mode SHALL render active tangent feedback from anchor to active tangent endpoint. diff --git a/packages/core/src/editor/shapes.ts b/packages/core/src/editor/shapes.ts index 57d1c2f9c..92d318821 100644 --- a/packages/core/src/editor/shapes.ts +++ b/packages/core/src/editor/shapes.ts @@ -223,8 +223,12 @@ export function createShapeActions(ctx: EditorContext) { : [] const network: VectorNetwork = { - vertices: ps.vertices, - segments: ps.segments, + vertices: ps.vertices.map((v) => ({ ...v })), + segments: ps.segments.map((s) => ({ + ...s, + tangentStart: { ...s.tangentStart }, + tangentEnd: { ...s.tangentEnd } + })), regions } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 592775f8d..9baf5660f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,6 +25,7 @@ export type { export { SceneGraph, generateId, + cloneVectorNetwork, type SceneNode, type NodeType, type Fill, diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index 91c02a6ed..f9a9d1cfd 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -58,6 +58,22 @@ export interface VectorNetwork { regions: VectorRegion[] } +/** Deep-copy a VectorNetwork, stripping any Vue Proxy wrappers. */ +export function cloneVectorNetwork(vn: VectorNetwork): VectorNetwork { + return { + vertices: vn.vertices.map((v) => ({ ...v })), + segments: vn.segments.map((s) => ({ + ...s, + tangentStart: { ...s.tangentStart }, + tangentEnd: { ...s.tangentEnd } + })), + regions: vn.regions.map((r) => ({ + windingRule: r.windingRule, + loops: r.loops.map((l) => [...l]) + })) + } +} + export interface GeometryPath { windingRule: WindingRule commandsBlob: Uint8Array diff --git a/packages/core/src/tools/vector.ts b/packages/core/src/tools/vector.ts index eb63908fe..3e5a20a44 100644 --- a/packages/core/src/tools/vector.ts +++ b/packages/core/src/tools/vector.ts @@ -1,6 +1,7 @@ import { defineTool, nodeSummary } from './schema' import type { FigmaAPI } from '../figma-api' +import { cloneVectorNetwork } from '../scene-graph' import type { SceneNode, VectorNetwork } from '../scene-graph' function getVectorNode( @@ -10,7 +11,7 @@ function getVectorNode( const node = figma.graph.getNode(id) if (!node) return { error: `Node "${id}" not found` } if (!node.vectorNetwork) return { error: `Node "${id}" has no vector data` } - return { node, vn: structuredClone(node.vectorNetwork) } + return { node, vn: cloneVectorNetwork(node.vectorNetwork) } } const CHUNK_SIZE = 0x8000 diff --git a/packages/vue/src/shared/input/move.ts b/packages/vue/src/shared/input/move.ts index ce70a1956..9896d0b06 100644 --- a/packages/vue/src/shared/input/move.ts +++ b/packages/vue/src/shared/input/move.ts @@ -28,22 +28,14 @@ export function duplicateAndDrag( for (const id of editor.state.selectedIds) { const src = editor.graph.getNode(id) if (!src) continue - const newId = editor.createShape(src.type, src.x, src.y, src.width, src.height) - editor.graph.updateNode(newId, { - name: src.name + ' copy', - fills: [...src.fills], - strokes: [...src.strokes], - effects: [...src.effects], - cornerRadius: src.cornerRadius, - opacity: src.opacity, - rotation: src.rotation - }) - newIds.push(newId) - const newNode = editor.graph.getNode(newId) - newOriginals.set(newId, { + const parentId = src.parentId ?? editor.state.currentPageId + const clone = editor.graph.cloneTree(id, parentId, { name: src.name + ' copy' }) + if (!clone) continue + newIds.push(clone.id) + newOriginals.set(clone.id, { x: src.x, y: src.y, - parentId: newNode?.parentId ?? editor.state.currentPageId + parentId }) } editor.select(newIds) diff --git a/packages/vue/src/shared/input/resize.ts b/packages/vue/src/shared/input/resize.ts index d8dc07094..2ae45f9f7 100644 --- a/packages/vue/src/shared/input/resize.ts +++ b/packages/vue/src/shared/input/resize.ts @@ -1,6 +1,7 @@ import { hitTestHandle } from './geometry' import type { DragResize, HandlePosition } from './types' +import { cloneVectorNetwork } from '@open-pencil/core' import type { Rect, SceneNode } from '@open-pencil/core' import type { Editor } from '@open-pencil/core/editor' @@ -140,7 +141,7 @@ export function tryStartResize( startY: cy, origRect: { x: node.x, y: node.y, width: node.width, height: node.height }, nodeId: id, - origVectorNetwork: node.vectorNetwork ? structuredClone(node.vectorNetwork) : null + origVectorNetwork: node.vectorNetwork ? cloneVectorNetwork(node.vectorNetwork) : null } } } diff --git a/src/components/properties/PositionSection.vue b/src/components/properties/PositionSection.vue index d21039d6e..038869969 100644 --- a/src/components/properties/PositionSection.vue +++ b/src/components/properties/PositionSection.vue @@ -3,9 +3,24 @@ import ScrubInput from '@/components/ScrubInput.vue' import Tip from '@/components/ui/Tip.vue' import { iconButton } from '@/components/ui/icon-button' import { sectionWrapper } from '@/components/ui/section' +import { useEditorStore } from '@/stores/editor' import { PositionControlsRoot, useI18n } from '@open-pencil/vue' const { panels } = useI18n() +const store = useEditorStore() + +function handleAlign( + nodeAlign: (axis: 'horizontal' | 'vertical', pos: 'min' | 'center' | 'max') => void, + axis: 'horizontal' | 'vertical', + pos: 'min' | 'center' | 'max' +) { + const es = store.state.nodeEditState + if (es && es.selectedVertexIndices.size >= 2) { + store.nodeEditAlignVertices(axis, pos) + } else { + nodeAlign(axis, pos) + } +}