openpencil/tests/engine/vue/input/resize/path-text/basic.test.ts
2026-08-17 12:00:01 +03:00

255 lines
7.5 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { reactive } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { DerivedTextGlyph, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
import { copyGeometryPaths, copyStrokes } from '@open-pencil/scene-graph/copy'
import { applyResize, commitResizePreview } from '#vue/shared/input/resize'
import type { DragResize, OrigChildState } from '#vue/shared/input/types'
import { expectDefined } from '#tests/helpers/assert'
const BLACK: Fill = {
type: 'SOLID',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL'
}
const WHITE_STROKE: Stroke = {
color: { r: 1, g: 1, b: 1, a: 1 },
weight: 4,
opacity: 1,
visible: true,
align: 'OUTSIDE',
cap: 'NONE',
join: 'MITER',
dashPattern: []
}
function squareBlob(size: number): Uint8Array {
const blob = new Uint8Array(1 + 4 * 9 + 1)
const view = new DataView(blob.buffer)
const pts = [
[1, 0, 0],
[2, size, 0],
[2, size, size],
[2, 0, size]
] as const
let o = 0
for (const [cmd, x, y] of pts) {
blob[o] = cmd
view.setFloat32(o + 1, x, true)
view.setFloat32(o + 5, y, true)
o += 9
}
blob[o] = 0
return blob
}
function readBlobX(blob: Uint8Array, cmdIndex: number): number {
// Each move/line is 9 bytes; second command starts at offset 9, x at +1
return new DataView(blob.buffer, blob.byteOffset).getFloat32(cmdIndex * 9 + 1, true)
}
describe('resize scales path-text stroke geometry and glyphs', () => {
test('group resize scales TEXT strokeGeometry + derived glyphs', () => {
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0])
const group = graph.createNode('GROUP', page.id, {
x: 0,
y: 0,
width: 200,
height: 200
})
const glyphs: DerivedTextGlyph[] = [
{
commandsBlob: squareBlob(1),
x: 100,
y: 50,
fontSize: 80,
rotation: -1.5
}
]
const text = graph.createNode('TEXT', group.id, {
x: 0,
y: 0,
width: 200,
height: 200,
text: 'A',
fills: [BLACK],
strokes: [WHITE_STROKE],
strokeGeometry: [{ windingRule: 'NONZERO', commandsBlob: squareBlob(200) }],
derivedTextGlyphs: glyphs
})
const editor = {
graph,
renderer: undefined,
requestRepaint: () => undefined
} as Editor
const origChild: OrigChildState = {
x: text.x,
y: text.y,
width: text.width,
height: text.height,
vectorNetwork: null,
fillGeometry: [],
strokeGeometry: copyGeometryPaths(text.strokeGeometry),
derivedTextGlyphs: glyphs.map((g) => ({
...g,
commandsBlob: new Uint8Array(g.commandsBlob)
})),
strokes: copyStrokes(text.strokes),
textPathData: null,
textPathBox: null
}
const drag: DragResize = {
type: 'resize',
handle: 'se',
startX: 200,
startY: 200,
origRect: { x: 0, y: 0, width: 200, height: 200 },
nodeId: group.id,
origVectorNetwork: null,
origFillGeometry: [],
origStrokeGeometry: [],
origDerivedTextGlyphs: null,
origStrokes: [],
origTextPathData: null,
origTextPathBox: null,
origChildren: new Map([[text.id, origChild]])
}
// Scale group to half size
applyResize(drag, 100, 100, false, editor)
const resized = expectDefined(graph.getNode(text.id))
expect(resized.width).toBe(100)
expect(resized.height).toBe(100)
// strokeGeometry must scale with the node — otherwise OUTSIDE outlines
// stay full-size and look massively thick after a shrink.
const sg = expectDefined(resized.strokeGeometry[0])
expect(readBlobX(sg.commandsBlob, 1)).toBeCloseTo(100, 3)
const g0 = expectDefined(resized.derivedTextGlyphs?.[0])
expect(g0.x).toBeCloseTo(50, 3)
expect(g0.y).toBeCloseTo(25, 3)
// fontSize stays; non-uniform/uniform scale is carried on scaleX/Y
expect(g0.fontSize).toBeCloseTo(80, 3)
expect(g0.scaleX).toBeCloseTo(0.5, 3)
expect(g0.scaleY).toBeCloseTo(0.5, 3)
expect(g0.rotation).toBeCloseTo(-1.5, 4)
expect(resized.strokes[0]?.weight).toBeCloseTo(2, 3)
})
test('reactive drag state does not leak proxies into the graph', () => {
// The app stores DragResize in a Vue ref, so commit-time reads through it
// yield reactive proxies. Writing those into the graph made every
// structuredClone consumer throw DataCloneError (export subgraph clone).
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0])
const group = graph.createNode('GROUP', page.id, { x: 0, y: 0, width: 200, height: 200 })
const text = graph.createNode('TEXT', group.id, {
x: 0,
y: 0,
width: 200,
height: 200,
text: 'A',
fills: [BLACK],
strokes: [WHITE_STROKE],
strokeGeometry: [{ windingRule: 'NONZERO', commandsBlob: squareBlob(200) }],
derivedTextGlyphs: [
{ commandsBlob: squareBlob(1), x: 100, y: 50, fontSize: 80, rotation: -1.5 }
]
})
const editor = {
graph,
renderer: undefined,
requestRepaint: () => undefined,
updateNode: (id: string, changes: Partial<SceneNode>) => graph.updateNode(id, changes),
commitGroupResize: () => undefined,
commitResize: () => undefined
} as Editor
const origChild: OrigChildState = {
x: text.x,
y: text.y,
width: text.width,
height: text.height,
vectorNetwork: null,
fillGeometry: [],
strokeGeometry: copyGeometryPaths(text.strokeGeometry),
derivedTextGlyphs: expectDefined(text.derivedTextGlyphs).map((g) => ({
...g,
commandsBlob: new Uint8Array(g.commandsBlob)
})),
strokes: copyStrokes(text.strokes),
textPathData: null,
textPathBox: null
}
const drag: DragResize = reactive({
type: 'resize',
handle: 'se',
startX: 200,
startY: 200,
origRect: { x: 0, y: 0, width: 200, height: 200 },
nodeId: group.id,
origVectorNetwork: null,
origFillGeometry: [],
origStrokeGeometry: [],
origDerivedTextGlyphs: null,
origStrokes: [],
origTextPathData: null,
origTextPathBox: null,
origChildren: new Map([[text.id, origChild]])
}) as DragResize
applyResize(drag, 100, 100, false, editor)
commitResizePreview(drag, editor)
for (const id of [group.id, text.id]) {
const node = expectDefined(graph.getNode(id))
for (const key of Object.keys(node) as (keyof SceneNode)[]) {
expect(() => structuredClone(node[key])).not.toThrow()
}
}
})
test('width/height resize does not drop derived glyphs', () => {
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0])
const glyphs: DerivedTextGlyph[] = [
{ commandsBlob: squareBlob(1), x: 10, y: 20, fontSize: 40, rotation: -1 }
]
const text = graph.createNode('TEXT', page.id, {
width: 100,
height: 100,
text: 'A',
textPathData: {
network: { vertices: [], segments: [], regions: [] },
normalizedSize: { x: 100, y: 100 },
tValue: 0,
forward: true
},
derivedTextGlyphs: glyphs
})
graph.updateNodePreview(text.id, { width: 50, height: 80 })
const updated = expectDefined(graph.getNode(text.id))
expect(updated.derivedTextGlyphs).not.toBeNull()
expect(updated.derivedTextGlyphs).toHaveLength(1)
expect(updated.textPathData).not.toBeNull()
})
})