@flighthq/displayobject-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/canvasBackground.d.ts +3 -0
- package/dist/canvasBackground.d.ts.map +1 -0
- package/dist/canvasBackground.js +19 -0
- package/dist/canvasBackground.js.map +1 -0
- package/dist/canvasBitmap.d.ts +4 -0
- package/dist/canvasBitmap.d.ts.map +1 -0
- package/dist/canvasBitmap.js +32 -0
- package/dist/canvasBitmap.js.map +1 -0
- package/dist/canvasCSSFilterBinding.d.ts +24 -0
- package/dist/canvasCSSFilterBinding.d.ts.map +1 -0
- package/dist/canvasCSSFilterBinding.js +43 -0
- package/dist/canvasCSSFilterBinding.js.map +1 -0
- package/dist/canvasCache.d.ts +33 -0
- package/dist/canvasCache.d.ts.map +1 -0
- package/dist/canvasCache.js +141 -0
- package/dist/canvasCache.js.map +1 -0
- package/dist/canvasClip.d.ts +3 -0
- package/dist/canvasClip.d.ts.map +1 -0
- package/dist/canvasClip.js +38 -0
- package/dist/canvasClip.js.map +1 -0
- package/dist/canvasClipRectangle.d.ts +5 -0
- package/dist/canvasClipRectangle.d.ts.map +1 -0
- package/dist/canvasClipRectangle.js +31 -0
- package/dist/canvasClipRectangle.js.map +1 -0
- package/dist/canvasDisplayObject.d.ts +5 -0
- package/dist/canvasDisplayObject.d.ts.map +1 -0
- package/dist/canvasDisplayObject.js +46 -0
- package/dist/canvasDisplayObject.js.map +1 -0
- package/dist/canvasElement.d.ts +2 -0
- package/dist/canvasElement.d.ts.map +1 -0
- package/dist/canvasElement.js +9 -0
- package/dist/canvasElement.js.map +1 -0
- package/dist/canvasFillPattern.d.ts +4 -0
- package/dist/canvasFillPattern.d.ts.map +1 -0
- package/dist/canvasFillPattern.js +108 -0
- package/dist/canvasFillPattern.js.map +1 -0
- package/dist/canvasMaterialRegistry.d.ts +6 -0
- package/dist/canvasMaterialRegistry.d.ts.map +1 -0
- package/dist/canvasMaterialRegistry.js +42 -0
- package/dist/canvasMaterialRegistry.js.map +1 -0
- package/dist/canvasMaterials.d.ts +5 -0
- package/dist/canvasMaterials.d.ts.map +1 -0
- package/dist/canvasMaterials.js +33 -0
- package/dist/canvasMaterials.js.map +1 -0
- package/dist/canvasParticleEmitter.d.ts +4 -0
- package/dist/canvasParticleEmitter.d.ts.map +1 -0
- package/dist/canvasParticleEmitter.js +64 -0
- package/dist/canvasParticleEmitter.js.map +1 -0
- package/dist/canvasQuadBatch.d.ts +4 -0
- package/dist/canvasQuadBatch.d.ts.map +1 -0
- package/dist/canvasQuadBatch.js +64 -0
- package/dist/canvasQuadBatch.js.map +1 -0
- package/dist/canvasRegistration.d.ts +4 -0
- package/dist/canvasRegistration.d.ts.map +1 -0
- package/dist/canvasRegistration.js +39 -0
- package/dist/canvasRegistration.js.map +1 -0
- package/dist/canvasRenderState.d.ts +5 -0
- package/dist/canvasRenderState.d.ts.map +1 -0
- package/dist/canvasRenderState.js +44 -0
- package/dist/canvasRenderState.js.map +1 -0
- package/dist/canvasRenderTarget.d.ts +16 -0
- package/dist/canvasRenderTarget.d.ts.map +1 -0
- package/dist/canvasRenderTarget.js +66 -0
- package/dist/canvasRenderTarget.js.map +1 -0
- package/dist/canvasRichText.d.ts +7 -0
- package/dist/canvasRichText.d.ts.map +1 -0
- package/dist/canvasRichText.js +127 -0
- package/dist/canvasRichText.js.map +1 -0
- package/dist/canvasScale9Mapper.d.ts +10 -0
- package/dist/canvasScale9Mapper.d.ts.map +1 -0
- package/dist/canvasScale9Mapper.js +148 -0
- package/dist/canvasScale9Mapper.js.map +1 -0
- package/dist/canvasScale9Shape.d.ts +5 -0
- package/dist/canvasScale9Shape.d.ts.map +1 -0
- package/dist/canvasScale9Shape.js +156 -0
- package/dist/canvasScale9Shape.js.map +1 -0
- package/dist/canvasShape.d.ts +5 -0
- package/dist/canvasShape.d.ts.map +1 -0
- package/dist/canvasShape.js +79 -0
- package/dist/canvasShape.js.map +1 -0
- package/dist/canvasShapeCommands.d.ts +19 -0
- package/dist/canvasShapeCommands.d.ts.map +1 -0
- package/dist/canvasShapeCommands.js +352 -0
- package/dist/canvasShapeCommands.js.map +1 -0
- package/dist/canvasShapeRegistry.d.ts +5 -0
- package/dist/canvasShapeRegistry.d.ts.map +1 -0
- package/dist/canvasShapeRegistry.js +17 -0
- package/dist/canvasShapeRegistry.js.map +1 -0
- package/dist/canvasSprite.d.ts +5 -0
- package/dist/canvasSprite.d.ts.map +1 -0
- package/dist/canvasSprite.js +59 -0
- package/dist/canvasSprite.js.map +1 -0
- package/dist/canvasTextInput.d.ts +4 -0
- package/dist/canvasTextInput.d.ts.map +1 -0
- package/dist/canvasTextInput.js +67 -0
- package/dist/canvasTextInput.js.map +1 -0
- package/dist/canvasTextLabel.d.ts +4 -0
- package/dist/canvasTextLabel.d.ts.map +1 -0
- package/dist/canvasTextLabel.js +55 -0
- package/dist/canvasTextLabel.js.map +1 -0
- package/dist/canvasTextMeasure.d.ts +3 -0
- package/dist/canvasTextMeasure.d.ts.map +1 -0
- package/dist/canvasTextMeasure.js +15 -0
- package/dist/canvasTextMeasure.js.map +1 -0
- package/dist/canvasTilemap.d.ts +4 -0
- package/dist/canvasTilemap.d.ts.map +1 -0
- package/dist/canvasTilemap.js +50 -0
- package/dist/canvasTilemap.js.map +1 -0
- package/dist/canvasTransform.d.ts +3 -0
- package/dist/canvasTransform.d.ts.map +1 -0
- package/dist/canvasTransform.js +9 -0
- package/dist/canvasTransform.js.map +1 -0
- package/dist/canvasVideo.d.ts +4 -0
- package/dist/canvasVideo.d.ts.map +1 -0
- package/dist/canvasVideo.js +26 -0
- package/dist/canvasVideo.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/src/canvasBackground.test.ts +65 -0
- package/src/canvasBitmap.test.ts +92 -0
- package/src/canvasCSSFilterBinding.test.ts +86 -0
- package/src/canvasCache.test.ts +189 -0
- package/src/canvasClip.test.ts +12 -0
- package/src/canvasClipRectangle.test.ts +101 -0
- package/src/canvasDisplayObject.test.ts +117 -0
- package/src/canvasElement.test.ts +21 -0
- package/src/canvasFillPattern.test.ts +96 -0
- package/src/canvasMaterialRegistry.test.ts +69 -0
- package/src/canvasMaterials.test.ts +85 -0
- package/src/canvasParticleEmitter.test.ts +105 -0
- package/src/canvasQuadBatch.test.ts +119 -0
- package/src/canvasRegistration.test.ts +57 -0
- package/src/canvasRenderState.test.ts +186 -0
- package/src/canvasRenderTarget.test.ts +154 -0
- package/src/canvasRichText.test.ts +89 -0
- package/src/canvasScale9Mapper.test.ts +53 -0
- package/src/canvasScale9Shape.test.ts +89 -0
- package/src/canvasShape.test.ts +183 -0
- package/src/canvasShapeCommands.test.ts +448 -0
- package/src/canvasShapeRegistry.test.ts +42 -0
- package/src/canvasSprite.test.ts +90 -0
- package/src/canvasTextInput.test.ts +67 -0
- package/src/canvasTextLabel.test.ts +32 -0
- package/src/canvasTextMeasure.test.ts +9 -0
- package/src/canvasTilemap.test.ts +69 -0
- package/src/canvasTransform.test.ts +42 -0
- package/src/canvasVideo.test.ts +43 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createMatrix } from '@flighthq/geometry';
|
|
2
|
+
|
|
3
|
+
import { createCanvasRenderState } from './canvasRenderState';
|
|
4
|
+
import {
|
|
5
|
+
beginCanvasRenderTarget,
|
|
6
|
+
createCanvasRenderTarget,
|
|
7
|
+
destroyCanvasRenderTarget,
|
|
8
|
+
endCanvasRenderTarget,
|
|
9
|
+
resizeCanvasRenderTarget,
|
|
10
|
+
} from './canvasRenderTarget';
|
|
11
|
+
|
|
12
|
+
function makeState() {
|
|
13
|
+
return createCanvasRenderState(document.createElement('canvas'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('beginCanvasRenderTarget', () => {
|
|
17
|
+
it('switches the state canvas to the target canvas', () => {
|
|
18
|
+
const state = makeState();
|
|
19
|
+
const target = createCanvasRenderTarget(64, 48);
|
|
20
|
+
const renderTransform = createMatrix();
|
|
21
|
+
|
|
22
|
+
beginCanvasRenderTarget(state, target, renderTransform);
|
|
23
|
+
|
|
24
|
+
expect(state.canvas).toBe(target.canvas);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('sets renderTransform2D to the provided transform', () => {
|
|
28
|
+
const state = makeState();
|
|
29
|
+
const target = createCanvasRenderTarget(64, 48);
|
|
30
|
+
const renderTransform = createMatrix();
|
|
31
|
+
renderTransform.tx = 10;
|
|
32
|
+
renderTransform.ty = 20;
|
|
33
|
+
|
|
34
|
+
beginCanvasRenderTarget(state, target, renderTransform);
|
|
35
|
+
|
|
36
|
+
expect(state.renderTransform2D!.tx).toBe(10);
|
|
37
|
+
expect(state.renderTransform2D!.ty).toBe(20);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('supports nested pairs', () => {
|
|
41
|
+
const state = makeState();
|
|
42
|
+
const outerCanvas = state.canvas;
|
|
43
|
+
const targetA = createCanvasRenderTarget(64, 48);
|
|
44
|
+
const targetB = createCanvasRenderTarget(32, 32);
|
|
45
|
+
|
|
46
|
+
beginCanvasRenderTarget(state, targetA, createMatrix());
|
|
47
|
+
expect(state.canvas).toBe(targetA.canvas);
|
|
48
|
+
|
|
49
|
+
beginCanvasRenderTarget(state, targetB, createMatrix());
|
|
50
|
+
expect(state.canvas).toBe(targetB.canvas);
|
|
51
|
+
|
|
52
|
+
endCanvasRenderTarget(state);
|
|
53
|
+
expect(state.canvas).toBe(targetA.canvas);
|
|
54
|
+
|
|
55
|
+
endCanvasRenderTarget(state);
|
|
56
|
+
expect(state.canvas).toBe(outerCanvas);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('createCanvasRenderTarget', () => {
|
|
61
|
+
it('creates a canvas with the requested dimensions', () => {
|
|
62
|
+
const target = createCanvasRenderTarget(128, 64);
|
|
63
|
+
expect(target.canvas.width).toBe(128);
|
|
64
|
+
expect(target.canvas.height).toBe(64);
|
|
65
|
+
expect(target.width).toBe(128);
|
|
66
|
+
expect(target.height).toBe(64);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('enforces a minimum size of 1', () => {
|
|
70
|
+
const target = createCanvasRenderTarget(0, 0);
|
|
71
|
+
expect(target.canvas.width).toBe(1);
|
|
72
|
+
expect(target.canvas.height).toBe(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('ceils fractional dimensions', () => {
|
|
76
|
+
const target = createCanvasRenderTarget(10.3, 20.9);
|
|
77
|
+
expect(target.canvas.width).toBe(11);
|
|
78
|
+
expect(target.canvas.height).toBe(21);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('destroyCanvasRenderTarget', () => {
|
|
83
|
+
it('collapses the canvas to zero size', () => {
|
|
84
|
+
const target = createCanvasRenderTarget(64, 32);
|
|
85
|
+
destroyCanvasRenderTarget(target);
|
|
86
|
+
expect(target.canvas.width).toBe(0);
|
|
87
|
+
expect(target.canvas.height).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('sets target width and height to zero', () => {
|
|
91
|
+
const target = createCanvasRenderTarget(128, 64);
|
|
92
|
+
destroyCanvasRenderTarget(target);
|
|
93
|
+
expect(target.width).toBe(0);
|
|
94
|
+
expect(target.height).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('endCanvasRenderTarget', () => {
|
|
99
|
+
it('restores the original canvas', () => {
|
|
100
|
+
const state = makeState();
|
|
101
|
+
const originalCanvas = state.canvas;
|
|
102
|
+
const target = createCanvasRenderTarget(64, 48);
|
|
103
|
+
|
|
104
|
+
beginCanvasRenderTarget(state, target, createMatrix());
|
|
105
|
+
endCanvasRenderTarget(state);
|
|
106
|
+
|
|
107
|
+
expect(state.canvas).toBe(originalCanvas);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('restores the original renderTransform2D', () => {
|
|
111
|
+
const state = makeState();
|
|
112
|
+
const originalTx = state.renderTransform2D?.tx ?? 0;
|
|
113
|
+
const target = createCanvasRenderTarget(64, 48);
|
|
114
|
+
const renderTransform = createMatrix();
|
|
115
|
+
renderTransform.tx = 99;
|
|
116
|
+
|
|
117
|
+
beginCanvasRenderTarget(state, target, renderTransform);
|
|
118
|
+
endCanvasRenderTarget(state);
|
|
119
|
+
|
|
120
|
+
expect(state.renderTransform2D?.tx).toBe(originalTx);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does not mutate the outer renderTransform2D matrix', () => {
|
|
124
|
+
const state = makeState();
|
|
125
|
+
const outerTransform = state.renderTransform2D!;
|
|
126
|
+
const target = createCanvasRenderTarget(64, 48);
|
|
127
|
+
const renderTransform = createMatrix();
|
|
128
|
+
renderTransform.tx = 50;
|
|
129
|
+
|
|
130
|
+
beginCanvasRenderTarget(state, target, renderTransform);
|
|
131
|
+
endCanvasRenderTarget(state);
|
|
132
|
+
|
|
133
|
+
expect(state.renderTransform2D).toBe(outerTransform);
|
|
134
|
+
expect(outerTransform.tx).not.toBe(50);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('without a matching begin is a no-op', () => {
|
|
138
|
+
const state = makeState();
|
|
139
|
+
const original = state.canvas;
|
|
140
|
+
expect(() => endCanvasRenderTarget(state)).not.toThrow();
|
|
141
|
+
expect(state.canvas).toBe(original);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('resizeCanvasRenderTarget', () => {
|
|
146
|
+
it('updates the canvas and target dimensions', () => {
|
|
147
|
+
const target = createCanvasRenderTarget(64, 64);
|
|
148
|
+
resizeCanvasRenderTarget(target, 256, 128);
|
|
149
|
+
expect(target.canvas.width).toBe(256);
|
|
150
|
+
expect(target.canvas.height).toBe(128);
|
|
151
|
+
expect(target.width).toBe(256);
|
|
152
|
+
expect(target.height).toBe(128);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { getOrCreateRenderProxy2D } from '@flighthq/render';
|
|
2
|
+
import { createRichText } from '@flighthq/text';
|
|
3
|
+
import { enableTextInput } from '@flighthq/textinput';
|
|
4
|
+
|
|
5
|
+
import { createCanvasRenderState } from './canvasRenderState';
|
|
6
|
+
import {
|
|
7
|
+
defaultCanvasRichTextRenderer,
|
|
8
|
+
drawCanvasRichText,
|
|
9
|
+
drawCanvasRichTextMask,
|
|
10
|
+
registerCanvasTextInputOverlay,
|
|
11
|
+
} from './canvasRichText';
|
|
12
|
+
|
|
13
|
+
function makeState() {
|
|
14
|
+
const canvas = document.createElement('canvas');
|
|
15
|
+
canvas.width = 200;
|
|
16
|
+
canvas.height = 200;
|
|
17
|
+
return createCanvasRenderState(canvas);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('defaultCanvasRichTextRenderer', () => {
|
|
21
|
+
it('has noopRendererData as createData', () => {
|
|
22
|
+
expect(typeof defaultCanvasRichTextRenderer.createData).toBe('function');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('has drawCanvasRichText as submit', () => {
|
|
26
|
+
expect(defaultCanvasRichTextRenderer.submit).toBe(drawCanvasRichText);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('drawCanvasRichText', () => {
|
|
31
|
+
it('does not throw when text is empty', () => {
|
|
32
|
+
const state = makeState();
|
|
33
|
+
const node = createRichText();
|
|
34
|
+
node.data.text = '';
|
|
35
|
+
const renderProxy = getOrCreateRenderProxy2D(state, node);
|
|
36
|
+
expect(() => drawCanvasRichText(state, renderProxy)).not.toThrow();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('calls fillText when the text field is non-empty', () => {
|
|
40
|
+
const state = makeState();
|
|
41
|
+
const node = createRichText();
|
|
42
|
+
node.data.text = 'hello';
|
|
43
|
+
const renderProxy = getOrCreateRenderProxy2D(state, node);
|
|
44
|
+
const spy = vi.spyOn(state.context, 'fillText');
|
|
45
|
+
drawCanvasRichText(state, renderProxy);
|
|
46
|
+
expect(spy).toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('renders resolved htmlText spans', () => {
|
|
50
|
+
const state = makeState();
|
|
51
|
+
const node = createRichText();
|
|
52
|
+
node.data.htmlText = '<font color="#ff0000">red</font><b>bold</b>';
|
|
53
|
+
const renderProxy = getOrCreateRenderProxy2D(state, node);
|
|
54
|
+
const spy = vi.spyOn(state.context, 'fillText');
|
|
55
|
+
|
|
56
|
+
drawCanvasRichText(state, renderProxy);
|
|
57
|
+
|
|
58
|
+
expect(spy).toHaveBeenCalledWith('red', expect.any(Number), expect.any(Number));
|
|
59
|
+
expect(spy).toHaveBeenCalledWith('bold', expect.any(Number), expect.any(Number));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('drawCanvasRichTextMask', () => {
|
|
64
|
+
// The mask path is a no-op stub on the Canvas backend — masking is handled uniformly
|
|
65
|
+
// through pushCanvasClipContours (the clip hooks), not through per-kind draw-mask functions.
|
|
66
|
+
it('does not throw', () => {
|
|
67
|
+
const state = makeState();
|
|
68
|
+
const node = createRichText({ data: { text: 'hello' } });
|
|
69
|
+
const renderProxy = getOrCreateRenderProxy2D(state, node);
|
|
70
|
+
expect(() => drawCanvasRichTextMask(state, renderProxy)).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('registerCanvasTextInputOverlay', () => {
|
|
75
|
+
it('invokes the registered overlay only for a RichText with an input slot', () => {
|
|
76
|
+
const overlay = vi.fn();
|
|
77
|
+
registerCanvasTextInputOverlay(overlay);
|
|
78
|
+
const state = makeState();
|
|
79
|
+
|
|
80
|
+
const plain = createRichText({ data: { text: 'x' } });
|
|
81
|
+
drawCanvasRichText(state, getOrCreateRenderProxy2D(state, plain));
|
|
82
|
+
expect(overlay).not.toHaveBeenCalled();
|
|
83
|
+
|
|
84
|
+
const editable = createRichText({ data: { text: 'x' } });
|
|
85
|
+
enableTextInput(editable);
|
|
86
|
+
drawCanvasRichText(state, getOrCreateRenderProxy2D(state, editable));
|
|
87
|
+
expect(overlay).toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { appendShapeRectangle, createShape } from '@flighthq/shape';
|
|
2
|
+
|
|
3
|
+
import { buildScale9Mapper } from './canvasScale9Mapper';
|
|
4
|
+
|
|
5
|
+
function makeGrid(x: number, y: number, width: number, height: number) {
|
|
6
|
+
return { x, y, width, height } as never;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('buildScale9Mapper', () => {
|
|
10
|
+
it('returns null when scaleX is zero', () => {
|
|
11
|
+
const shape = createShape();
|
|
12
|
+
appendShapeRectangle(shape, 0, 0, 100, 100);
|
|
13
|
+
expect(buildScale9Mapper(shape.data.commands, makeGrid(10, 10, 80, 80), 0, 1)).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns null when scaleY is negative', () => {
|
|
17
|
+
const shape = createShape();
|
|
18
|
+
appendShapeRectangle(shape, 0, 0, 100, 100);
|
|
19
|
+
expect(buildScale9Mapper(shape.data.commands, makeGrid(10, 10, 80, 80), 1, -1)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns null when commands have no drawable points', () => {
|
|
23
|
+
const shape = createShape();
|
|
24
|
+
expect(buildScale9Mapper(shape.data.commands, makeGrid(10, 10, 80, 80), 2, 2)).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('maps corner coordinates unchanged (start region)', () => {
|
|
28
|
+
const shape = createShape();
|
|
29
|
+
appendShapeRectangle(shape, 0, 0, 100, 100);
|
|
30
|
+
const mapper = buildScale9Mapper(shape.data.commands, makeGrid(10, 10, 80, 80), 2, 2)!;
|
|
31
|
+
expect(mapper).not.toBeNull();
|
|
32
|
+
// x=5 is in the start (corner) region — should not move
|
|
33
|
+
expect(mapper.mapX(5)).toBeCloseTo(5);
|
|
34
|
+
expect(mapper.mapY(5)).toBeCloseTo(5);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('maps end-region coordinates by adding scaled center size', () => {
|
|
38
|
+
const shape = createShape();
|
|
39
|
+
appendShapeRectangle(shape, 0, 0, 100, 100);
|
|
40
|
+
const mapper = buildScale9Mapper(shape.data.commands, makeGrid(10, 10, 80, 80), 2, 2)!;
|
|
41
|
+
// With scaleX=2, boundsWidth=100: size=200, corner=10, end=10, center=180
|
|
42
|
+
// pos=95 is in end region (>= 10+80=90): should be 10 + 180 + (95-90) = 195
|
|
43
|
+
expect(mapper.mapX(95)).toBeCloseTo(195);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('stretches center-region coordinates proportionally', () => {
|
|
47
|
+
const shape = createShape();
|
|
48
|
+
appendShapeRectangle(shape, 0, 0, 100, 100);
|
|
49
|
+
const mapper = buildScale9Mapper(shape.data.commands, makeGrid(10, 10, 80, 80), 2, 2)!;
|
|
50
|
+
// pos=50 is center (between 10 and 90): 10 + 180*(50-10)/80 = 10 + 90 = 100
|
|
51
|
+
expect(mapper.mapX(50)).toBeCloseTo(100);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { registerRenderer } from '@flighthq/render';
|
|
2
|
+
import { getOrCreateRenderProxy2D } from '@flighthq/render';
|
|
3
|
+
import { appendShapeBeginFill, appendShapeRectangle, createScale9Shape } from '@flighthq/shape';
|
|
4
|
+
import { Scale9ShapeKind } from '@flighthq/types';
|
|
5
|
+
|
|
6
|
+
import { createCanvasRenderState } from './canvasRenderState';
|
|
7
|
+
import { buildScale9Mapper } from './canvasScale9Mapper';
|
|
8
|
+
import {
|
|
9
|
+
defaultCanvasScale9ShapeRenderer,
|
|
10
|
+
drawCanvasScale9Shape,
|
|
11
|
+
mapCanvasScale9ShapeCommands,
|
|
12
|
+
} from './canvasScale9Shape';
|
|
13
|
+
import { defaultCanvasShapeCommands } from './canvasShapeCommands';
|
|
14
|
+
import { registerCanvasShapeCommands } from './canvasShapeRegistry';
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
registerCanvasShapeCommands(defaultCanvasShapeCommands);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const grid = { x: 10, y: 10, width: 80, height: 80 };
|
|
21
|
+
|
|
22
|
+
describe('drawCanvasScale9Shape', () => {
|
|
23
|
+
it('does not throw when commands list is empty', () => {
|
|
24
|
+
const canvas = document.createElement('canvas');
|
|
25
|
+
canvas.width = 200;
|
|
26
|
+
canvas.height = 200;
|
|
27
|
+
const state = createCanvasRenderState(canvas);
|
|
28
|
+
registerRenderer(state, Scale9ShapeKind, defaultCanvasScale9ShapeRenderer);
|
|
29
|
+
const shape = createScale9Shape(grid);
|
|
30
|
+
const data = getOrCreateRenderProxy2D(state, shape);
|
|
31
|
+
expect(() => drawCanvasScale9Shape(state, data)).not.toThrow();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('mapCanvasScale9ShapeCommands', () => {
|
|
36
|
+
const out: unknown[] = [];
|
|
37
|
+
|
|
38
|
+
it('passes style commands through unchanged', () => {
|
|
39
|
+
const mapper = { mapX: (x: number) => x, mapY: (y: number) => y };
|
|
40
|
+
mapCanvasScale9ShapeCommands(out, ['beginFill', 2, 0xff0000, 1], mapper);
|
|
41
|
+
expect(out).toEqual(['beginFill', 2, 0xff0000, 1]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('remaps moveTo coordinates', () => {
|
|
45
|
+
const mapper = { mapX: (x: number) => x * 2, mapY: (y: number) => y * 3 };
|
|
46
|
+
mapCanvasScale9ShapeCommands(out, ['moveTo', 2, 10, 20], mapper);
|
|
47
|
+
expect(out).toEqual(['moveTo', 2, 20, 60]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('remaps lineTo coordinates', () => {
|
|
51
|
+
const mapper = { mapX: (x: number) => x + 5, mapY: (y: number) => y + 10 };
|
|
52
|
+
mapCanvasScale9ShapeCommands(out, ['lineTo', 2, 100, 50], mapper);
|
|
53
|
+
expect(out).toEqual(['lineTo', 2, 105, 60]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('remaps drawRectangle corners and recomputes size', () => {
|
|
57
|
+
const mapper = { mapX: (x: number) => x * 2, mapY: (y: number) => y * 2 };
|
|
58
|
+
mapCanvasScale9ShapeCommands(out, ['drawRectangle', 4, 10, 20, 50, 30], mapper);
|
|
59
|
+
// x=10→20, y=20→40, x+w=60→120 (w=100), y+h=50→100 (h=60)
|
|
60
|
+
expect(out).toEqual(['drawRectangle', 4, 20, 40, 100, 60]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('remaps drawRoundRectangle corners but leaves ellipse radii unchanged', () => {
|
|
64
|
+
const mapper = { mapX: (x: number) => x * 2, mapY: (y: number) => y * 2 };
|
|
65
|
+
mapCanvasScale9ShapeCommands(out, ['drawRoundRectangle', 6, 10, 20, 50, 30, 8, 8], mapper);
|
|
66
|
+
expect(out).toEqual(['drawRoundRectangle', 6, 20, 40, 100, 60, 8, 8]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('remaps drawCircle center but leaves radius unchanged', () => {
|
|
70
|
+
const mapper = { mapX: (x: number) => x + 5, mapY: (y: number) => y + 10 };
|
|
71
|
+
mapCanvasScale9ShapeCommands(out, ['drawCircle', 3, 50, 50, 25], mapper);
|
|
72
|
+
expect(out).toEqual(['drawCircle', 3, 55, 60, 25]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('remaps drawEllipse corners and recomputes size', () => {
|
|
76
|
+
const mapper = { mapX: (x: number) => x * 2, mapY: (y: number) => y * 2 };
|
|
77
|
+
mapCanvasScale9ShapeCommands(out, ['drawEllipse', 4, 0, 0, 100, 50], mapper);
|
|
78
|
+
expect(out).toEqual(['drawEllipse', 4, 0, 0, 200, 100]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns a buffer with the same element count as the input', () => {
|
|
82
|
+
const shape = createScale9Shape(grid);
|
|
83
|
+
appendShapeBeginFill(shape, 0xff0000);
|
|
84
|
+
appendShapeRectangle(shape, 0, 0, 100, 100);
|
|
85
|
+
const mapper = buildScale9Mapper(shape.data.commands, grid, 2, 2)!;
|
|
86
|
+
mapCanvasScale9ShapeCommands(out, shape.data.commands, mapper);
|
|
87
|
+
expect(out).toHaveLength(shape.data.commands.length);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createMatrix } from '@flighthq/geometry';
|
|
2
|
+
import { registerRenderer } from '@flighthq/render';
|
|
3
|
+
import { getOrCreateRenderProxy2D } from '@flighthq/render';
|
|
4
|
+
import {
|
|
5
|
+
appendShapeBeginBitmapFill,
|
|
6
|
+
appendShapeBeginFill,
|
|
7
|
+
appendShapeEndFill,
|
|
8
|
+
appendShapeLineStyle,
|
|
9
|
+
appendShapeLineTo,
|
|
10
|
+
appendShapeMoveTo,
|
|
11
|
+
appendShapeRectangle,
|
|
12
|
+
createShape,
|
|
13
|
+
} from '@flighthq/shape';
|
|
14
|
+
import { ShapeKind } from '@flighthq/types';
|
|
15
|
+
|
|
16
|
+
import { createCanvasRenderState } from './canvasRenderState';
|
|
17
|
+
import { defaultCanvasShapeRenderer, drawCanvasShape, renderCanvasShapeCommands } from './canvasShape';
|
|
18
|
+
import { defaultCanvasShapeCommands } from './canvasShapeCommands';
|
|
19
|
+
import { registerCanvasShapeCommands } from './canvasShapeRegistry';
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
registerCanvasShapeCommands(defaultCanvasShapeCommands);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function makeContext(): CanvasRenderingContext2D {
|
|
26
|
+
const canvas = document.createElement('canvas');
|
|
27
|
+
canvas.width = 200;
|
|
28
|
+
canvas.height = 200;
|
|
29
|
+
return canvas.getContext('2d') as CanvasRenderingContext2D;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('drawCanvasShape', () => {
|
|
33
|
+
it('does not throw for a shape with no commands', () => {
|
|
34
|
+
const canvas = document.createElement('canvas');
|
|
35
|
+
canvas.width = 200;
|
|
36
|
+
canvas.height = 200;
|
|
37
|
+
const state = createCanvasRenderState(canvas);
|
|
38
|
+
registerRenderer(state, ShapeKind, defaultCanvasShapeRenderer);
|
|
39
|
+
const shape = createShape();
|
|
40
|
+
const data = getOrCreateRenderProxy2D(state, shape);
|
|
41
|
+
expect(() => drawCanvasShape(state, data)).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('calls fill when shape has beginFill and drawRectangle commands', () => {
|
|
45
|
+
const canvas = document.createElement('canvas');
|
|
46
|
+
canvas.width = 200;
|
|
47
|
+
canvas.height = 200;
|
|
48
|
+
const state = createCanvasRenderState(canvas);
|
|
49
|
+
registerRenderer(state, ShapeKind, defaultCanvasShapeRenderer);
|
|
50
|
+
const shape = createShape();
|
|
51
|
+
appendShapeBeginFill(shape, 0xff0000);
|
|
52
|
+
appendShapeRectangle(shape, 0, 0, 50, 50);
|
|
53
|
+
appendShapeEndFill(shape);
|
|
54
|
+
const data = getOrCreateRenderProxy2D(state, shape);
|
|
55
|
+
const spy = vi.spyOn(state.context, 'fill');
|
|
56
|
+
drawCanvasShape(state, data);
|
|
57
|
+
expect(spy).toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('renderCanvasShapeCommands', () => {
|
|
62
|
+
it('does nothing when the command list is empty', () => {
|
|
63
|
+
const context = makeContext();
|
|
64
|
+
const spy = vi.spyOn(context, 'fill');
|
|
65
|
+
renderCanvasShapeCommands(context, createShape().data.commands);
|
|
66
|
+
expect(spy).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('calls fill after beginFill + drawRectangle + endFill', () => {
|
|
70
|
+
const context = makeContext();
|
|
71
|
+
const spy = vi.spyOn(context, 'fill');
|
|
72
|
+
const shape = createShape();
|
|
73
|
+
appendShapeBeginFill(shape, 0xff0000);
|
|
74
|
+
appendShapeRectangle(shape, 0, 0, 100, 50);
|
|
75
|
+
appendShapeEndFill(shape);
|
|
76
|
+
renderCanvasShapeCommands(context, shape.data.commands);
|
|
77
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('calls stroke once when lineStyle is set', () => {
|
|
81
|
+
const context = makeContext();
|
|
82
|
+
const spy = vi.spyOn(context, 'stroke');
|
|
83
|
+
const shape = createShape();
|
|
84
|
+
appendShapeLineStyle(shape, 2, 0x000000);
|
|
85
|
+
appendShapeRectangle(shape, 0, 0, 100, 50);
|
|
86
|
+
appendShapeEndFill(shape);
|
|
87
|
+
renderCanvasShapeCommands(context, shape.data.commands);
|
|
88
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('draws fill before stroke so strokes render on top', () => {
|
|
92
|
+
const context = makeContext();
|
|
93
|
+
const order: string[] = [];
|
|
94
|
+
vi.spyOn(context, 'fill').mockImplementation(() => {
|
|
95
|
+
order.push('fill');
|
|
96
|
+
});
|
|
97
|
+
vi.spyOn(context, 'stroke').mockImplementation(() => {
|
|
98
|
+
order.push('stroke');
|
|
99
|
+
});
|
|
100
|
+
const shape = createShape();
|
|
101
|
+
appendShapeLineStyle(shape, 2, 0x000000);
|
|
102
|
+
appendShapeBeginFill(shape, 0xff0000);
|
|
103
|
+
appendShapeRectangle(shape, 0, 0, 100, 50);
|
|
104
|
+
appendShapeEndFill(shape);
|
|
105
|
+
renderCanvasShapeCommands(context, shape.data.commands);
|
|
106
|
+
expect(order).toEqual(['fill', 'stroke']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('calls fill with evenodd winding rule by default', () => {
|
|
110
|
+
const context = makeContext();
|
|
111
|
+
const spy = vi.spyOn(context, 'fill');
|
|
112
|
+
const shape = createShape();
|
|
113
|
+
appendShapeBeginFill(shape, 0xff0000, 1);
|
|
114
|
+
appendShapeRectangle(shape, 0, 0, 10, 10);
|
|
115
|
+
appendShapeEndFill(shape);
|
|
116
|
+
renderCanvasShapeCommands(context, shape.data.commands);
|
|
117
|
+
expect(spy).toHaveBeenCalledWith('evenodd');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does not throw on a zero-size rectangle', () => {
|
|
121
|
+
const context = makeContext();
|
|
122
|
+
const shape = createShape();
|
|
123
|
+
appendShapeBeginFill(shape, 0xff0000, 1);
|
|
124
|
+
appendShapeRectangle(shape, 10, 10, 0, 0);
|
|
125
|
+
appendShapeEndFill(shape);
|
|
126
|
+
expect(() => renderCanvasShapeCommands(context, shape.data.commands)).not.toThrow();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('does not throw on NaN coordinates', () => {
|
|
130
|
+
const context = makeContext();
|
|
131
|
+
const shape = createShape();
|
|
132
|
+
appendShapeBeginFill(shape, 0xff0000, 1);
|
|
133
|
+
appendShapeMoveTo(shape, Number.NaN, Number.NaN);
|
|
134
|
+
appendShapeLineTo(shape, Number.NaN, 10);
|
|
135
|
+
appendShapeRectangle(shape, Number.NaN, 0, Number.NaN, 10);
|
|
136
|
+
appendShapeEndFill(shape);
|
|
137
|
+
expect(() => renderCanvasShapeCommands(context, shape.data.commands)).not.toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('does not throw on Infinity coordinates', () => {
|
|
141
|
+
const context = makeContext();
|
|
142
|
+
const shape = createShape();
|
|
143
|
+
appendShapeBeginFill(shape, 0xff0000, 1);
|
|
144
|
+
appendShapeMoveTo(shape, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY);
|
|
145
|
+
appendShapeLineTo(shape, Number.POSITIVE_INFINITY, 0);
|
|
146
|
+
appendShapeEndFill(shape);
|
|
147
|
+
expect(() => renderCanvasShapeCommands(context, shape.data.commands)).not.toThrow();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('does not throw on very large coordinates', () => {
|
|
151
|
+
const context = makeContext();
|
|
152
|
+
const shape = createShape();
|
|
153
|
+
appendShapeBeginFill(shape, 0xff0000, 1);
|
|
154
|
+
appendShapeRectangle(shape, -1e20, -1e20, 2e20, 2e20);
|
|
155
|
+
appendShapeEndFill(shape);
|
|
156
|
+
expect(() => renderCanvasShapeCommands(context, shape.data.commands)).not.toThrow();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('does not throw on a singular bitmap-fill matrix', () => {
|
|
160
|
+
const context = makeContext();
|
|
161
|
+
const bitmapSource = document.createElement('canvas');
|
|
162
|
+
bitmapSource.width = 50;
|
|
163
|
+
bitmapSource.height = 50;
|
|
164
|
+
const bitmap = { source: bitmapSource, width: 50, height: 50 } as never;
|
|
165
|
+
const singular = createMatrix(0, 0, 0, 0, 0, 0);
|
|
166
|
+
const shape = createShape();
|
|
167
|
+
appendShapeBeginBitmapFill(shape, bitmap, singular);
|
|
168
|
+
appendShapeRectangle(shape, 0, 0, 100, 100);
|
|
169
|
+
appendShapeEndFill(shape);
|
|
170
|
+
expect(() => renderCanvasShapeCommands(context, shape.data.commands)).not.toThrow();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('skips an unknown command key without throwing', () => {
|
|
174
|
+
const context = makeContext();
|
|
175
|
+
const fillSpy = vi.spyOn(context, 'fill');
|
|
176
|
+
// Raw buffer: [key, argCount, ...args]. The unknown key has no registered
|
|
177
|
+
// handler, so getCanvasShapeCommand returns the undefined sentinel and the
|
|
178
|
+
// walk advances past it rather than throwing.
|
|
179
|
+
const commands: unknown[] = ['acme.unknownCommand', 2, 1, 2];
|
|
180
|
+
expect(() => renderCanvasShapeCommands(context, commands)).not.toThrow();
|
|
181
|
+
expect(fillSpy).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
});
|