255 lines
7.5 KiB
TypeScript
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()
|
|
})
|
|
})
|