Something went wrong. Try again.
inkfinite (an infinite canvas/whiteboard) monorepo ink.stormlightlabs.org
svelte rust tauri svg canvas perfect-freehand
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951import { EditorPageRecord, EditorShapeRecord, Store } from '@inkfinite/core';import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';import { createRenderer } from '../src/renderer';
describe('Renderer', () => { let canvas: HTMLCanvasElement; let context: CanvasRenderingContext2D;
beforeEach(() => { canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600;
context = { canvas, save: vi.fn(), restore: vi.fn(), scale: vi.fn(), translate: vi.fn(), rotate: vi.fn(), setTransform: vi.fn(), clearRect: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(), fillText: vi.fn(), strokeText: vi.fn(), measureText: vi.fn(() => ({ width: 100 })), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), quadraticCurveTo: vi.fn(), bezierCurveTo: vi.fn(), arc: vi.fn(), arcTo: vi.fn(), ellipse: vi.fn(), rect: vi.fn(), closePath: vi.fn(), fill: vi.fn(), stroke: vi.fn(), setLineDash: vi.fn(), getLineDash: vi.fn(() => []), globalAlpha: 1, fillStyle: '', strokeStyle: '', lineWidth: 1, font: '', textBaseline: 'alphabetic' } as unknown as CanvasRenderingContext2D;
vi.spyOn(canvas, 'getContext').mockReturnValue(context);
Object.defineProperty(canvas, 'getBoundingClientRect', { value: () => ({ width: 800, height: 600, top: 0, left: 0, right: 800, bottom: 600 }) });
globalThis.requestAnimationFrame = vi.fn((callback) => { setTimeout(callback, 16); return 1; });
globalThis.cancelAnimationFrame = vi.fn(); });
afterEach(() => { vi.restoreAllMocks(); });
describe('createRenderer', () => { it('should create renderer with dispose method', () => { const store = new Store(); const renderer = createRenderer(canvas, store);
expect(renderer).toBeDefined(); expect(renderer.dispose).toBeInstanceOf(Function); expect(renderer.markDirty).toBeInstanceOf(Function);
renderer.dispose(); });
it('should throw error if canvas context is not available', () => { const badCanvas = document.createElement('canvas'); vi.spyOn(badCanvas, 'getContext').mockReturnValue(null);
const store = new Store();
expect(() => createRenderer(badCanvas, store)).toThrow('Failed to get 2D context from canvas'); });
it('should mark dirty on initial render', () => { const store = new Store(); const renderer = createRenderer(canvas, store);
expect(globalThis.requestAnimationFrame).toHaveBeenCalled();
renderer.dispose(); });
it('should unsubscribe from store on dispose', () => { const store = new Store(); const renderer = createRenderer(canvas, store);
const _unsubscribeSpy = vi.spyOn(store, 'subscribe');
renderer.dispose();
expect(globalThis.cancelAnimationFrame).toHaveBeenCalled(); }); });
describe('rendering', () => { it('preserves explicit and blank lines in text shapes', async () => { const scheduledFrames: FrameRequestCallback[] = []; globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { scheduledFrames.push(callback); return scheduledFrames.length; }); const store = new Store(); const page = EditorPageRecord.create('Page', 'page'); const text = EditorShapeRecord.createText( page.id, 0, 0, { text: 'Overview\n\nIncidents', fontSize: 16, fontFamily: 'sans-serif', color: '#111827', w: 200 }, 'text' ); store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [text.id] } }, shapes: { [text.id]: text }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store); scheduledFrames.shift()?.(0);
expect(context.fillText).toHaveBeenNthCalledWith(1, 'Overview', 0, 0); expect(context.fillText).toHaveBeenNthCalledWith(2, '', 0, 19.2); expect(context.fillText).toHaveBeenNthCalledWith(3, 'Incidents', 0, 38.4); renderer.dispose(); });
it('renders visible layers in order with isolated opacity and skips hidden layers', () => { const scheduledFrames: FrameRequestCallback[] = []; globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { scheduledFrames.push(callback); return scheduledFrames.length; }); let alpha = 1; const alphaWrites: number[] = []; Object.defineProperty(context, 'globalAlpha', { configurable: true, get: () => alpha, set: (value: number) => { alpha = value; alphaWrites.push(value); } }); const page = EditorPageRecord.create('Page', 'page'); const visible = EditorShapeRecord.createRect( 'page', 0, 0, { w: 10, h: 10, fill: '#fff', stroke: '#000', radius: 0 }, 'visible' ); const hidden = EditorShapeRecord.createRect( 'page', 20, 0, { w: 10, h: 10, fill: '#fff', stroke: '#000', radius: 0 }, 'hidden' ); const store = new Store(); store.setState((state) => ({ ...state, doc: { pages: { page: { ...page, shapeIds: [visible.id, hidden.id], layerIds: ['visible-layer', 'hidden-layer'] } }, layers: { 'visible-layer': { id: 'visible-layer', pageId: 'page', name: 'Visible', shapeIds: [visible.id], visible: true, locked: false, opacity: 0.4 }, 'hidden-layer': { id: 'hidden-layer', pageId: 'page', name: 'Hidden', shapeIds: [hidden.id], visible: false, locked: false, opacity: 1 } }, shapes: { visible: { ...visible, layerId: 'visible-layer' }, hidden: { ...hidden, layerId: 'hidden-layer' } }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store); scheduledFrames.shift()?.(0); expect(alphaWrites).toContain(0.4); expect(context.fill).toHaveBeenCalledTimes(2); expect(context.save).toHaveBeenCalledTimes(vi.mocked(context.restore).mock.calls.length); renderer.dispose(); });
it('composes shape, fill, and stroke opacity deterministically', () => { const scheduledFrames: FrameRequestCallback[] = []; globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { scheduledFrames.push(callback); return scheduledFrames.length; }); let alpha = 1; const alphaWrites: number[] = []; Object.defineProperty(context, 'globalAlpha', { configurable: true, get: () => alpha, set: (value: number) => { alpha = value; alphaWrites.push(value); } }); const strokeAlphas: number[] = []; vi.mocked(context.stroke).mockImplementation(() => { strokeAlphas.push(alpha); }); const page = EditorPageRecord.create('Page', 'page'); const shape = { ...EditorShapeRecord.createRect( page.id, 0, 0, { w: 10, h: 10, fill: '#fff', stroke: '#000', radius: 0 }, 'shape' ), opacity: 0.8, fillOpacity: 0.25, strokeOpacity: 0.5 }; const store = new Store(); store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [shape.id] } }, shapes: { [shape.id]: shape }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store); scheduledFrames.shift()?.(0); expect(alphaWrites).toContain(0.2); expect(alphaWrites).toContain(0.4); expect(strokeAlphas).toContain(0.4); expect(context.fill).toHaveBeenCalledTimes(2); renderer.dispose(); });
it('resets and clears the full backing store before every frame', () => { const scheduledFrames: FrameRequestCallback[] = []; Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 }); globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { scheduledFrames.push(callback); return scheduledFrames.length; }); const store = new Store(); const renderer = createRenderer(canvas, store);
scheduledFrames.shift()?.(0); store.setState((state) => ({ ...state, camera: { x: 120, y: -80, zoom: 1.5 } })); scheduledFrames.shift()?.(16);
expect(context.setTransform).toHaveBeenCalledWith(2, 0, 0, 2, 0, 0); expect(context.setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0); expect(context.clearRect).toHaveBeenLastCalledWith(0, 0, 1600, 1200); expect(context.translate).toHaveBeenCalledWith(-120, 80); expect(scheduledFrames).toHaveLength(0); renderer.dispose(); });
it('culls offscreen shapes while retaining offscreen selection overlays', () => { const scheduledFrames: FrameRequestCallback[] = []; globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { scheduledFrames.push(callback); return scheduledFrames.length; }); const page = EditorPageRecord.create('Page 1', 'page:1'); const visible = EditorShapeRecord.createRect( 'page:1', 0, 0, { w: 50, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 'shape:visible' ); const selectedOffscreen = EditorShapeRecord.createRect( 'page:1', 10_000, 10_000, { w: 50, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 'shape:offscreen' ); const store = new Store(); store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [visible.id, selectedOffscreen.id] } }, shapes: { [visible.id]: visible, [selectedOffscreen.id]: selectedOffscreen }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id, selectionIds: [selectedOffscreen.id] } }));
const renderer = createRenderer(canvas, store); scheduledFrames.shift()?.(0);
expect(context.fill).toHaveBeenCalledTimes(2); expect(context.strokeRect).toHaveBeenCalledWith(0, 0, 50, 50); expect(context.translate).toHaveBeenCalledWith(10_000, 10_000); renderer.dispose(); });
it('should render empty scene with no shapes', async () => { const store = new Store(); const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); });
it('should render scene with rect shape', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1'); const rect = EditorShapeRecord.createRect( 'page:1', 100, 100, { w: 200, h: 100, fill: '#ff0000', stroke: '#000000', radius: 0 }, 'shape:1' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); });
it('should render scene with ellipse shape', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1'); const ellipse = EditorShapeRecord.createEllipse( 'page:1', 100, 100, { w: 200, h: 100, fill: '#00ff00', stroke: '#000000' }, 'shape:1' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [ellipse.id] } }, shapes: { [ellipse.id]: ellipse }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); });
it('renders native path segments with the stored fill rule', () => { const scheduledFrames: FrameRequestCallback[] = []; globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { scheduledFrames.push(callback); return scheduledFrames.length; }); const page = EditorPageRecord.create('Page 1', 'page:1'); const path = EditorShapeRecord.createPath( 'page:1', 0, 0, { subpaths: [ { segments: [ { type: 'move', to: { x: 0, y: 0 } }, { type: 'line', to: { x: 40, y: 0 } }, { type: 'quadratic', control: { x: 50, y: 10 }, to: { x: 40, y: 20 } }, { type: 'cubic', control_1: { x: 40, y: 30 }, control_2: { x: 0, y: 30 }, to: { x: 0, y: 20 } } ], closed: true } ], fill_rule: 'evenodd', fill: '#fff', stroke: '#000', stroke_width: 3 }, 'path:1' ); const store = new Store(); store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [path.id] } }, shapes: { [path.id]: path }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store); scheduledFrames.shift()?.(0);
expect(context.quadraticCurveTo).toHaveBeenCalledWith(50, 10, 40, 20); expect(context.bezierCurveTo).toHaveBeenCalledWith(40, 30, 0, 30, 0, 20); expect(context.fill).toHaveBeenCalledWith('evenodd'); expect(context.lineWidth).toBe(3); renderer.dispose(); });
it('does not fill a native path whose fill is none', () => { const scheduledFrames: FrameRequestCallback[] = []; globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { scheduledFrames.push(callback); return scheduledFrames.length; }); const page = EditorPageRecord.create('Page 1', 'page:1'); const path = EditorShapeRecord.createPath( page.id, 0, 0, { subpaths: [ { segments: [ { type: 'move', to: { x: 0, y: 20 } }, { type: 'line', to: { x: 40, y: 0 } } ], closed: false } ], fill_rule: 'nonzero', fill: 'none', stroke: '#0f766e', stroke_width: 3 }, 'path:none-fill' ); const store = new Store(); store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [path.id] } }, shapes: { [path.id]: path }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store); scheduledFrames.shift()?.(0);
expect(context.fill).not.toHaveBeenCalledWith('nonzero'); expect(context.stroke).toHaveBeenCalled(); renderer.dispose(); });
it('should render scene with line shape', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1'); const line = EditorShapeRecord.createLine( 'page:1', 0, 0, { a: { x: 0, y: 0 }, b: { x: 100, y: 100 }, stroke: '#000000', width: 2 }, 'shape:1' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [line.id] } }, shapes: { [line.id]: line }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); });
it('should render scene with arrow shape', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1'); const arrow = EditorShapeRecord.createArrow( 'page:1', 0, 0, { a: { x: 0, y: 0 }, b: { x: 100, y: 100 }, stroke: '#000000', width: 2 }, 'shape:1' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [arrow.id] } }, shapes: { [arrow.id]: arrow }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); });
it('should render scene with text shape', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1'); const text = EditorShapeRecord.createText( 'page:1', 100, 100, { text: 'Hello World', fontSize: 16, fontFamily: 'Arial', color: '#000000' }, 'shape:1' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [text.id] } }, shapes: { [text.id]: text }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); });
it('should render text shape with word wrapping', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1'); const text = EditorShapeRecord.createText( 'page:1', 100, 100, { text: 'Hello World this is a long text', fontSize: 16, fontFamily: 'Arial', color: '#000000', w: 100 }, 'shape:1' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [text.id] } }, shapes: { [text.id]: text }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); });
it('should render multiple shapes', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1'); const rect = EditorShapeRecord.createRect( 'page:1', 100, 100, { w: 200, h: 100, fill: '#ff0000', stroke: '#000000', radius: 0 }, 'shape:1' ); const ellipse = EditorShapeRecord.createEllipse( 'page:1', 400, 200, { w: 150, h: 100, fill: '#00ff00', stroke: '#000000' }, 'shape:2' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id, ellipse.id] } }, shapes: { [rect.id]: rect, [ellipse.id]: ellipse }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); });
it('traces selected arrows instead of drawing a rectangular outline', async () => { const store = new Store(); const page = EditorPageRecord.create('Page 1', 'page:1'); const arrow = EditorShapeRecord.createArrow( page.id, 0, 0, { a: { x: 0, y: 0 }, b: { x: 100, y: 0 }, stroke: '#000000', width: 2 }, 'shape:arrow' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [arrow.id] } }, shapes: { [arrow.id]: arrow }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id, selectionIds: [arrow.id] } }));
const renderer = createRenderer(canvas, store); await new Promise((resolve) => setTimeout(resolve, 50));
expect(context.strokeRect).not.toHaveBeenCalled(); renderer.dispose(); });
it('renders a dashed outline for selected shapes', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1'); const rect = EditorShapeRecord.createRect( 'page:1', 100, 100, { w: 200, h: 100, fill: '#ff0000', stroke: '#000000', radius: 0 }, 'shape:1' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id, selectionIds: [rect.id] } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50)); const selectionStrokes = vi .mocked(context.strokeRect) .mock.calls.filter(([x, y, width, height]) => x === 0 && y === 0 && width === 200 && height === 100); expect(selectionStrokes).toHaveLength(1); expect(context.setLineDash).toHaveBeenCalledWith([7, 5]);
renderer.dispose(); });
it('renders direct-selection anchors and quadratic and cubic controls', () => { const scheduledFrames: FrameRequestCallback[] = []; globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { scheduledFrames.push(callback); return scheduledFrames.length; }); const page = EditorPageRecord.create('Page', 'page:direct-render'); const path = EditorShapeRecord.createPath( page.id, 0, 0, { subpaths: [ { segments: [ { type: 'move', to: { x: 0, y: 0 } }, { type: 'line', to: { x: 40, y: 0 } }, { type: 'quadratic', control: { x: 60, y: 20 }, to: { x: 40, y: 40 } }, { type: 'cubic', control_1: { x: 40, y: 60 }, control_2: { x: 0, y: 60 }, to: { x: 0, y: 40 } } ], closed: true } ], fill_rule: 'nonzero', stroke: '#000', stroke_width: 2 }, 'path:direct-render' ); const store = new Store(); store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [path.id] } }, shapes: { [path.id]: path }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id, selectionIds: [path.id], toolId: 'direct-select', pathSelection: { pathId: path.id, anchors: [{ subpathIndex: 0, segmentIndex: 0 }] } } }));
const renderer = createRenderer(canvas, store); scheduledFrames.shift()?.(0);
const controlHandleArcs = vi.mocked(context.arc).mock.calls.filter((call) => call[2] === 4); expect(controlHandleArcs).toHaveLength(3); expect(context.rect).toHaveBeenCalledTimes(4); renderer.dispose(); });
it('should update render when store changes', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1');
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
const rect = EditorShapeRecord.createRect( 'page:1', 100, 100, { w: 200, h: 100, fill: '#ff0000', stroke: '#000000', radius: 0 }, 'shape:1' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} } }));
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); });
it('should apply camera transform correctly', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1'); const rect = EditorShapeRecord.createRect( 'page:1', 0, 0, { w: 100, h: 100, fill: '#ff0000', stroke: '#000000', radius: 0 }, 'shape:1' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id }, camera: { x: 100, y: 100, zoom: 2 } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); });
it('should handle rounded rectangle', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1'); const rect = EditorShapeRecord.createRect( 'page:1', 100, 100, { w: 200, h: 100, fill: '#ff0000', stroke: '#000000', radius: 10 }, 'shape:1' );
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); });
it('should render shapes with rotation', async () => { const store = new Store();
const page = EditorPageRecord.create('Page 1', 'page:1'); const rect = EditorShapeRecord.createRect( 'page:1', 100, 100, { w: 200, h: 100, fill: '#ff0000', stroke: '#000000', radius: 0 }, 'shape:1' ); rect.rot = Math.PI / 4;
store.setState((state) => ({ ...state, doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, ui: { ...state.ui, currentPageId: page.id } }));
const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose(); }); });
describe('markDirty', () => { it('should allow manual dirty marking', async () => { const store = new Store(); const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame');
renderer.markDirty();
expect(rafSpy).toHaveBeenCalled();
renderer.dispose(); });
it('should not mark dirty after dispose', async () => { const store = new Store(); const renderer = createRenderer(canvas, store);
await new Promise((resolve) => setTimeout(resolve, 50));
renderer.dispose();
// @ts-expect-error mocked const rafCallCount = globalThis.requestAnimationFrame.mock.calls.length;
renderer.markDirty();
// @ts-expect-error mocked expect(globalThis.requestAnimationFrame.mock.calls.length).toBe(rafCallCount); }); });});