feat(vector): add anchor point alignment for selected vertices
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, enabling precise vector path editing workflows.
This commit is contained in:
parent
6ad25297df
commit
2b92916881
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export type {
|
|||
export {
|
||||
SceneGraph,
|
||||
generateId,
|
||||
cloneVectorNetwork,
|
||||
type SceneNode,
|
||||
type NodeType,
|
||||
type Fill,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -34,7 +49,7 @@ const { panels } = useI18n()
|
|||
<button
|
||||
:class="iconButton({ size: 'md' })"
|
||||
data-test-id="position-align-left"
|
||||
@click="align('horizontal', 'min')"
|
||||
@click="handleAlign(align, 'horizontal', 'min')"
|
||||
>
|
||||
<icon-lucide-align-horizontal-justify-start class="size-3.5" />
|
||||
</button>
|
||||
|
|
@ -43,7 +58,7 @@ const { panels } = useI18n()
|
|||
<button
|
||||
:class="iconButton({ size: 'md' })"
|
||||
data-test-id="position-align-center-h"
|
||||
@click="align('horizontal', 'center')"
|
||||
@click="handleAlign(align, 'horizontal', 'center')"
|
||||
>
|
||||
<icon-lucide-align-horizontal-justify-center class="size-3.5" />
|
||||
</button>
|
||||
|
|
@ -52,7 +67,7 @@ const { panels } = useI18n()
|
|||
<button
|
||||
:class="iconButton({ size: 'md' })"
|
||||
data-test-id="position-align-right"
|
||||
@click="align('horizontal', 'max')"
|
||||
@click="handleAlign(align, 'horizontal', 'max')"
|
||||
>
|
||||
<icon-lucide-align-horizontal-justify-end class="size-3.5" />
|
||||
</button>
|
||||
|
|
@ -63,7 +78,7 @@ const { panels } = useI18n()
|
|||
<button
|
||||
:class="iconButton({ size: 'md' })"
|
||||
data-test-id="position-align-top"
|
||||
@click="align('vertical', 'min')"
|
||||
@click="handleAlign(align, 'vertical', 'min')"
|
||||
>
|
||||
<icon-lucide-align-vertical-justify-start class="size-3.5" />
|
||||
</button>
|
||||
|
|
@ -72,7 +87,7 @@ const { panels } = useI18n()
|
|||
<button
|
||||
:class="iconButton({ size: 'md' })"
|
||||
data-test-id="position-align-center-v"
|
||||
@click="align('vertical', 'center')"
|
||||
@click="handleAlign(align, 'vertical', 'center')"
|
||||
>
|
||||
<icon-lucide-align-vertical-justify-center class="size-3.5" />
|
||||
</button>
|
||||
|
|
@ -81,7 +96,7 @@ const { panels } = useI18n()
|
|||
<button
|
||||
:class="iconButton({ size: 'md' })"
|
||||
data-test-id="position-align-bottom"
|
||||
@click="align('vertical', 'max')"
|
||||
@click="handleAlign(align, 'vertical', 'max')"
|
||||
>
|
||||
<icon-lucide-align-vertical-justify-end class="size-3.5" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { loadFont } from '@/engine/fonts'
|
|||
import { toast } from '@/utils/toast'
|
||||
import {
|
||||
breakAtVertex,
|
||||
cloneVectorNetwork,
|
||||
computeAccurateBounds,
|
||||
createDefaultEditorState,
|
||||
createEditor,
|
||||
|
|
@ -454,7 +455,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
|
|||
|
||||
state.nodeEditState = {
|
||||
nodeId,
|
||||
origNetwork: structuredClone(node.vectorNetwork),
|
||||
origNetwork: cloneVectorNetwork(node.vectorNetwork),
|
||||
origBounds: { x: node.x, y: node.y, width: node.width, height: node.height },
|
||||
vertices: absVertices,
|
||||
segments: node.vectorNetwork.segments.map((s) => ({
|
||||
|
|
@ -495,7 +496,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
|
|||
y: es.origBounds.y,
|
||||
width: es.origBounds.width,
|
||||
height: es.origBounds.height,
|
||||
vectorNetwork: structuredClone(es.origNetwork)
|
||||
vectorNetwork: cloneVectorNetwork(es.origNetwork)
|
||||
})
|
||||
editor.requestRender()
|
||||
}
|
||||
|
|
@ -773,6 +774,28 @@ export function createEditorStore(initialGraph?: SceneGraph) {
|
|||
editor.requestRender()
|
||||
}
|
||||
|
||||
function nodeEditAlignVertices(axis: 'horizontal' | 'vertical', align: 'min' | 'center' | 'max') {
|
||||
const es = getNodeEditState()
|
||||
if (!es || es.selectedVertexIndices.size < 2) return
|
||||
|
||||
const indices = [...es.selectedVertexIndices]
|
||||
const prop = axis === 'horizontal' ? 'x' : 'y'
|
||||
|
||||
let lo = Infinity
|
||||
let hi = -Infinity
|
||||
for (const i of indices) {
|
||||
const v = es.vertices[i][prop]
|
||||
if (v < lo) lo = v
|
||||
if (v > hi) hi = v
|
||||
}
|
||||
|
||||
const target = align === 'min' ? lo : (align === 'max' ? hi : (lo + hi) / 2)
|
||||
for (const i of indices) {
|
||||
es.vertices[i] = { ...es.vertices[i], [prop]: target }
|
||||
}
|
||||
editor.requestRepaint()
|
||||
}
|
||||
|
||||
function nodeEditDeleteSelected() {
|
||||
const es = getNodeEditState()
|
||||
if (!es) return
|
||||
|
|
@ -1174,6 +1197,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
|
|||
nodeEditConnectEndpoints,
|
||||
nodeEditAddVertex,
|
||||
nodeEditRemoveVertex,
|
||||
nodeEditAlignVertices,
|
||||
nodeEditDeleteSelected,
|
||||
nodeEditBreakAtVertex,
|
||||
openFigFile,
|
||||
|
|
|
|||
Loading…
Reference in a new issue