Multi-selection properties panel with tests
Show position, appearance, fill, stroke, and effects sections
when multiple nodes are selected. Shared values display normally,
differing values show 'Mixed'. Editing applies to all selected.
- Shared composable (use-multi-props) for isMulti, active, activeNode
- ScrubInput supports MIXED symbol as model-value
- Per-node previous values for correct undo on multi-edit
- Fix flip transform encoding (scale first column only)
- Fix npm publish auth for MCP package
- 22 new tests: flip, alignment, multi-node merging, kiwi roundtrip
2026-03-03 17:35:51 +00:00
|
|
|
import { describe, test, expect } from 'bun:test'
|
|
|
|
|
|
Clean up tools/schema, add geometry.ts, isMixed helper, fix imports, add tests
- Strip tools/schema.ts from 1831 to 65 lines (remove duplicate tool defs)
- Fix tools/index.ts to export ALL_TOOLS from registry instead of schema
- Fix renderJsx → renderJSX typo in tools/create.ts and structure.ts
- Add geometry.ts with shared rotation/coordinate math utilities
- Add isArrayMixed() helper to use-multi-props.ts, deduplicate FillSection/StrokeSection/EffectsSection
- Fix broken test imports: use @open-pencil/core instead of relative paths into packages/core/src/
- Merge duplicate import statements across all test files
- Export profiler internals and style-run helpers from core index.ts
- Add 6 new test files: color, undo, snap, vector, style-runs, text-editor (98 new tests)
- Update renderer-effects test for renderer/ directory split
2026-03-06 19:33:10 +00:00
|
|
|
import { SceneGraph } from '@open-pencil/core'
|
Multi-selection properties panel with tests
Show position, appearance, fill, stroke, and effects sections
when multiple nodes are selected. Shared values display normally,
differing values show 'Mixed'. Editing applies to all selected.
- Shared composable (use-multi-props) for isMulti, active, activeNode
- ScrubInput supports MIXED symbol as model-value
- Per-node previous values for correct undo on multi-edit
- Fix flip transform encoding (scale first column only)
- Fix npm publish auth for MCP package
- 22 new tests: flip, alignment, multi-node merging, kiwi roundtrip
2026-03-03 17:35:51 +00:00
|
|
|
|
|
|
|
|
function pageId(graph: SceneGraph) {
|
|
|
|
|
return graph.getPages()[0].id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rect(
|
|
|
|
|
graph: SceneGraph,
|
|
|
|
|
parentId: string,
|
|
|
|
|
props: { name?: string; x?: number; y?: number; width?: number; height?: number } = {}
|
|
|
|
|
) {
|
|
|
|
|
return graph.createNode('RECTANGLE', parentId, {
|
|
|
|
|
name: props.name ?? 'Rect',
|
|
|
|
|
x: props.x ?? 0,
|
|
|
|
|
y: props.y ?? 0,
|
|
|
|
|
width: props.width ?? 50,
|
|
|
|
|
height: props.height ?? 50
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('flip', () => {
|
|
|
|
|
test('flipX defaults to false', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const node = rect(graph, pageId(graph))
|
|
|
|
|
expect(node.flipX).toBe(false)
|
|
|
|
|
expect(node.flipY).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('flipX can be set via updateNode', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const node = rect(graph, pageId(graph))
|
|
|
|
|
graph.updateNode(node.id, { flipX: true })
|
|
|
|
|
expect(graph.getNode(node.id)!.flipX).toBe(true)
|
|
|
|
|
expect(graph.getNode(node.id)!.flipY).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('flipY can be set via updateNode', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const node = rect(graph, pageId(graph))
|
|
|
|
|
graph.updateNode(node.id, { flipY: true })
|
|
|
|
|
expect(graph.getNode(node.id)!.flipX).toBe(false)
|
|
|
|
|
expect(graph.getNode(node.id)!.flipY).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('flip toggles', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const node = rect(graph, pageId(graph))
|
|
|
|
|
graph.updateNode(node.id, { flipX: true })
|
|
|
|
|
expect(graph.getNode(node.id)!.flipX).toBe(true)
|
|
|
|
|
graph.updateNode(node.id, { flipX: false })
|
|
|
|
|
expect(graph.getNode(node.id)!.flipX).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('single-node alignment to parent', () => {
|
|
|
|
|
function setup() {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
|
|
|
name: 'Container',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 400,
|
|
|
|
|
height: 300
|
|
|
|
|
})
|
|
|
|
|
const child = rect(graph, frame.id, { name: 'Child', x: 50, y: 50, width: 100, height: 80 })
|
|
|
|
|
return { graph, frame, child }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('align left within parent', () => {
|
|
|
|
|
const { graph, frame, child } = setup()
|
|
|
|
|
graph.updateNode(child.id, { x: 0 })
|
|
|
|
|
expect(graph.getNode(child.id)!.x).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('align right within parent', () => {
|
|
|
|
|
const { graph, frame, child } = setup()
|
|
|
|
|
const targetX = frame.width - child.width
|
|
|
|
|
graph.updateNode(child.id, { x: targetX })
|
|
|
|
|
expect(graph.getNode(child.id)!.x).toBe(300)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('align center horizontal within parent', () => {
|
|
|
|
|
const { graph, frame, child } = setup()
|
|
|
|
|
const targetX = (frame.width - child.width) / 2
|
|
|
|
|
graph.updateNode(child.id, { x: targetX })
|
|
|
|
|
expect(graph.getNode(child.id)!.x).toBe(150)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('align top within parent', () => {
|
|
|
|
|
const { graph, child } = setup()
|
|
|
|
|
graph.updateNode(child.id, { y: 0 })
|
|
|
|
|
expect(graph.getNode(child.id)!.y).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('align bottom within parent', () => {
|
|
|
|
|
const { graph, frame, child } = setup()
|
|
|
|
|
const targetY = frame.height - child.height
|
|
|
|
|
graph.updateNode(child.id, { y: targetY })
|
|
|
|
|
expect(graph.getNode(child.id)!.y).toBe(220)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('align center vertical within parent', () => {
|
|
|
|
|
const { graph, frame, child } = setup()
|
|
|
|
|
const targetY = (frame.height - child.height) / 2
|
|
|
|
|
graph.updateNode(child.id, { y: targetY })
|
|
|
|
|
expect(graph.getNode(child.id)!.y).toBe(110)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('multi-node alignment', () => {
|
|
|
|
|
function setup() {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
const a = rect(graph, page, { name: 'A', x: 10, y: 20, width: 50, height: 30 })
|
|
|
|
|
const b = rect(graph, page, { name: 'B', x: 100, y: 80, width: 60, height: 40 })
|
|
|
|
|
const c = rect(graph, page, { name: 'C', x: 200, y: 50, width: 40, height: 50 })
|
|
|
|
|
return { graph, a, b, c }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('align left — all nodes move to min x', () => {
|
|
|
|
|
const { graph, a, b, c } = setup()
|
|
|
|
|
const nodes = [a, b, c].map((n) => graph.getNode(n.id)!)
|
|
|
|
|
const abs = nodes.map((n) => graph.getAbsolutePosition(n.id))
|
|
|
|
|
const minX = Math.min(...abs.map((p, i) => p.x))
|
|
|
|
|
|
|
|
|
|
for (const n of nodes) {
|
|
|
|
|
const nodeAbs = graph.getAbsolutePosition(n.id)
|
|
|
|
|
graph.updateNode(n.id, { x: n.x + (minX - nodeAbs.x) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(graph.getNode(a.id)!.x).toBe(10)
|
|
|
|
|
expect(graph.getNode(b.id)!.x).toBe(10)
|
|
|
|
|
expect(graph.getNode(c.id)!.x).toBe(10)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('align right — all nodes align to max right edge', () => {
|
|
|
|
|
const { graph, a, b, c } = setup()
|
|
|
|
|
const nodes = [a, b, c].map((n) => graph.getNode(n.id)!)
|
|
|
|
|
const abs = nodes.map((n) => graph.getAbsolutePosition(n.id))
|
|
|
|
|
const maxX = Math.max(...abs.map((p, i) => p.x + nodes[i].width))
|
|
|
|
|
|
|
|
|
|
for (const n of nodes) {
|
|
|
|
|
const nodeAbs = graph.getAbsolutePosition(n.id)
|
|
|
|
|
const targetX = maxX - n.width
|
|
|
|
|
graph.updateNode(n.id, { x: n.x + (targetX - nodeAbs.x) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(graph.getNode(a.id)!.x).toBe(190)
|
|
|
|
|
expect(graph.getNode(b.id)!.x).toBe(180)
|
|
|
|
|
expect(graph.getNode(c.id)!.x).toBe(200)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('align top — all nodes move to min y', () => {
|
|
|
|
|
const { graph, a, b, c } = setup()
|
|
|
|
|
const nodes = [a, b, c].map((n) => graph.getNode(n.id)!)
|
|
|
|
|
const abs = nodes.map((n) => graph.getAbsolutePosition(n.id))
|
|
|
|
|
const minY = Math.min(...abs.map((p) => p.y))
|
|
|
|
|
|
|
|
|
|
for (const n of nodes) {
|
|
|
|
|
const nodeAbs = graph.getAbsolutePosition(n.id)
|
|
|
|
|
graph.updateNode(n.id, { y: n.y + (minY - nodeAbs.y) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(graph.getNode(a.id)!.y).toBe(20)
|
|
|
|
|
expect(graph.getNode(b.id)!.y).toBe(20)
|
|
|
|
|
expect(graph.getNode(c.id)!.y).toBe(20)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('multi-node property merging', () => {
|
|
|
|
|
test('same values merge to single value', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
const a = rect(graph, page, { width: 100, height: 50 })
|
|
|
|
|
const b = rect(graph, page, { width: 100, height: 50 })
|
|
|
|
|
const nodes = [graph.getNode(a.id)!, graph.getNode(b.id)!]
|
|
|
|
|
|
|
|
|
|
const widths = new Set(nodes.map((n) => n.width))
|
|
|
|
|
expect(widths.size).toBe(1)
|
|
|
|
|
expect([...widths][0]).toBe(100)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('different values are detected', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
const a = rect(graph, page, { width: 100 })
|
|
|
|
|
const b = rect(graph, page, { width: 200 })
|
|
|
|
|
const nodes = [graph.getNode(a.id)!, graph.getNode(b.id)!]
|
|
|
|
|
|
|
|
|
|
const widths = new Set(nodes.map((n) => n.width))
|
|
|
|
|
expect(widths.size).toBe(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('update applies to all selected nodes', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
const a = rect(graph, page, { width: 100 })
|
|
|
|
|
const b = rect(graph, page, { width: 200 })
|
|
|
|
|
const c = rect(graph, page, { width: 150 })
|
|
|
|
|
|
|
|
|
|
for (const id of [a.id, b.id, c.id]) {
|
|
|
|
|
graph.updateNode(id, { width: 300 })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(graph.getNode(a.id)!.width).toBe(300)
|
|
|
|
|
expect(graph.getNode(b.id)!.width).toBe(300)
|
|
|
|
|
expect(graph.getNode(c.id)!.width).toBe(300)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('opacity update applies uniformly', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
const a = rect(graph, page)
|
|
|
|
|
const b = rect(graph, page)
|
|
|
|
|
|
|
|
|
|
graph.updateNode(a.id, { opacity: 0.5 })
|
|
|
|
|
graph.updateNode(b.id, { opacity: 0.8 })
|
|
|
|
|
|
|
|
|
|
for (const id of [a.id, b.id]) {
|
|
|
|
|
graph.updateNode(id, { opacity: 0.75 })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(graph.getNode(a.id)!.opacity).toBe(0.75)
|
|
|
|
|
expect(graph.getNode(b.id)!.opacity).toBe(0.75)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('fills comparison detects mixed', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
const a = rect(graph, page)
|
|
|
|
|
const b = rect(graph, page)
|
|
|
|
|
|
|
|
|
|
graph.updateNode(a.id, {
|
|
|
|
|
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
|
|
|
|
})
|
|
|
|
|
graph.updateNode(b.id, {
|
|
|
|
|
fills: [{ type: 'SOLID', color: { r: 0, g: 1, b: 0, a: 1 }, opacity: 1, visible: true }]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const nodeA = graph.getNode(a.id)!
|
|
|
|
|
const nodeB = graph.getNode(b.id)!
|
|
|
|
|
expect(JSON.stringify(nodeA.fills)).not.toBe(JSON.stringify(nodeB.fills))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('fills comparison detects same', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
const fill = { type: 'SOLID' as const, color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
|
|
|
|
|
const a = rect(graph, page)
|
|
|
|
|
const b = rect(graph, page)
|
|
|
|
|
|
|
|
|
|
graph.updateNode(a.id, { fills: [{ ...fill }] })
|
|
|
|
|
graph.updateNode(b.id, { fills: [{ ...fill }] })
|
|
|
|
|
|
|
|
|
|
const nodeA = graph.getNode(a.id)!
|
|
|
|
|
const nodeB = graph.getNode(b.id)!
|
|
|
|
|
expect(JSON.stringify(nodeA.fills)).toBe(JSON.stringify(nodeB.fills))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('flip roundtrip via kiwi', () => {
|
|
|
|
|
async function roundtrip(graph: SceneGraph) {
|
|
|
|
|
const { initCodec } = await import('../../packages/core/src/kiwi/codec')
|
|
|
|
|
const { parseFigFile } = await import('../../packages/core/src/kiwi/fig-file')
|
|
|
|
|
const { exportFigFile } = await import('../../packages/core/src/fig-export')
|
|
|
|
|
await initCodec()
|
|
|
|
|
const buf = await exportFigFile(graph)
|
|
|
|
|
return parseFigFile(buf)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findChild(graph: SceneGraph, name: string) {
|
|
|
|
|
const pages = graph.getPages()
|
|
|
|
|
const children = graph.getChildren(pages[0].id)
|
|
|
|
|
return children.find((c) => c.name === name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('flipX preserved through export/import', async () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
rect(graph, page, { name: 'Flipped', x: 50, y: 50, width: 100, height: 80 })
|
|
|
|
|
graph.updateNode(graph.getChildren(page).find((n) => n.name === 'Flipped')!.id, { flipX: true })
|
|
|
|
|
|
|
|
|
|
const imported = await roundtrip(graph)
|
|
|
|
|
const found = findChild(imported, 'Flipped')
|
|
|
|
|
expect(found).toBeTruthy()
|
|
|
|
|
expect(found!.flipX).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('non-flipped node stays non-flipped', async () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
rect(graph, pageId(graph), { name: 'Normal', x: 100, y: 50, width: 200, height: 100 })
|
|
|
|
|
|
|
|
|
|
const imported = await roundtrip(graph)
|
|
|
|
|
const found = findChild(imported, 'Normal')
|
|
|
|
|
expect(found).toBeTruthy()
|
|
|
|
|
expect(found!.flipX).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('flipX with rotation preserved', async () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
rect(graph, page, { name: 'RotFlip', x: 0, y: 0, width: 100, height: 100 })
|
|
|
|
|
graph.updateNode(graph.getChildren(page).find((n) => n.name === 'RotFlip')!.id, {
|
|
|
|
|
flipX: true,
|
|
|
|
|
rotation: 45
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const imported = await roundtrip(graph)
|
|
|
|
|
const found = findChild(imported, 'RotFlip')
|
|
|
|
|
expect(found).toBeTruthy()
|
|
|
|
|
expect(found!.flipX).toBe(true)
|
|
|
|
|
expect(Math.round(found!.rotation)).toBe(45)
|
|
|
|
|
})
|
|
|
|
|
})
|