fix(vector): edit vectors from opened files

- Resolve the active graph at action time and map edit geometry through full world transforms
- Regenerate live path fills and discard stale imported stroke outlines after edits
- Preserve fill and stroke geometry through resize previews, undo, and redo
- Cover graph replacement, nested transforms, edit history, resizing, and CanvasKit output

Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
This commit is contained in:
Danila Poyarkov 2026-07-28 12:30:21 +03:00
parent a7d7b71995
commit cb2c4aa3d3
43 changed files with 1245 additions and 233 deletions

View file

@ -7,6 +7,7 @@
- Match regional browser languages to supported locales without selecting a secondary language. (#417)
- Save auto-layout frames that stretch their children to `.fig` without failing. (#427)
- Preserve multiple colors in imported vector artwork such as multi-color logos. (#386)
- Edit vectors in opened documents at the correct position, with live fills and undo/redo. (#390)
- Reduce large `.fig` page-switch work to the active page, reuse fixed-point propagation indexes, and coalesce Layers tree rebuilds. (#420)
- Center text glyphs within explicit line-height leading in CanvasKit paragraph rendering.

View file

@ -1,16 +1,18 @@
import type { Canvas, Paint } from 'canvaskit-wasm'
import { transformVectorNetwork } from '@open-pencil/scene-graph'
import type {
VectorVertex,
VectorSegment,
VectorRegion,
SceneGraph
} from '@open-pencil/scene-graph'
import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import Matrix from '@open-pencil/scene-graph/matrix'
import type { Vector } from '@open-pencil/scene-graph/primitives'
import { PEN_HANDLE_RADIUS, PEN_VERTEX_RADIUS } from '#core/constants'
import { vectorNetworkToPath } from '#core/vector'
import { computeAccurateBounds } from '#core/vector/bezier'
import { regenerateFillGeometry, vectorNetworkToPath } from '#core/vector'
import type { SkiaRenderer, RenderOverlays } from './renderer'
@ -124,60 +126,49 @@ function drawLiveShape(
const node = graph.getNode(nodeId)
if (!node) return
// Compute bounds from live (absolute) vertices
const liveNetwork = { vertices, segments, regions }
const bounds = computeAccurateBounds(liveNetwork)
// The edit state is page-absolute; map it back into the node's local frame
// so static fillGeometry (multi-color vectors) stays aligned, and render
// through the node's full world matrix (nesting, rotation, flips).
const world = getWorldMatrix(node, graph)
const inverse = Matrix.invert(world)
if (!inverse) return
const localNetwork = transformVectorNetwork(inverse, { vertices, segments, regions })
// Build a normalized VectorNetwork (relative to bounds origin)
const normalizedNetwork = {
vertices: vertices.map((v) => ({
...v,
x: v.x - bounds.x,
y: v.y - bounds.y
})),
segments,
regions
const invalidatePathCaches = () => {
r.vectorPathCache.delete(nodeId)
r.fillGeometryCache.delete(nodeId)
r.strokeGeometryCache.delete(nodeId)
}
// Temporarily patch the node so renderShapeUncached uses our live network
// Temporarily patch the node so renderShapeUncached uses our live network;
// fills draw from fillGeometry blobs, so rebuild those from the live network.
// Clear strokeGeometry so strokes use the live vector network instead of
// stale imported stroke blobs.
const origNetwork = node.vectorNetwork
const origX = node.x
const origY = node.y
const origW = node.width
const origH = node.height
const origFillGeometry = node.fillGeometry
const origStrokeGeometry = node.strokeGeometry
node.vectorNetwork = normalizedNetwork
node.x = bounds.x
node.y = bounds.y
node.width = bounds.width
node.height = bounds.height
// Invalidate cached paths so they're rebuilt from our live network
r.vectorPathCache.delete(nodeId)
r.fillGeometryCache.delete(nodeId)
r.strokeGeometryCache.delete(nodeId)
// The overlay canvas is in screen space after panX/panY + zoom scaling.
// renderShapeUncached expects a canvas translated to the node's local origin.
// The overlay canvas is in screen space; renderShapeUncached expects the
// node's local frame, so apply viewport then the node's world matrix.
canvas.save()
canvas.translate(bounds.x * r.zoom + r.panX, bounds.y * r.zoom + r.panY)
canvas.scale(r.zoom, r.zoom)
r.renderShapeUncached(canvas, node, graph)
canvas.restore()
// Restore the original node properties
node.vectorNetwork = origNetwork
node.x = origX
node.y = origY
node.width = origW
node.height = origH
// Invalidate caches again so the original renders correctly after exit
r.vectorPathCache.delete(nodeId)
r.fillGeometryCache.delete(nodeId)
r.strokeGeometryCache.delete(nodeId)
try {
node.vectorNetwork = localNetwork
node.fillGeometry = regenerateFillGeometry(localNetwork, origFillGeometry)
node.strokeGeometry = []
invalidatePathCaches()
canvas.translate(r.panX, r.panY)
canvas.scale(r.zoom, r.zoom)
canvas.concat(world)
r.renderShapeUncached(canvas, node, graph)
} finally {
canvas.restore()
// Restore the original node properties and invalidate again so the
// original renders correctly after exit — even if regeneration or rendering throws.
node.vectorNetwork = origNetwork
node.fillGeometry = origFillGeometry
node.strokeGeometry = origStrokeGeometry
invalidatePathCaches()
}
}
// ---------------------------------------------------------------------------

View file

@ -1,6 +1,7 @@
import { pick } from 'es-toolkit/object'
import { cloneVectorNetwork, type SceneNode } from '@open-pencil/scene-graph'
import { copyGeometryPaths } from '@open-pencil/scene-graph/copy'
import type { Rect, Vector } from '@open-pencil/scene-graph/primitives'
import type { UndoEntry } from '@open-pencil/scene-graph/undo'
@ -14,8 +15,12 @@ import {
import { textAutoResizeChanges } from './text/auto-resize'
import type { EditorContext } from './types'
type ResizeSnapshot = Pick<SceneNode, 'x' | 'y' | 'width' | 'height' | 'vectorNetwork'>
type ResizeOriginal = Rect & { vectorNetwork?: SceneNode['vectorNetwork'] }
type ResizeSnapshot = Pick<
SceneNode,
'x' | 'y' | 'width' | 'height' | 'vectorNetwork' | 'fillGeometry' | 'strokeGeometry'
>
type ResizeOriginal = Rect &
Partial<Pick<SceneNode, 'vectorNetwork' | 'fillGeometry' | 'strokeGeometry'>>
function createResizeSnapshot(node: SceneNode): ResizeSnapshot {
return {
@ -23,7 +28,9 @@ function createResizeSnapshot(node: SceneNode): ResizeSnapshot {
y: node.y,
width: node.width,
height: node.height,
vectorNetwork: node.vectorNetwork ? cloneVectorNetwork(node.vectorNetwork) : null
vectorNetwork: node.vectorNetwork ? cloneVectorNetwork(node.vectorNetwork) : null,
fillGeometry: copyGeometryPaths(node.fillGeometry),
strokeGeometry: copyGeometryPaths(node.strokeGeometry)
}
}
@ -94,10 +101,11 @@ export function createUndoActions(ctx: EditorContext) {
function commitResize(nodeId: string, original: ResizeOriginal) {
const node = ctx.graph.getNode(nodeId)
if (!node) return
const final: ResizeOriginal =
'vectorNetwork' in original
? createResizeSnapshot(node)
: { x: node.x, y: node.y, width: node.width, height: node.height }
const includesGeometry =
'vectorNetwork' in original || 'fillGeometry' in original || 'strokeGeometry' in original
const final: ResizeOriginal = includesGeometry
? createResizeSnapshot(node)
: { x: node.x, y: node.y, width: node.width, height: node.height }
ctx.undo.push({
label: 'Resize',
forward: () => {

View file

@ -219,7 +219,8 @@ export {
geometryBlobToPath,
decodeVectorNetworkBlob,
encodeVectorNetworkBlob,
buildStyleOverrideTable
buildStyleOverrideTable,
regenerateFillGeometry
} from './vector'
export {
evalCubic,

View file

@ -0,0 +1,90 @@
import type { GeometryPath, VectorNetwork } from '@open-pencil/scene-graph'
import { addLoopToPath, addOpenSegmentsToPath } from './path-helpers'
import type { PathSink } from './path-helpers'
const CMD_CLOSE = 0
const CMD_MOVE_TO = 1
const CMD_LINE_TO = 2
const CMD_CUBIC_TO = 4
/** PathSink that encodes commands into the .fig geometry commandsBlob format. */
class GeometryBlobBuilder implements PathSink {
private cmds: { code: number; args: number[] }[] = []
moveTo(x: number, y: number) {
this.cmds.push({ code: CMD_MOVE_TO, args: [x, y] })
}
lineTo(x: number, y: number) {
this.cmds.push({ code: CMD_LINE_TO, args: [x, y] })
}
cubicTo(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number) {
this.cmds.push({ code: CMD_CUBIC_TO, args: [x1, y1, x2, y2, x3, y3] })
}
close() {
this.cmds.push({ code: CMD_CLOSE, args: [] })
}
toBlob(): Uint8Array {
const size = this.cmds.reduce((n, c) => n + 1 + c.args.length * 4, 0)
const out = new Uint8Array(size)
const view = new DataView(out.buffer)
let o = 0
for (const c of this.cmds) {
out[o++] = c.code
for (const v of c.args) {
view.setFloat32(o, v, true)
o += 4
}
}
return out
}
}
/**
* Rebuild fillGeometry command blobs from a (possibly edited) VectorNetwork so
* fills follow network edits. Imported .fig vectors pair fillGeometry entries
* with network regions positionally (fillGeometry[i] ↔ regions[i]); per-path
* fills are preserved. Networks without regions (open chains) map to a
* single fillGeometry entry.
*/
export function regenerateFillGeometry(
network: VectorNetwork,
existing: GeometryPath[]
): GeometryPath[] {
if (existing.length === 0) return existing
const { vertices, segments, regions } = network
if (regions.length === existing.length) {
return existing.map((g, i) => {
const region = regions[i]
const builder = new GeometryBlobBuilder()
for (const loop of region.loops) addLoopToPath(builder, loop, segments, vertices)
return { ...g, windingRule: region.windingRule, commandsBlob: builder.toBlob() }
})
}
if (regions.length === 0 && existing.length === 1) {
const builder = new GeometryBlobBuilder()
addOpenSegmentsToPath(builder, segments, vertices)
return [{ ...existing[0], commandsBlob: builder.toBlob() }]
}
// Region count no longer matches the imported fillGeometry (e.g. a region was
// deleted mid-edit) — the style mapping is unknown. Rebuild from the live
// network so committed fillGeometry stays coherent with vectorNetwork; styles
// fall back to node-level fills.
if (regions.length > 0) {
return regions.map((region) => {
const builder = new GeometryBlobBuilder()
for (const loop of region.loops) addLoopToPath(builder, loop, segments, vertices)
return { windingRule: region.windingRule, commandsBlob: builder.toBlob() }
})
}
if (segments.length > 0) {
const builder = new GeometryBlobBuilder()
addOpenSegmentsToPath(builder, segments, vertices)
return [{ windingRule: 'NONZERO' as const, commandsBlob: builder.toBlob() }]
}
return []
}

View file

@ -19,10 +19,11 @@ export {
decodeVectorNetworkBlob,
encodeVectorNetworkBlob
} from '@open-pencil/fig/node-change'
import type { VectorNetwork, VectorSegment, VectorVertex } from '@open-pencil/scene-graph'
import type { VectorNetwork, WindingRule } from '@open-pencil/scene-graph'
import { addOpenSegmentsToPath, addSegmentDirected } from './path-helpers'
import { addLoopToPath, addOpenSegmentsToPath } from './path-helpers'
export { vectorNetworkToCenterlinePath, fitCircleArc, isClosedThinCrescent } from './centerline'
export { regenerateFillGeometry } from './fill-geometry'
export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path[] {
const { vertices, segments, regions } = network
@ -47,43 +48,6 @@ export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path
return [path]
}
function addLoopToPath(
path: Path,
loop: number[],
segments: VectorSegment[],
vertices: VectorVertex[]
): void {
if (loop.length === 0) return
const firstSeg = segments[loop[0]]
// Determine the starting vertex — if the loop has multiple segments,
// the first segment's direction is determined by which vertex connects
// to the second segment.
let current: number
if (loop.length === 1) {
current = firstSeg.start
} else {
const secondSeg = segments[loop[1]]
if (firstSeg.end === secondSeg.start || firstSeg.end === secondSeg.end) {
current = firstSeg.start
} else {
current = firstSeg.end
}
}
path.moveTo(vertices[current].x, vertices[current].y)
for (const segIdx of loop) {
const seg = segments[segIdx]
const forward = seg.start === current
addSegmentDirected(path, seg, vertices, forward)
current = forward ? seg.end : seg.start
}
path.close()
}
const CMD_CLOSE = 0
const CMD_MOVE_TO = 1
const CMD_LINE_TO = 2

View file

@ -1,9 +1,15 @@
import type { Path } from 'canvaskit-wasm'
import type { VectorSegment, VectorVertex } from '@open-pencil/scene-graph'
/** Anything path commands can be emitted into: a canvaskit Path or a blob encoder. */
export interface PathSink {
moveTo(x: number, y: number): unknown
lineTo(x: number, y: number): unknown
cubicTo(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number): unknown
close(): unknown
}
export function addSegmentDirected(
path: Path,
path: PathSink,
seg: VectorSegment,
vertices: VectorVertex[],
forward: boolean
@ -32,8 +38,45 @@ export function findChainStart(chain: number[], segments: VectorSegment[]): numb
return first.start
}
export function addLoopToPath(
path: PathSink,
loop: number[],
segments: VectorSegment[],
vertices: VectorVertex[]
): void {
if (loop.length === 0) return
const firstSeg = segments[loop[0]]
// Determine the starting vertex — if the loop has multiple segments,
// the first segment's direction is determined by which vertex connects
// to the second segment.
let current: number
if (loop.length === 1) {
current = firstSeg.start
} else {
const secondSeg = segments[loop[1]]
if (firstSeg.end === secondSeg.start || firstSeg.end === secondSeg.end) {
current = firstSeg.start
} else {
current = firstSeg.end
}
}
path.moveTo(vertices[current].x, vertices[current].y)
for (const segIdx of loop) {
const seg = segments[segIdx]
const forward = seg.start === current
addSegmentDirected(path, seg, vertices, forward)
current = forward ? seg.end : seg.start
}
path.close()
}
export function addOpenSegmentsToPath(
path: Path,
path: PathSink,
segments: VectorSegment[],
vertices: VectorVertex[]
): void {

View file

@ -1,7 +1,6 @@
import type { SceneGraph, SceneNode, VectorNetwork } from '@open-pencil/scene-graph'
import { copyGeometryPaths } from '@open-pencil/scene-graph/copy'
import { copyGeometryPaths, scaleGeometryPaths } from '@open-pencil/scene-graph/copy'
import { scaleGeometryPaths } from './geometry-paths'
import { buildClonesMap } from './sync'
import type { OverrideContext } from './types'
import { overrideCandidates } from './utils'

View file

@ -4,8 +4,8 @@ import {
resolveVectorStyleOverrideFills
} from '@open-pencil/fig/node-change'
import type { SceneNode } from '@open-pencil/scene-graph'
import { scaleGeometryPaths } from '@open-pencil/scene-graph/copy'
import { scaleGeometryPaths } from '../geometry-paths'
import type { DerivedSymbolOverride } from '../types'
export function resolveDsdGeometry(

View file

@ -1,37 +0,0 @@
import type { GeometryPath } from '@open-pencil/scene-graph'
import { copyGeometryPaths } from '@open-pencil/scene-graph/copy'
export function scaleGeometryPaths(
geometry: GeometryPath[],
scaleX: number,
scaleY: number
): GeometryPath[] {
const copies = copyGeometryPaths(geometry)
if (scaleX === 1 && scaleY === 1) return copies
for (const path of copies) {
const view = new DataView(
path.commandsBlob.buffer,
path.commandsBlob.byteOffset,
path.commandsBlob.byteLength
)
let offset = 0
while (offset < path.commandsBlob.length) {
const command = path.commandsBlob[offset]
offset += 1
if (command === 0) continue
let coordinateCount = 0
if (command === 1 || command === 2) coordinateCount = 1
else if (command === 3) coordinateCount = 2
else if (command === 4) coordinateCount = 3
if (coordinateCount === 0 || offset + coordinateCount * 8 > path.commandsBlob.length) break
for (let index = 0; index < coordinateCount; index++) {
view.setFloat32(offset, view.getFloat32(offset, true) * scaleX, true)
view.setFloat32(offset + 4, view.getFloat32(offset + 4, true) * scaleY, true)
offset += 8
}
}
}
return copies
}

View file

@ -3,9 +3,9 @@ import { describe, expect, test } from 'bun:test'
import type { Paint } from '@open-pencil/kiwi/fig/codec'
import type { Fill } from '@open-pencil/scene-graph'
import { SceneGraph } from '@open-pencil/scene-graph'
import { scaleGeometryPaths } from '@open-pencil/scene-graph/copy'
import { resolveDsdGeometry } from '../src/instance-overrides/derived-symbol-data/geometry'
import { scaleGeometryPaths } from '../src/instance-overrides/geometry-paths'
import {
resolveGeometryPaths,
resolveStyleOverrideFills,

View file

@ -113,6 +113,38 @@ export function copyGeometryPaths(paths: GeometryPath[]): GeometryPath[] {
}))
}
/** Scale geometry path coordinates while preserving independent path fills. */
export function scaleGeometryPaths(paths: GeometryPath[], scaleX: number, scaleY: number) {
const copies = copyGeometryPaths(paths)
if (scaleX === 1 && scaleY === 1) return copies
for (const path of copies) {
const view = new DataView(
path.commandsBlob.buffer,
path.commandsBlob.byteOffset,
path.commandsBlob.byteLength
)
let offset = 0
while (offset < path.commandsBlob.length) {
const command = path.commandsBlob[offset]
offset += 1
if (command === 0) continue
let coordinateCount = 0
if (command === 1 || command === 2) coordinateCount = 1
else if (command === 3) coordinateCount = 2
else if (command === 4) coordinateCount = 3
if (coordinateCount === 0 || offset + coordinateCount * 8 > path.commandsBlob.length) break
for (let index = 0; index < coordinateCount; index++) {
view.setFloat32(offset, view.getFloat32(offset, true) * scaleX, true)
view.setFloat32(offset + 4, view.getFloat32(offset + 4, true) * scaleY, true)
offset += 8
}
}
}
return copies
}
// --- Internal helpers ---
/** Copy an optional array: non-empty → mapped, empty → [], undefined → undefined. */

View file

@ -48,7 +48,13 @@ import type {
VariableValue
} from './types'
export { cloneVectorNetwork, normalizeVectorNetwork, validateVectorNetwork } from './vector-network'
export {
cloneVectorNetwork,
normalizeVectorNetwork,
transformVectorNetwork,
validateVectorNetwork,
vectorNetworksEqual
} from './vector-network'
let nextLocalID = 1

View file

@ -1,8 +1,12 @@
import { copyGeometryPaths, scaleGeometryPaths } from './copy'
import type { Rect } from './primitives'
import type { ConstraintType, SceneNode, VectorNetwork } from './types'
import { cloneVectorNetwork } from './vector-network'
export type ResizeSnapshot = Pick<SceneNode, 'x' | 'y' | 'width' | 'height' | 'vectorNetwork'>
export type ResizeSnapshot = Pick<
SceneNode,
'x' | 'y' | 'width' | 'height' | 'vectorNetwork' | 'fillGeometry' | 'strokeGeometry'
>
interface ResizeGraph {
getNode(id: string): SceneNode | undefined
@ -119,7 +123,9 @@ export function collectResizeDescendants(
y: child.y,
width: child.width,
height: child.height,
vectorNetwork: child.vectorNetwork ? cloneVectorNetwork(child.vectorNetwork) : null
vectorNetwork: child.vectorNetwork ? cloneVectorNetwork(child.vectorNetwork) : null,
fillGeometry: copyGeometryPaths(child.fillGeometry),
strokeGeometry: copyGeometryPaths(child.strokeGeometry)
})
collect(childId)
}
@ -173,6 +179,22 @@ export function computeConstrainedResizeChanges(
rect.height
)
if (vectorNetwork) childChanges.vectorNetwork = vectorNetwork
if (original.width > 0 && original.height > 0) {
const scaleX = rect.width / original.width
const scaleY = rect.height / original.height
if (scaleX !== 1 || scaleY !== 1) {
if (original.fillGeometry.length > 0) {
childChanges.fillGeometry = scaleGeometryPaths(original.fillGeometry, scaleX, scaleY)
}
if (original.strokeGeometry.length > 0) {
childChanges.strokeGeometry = scaleGeometryPaths(
original.strokeGeometry,
scaleX,
scaleY
)
}
}
}
changes.set(childId, childChanges)
// The final pass sees layout containers after Yoga has resolved HUG/FILL sizing.
compute(childId, original, child.layoutMode === 'NONE' ? rect : child)

View file

@ -1,6 +1,46 @@
import { isEqual } from 'es-toolkit/predicate'
import type { Mat3 } from './matrix'
import type { Vector } from './primitives'
import type { VectorNetwork, VectorSegment } from './types'
/**
* Map a VectorNetwork through an affine matrix: vertices as points,
* tangents as direction vectors (linear part only, no translation).
* Returns a deep copy; the input is not mutated.
*/
export function transformVectorNetwork(m: Mat3, vn: VectorNetwork): VectorNetwork {
const mapVector = (v: Vector): Vector => ({
x: m[0] * v.x + m[1] * v.y,
y: m[3] * v.x + m[4] * v.y
})
return {
vertices: vn.vertices.map((v) => ({
...v,
x: m[0] * v.x + m[1] * v.y + m[2],
y: m[3] * v.x + m[4] * v.y + m[5]
})),
segments: vn.segments.map((s) => ({
...s,
tangentStart: mapVector(s.tangentStart),
tangentEnd: mapVector(s.tangentEnd)
})),
regions: vn.regions.map((r) => ({
windingRule: r.windingRule,
loops: r.loops.map((l) => [...l])
}))
}
}
/** Structural equality of two VectorNetworks (order-sensitive, exact values). */
export function vectorNetworksEqual(a: VectorNetwork, b: VectorNetwork): boolean {
return (
isEqual(a.vertices, b.vertices) &&
isEqual(a.segments, b.segments) &&
isEqual(a.regions, b.regions)
)
}
/** Deep-copy a VectorNetwork, stripping any Vue Proxy wrappers. */
export function cloneVectorNetwork(vn: VectorNetwork): VectorNetwork {
return {

View file

@ -3,11 +3,19 @@ import type { EditorCommand } from './types'
type EditCommandId = 'edit.undo' | 'edit.redo'
// Vector edit mode keeps a session-local history; undo/redo route there
// while it is active instead of the document undo stack.
type NodeEditHistoryEditor = Partial<{
nodeEditUndo: () => void
nodeEditRedo: () => void
}>
export function createEditCommands({
editor,
capabilities,
messages: t
}: EditorCommandMapOptions): Record<EditCommandId, EditorCommand> {
const nodeEditEditor = editor as typeof editor & NodeEditHistoryEditor
return {
'edit.undo': {
id: 'edit.undo',
@ -15,7 +23,13 @@ export function createEditCommands({
return t.value.undo
},
enabled: capabilities.canUndo,
run: () => editor.undoAction()
run: () => {
if (editor.state.nodeEditState) {
nodeEditEditor.nodeEditUndo?.()
return
}
editor.undoAction()
}
},
'edit.redo': {
id: 'edit.redo',
@ -23,7 +37,13 @@ export function createEditCommands({
return t.value.redo
},
enabled: capabilities.canRedo,
run: () => editor.redoAction()
run: () => {
if (editor.state.nodeEditState) {
nodeEditEditor.nodeEditRedo?.()
return
}
editor.redoAction()
}
}
}
}

View file

@ -70,8 +70,10 @@ export function useSelectionCapabilities() {
canSelectAll: useSceneComputed(
() => editor.graph.getChildren(editor.state.currentPageId).length > 0
),
canUndo: useSceneComputed(() => editor.undo.canUndo),
canRedo: useSceneComputed(() => editor.undo.canRedo),
// In vector edit mode, undo/redo route to the session-local history —
// keep the commands enabled so the shortcut reaches them.
canUndo: useSceneComputed(() => editor.state.nodeEditState != null || editor.undo.canUndo),
canRedo: useSceneComputed(() => editor.state.nodeEditState != null || editor.undo.canRedo),
canZoomToSelection: computed(() => hasSelection.value)
}
}

View file

@ -21,6 +21,7 @@ type NodeEditEditor = Partial<{
nodeEditRemoveVertex: (vertexIndex: number) => void
penResumeFromEndpoint: (nodeId: string, endpointVertexIndex: number) => void
nodeEditAddVertex: (cx: number, cy: number) => void
nodeEditPushHistory: () => void
nodeEditSetHandle: (
segmentIndex: number,
tangentField: 'tangentStart' | 'tangentEnd',
@ -56,6 +57,7 @@ export function handleNodeEditDown(
es.selectedVertexIndices = new Set()
es.selectedHandles = new Set([key])
}
nodeEditEditor.nodeEditPushHistory?.()
setDrag({
type: 'edit-handle',
segmentIndex: handleHit.segmentIndex,
@ -79,6 +81,7 @@ export function handleNodeEditDown(
if (e.metaKey || e.ctrlKey) {
nodeEditEditor.nodeEditSelectVertex?.(vi, false)
nodeEditEditor.nodeEditPushHistory?.()
setDrag({
type: 'bend-handle',
vertexIndex: vi,
@ -104,6 +107,7 @@ export function handleNodeEditDown(
origPositions.set(vi, { x: es.vertices[vi].x, y: es.vertices[vi].y })
}
nodeEditEditor.nodeEditPushHistory?.()
setDrag({
type: 'edit-node',
startX: cx,

View file

@ -2,7 +2,9 @@ export { constrainToAspectRatio } from '#vue/shared/input/resize/rect'
export { tryStartResize } from '#vue/shared/input/resize/start'
import type { Editor } from '@open-pencil/core/editor'
import { computeAllLayouts } from '@open-pencil/core/layout'
import { cloneVectorNetwork } from '@open-pencil/scene-graph'
import type { SceneNode } from '@open-pencil/scene-graph'
import { copyGeometryPaths, scaleGeometryPaths } from '@open-pencil/scene-graph/copy'
import {
computeConstrainedResizeChanges,
scaleVectorNetworkForResize
@ -25,6 +27,18 @@ function resizeChanges(d: DragResize, cx: number, cy: number, constrain: boolean
newRect.height
)
if (resizedVectorNetwork) changes.vectorNetwork = resizedVectorNetwork
if (origRect.width > 0 && origRect.height > 0) {
const scaleX = newRect.width / origRect.width
const scaleY = newRect.height / origRect.height
if (scaleX !== 1 || scaleY !== 1) {
if (d.origFillGeometry.length > 0) {
changes.fillGeometry = scaleGeometryPaths(d.origFillGeometry, scaleX, scaleY)
}
if (d.origStrokeGeometry.length > 0) {
changes.strokeGeometry = scaleGeometryPaths(d.origStrokeGeometry, scaleX, scaleY)
}
}
}
return { changes, newRect }
}
@ -72,7 +86,9 @@ export function commitResizePreview(d: DragResize, editor: Editor) {
width: node.width,
height: node.height
}
if (node.vectorNetwork) finalChanges.vectorNetwork = node.vectorNetwork
if (node.vectorNetwork) finalChanges.vectorNetwork = cloneVectorNetwork(node.vectorNetwork)
finalChanges.fillGeometry = copyGeometryPaths(node.fillGeometry)
finalChanges.strokeGeometry = copyGeometryPaths(node.strokeGeometry)
if (d.origChildren) {
const finalChildren = new Map<string, Partial<SceneNode>>()
@ -85,7 +101,9 @@ export function commitResizePreview(d: DragResize, editor: Editor) {
width: child.width,
height: child.height
}
if (child.vectorNetwork) final.vectorNetwork = child.vectorNetwork
if (child.vectorNetwork) final.vectorNetwork = cloneVectorNetwork(child.vectorNetwork)
final.fillGeometry = copyGeometryPaths(child.fillGeometry)
final.strokeGeometry = copyGeometryPaths(child.strokeGeometry)
finalChildren.set(childId, final)
}
editor.graph.updateNodePreview(d.nodeId, d.origRect)
@ -103,7 +121,9 @@ export function commitResizePreview(d: DragResize, editor: Editor) {
editor.updateNode(d.nodeId, finalChanges)
editor.commitResize(d.nodeId, {
...d.origRect,
...(d.origVectorNetwork || node.vectorNetwork ? { vectorNetwork: d.origVectorNetwork } : {})
vectorNetwork: d.origVectorNetwork,
fillGeometry: d.origFillGeometry,
strokeGeometry: d.origStrokeGeometry
})
}
}

View file

@ -1,5 +1,6 @@
import type { Editor } from '@open-pencil/core/editor'
import { cloneVectorNetwork } from '@open-pencil/scene-graph'
import { copyGeometryPaths } from '@open-pencil/scene-graph/copy'
import { collectResizeDescendants } from '@open-pencil/scene-graph/resize'
import { getHitHandleByMatrix } from '#vue/shared/input/geometry'
@ -19,6 +20,8 @@ export function tryStartResize(cx: number, cy: number, editor: Editor): DragResi
origRect: { x: node.x, y: node.y, width: node.width, height: node.height },
nodeId: id,
origVectorNetwork: node.vectorNetwork ? cloneVectorNetwork(node.vectorNetwork) : null,
origFillGeometry: copyGeometryPaths(node.fillGeometry),
origStrokeGeometry: copyGeometryPaths(node.strokeGeometry),
origChildren: collectResizeDescendants(editor.graph, id)
}
}

View file

@ -1,5 +1,5 @@
import type { Tool } from '@open-pencil/core/editor'
import type { NodeType, VectorNetwork } from '@open-pencil/scene-graph'
import type { GeometryPath, NodeType, VectorNetwork } from '@open-pencil/scene-graph'
import type { Rect, Vector } from '@open-pencil/scene-graph/primitives'
import type { ResizeSnapshot } from '@open-pencil/scene-graph/resize'
@ -46,6 +46,8 @@ export interface DragResize {
origRect: Rect
nodeId: string
origVectorNetwork: VectorNetwork | null
origFillGeometry: GeometryPath[]
origStrokeGeometry: GeometryPath[]
origChildren: Map<string, ResizeSnapshot> | null
}

View file

@ -1,16 +1,20 @@
import type { Editor, Tool } from '@open-pencil/core/editor'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { transformVectorNetwork } from '@open-pencil/scene-graph'
import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import {
absoluteVertices,
cloneSegments,
createResumedPenState,
walkChainOrdered,
walkChainToEnd,
type PenState
} from '@/app/editor/pen/resume'
export function createPenActions(editor: Editor, graph: SceneGraph, state: PenState) {
// Always read editor.graph at call time: opening a file swaps the graph
// instance (editor.replaceGraph), so a captured reference goes stale.
//
// Pen state is page-absolute; resumed geometry maps through the node's world
// matrix so nested, rotated, and flipped vectors resume in place.
export function createPenActions(editor: Editor, state: PenState) {
function setTool(tool: Tool) {
if (state.penState && tool !== 'PEN' && tool !== 'HAND') {
editor.penCommit(false)
@ -19,27 +23,31 @@ export function createPenActions(editor: Editor, graph: SceneGraph, state: PenSt
}
function penResumeOnPath(nodeId: string) {
const node = graph.getNode(nodeId)
const node = editor.graph.getNode(nodeId)
if (node?.type !== 'VECTOR' || !node.vectorNetwork) return
state.penState = createResumedPenState(
node,
absoluteVertices(node, node.vectorNetwork.vertices),
cloneSegments(node.vectorNetwork.segments)
const absNetwork = transformVectorNetwork(
getWorldMatrix(node, editor.graph),
node.vectorNetwork
)
state.penState = createResumedPenState(node, absNetwork.vertices, absNetwork.segments)
graph.deleteNode(nodeId)
editor.graph.deleteNode(nodeId)
editor.clearSelection()
editor.setTool('PEN')
editor.requestRender()
}
function penResumeFromEndpoint(nodeId: string, endpointVertexIndex: number) {
const node = graph.getNode(nodeId)
const node = editor.graph.getNode(nodeId)
if (node?.type !== 'VECTOR' || !node.vectorNetwork) return
const absVertices = absoluteVertices(node, node.vectorNetwork.vertices)
const absSegments = cloneSegments(node.vectorNetwork.segments)
const absNetwork = transformVectorNetwork(
getWorldMatrix(node, editor.graph),
node.vectorNetwork
)
const absVertices = absNetwork.vertices
const absSegments = absNetwork.segments
const otherEnd = walkChainToEnd(absSegments, endpointVertexIndex)
const { orderedVertices, orderedSegments } = walkChainOrdered(
absVertices,
@ -48,7 +56,7 @@ export function createPenActions(editor: Editor, graph: SceneGraph, state: PenSt
)
state.penState = createResumedPenState(node, orderedVertices, orderedSegments)
graph.deleteNode(nodeId)
editor.graph.deleteNode(nodeId)
editor.clearSelection()
editor.setTool('PEN')
editor.requestRender()

View file

@ -4,22 +4,6 @@ import type { SceneNode, VectorSegment, VectorVertex } from '@open-pencil/scene-
export type PenState = EditorState
type PenStateInit = NonNullable<EditorState['penState']>
export function absoluteVertices(node: SceneNode, vertices: VectorVertex[]): VectorVertex[] {
return vertices.map((v) => ({
...v,
x: v.x + node.x,
y: v.y + node.y
}))
}
export function cloneSegments(segments: VectorSegment[]): VectorSegment[] {
return segments.map((s) => ({
...s,
tangentStart: { ...s.tangentStart },
tangentEnd: { ...s.tangentEnd }
}))
}
export function createResumedPenState(
node: SceneNode,
vertices: VectorVertex[],

View file

@ -49,7 +49,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
const { selectedNodes, selectedNode, layerTree } = createEditorComputedRefs(editor, state)
const modules = createEditorStoreModules(editor, graph, state, io, viewportSize)
const modules = createEditorStoreModules(editor, state, io, viewportSize)
// ─── Public API ───────────────────────────────────────────────
// Spread all core Editor methods, then override getters and add app-specific.

View file

@ -2,7 +2,6 @@ import { computed } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import type { IORegistry } from '@open-pencil/core/io'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { createDocumentExportActions } from '@/app/document/export'
import { createDocumentIOActions } from '@/app/document/io'
@ -51,14 +50,13 @@ export function createEditorComputedRefs(editor: Editor, state: AppEditorState)
export function createEditorStoreModules(
editor: Editor,
graph: SceneGraph,
state: AppEditorState,
io: IORegistry,
viewportSize: ViewportSize
) {
const flash = createFlashActions(editor, state)
const pen = createPenActions(editor, graph, state)
const vectorEdit = createVectorEditActions(editor, graph, state)
const pen = createPenActions(editor, state)
const vectorEdit = createVectorEditActions(editor, state)
const documentIO = createDocumentIOActions(editor, state, viewportSize)
const documentExport = createDocumentExportActions(editor, state, io, documentIO.downloadBlob)
const mobileClipboard = createMobileClipboardActions(editor, state)

View file

@ -1,15 +1,15 @@
import type { Editor } from '@open-pencil/core/editor'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { createVectorEditHandleActions } from './handle-actions'
import { createVectorEditHistoryActions } from './history'
import { createVectorEditLifecycle } from './lifecycle'
import { createVectorEditNetworkActions, getLiveNetwork, setNodeEditNetwork } from './network'
import { createVectorEditSelectionActions } from './selection'
import type { VectorEditState } from './types'
export function createVectorEditActions(editor: Editor, graph: SceneGraph, state: VectorEditState) {
export function createVectorEditActions(editor: Editor, state: VectorEditState) {
const { getNodeEditState, applyNodeEditToNode, enterNodeEditMode, exitNodeEditMode } =
createVectorEditLifecycle(editor, graph, state)
createVectorEditLifecycle(editor, state)
const {
nodeEditSelectVertex,
nodeEditAlignVertices,
@ -19,6 +19,10 @@ export function createVectorEditActions(editor: Editor, graph: SceneGraph, state
const { nodeEditSetHandle, nodeEditBendHandle, nodeEditZeroVertexHandles } =
createVectorEditHandleActions(editor, getNodeEditState)
const { nodeEditPushHistory, nodeEditUndo, nodeEditRedo } = createVectorEditHistoryActions(
editor,
state
)
const { nodeEditConnectEndpoints, nodeEditAddVertex, nodeEditRemoveVertex } =
createVectorEditNetworkActions(editor, state, getNodeEditState)
@ -38,6 +42,9 @@ export function createVectorEditActions(editor: Editor, graph: SceneGraph, state
nodeEditRemoveVertex,
nodeEditAlignVertices,
nodeEditDeleteSelected,
nodeEditBreakAtVertex
nodeEditBreakAtVertex,
nodeEditPushHistory,
nodeEditUndo,
nodeEditRedo
}
}

View file

@ -3,6 +3,7 @@ import { findAllHandles, findOppositeHandle, mirrorHandle } from '@open-pencil/c
import type { Vector } from '@open-pencil/scene-graph/primitives'
import { constrainContinuousTangent } from './handles'
import { pushNodeEditHistory } from './history'
import { getLiveNetwork } from './network'
import type { NodeEditState } from './types'
@ -127,6 +128,7 @@ export function createVectorEditHandleActions(editor: Editor, getNodeEditState:
function nodeEditZeroVertexHandles(vertexIndex: number) {
const es = getNodeEditState()
if (!es) return
pushNodeEditHistory(es)
const live = getLiveNetwork(es)
const handles = findAllHandles(live, vertexIndex)
for (const h of handles) {

View file

@ -0,0 +1,72 @@
import type { Editor } from '@open-pencil/core/editor'
import { cloneVectorNetwork, vectorNetworksEqual } from '@open-pencil/scene-graph'
import type { VectorNetwork } from '@open-pencil/scene-graph'
import type { NodeEditState, VectorEditState } from './types'
function snapshot(es: NodeEditState): VectorNetwork {
return cloneVectorNetwork({ vertices: es.vertices, segments: es.segments, regions: es.regions })
}
/** Snapshot the current geometry onto the session undo stack (call BEFORE mutating).
* Redo entries are NOT cleared here — pointer-down alone is not a change; stale
* redo entries are dropped lazily once a real geometry change is observed. */
export function pushNodeEditHistory(es: NodeEditState) {
es.history.push(snapshot(es))
}
/** Drop redo entries when the geometry diverged from the last undo/redo restore
* point — i.e. a real change happened, which invalidates the redo timeline. */
function invalidateStaleFuture(es: NodeEditState, current: VectorNetwork) {
if (!es.futureBaseline || es.future.length === 0) return
if (!vectorNetworksEqual(es.futureBaseline, current)) {
es.future = []
es.futureBaseline = null
}
}
export function createVectorEditHistoryActions(editor: Editor, state: VectorEditState) {
function nodeEditPushHistory() {
const es = state.nodeEditState
if (es) pushNodeEditHistory(es)
}
function restore(es: NodeEditState, network: VectorNetwork) {
const clone = cloneVectorNetwork(network)
es.vertices = clone.vertices
es.segments = clone.segments
es.regions = clone.regions
es.futureBaseline = cloneVectorNetwork(network)
// indices may no longer exist in the restored geometry
es.selectedVertexIndices = new Set()
es.selectedHandles = new Set()
es.hoveredHandleInfo = null
editor.requestRender()
}
function nodeEditUndo() {
const es = state.nodeEditState
if (!es) return
const current = snapshot(es)
invalidateStaleFuture(es, current)
// Drag-start snapshots can be no-ops (click without move) — skip those
let entry = es.history.pop()
while (entry && vectorNetworksEqual(entry, current)) entry = es.history.pop()
if (!entry) return
es.future.push(current)
restore(es, entry)
}
function nodeEditRedo() {
const es = state.nodeEditState
if (!es) return
const current = snapshot(es)
invalidateStaleFuture(es, current)
const next = es.future.pop()
if (!next) return
es.history.push(current)
restore(es, next)
}
return { nodeEditPushHistory, nodeEditUndo, nodeEditRedo }
}

View file

@ -1,70 +1,98 @@
import type { Editor } from '@open-pencil/core/editor'
import { computeAccurateBounds } from '@open-pencil/core/vector'
import { cloneVectorNetwork } from '@open-pencil/scene-graph'
import type { SceneGraph, VectorNetwork } from '@open-pencil/scene-graph'
import { computeAccurateBounds, regenerateFillGeometry } from '@open-pencil/core/vector'
import {
cloneVectorNetwork,
transformVectorNetwork,
vectorNetworksEqual
} from '@open-pencil/scene-graph'
import type { VectorNetwork } from '@open-pencil/scene-graph'
import { getNodeLocalMatrix, getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import Matrix from '@open-pencil/scene-graph/matrix'
import { getLiveNetwork } from './network'
import type { VectorEditState } from './types'
export function createVectorEditLifecycle(
editor: Editor,
graph: SceneGraph,
state: VectorEditState
) {
// Always read editor.graph at call time: opening a file swaps the graph
// instance (editor.replaceGraph), so a captured reference goes stale.
//
// Edit-state geometry lives in page-absolute space, mapped through the node's
// full world matrix so nested, rotated, and flipped nodes edit correctly.
export function createVectorEditLifecycle(editor: Editor, state: VectorEditState) {
function getNodeEditState() {
return state.nodeEditState
}
function applyNodeEditToNode(es: NonNullable<typeof state.nodeEditState>) {
const node = graph.getNode(es.nodeId)
const node = editor.graph.getNode(es.nodeId)
if (node?.type !== 'VECTOR') return
const live = getLiveNetwork(es)
const bounds = computeAccurateBounds(live)
// Session geometry only changes through explicit edits, so an exact
// comparison detects a no-op session — skip it to keep undo clean.
if (vectorNetworksEqual(getLiveNetwork(es), es.origAbsNetwork)) return
const world = getWorldMatrix(node, editor.graph)
const inverse = Matrix.invert(world)
if (!inverse) return
// Map the edited page-absolute network back into the node's local frame
const localNetwork = transformVectorNetwork(inverse, getLiveNetwork(es))
const bounds = computeAccurateBounds(localNetwork)
const relativeNetwork: VectorNetwork = {
vertices: live.vertices.map((v) => ({
vertices: localNetwork.vertices.map((v) => ({
...v,
x: v.x - bounds.x,
y: v.y - bounds.y
})),
segments: live.segments,
regions: live.regions
segments: localNetwork.segments,
regions: localNetwork.regions
}
graph.updateNode(node.id, {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
vectorNetwork: relativeNetwork
// The node rotates/flips about its center, so with rotation preserved the
// new x/y follow from where the new geometry's center lands in the parent
// frame (via the OLD local matrix — the geometry itself has not moved).
const localMatrix = getNodeLocalMatrix(node)
const center = Matrix.mapPoint(localMatrix, {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2
})
editor.updateNodeWithUndo(
node.id,
{
x: center.x - bounds.width / 2,
y: center.y - bounds.height / 2,
width: bounds.width,
height: bounds.height,
vectorNetwork: relativeNetwork,
// Fills render from fillGeometry blobs when present — rebuild them from
// the edited network so fills follow the edit.
fillGeometry: regenerateFillGeometry(relativeNetwork, node.fillGeometry),
// Drop stale imported stroke outline blobs; post-edit strokes come from
// the live vector network path.
strokeGeometry: []
},
'Edit vector'
)
editor.requestRender()
}
function enterNodeEditMode(nodeId: string) {
const node = graph.getNode(nodeId)
const node = editor.graph.getNode(nodeId)
if (node?.type !== 'VECTOR' || !node.vectorNetwork) return
const absVertices = node.vectorNetwork.vertices.map((v) => ({
...v,
x: v.x + node.x,
y: v.y + node.y
}))
const world = getWorldMatrix(node, editor.graph)
const absNetwork = transformVectorNetwork(world, node.vectorNetwork)
state.nodeEditState = {
nodeId,
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) => ({
...s,
tangentStart: { ...s.tangentStart },
tangentEnd: { ...s.tangentEnd }
})),
regions: node.vectorNetwork.regions.map((r) => ({
windingRule: r.windingRule,
loops: r.loops.map((l) => [...l])
})),
origAbsNetwork: cloneVectorNetwork(absNetwork),
vertices: absNetwork.vertices,
segments: absNetwork.segments,
regions: absNetwork.regions,
history: [],
future: [],
selectedVertexIndices: new Set(),
draggedHandleInfo: null,
selectedHandles: new Set(),
@ -79,7 +107,7 @@ export function createVectorEditLifecycle(
const es = getNodeEditState()
if (!es) return
const node = graph.getNode(es.nodeId)
const node = editor.graph.getNode(es.nodeId)
if (node?.type !== 'VECTOR') {
state.nodeEditState = null
editor.requestRender()
@ -89,7 +117,7 @@ export function createVectorEditLifecycle(
if (commit) {
applyNodeEditToNode(es)
} else {
graph.updateNode(es.nodeId, {
editor.graph.updateNode(es.nodeId, {
x: es.origBounds.x,
y: es.origBounds.y,
width: es.origBounds.width,

View file

@ -2,6 +2,7 @@ import type { Editor } from '@open-pencil/core/editor'
import { nearestPointOnNetwork, removeVertex, splitSegmentAt } from '@open-pencil/core/vector'
import type { VectorNetwork } from '@open-pencil/scene-graph'
import { pushNodeEditHistory } from './history'
import type { NodeEditState, VectorEditState } from './types'
export function setNodeEditNetwork(es: NodeEditState, network: VectorNetwork) {
@ -43,6 +44,7 @@ export function createVectorEditNetworkActions(
const es = getNodeEditState()
if (!es || a === b) return
if (a < 0 || b < 0 || a >= es.vertices.length || b >= es.vertices.length) return
pushNodeEditHistory(es)
const removeIndex = a
const keepIndex = b
@ -74,6 +76,7 @@ export function createVectorEditNetworkActions(
const live = getLiveNetwork(es)
const nearest = nearestPointOnNetwork(cx, cy, live, nodeEditHitThreshold / state.zoom)
if (!nearest) return
pushNodeEditHistory(es)
const split = splitSegmentAt(live, nearest.segmentIndex, nearest.t)
setNodeEditNetwork(es, split.network)
es.selectedVertexIndices = new Set([split.newVertexIndex])
@ -87,6 +90,7 @@ export function createVectorEditNetworkActions(
const live = getLiveNetwork(es)
const next = removeVertex(live, vertexIndex)
if (!next) return
pushNodeEditHistory(es)
setNodeEditNetwork(es, next)
es.selectedVertexIndices = new Set()
es.selectedHandles = new Set()

View file

@ -1,6 +1,7 @@
import type { Editor } from '@open-pencil/core/editor'
import { breakAtVertex, deleteVertex } from '@open-pencil/core/vector'
import { pushNodeEditHistory } from './history'
import { getLiveNetwork, setNodeEditNetwork } from './network'
import type { VectorEditState } from './types'
@ -26,6 +27,7 @@ export function createVectorEditSelectionActions(editor: Editor, state: VectorEd
function nodeEditAlignVertices(axis: 'horizontal' | 'vertical', align: 'min' | 'center' | 'max') {
const es = getNodeEditState()
if (!es || es.selectedVertexIndices.size < 2) return
pushNodeEditHistory(es)
const indices = [...es.selectedVertexIndices]
const prop = axis === 'horizontal' ? 'x' : 'y'
@ -50,6 +52,8 @@ export function createVectorEditSelectionActions(editor: Editor, state: VectorEd
function nodeEditDeleteSelected() {
const es = getNodeEditState()
if (!es) return
if (es.selectedHandles.size === 0 && es.selectedVertexIndices.size === 0) return
pushNodeEditHistory(es)
let live = getLiveNetwork(es)
for (const key of es.selectedHandles) {
@ -76,6 +80,7 @@ export function createVectorEditSelectionActions(editor: Editor, state: VectorEd
function nodeEditBreakAtVertex() {
const es = getNodeEditState()
if (!es || es.selectedVertexIndices.size === 0) return
pushNodeEditHistory(es)
const [vertexIndex] = es.selectedVertexIndices
const live = getLiveNetwork(es)
const next = breakAtVertex(live, vertexIndex)

View file

@ -11,9 +11,16 @@ export type NodeEditState = {
nodeId: string
origNetwork: VectorNetwork
origBounds: Rect
/** Session geometry as entered (page-absolute) — commit is a no-op when unchanged */
origAbsNetwork: VectorNetwork
vertices: VectorVertex[]
segments: VectorSegment[]
regions: VectorRegion[]
/** Session-local undo/redo stacks of geometry snapshots (Cmd+Z inside edit mode) */
history: VectorNetwork[]
future: VectorNetwork[]
/** Geometry at the last undo/redo restore; divergence invalidates `future` */
futureBaseline?: VectorNetwork | null
selectedVertexIndices: Set<number>
draggedHandleInfo: {
vertexIndex: number

View file

@ -0,0 +1,102 @@
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
test('vector edit overlay follows nested transforms and live path fills', async () => {
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageId = store.state.currentPageId
const rectBlob = (x: number, y: number, width: number, height: number) => {
const blob = new Uint8Array(38)
const view = new DataView(blob.buffer)
const points = [
{ command: 1, x, y },
{ command: 2, x: x + width, y },
{ command: 2, x: x + width, y: y + height },
{ command: 2, x, y: y + height }
]
let offset = 0
for (const point of points) {
blob[offset] = point.command
view.setFloat32(offset + 1, point.x, true)
view.setFloat32(offset + 5, point.y, true)
offset += 9
}
blob[offset] = 0
return blob
}
const blue = {
type: 'SOLID' as const,
color: { r: 0.23, g: 0.51, b: 0.96, a: 1 },
visible: true,
opacity: 1
}
const orange = {
type: 'SOLID' as const,
color: { r: 1, g: 0.4, b: 0, a: 1 },
visible: true,
opacity: 1
}
const frame = store.graph.createNode('FRAME', pageId, {
name: 'Edit host',
x: 220,
y: 120,
width: 400,
height: 300,
rotation: 30,
fills: [
{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, visible: true, opacity: 1 }
]
})
const vector = store.graph.createNode('VECTOR', frame.id, {
name: 'Edited vector',
x: 60,
y: 80,
width: 200,
height: 100,
rotation: 20,
fills: [blue],
fillGeometry: [
{ windingRule: 'NONZERO', commandsBlob: rectBlob(0, 0, 100, 100) },
{ windingRule: 'NONZERO', commandsBlob: rectBlob(100, 0, 100, 100), fills: [orange] }
],
vectorNetwork: {
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 },
{ x: 100, y: 0 },
{ x: 200, y: 0 },
{ x: 200, y: 100 },
{ x: 100, y: 100 }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 20, y: 20 }, tangentEnd: { x: -20, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 3, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 4, end: 5, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 5, end: 6, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 6, end: 7, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 7, end: 4, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [
{ windingRule: 'NONZERO', loops: [[0, 1, 2, 3]] },
{ windingRule: 'NONZERO', loops: [[4, 5, 6, 7]] }
]
}
})
store.enterNodeEditMode(vector.id)
const editState = store.getNodeEditState()
if (editState) editState.vertices[6].x += 30
store.requestRender()
})
await editor.canvas.waitForRender()
editor.canvas.assertNoErrors()
const buffer = await editor.canvas.canvas.screenshot()
expect(buffer).toMatchSnapshot('vector-edit-mode-overlay.png')
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -0,0 +1,320 @@
import { describe, test, expect } from 'bun:test'
import { createEditor, type Editor } from '@open-pencil/core/editor'
import { regenerateFillGeometry } from '@open-pencil/core/vector'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { SceneNode, VectorNetwork } from '@open-pencil/scene-graph'
import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import Matrix from '@open-pencil/scene-graph/matrix'
import { createVectorEditHistoryActions } from '@/app/editor/vector-edit/history'
import { createVectorEditLifecycle } from '@/app/editor/vector-edit/lifecycle'
import type { VectorEditState } from '@/app/editor/vector-edit/types'
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
const NETWORK: VectorNetwork = {
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 50 },
{ x: 0, y: 50 }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 20, y: 10 }, tangentEnd: { x: -20, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 3, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2, 3]] }]
}
function setup(frameRotation: number, vectorRotation: number) {
const graph = new SceneGraph()
const pageId = expectDefined(graph.getPages()[0]).id
const frame = graph.createNode('FRAME', pageId, {
x: 300,
y: 200,
width: 400,
height: 300,
rotation: frameRotation
})
const vector = graph.createNode('VECTOR', frame.id, {
x: 40,
y: 60,
width: 100,
height: 50,
rotation: vectorRotation,
vectorNetwork: {
vertices: NETWORK.vertices.map((v) => ({ ...v })),
segments: NETWORK.segments.map((s) => ({
...s,
tangentStart: { ...s.tangentStart },
tangentEnd: { ...s.tangentEnd }
})),
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2, 3]] }]
}
})
const undoLabels: string[] = []
const editor = {
get graph() {
return graph
},
select: () => undefined,
requestRender: () => undefined,
updateNodeWithUndo(id: string, changes: Partial<SceneNode>, label = 'Update') {
undoLabels.push(label)
graph.updateNode(id, changes)
}
} as Editor
const state: VectorEditState = { nodeEditState: null } as VectorEditState
const lifecycle = createVectorEditLifecycle(editor, state)
return { graph, vector, state, lifecycle, undoLabels }
}
/** World-space positions of a node's network vertices via its world matrix. */
function worldVertices(graph: SceneGraph, nodeId: string) {
const node = getNodeOrThrow(graph, nodeId)
const network = expectDefined(node.vectorNetwork, 'vectorNetwork')
const world = getWorldMatrix(node, graph)
return network.vertices.map((v) => Matrix.mapPoint(world, v))
}
describe('vector edit graph ownership', () => {
test('enters edit mode after the editor graph is replaced', () => {
const initial = new SceneGraph()
const replacement = new SceneGraph()
const vector = replacement.createNode('VECTOR', expectDefined(replacement.getPages()[0]).id, {
width: 100,
height: 50,
vectorNetwork: NETWORK
})
const editor = createEditor({ graph: initial })
const state = { nodeEditState: null } as VectorEditState
const lifecycle = createVectorEditLifecycle(editor, state)
editor.replaceGraph(replacement)
lifecycle.enterNodeEditMode(vector.id)
expect(state.nodeEditState?.nodeId).toBe(vector.id)
})
test('commits network and fill geometry as one document undo step', () => {
const editor = createEditor()
const page = expectDefined(editor.graph.getPages()[0])
const fillGeometry = regenerateFillGeometry(NETWORK, [
{
windingRule: 'NONZERO',
commandsBlob: new Uint8Array(),
fills: [
{
type: 'SOLID',
color: { r: 1, g: 0.4, b: 0, a: 1 },
visible: true,
opacity: 1,
blendMode: 'NORMAL'
}
]
}
])
const vector = editor.graph.createNode('VECTOR', page.id, {
width: 100,
height: 50,
vectorNetwork: NETWORK,
fillGeometry
})
const state = { nodeEditState: null } as VectorEditState
const lifecycle = createVectorEditLifecycle(editor, state)
const originalBlob = expectDefined(vector.fillGeometry[0]).commandsBlob.slice()
lifecycle.enterNodeEditMode(vector.id)
const editState = expectDefined(state.nodeEditState)
expectDefined(editState.vertices[1]).x += 20
lifecycle.exitNodeEditMode(true)
const edited = getNodeOrThrow(editor.graph, vector.id)
const editedBlob = expectDefined(edited.fillGeometry[0]).commandsBlob.slice()
expect(editedBlob).not.toEqual(originalBlob)
expect(editor.undo.canUndo).toBe(true)
editor.undo.undo()
expect(
expectDefined(getNodeOrThrow(editor.graph, vector.id).fillGeometry[0]).commandsBlob
).toEqual(originalBlob)
expect(editor.undo.canUndo).toBe(false)
editor.undo.redo()
expect(
expectDefined(getNodeOrThrow(editor.graph, vector.id).fillGeometry[0]).commandsBlob
).toEqual(editedBlob)
})
})
describe('vector edit with rotated ancestors', () => {
test('enter maps vertices through the full world matrix', () => {
const { graph, vector, state, lifecycle } = setup(30, 20)
lifecycle.enterNodeEditMode(vector.id)
const expected = worldVertices(graph, vector.id)
const es = expectDefined(state.nodeEditState, 'nodeEditState')
for (let i = 0; i < expected.length; i++) {
const point = expectDefined(expected[i])
const vertex = expectDefined(es.vertices[i])
expect(vertex.x).toBeCloseTo(point.x, 6)
expect(vertex.y).toBeCloseTo(point.y, 6)
}
})
test('enter + commit without edits is a no-op', () => {
const { graph, vector, lifecycle, undoLabels } = setup(30, 20)
const before = worldVertices(graph, vector.id)
lifecycle.enterNodeEditMode(vector.id)
lifecycle.exitNodeEditMode(true)
const node = getNodeOrThrow(graph, vector.id)
expect(node.x).toBe(40)
expect(node.y).toBe(60)
expect(node.width).toBe(100)
expect(node.height).toBe(50)
const after = worldVertices(graph, vector.id)
for (let i = 0; i < before.length; i++) {
const b = expectDefined(before[i])
const a = expectDefined(after[i])
expect(a.x).toBeCloseTo(b.x, 6)
expect(a.y).toBeCloseTo(b.y, 6)
}
// no geometry change → no document undo entry
expect(undoLabels).toEqual([])
})
test('dragging a vertex commits without moving the rest of the shape', () => {
const { graph, vector, state, lifecycle, undoLabels } = setup(30, 20)
const before = worldVertices(graph, vector.id)
lifecycle.enterNodeEditMode(vector.id)
const es = expectDefined(state.nodeEditState, 'nodeEditState')
const dragged = expectDefined(es.vertices[1])
dragged.x += 25
dragged.y -= 15
const movedWorld = { x: dragged.x, y: dragged.y }
lifecycle.exitNodeEditMode(true)
const after = worldVertices(graph, vector.id)
expect(expectDefined(after[1]).x).toBeCloseTo(movedWorld.x, 5)
expect(expectDefined(after[1]).y).toBeCloseTo(movedWorld.y, 5)
for (const i of [0, 2, 3]) {
const b = expectDefined(before[i])
const a = expectDefined(after[i])
expect(a.x).toBeCloseTo(b.x, 5)
expect(a.y).toBeCloseTo(b.y, 5)
}
// rotation is preserved, geometry is re-normalized to the new bounds
const node = getNodeOrThrow(graph, vector.id)
const network = expectDefined(node.vectorNetwork, 'vectorNetwork')
expect(node.rotation).toBe(20)
expect(Math.min(...network.vertices.map((v) => v.x))).toBeCloseTo(0, 5)
expect(Math.min(...network.vertices.map((v) => v.y))).toBeCloseTo(0, 5)
// committing goes through the undo-aware update so Cmd+Z works
expect(undoLabels).toEqual(['Edit vector'])
})
test('tangents rotate with the node on enter', () => {
const { state, lifecycle, vector } = setup(0, 90)
lifecycle.enterNodeEditMode(vector.id)
const es = expectDefined(state.nodeEditState, 'nodeEditState')
const t = expectDefined(es.segments[0]).tangentStart
// local tangent (20, 10) rotated 90° → (-10, 20)
expect(t.x).toBeCloseTo(-10, 6)
expect(t.y).toBeCloseTo(20, 6)
})
})
describe('vector edit session undo/redo', () => {
function editSession() {
const { graph, vector, state, lifecycle } = setup(0, 0)
const editor = {
get graph() {
return graph
},
requestRender: () => undefined
} as Editor
const history = createVectorEditHistoryActions(editor, state)
lifecycle.enterNodeEditMode(vector.id)
return { state, history, es: expectDefined(state.nodeEditState, 'nodeEditState') }
}
test('undo restores geometry from before the mutation', () => {
const { es, history } = editSession()
const orig = { ...expectDefined(es.vertices[0]) }
history.nodeEditPushHistory() // drag start
es.vertices[0] = { ...orig, x: orig.x + 50, y: orig.y - 20 }
history.nodeEditUndo()
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x, 6)
expect(expectDefined(es.vertices[0]).y).toBeCloseTo(orig.y, 6)
})
test('redo reapplies the undone mutation', () => {
const { es, history } = editSession()
const orig = { ...expectDefined(es.vertices[0]) }
history.nodeEditPushHistory()
es.vertices[0] = { ...orig, x: orig.x + 50 }
history.nodeEditUndo()
history.nodeEditRedo()
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x + 50, 6)
})
test('no-op drag snapshots are skipped', () => {
const { es, history } = editSession()
const orig = { ...expectDefined(es.vertices[0]) }
history.nodeEditPushHistory()
es.vertices[0] = { ...orig, x: orig.x + 50 }
history.nodeEditPushHistory() // click without move → identical snapshot
history.nodeEditPushHistory()
history.nodeEditUndo()
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x, 6)
})
test('undo with no history is a no-op', () => {
const { es, history } = editSession()
const orig = { ...expectDefined(es.vertices[0]) }
history.nodeEditUndo()
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x, 6)
})
test('clicking without dragging after undo keeps redo', () => {
const { state, history } = editSession()
const es = () => expectDefined(state.nodeEditState, 'nodeEditState')
const orig = { ...expectDefined(es().vertices[0]) }
history.nodeEditPushHistory()
es().vertices[0] = { ...orig, x: orig.x + 50 }
history.nodeEditUndo()
history.nodeEditPushHistory() // click a vertex without moving it
history.nodeEditRedo()
expect(expectDefined(es().vertices[0]).x).toBeCloseTo(orig.x + 50, 6)
})
test('a real change after undo clears redo', () => {
const { state, history } = editSession()
const es = () => expectDefined(state.nodeEditState, 'nodeEditState')
const orig = { ...expectDefined(es().vertices[0]) }
history.nodeEditPushHistory()
es().vertices[0] = { ...orig, x: orig.x + 50 }
history.nodeEditUndo()
history.nodeEditPushHistory()
es().vertices[0] = { ...orig, x: orig.x - 30 } // new timeline
history.nodeEditRedo() // stale redo must not restore +50
expect(expectDefined(es().vertices[0]).x).toBeCloseTo(orig.x - 30, 6)
})
})

View file

@ -101,7 +101,9 @@ describe('resize undo', () => {
y: 0,
width: 10,
height: 10,
vectorNetwork: originalVectorNetwork
vectorNetwork: originalVectorNetwork,
fillGeometry: [],
strokeGeometry: []
}
]
])

View file

@ -0,0 +1,115 @@
import { describe, test, expect } from 'bun:test'
import { regenerateFillGeometry } from '@open-pencil/core/vector'
import type { GeometryPath, VectorNetwork } from '@open-pencil/scene-graph'
function square(): VectorNetwork {
return {
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 3, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [{ windingRule: 'EVENODD', loops: [[0, 1, 2, 3]] }]
}
}
/** Decode a commandsBlob into a readable command list for assertions. */
function decode(blob: Uint8Array): string[] {
const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength)
const out: string[] = []
let o = 0
while (o < blob.length) {
const cmd = blob[o]
o += 1
if (cmd === 0) {
out.push('Z')
} else if (cmd === 1 || cmd === 2) {
out.push(
`${cmd === 1 ? 'M' : 'L'} ${view.getFloat32(o, true)},${view.getFloat32(o + 4, true)}`
)
o += 8
} else if (cmd === 4) {
out.push(`C ${view.getFloat32(o + 16, true)},${view.getFloat32(o + 20, true)}`)
o += 24
} else {
throw new Error(`unknown cmd ${cmd}`)
}
}
return out
}
const ENTRY: GeometryPath = {
windingRule: 'NONZERO',
commandsBlob: new Uint8Array([9, 9, 9]),
fills: [
{
type: 'SOLID',
color: { r: 1, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL'
}
]
}
describe('regenerateFillGeometry', () => {
test('rebuilds region blobs positionally, preserving fills', () => {
const network = square()
const [g] = regenerateFillGeometry(network, [ENTRY])
expect(g.fills).toEqual(ENTRY.fills)
expect(g.windingRule).toBe('EVENODD') // taken from the region
expect(decode(g.commandsBlob)).toEqual(['M 0,0', 'L 10,0', 'L 10,10', 'L 0,10', 'L 0,0', 'Z'])
})
test('edited vertices land in the regenerated blob', () => {
const network = square()
network.vertices[2] = { x: 14, y: 12 }
const [g] = regenerateFillGeometry(network, [ENTRY])
expect(decode(g.commandsBlob)).toContain('L 14,12')
})
test('curved segments emit cubics', () => {
const network = square()
network.segments[0].tangentStart = { x: 3, y: 4 }
const [g] = regenerateFillGeometry(network, [ENTRY])
expect(decode(g.commandsBlob)[1]).toBe('C 10,0')
})
test('region/entry count mismatch rebuilds without stale styles', () => {
const network = square()
// Two style entries but only one region — mapping is unknown, so rebuild
// one path per region and drop the mismatched style metadata.
const out = regenerateFillGeometry(network, [ENTRY, { ...ENTRY }])
expect(out).toHaveLength(1)
const path = out[0]
expect(path).toBeDefined()
if (!path) return
expect(path.fills).toBeUndefined()
expect(path.commandsBlob).not.toBe(ENTRY.commandsBlob)
expect(decode(path.commandsBlob)).toEqual([
'M 0,0',
'L 10,0',
'L 10,10',
'L 0,10',
'L 0,0',
'Z'
])
})
test('regionless networks rebuild a single entry from segment chains', () => {
const network = square()
network.regions = []
const [g] = regenerateFillGeometry(network, [ENTRY])
const cmds = decode(g.commandsBlob)
expect(cmds[0]).toBe('M 0,0')
expect(cmds).toHaveLength(5) // M + 4 line segments around the square
})
})

View file

@ -26,7 +26,9 @@ function original(node: {
y: node.y,
width: node.width,
height: node.height,
vectorNetwork: node.vectorNetwork
vectorNetwork: node.vectorNetwork,
fillGeometry: [],
strokeGeometry: []
}
}
@ -63,7 +65,9 @@ describe('constraint resize geometry', () => {
y: 30,
width: 40,
height: 20,
vectorNetwork: null
vectorNetwork: null,
fillGeometry: [],
strokeGeometry: []
}
const expectedX: Record<ConstraintType, [number, number]> = {
MIN: [20, 40],
@ -114,6 +118,8 @@ describe('constraint resize geometry', () => {
origRect: { x: root.x, y: root.y, width: root.width, height: root.height },
nodeId: root.id,
origVectorNetwork: null,
origFillGeometry: [],
origStrokeGeometry: [],
origChildren: new Map([
[nested.id, original(nested)],
[grandchild.id, original(grandchild)]
@ -157,6 +163,8 @@ describe('constraint resize geometry', () => {
origRect: { x: root.x, y: root.y, width: root.width, height: root.height },
nodeId: root.id,
origVectorNetwork: null,
origFillGeometry: [],
origStrokeGeometry: [],
origChildren: collectResizeDescendants(graph, root.id)
}

View file

@ -0,0 +1,139 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import type { Fill, GeometryPath, SceneNode, VectorNetwork } from '@open-pencil/scene-graph'
import { cloneVectorNetwork } from '@open-pencil/scene-graph'
import { copyGeometryPaths } from '@open-pencil/scene-graph/copy'
import { collectResizeDescendants } from '@open-pencil/scene-graph/resize'
import { applyResize, commitResizePreview } from '#vue/shared/input/resize'
import type { DragResize } from '#vue/shared/input/types'
import { getNodeOrThrow } from '#tests/helpers/assert'
const RED: Fill = {
type: 'SOLID',
color: { r: 1, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL'
}
const NETWORK: VectorNetwork = {
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 3, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2, 3]] }]
}
function movePath(x: number, y: number, fills?: Fill[]): GeometryPath {
const commandsBlob = new Uint8Array(9)
const view = new DataView(commandsBlob.buffer)
commandsBlob[0] = 1
view.setFloat32(1, x, true)
view.setFloat32(5, y, true)
return { windingRule: 'NONZERO', commandsBlob, fills }
}
function pathPoint(path: GeometryPath | undefined) {
if (!path) return null
const view = new DataView(
path.commandsBlob.buffer,
path.commandsBlob.byteOffset,
path.commandsBlob.byteLength
)
return [view.getFloat32(1, true), view.getFloat32(5, true)]
}
function vectorGeometry(node: SceneNode) {
return {
network: node.vectorNetwork ? cloneVectorNetwork(node.vectorNetwork) : null,
fills: copyGeometryPaths(node.fillGeometry),
strokes: copyGeometryPaths(node.strokeGeometry)
}
}
function dragFor(node: SceneNode, origChildren: DragResize['origChildren'] = null): DragResize {
const geometry = vectorGeometry(node)
return {
type: 'resize',
handle: 'se',
startX: 10,
startY: 10,
origRect: { x: node.x, y: node.y, width: node.width, height: node.height },
nodeId: node.id,
origVectorNetwork: geometry.network,
origFillGeometry: geometry.fills,
origStrokeGeometry: geometry.strokes,
origChildren
}
}
function expectGeometry(node: SceneNode, size: number) {
expect(node.width).toBe(size)
expect(node.height).toBe(size)
expect(node.vectorNetwork?.vertices[2]).toMatchObject({ x: size, y: size })
expect(pathPoint(node.fillGeometry[0])).toEqual([size, size])
expect(pathPoint(node.strokeGeometry[0])).toEqual([size / 2, size / 2])
expect(node.fillGeometry[0]?.fills?.[0]?.color.r).toBe(1)
}
describe('vector resize geometry', () => {
test('keeps fill and stroke paths aligned through resize, undo, and redo', () => {
const editor = createEditor()
const page = editor.graph.getPages()[0]
const vector = editor.graph.createNode('VECTOR', page.id, {
width: 10,
height: 10,
vectorNetwork: cloneVectorNetwork(NETWORK),
fillGeometry: [movePath(10, 10, [RED])],
strokeGeometry: [movePath(5, 5)]
})
const drag = dragFor(vector)
applyResize(drag, 20, 20, false, editor)
commitResizePreview(drag, editor)
expectGeometry(getNodeOrThrow(editor.graph, vector.id), 20)
editor.undo.undo()
expectGeometry(getNodeOrThrow(editor.graph, vector.id), 10)
editor.undo.redo()
expectGeometry(getNodeOrThrow(editor.graph, vector.id), 20)
})
test('keeps descendant geometry aligned through group resize history', () => {
const editor = createEditor()
const page = editor.graph.getPages()[0]
const group = editor.graph.createNode('GROUP', page.id, { width: 10, height: 10 })
const vector = editor.graph.createNode('VECTOR', group.id, {
width: 10,
height: 10,
vectorNetwork: cloneVectorNetwork(NETWORK),
fillGeometry: [movePath(10, 10, [RED])],
strokeGeometry: [movePath(5, 5)]
})
const descendants = collectResizeDescendants(editor.graph, group.id)
expect(descendants).not.toBeNull()
const drag = dragFor(group, descendants)
applyResize(drag, 20, 20, false, editor)
commitResizePreview(drag, editor)
expectGeometry(getNodeOrThrow(editor.graph, vector.id), 20)
editor.undo.undo()
expectGeometry(getNodeOrThrow(editor.graph, vector.id), 10)
editor.undo.redo()
expectGeometry(getNodeOrThrow(editor.graph, vector.id), 20)
})
})