openpencil/tests/engine/vue/controls/stroke.test.ts
Danila Poyarkov 2f8d799a5f feat(editor): add stroke geometry controls
- Add mixed-selection cap, join, and miter-limit controls with one-step undo
- Apply node miter limits across CanvasKit stroke paths and vector outline caches
- Preserve imported and edited stroke geometry through Plugin API and .fig roundtrips
2026-07-17 19:01:42 +03:00

104 lines
3.6 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { computed, ref } from 'vue'
import { createEditor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/scene-graph'
import { MIXED, type MixedValue } from '#vue/controls/node-props/use'
import {
DEFAULT_STROKE,
createStrokeGeometryActions,
createStrokeGeometryState
} from '#vue/controls/stroke/helpers'
import { firstPageId, makeSceneGraph } from '#tests/helpers/scene'
function strokedRect(overrides: Partial<SceneNode> = {}) {
const graph = makeSceneGraph()
const node = graph.createNode('RECTANGLE', firstPageId(graph), {
strokes: [{ ...DEFAULT_STROKE }],
...overrides
})
return { graph, node }
}
function merged(nodes: SceneNode[]) {
return <K extends keyof SceneNode>(key: K): MixedValue<SceneNode[K]> => {
const first = nodes[0]?.[key]
if (first === undefined || nodes.some((node) => node[key] !== first)) return MIXED
return first
}
}
describe('stroke geometry controls', () => {
test('is active only when every selected node has a stroke and reports mixed values', () => {
const { node } = strokedRect()
const other = structuredClone(node)
other.id = 'other'
other.strokeJoin = 'BEVEL'
const nodes = ref([node, other])
const state = createStrokeGeometryState({
nodes: computed(() => nodes.value),
merged: (key) => merged(nodes.value)(key)
})
expect(state.advancedActive.value).toBe(true)
expect(state.cap.value).toBe('NONE')
expect(state.join.value).toBe(MIXED)
nodes.value[1].strokes = []
expect(state.advancedActive.value).toBe(false)
})
test('synchronizes node and paint cap and join with one multi-selection undo', () => {
const graph = makeSceneGraph()
const pageId = firstPageId(graph)
const first = graph.createNode('RECTANGLE', pageId, { strokes: [{ ...DEFAULT_STROKE }] })
const second = graph.createNode('RECTANGLE', pageId, { strokes: [{ ...DEFAULT_STROKE }] })
const editor = createEditor({ graph })
const nodes = computed(() =>
[graph.getNode(first.id), graph.getNode(second.id)].filter(Boolean)
)
const actions = createStrokeGeometryActions(editor, nodes)
actions.setCap('ROUND')
actions.setJoin('BEVEL')
expect(graph.getNode(first.id)).toMatchObject({
strokeCap: 'ROUND',
strokeJoin: 'BEVEL',
strokes: [{ cap: 'ROUND', join: 'BEVEL' }]
})
expect(graph.getNode(second.id)?.strokes[0]).toMatchObject({ cap: 'ROUND', join: 'BEVEL' })
editor.undo.undo()
expect(graph.getNode(first.id)?.strokeJoin).toBe('MITER')
expect(graph.getNode(second.id)?.strokeJoin).toBe('MITER')
expect(graph.getNode(first.id)?.strokeCap).toBe('ROUND')
})
test('previews and commits miter limit as one multi-selection undo step', () => {
const graph = makeSceneGraph()
const pageId = firstPageId(graph)
const first = graph.createNode('RECTANGLE', pageId, { strokes: [{ ...DEFAULT_STROKE }] })
const second = graph.createNode('RECTANGLE', pageId, {
strokes: [{ ...DEFAULT_STROKE }],
strokeMiterLimit: 8
})
const editor = createEditor({ graph })
const nodes = computed(() =>
[graph.getNode(first.id), graph.getNode(second.id)].filter(Boolean)
)
const actions = createStrokeGeometryActions(editor, nodes)
actions.updateMiterLimit(12)
expect(graph.getNode(first.id)?.strokeMiterLimit).toBe(12)
expect(graph.getNode(second.id)?.strokeMiterLimit).toBe(12)
actions.commitMiterLimit(12)
editor.undo.undo()
expect(graph.getNode(first.id)?.strokeMiterLimit).toBe(4)
expect(graph.getNode(second.id)?.strokeMiterLimit).toBe(8)
})
})