From 920049dd9ee1932178322ab183d5832d661c8a33 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 21 Dec 2025 13:19:36 -0600 Subject: [PATCH] feat: implement browser canvas renderer --- TODO.txt | 10 +- eslint.config.js | 9 + packages/core/src/index.ts | 7 +- packages/core/tests/reactivity.test.ts | 646 ++++++++++--------------- packages/core/tsdown.config.ts | 7 +- packages/renderer/package.json | 25 +- packages/renderer/src/index.ts | 404 +++++++++++++++- packages/renderer/tests/index.test.ts | 472 +++++++++++++++++- packages/renderer/tsconfig.json | 2 +- packages/renderer/tsdown.config.ts | 7 +- packages/renderer/vitest.config.ts | 3 + pnpm-lock.yaml | 388 ++++++++++++++- 12 files changed, 1546 insertions(+), 434 deletions(-) create mode 100644 packages/renderer/vitest.config.ts diff --git a/TODO.txt b/TODO.txt index 2749356..d4ab5bf 100644 --- a/TODO.txt +++ b/TODO.txt @@ -164,21 +164,21 @@ Tests Goal: draw the document from state, no interactivity yet. Renderer (/packages/renderer): -[ ] createRenderer(canvas, store) -> { dispose() } -[ ] Implement render loop strategy: +[x] createRenderer(canvas, store) -> { dispose() } +[x] Implement render loop strategy: - requestAnimationFrame redraw on "dirty" flag - mark dirty on store updates -[ ] Implement draw pipeline: +[x] Implement draw pipeline: - clear canvas - apply camera transform - draw shapes (rect/ellipse/line/arrow/text) - draw selection outline if selectionIds non-empty -[ ] Implement text measurement fallback: +[x] Implement text measurement fallback: - if text shape has w? else measureText and derive bounds -[ ] Implement pixel ratio handling: +[x] Implement pixel ratio handling: - set canvas width/height by devicePixelRatio - scale context accordingly diff --git a/eslint.config.js b/eslint.config.js index eb5c585..e0df4d4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,6 +11,15 @@ export default defineConfig( eslintPluginUnicorn.configs.recommended, [{ rules: { + "@typescript-eslint/no-unused-vars": ["error", { + "args": "all", + "argsIgnorePattern": "^_", + "caughtErrors": "all", + "caughtErrorsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "ignoreRestSiblings": true, + }], "unicorn/no-null": "off", "unicorn/prevent-abbreviations": ["error", { "replacements": { "i": false, "props": false, "doc": false } }], }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ff0fb70..ed11aea 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ -export function fn() { - return 'Hello, tsdown!' -} +export * from "./camera"; +export * from "./math"; +export * from "./model"; +export * from "./reactivity"; diff --git a/packages/core/tests/reactivity.test.ts b/packages/core/tests/reactivity.test.ts index 83c43d6..f6d2d1c 100644 --- a/packages/core/tests/reactivity.test.ts +++ b/packages/core/tests/reactivity.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; -import { Camera } from '../src/camera'; -import { PageRecord, ShapeRecord } from '../src/model'; +import { describe, expect, it, vi } from "vitest"; +import { Camera } from "../src/camera"; +import { PageRecord, ShapeRecord } from "../src/model"; import { EditorState as EditorStateOps, getAllPages, @@ -10,11 +10,11 @@ import { getShapesOnCurrentPage, isShapeSelected, Store, -} from '../src/reactivity'; +} from "../src/reactivity"; -describe('EditorState', () => { - describe('create', () => { - it('should create initial editor state', () => { +describe("EditorState", () => { + describe("create", () => { + it("should create initial editor state", () => { const state = EditorStateOps.create(); expect(state.doc.pages).toEqual({}); @@ -22,28 +22,28 @@ describe('EditorState', () => { expect(state.doc.bindings).toEqual({}); expect(state.ui.currentPageId).toBeNull(); expect(state.ui.selectionIds).toEqual([]); - expect(state.ui.toolId).toBe('select'); + expect(state.ui.toolId).toBe("select"); expect(state.camera).toEqual({ x: 0, y: 0, zoom: 1 }); }); }); - describe('clone', () => { - it('should deep clone editor state', () => { + describe("clone", () => { + it("should deep clone editor state", () => { const state = EditorStateOps.create(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); const shape = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); - page.shapeIds = ['shape1']; + page.shapeIds = ["shape1"]; state.doc.pages = { page1: page }; state.doc.shapes = { shape1: shape }; - state.ui.currentPageId = 'page1'; - state.ui.selectionIds = ['shape1']; + state.ui.currentPageId = "page1"; + state.ui.selectionIds = ["shape1"]; state.camera = { x: 100, y: 200, zoom: 1.5 }; const cloned = EditorStateOps.clone(state); @@ -58,35 +58,35 @@ describe('EditorState', () => { }); }); -describe('Store', () => { - describe('constructor', () => { - it('should create store with default initial state', () => { +describe("Store", () => { + describe("constructor", () => { + it("should create store with default initial state", () => { const store = new Store(); const state = store.getState(); expect(state.doc.pages).toEqual({}); expect(state.ui.currentPageId).toBeNull(); expect(state.ui.selectionIds).toEqual([]); - expect(state.ui.toolId).toBe('select'); + expect(state.ui.toolId).toBe("select"); }); - it('should create store with custom initial state', () => { + it("should create store with custom initial state", () => { const initialState = EditorStateOps.create(); - initialState.ui.toolId = 'rect'; + initialState.ui.toolId = "rect"; initialState.camera = { x: 100, y: 200, zoom: 2 }; const store = new Store(initialState); const state = store.getState(); - expect(state.ui.toolId).toBe('rect'); + expect(state.ui.toolId).toBe("rect"); expect(state.camera.x).toBe(100); expect(state.camera.y).toBe(200); expect(state.camera.zoom).toBe(2); }); }); - describe('getState', () => { - it('should return current state', () => { + describe("getState", () => { + it("should return current state", () => { const store = new Store(); const state = store.getState(); @@ -96,7 +96,7 @@ describe('Store', () => { expect(state.camera).toBeDefined(); }); - it('should return same state on multiple calls if no updates', () => { + it("should return same state on multiple calls if no updates", () => { const store = new Store(); const state1 = store.getState(); const state2 = store.getState(); @@ -105,52 +105,40 @@ describe('Store', () => { }); }); - describe('setState', () => { - it('should update state using updater function', () => { + describe("setState", () => { + it("should update state using updater function", () => { const store = new Store(); - store.setState((state) => ({ - ...state, - ui: { ...state.ui, toolId: 'ellipse' }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "ellipse" } })); const state = store.getState(); - expect(state.ui.toolId).toBe('ellipse'); + expect(state.ui.toolId).toBe("ellipse"); }); - it('should update camera position', () => { + it("should update camera position", () => { const store = new Store(); - store.setState((state) => ({ - ...state, - camera: Camera.pan(state.camera, { x: 50, y: 30 }), - })); + store.setState((state) => ({ ...state, camera: Camera.pan(state.camera, { x: 50, y: 30 }) })); const state = store.getState(); expect(state.camera.x).toBe(-50); expect(state.camera.y).toBe(-30); }); - it('should update document', () => { + it("should update document", () => { const store = new Store(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); - store.setState((state) => ({ - ...state, - doc: { - ...state.doc, - pages: { page1: page }, - }, - })); + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); const state = store.getState(); expect(state.doc.pages.page1).toBeDefined(); - expect(state.doc.pages.page1.name).toBe('Page 1'); + expect(state.doc.pages.page1.name).toBe("Page 1"); }); }); - describe('subscribe', () => { - it('should call listener immediately with current state', () => { + describe("subscribe", () => { + it("should call listener immediately with current state", () => { const store = new Store(); const listener = vi.fn(); @@ -160,45 +148,36 @@ describe('Store', () => { expect(listener).toHaveBeenCalledWith(store.getState()); }); - it('should call listener on state change', () => { + it("should call listener on state change", () => { const store = new Store(); const listener = vi.fn(); store.subscribe(listener); listener.mockClear(); - store.setState((state) => ({ - ...state, - ui: { ...state.ui, toolId: 'rect' }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "rect" } })); expect(listener).toHaveBeenCalledTimes(1); }); - it('should call listener exactly once per setState', () => { + it("should call listener exactly once per setState", () => { const store = new Store(); const listener = vi.fn(); store.subscribe(listener); listener.mockClear(); - store.setState((state) => ({ - ...state, - ui: { ...state.ui, toolId: 'rect' }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "rect" } })); expect(listener).toHaveBeenCalledTimes(1); listener.mockClear(); - store.setState((state) => ({ - ...state, - ui: { ...state.ui, toolId: 'ellipse' }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "ellipse" } })); expect(listener).toHaveBeenCalledTimes(1); }); - it('should support multiple subscribers', () => { + it("should support multiple subscribers", () => { const store = new Store(); const listener1 = vi.fn(); const listener2 = vi.fn(); @@ -209,16 +188,13 @@ describe('Store', () => { listener1.mockClear(); listener2.mockClear(); - store.setState((state) => ({ - ...state, - ui: { ...state.ui, toolId: 'arrow' }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "arrow" } })); expect(listener1).toHaveBeenCalledTimes(1); expect(listener2).toHaveBeenCalledTimes(1); }); - it('should unsubscribe when unsubscribe function is called', () => { + it("should unsubscribe when unsubscribe function is called", () => { const store = new Store(); const listener = vi.fn(); @@ -227,15 +203,12 @@ describe('Store', () => { unsubscribe(); - store.setState((state) => ({ - ...state, - ui: { ...state.ui, toolId: 'line' }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "line" } })); expect(listener).not.toHaveBeenCalled(); }); - it('should handle multiple subscriptions and unsubscriptions independently', () => { + it("should handle multiple subscriptions and unsubscriptions independently", () => { const store = new Store(); const listener1 = vi.fn(); const listener2 = vi.fn(); @@ -248,10 +221,7 @@ describe('Store', () => { unsubscribe1(); - store.setState((state) => ({ - ...state, - ui: { ...state.ui, toolId: 'text' }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "text" } })); expect(listener1).not.toHaveBeenCalled(); expect(listener2).toHaveBeenCalledTimes(1); @@ -259,25 +229,22 @@ describe('Store', () => { listener2.mockClear(); unsubscribe2(); - store.setState((state) => ({ - ...state, - ui: { ...state.ui, toolId: 'pen' }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "pen" } })); expect(listener2).not.toHaveBeenCalled(); }); }); - describe('getObservable', () => { - it('should return RxJS observable', () => { + describe("getObservable", () => { + it("should return RxJS observable", () => { const store = new Store(); const observable = store.getObservable(); expect(observable).toBeDefined(); - expect(typeof observable.subscribe).toBe('function'); + expect(typeof observable.subscribe).toBe("function"); }); - it('should emit values on state changes', () => { + it("should emit values on state changes", () => { const store = new Store(); const observable = store.getObservable(); @@ -286,332 +253,255 @@ describe('Store', () => { states.push(state.ui.toolId); }); - store.setState((state) => ({ - ...state, - ui: { ...state.ui, toolId: 'rect' }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "rect" } })); - expect(states).toEqual(['select', 'rect']); + expect(states).toEqual(["select", "rect"]); }); }); }); -describe('Invariants', () => { - describe('currentPageId invariant', () => { - it('should repair invalid currentPageId to null when page does not exist', () => { +describe("Invariants", () => { + describe("currentPageId invariant", () => { + it("should repair invalid currentPageId to null when page does not exist", () => { const store = new Store(); - store.setState((state) => ({ - ...state, - ui: { ...state.ui, currentPageId: 'nonexistent' }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, currentPageId: "nonexistent" } })); const state = store.getState(); expect(state.ui.currentPageId).toBeNull(); }); - it('should repair invalid currentPageId to first page when pages exist', () => { + it("should repair invalid currentPageId to first page when pages exist", () => { const store = new Store(); - const page1 = PageRecord.create('Page 1', 'page1'); - const page2 = PageRecord.create('Page 2', 'page2'); + const page1 = PageRecord.create("Page 1", "page1"); + const page2 = PageRecord.create("Page 2", "page2"); store.setState((state) => ({ ...state, - doc: { - ...state.doc, - pages: { page1, page2 }, - }, - ui: { ...state.ui, currentPageId: 'nonexistent' }, + doc: { ...state.doc, pages: { page1, page2 } }, + ui: { ...state.ui, currentPageId: "nonexistent" }, })); const state = store.getState(); - expect(state.ui.currentPageId).toBe('page1'); + expect(state.ui.currentPageId).toBe("page1"); }); - it('should keep valid currentPageId', () => { + it("should keep valid currentPageId", () => { const store = new Store(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); store.setState((state) => ({ ...state, - doc: { - ...state.doc, - pages: { page1: page }, - }, - ui: { ...state.ui, currentPageId: 'page1' }, + doc: { ...state.doc, pages: { page1: page } }, + ui: { ...state.ui, currentPageId: "page1" }, })); const state = store.getState(); - expect(state.ui.currentPageId).toBe('page1'); + expect(state.ui.currentPageId).toBe("page1"); }); }); - describe('selectionIds invariant', () => { - it('should clear selection when currentPageId is null', () => { + describe("selectionIds invariant", () => { + it("should clear selection when currentPageId is null", () => { const store = new Store(); store.setState((state) => ({ ...state, - ui: { ...state.ui, currentPageId: null, selectionIds: ['shape1', 'shape2'] }, + ui: { ...state.ui, currentPageId: null, selectionIds: ["shape1", "shape2"] }, })); const state = store.getState(); expect(state.ui.selectionIds).toEqual([]); }); - it('should remove non-existent shapes from selection', () => { + it("should remove non-existent shapes from selection", () => { const store = new Store(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); const shape = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); - page.shapeIds = ['shape1']; + page.shapeIds = ["shape1"]; store.setState((state) => ({ ...state, - doc: { - ...state.doc, - pages: { page1: page }, - shapes: { shape1: shape }, - }, - ui: { - ...state.ui, - currentPageId: 'page1', - selectionIds: ['shape1', 'nonexistent', 'shape2'], - }, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + ui: { ...state.ui, currentPageId: "page1", selectionIds: ["shape1", "nonexistent", "shape2"] }, })); const state = store.getState(); - expect(state.ui.selectionIds).toEqual(['shape1']); + expect(state.ui.selectionIds).toEqual(["shape1"]); }); - it('should remove shapes not on current page from selection', () => { + it("should remove shapes not on current page from selection", () => { const store = new Store(); - const page1 = PageRecord.create('Page 1', 'page1'); - const page2 = PageRecord.create('Page 2', 'page2'); + const page1 = PageRecord.create("Page 1", "page1"); + const page2 = PageRecord.create("Page 2", "page2"); const shape1 = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); const shape2 = ShapeRecord.createRect( - 'page2', + "page2", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape2', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape2", ); - page1.shapeIds = ['shape1']; - page2.shapeIds = ['shape2']; + page1.shapeIds = ["shape1"]; + page2.shapeIds = ["shape2"]; store.setState((state) => ({ ...state, - doc: { - ...state.doc, - pages: { page1, page2 }, - shapes: { shape1, shape2 }, - }, - ui: { - ...state.ui, - currentPageId: 'page1', - selectionIds: ['shape1', 'shape2'], - }, + doc: { ...state.doc, pages: { page1, page2 }, shapes: { shape1, shape2 } }, + ui: { ...state.ui, currentPageId: "page1", selectionIds: ["shape1", "shape2"] }, })); const state = store.getState(); - expect(state.ui.selectionIds).toEqual(['shape1']); + expect(state.ui.selectionIds).toEqual(["shape1"]); }); - it('should keep valid selection', () => { + it("should keep valid selection", () => { const store = new Store(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); const shape1 = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); const shape2 = ShapeRecord.createRect( - 'page1', + "page1", 50, 50, - { w: 75, h: 75, fill: '#000', stroke: '#fff', radius: 0 }, - 'shape2', + { w: 75, h: 75, fill: "#000", stroke: "#fff", radius: 0 }, + "shape2", ); - page.shapeIds = ['shape1', 'shape2']; + page.shapeIds = ["shape1", "shape2"]; store.setState((state) => ({ ...state, - doc: { - ...state.doc, - pages: { page1: page }, - shapes: { shape1, shape2 }, - }, - ui: { - ...state.ui, - currentPageId: 'page1', - selectionIds: ['shape1', 'shape2'], - }, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1, shape2 } }, + ui: { ...state.ui, currentPageId: "page1", selectionIds: ["shape1", "shape2"] }, })); const state = store.getState(); - expect(state.ui.selectionIds).toEqual(['shape1', 'shape2']); + expect(state.ui.selectionIds).toEqual(["shape1", "shape2"]); }); - it('should maintain reference equality when no repair needed', () => { + it("should maintain reference equality when no repair needed", () => { const store = new Store(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); const shape = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); - page.shapeIds = ['shape1']; + page.shapeIds = ["shape1"]; store.setState((state) => ({ ...state, - doc: { - ...state.doc, - pages: { page1: page }, - shapes: { shape1: shape }, - }, - ui: { - ...state.ui, - currentPageId: 'page1', - selectionIds: ['shape1'], - }, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + ui: { ...state.ui, currentPageId: "page1", selectionIds: ["shape1"] }, })); const stateBefore = store.getState(); - store.setState((state) => ({ - ...state, - camera: { ...state.camera, x: 100 }, - })); + store.setState((state) => ({ ...state, camera: { ...state.camera, x: 100 } })); const stateAfter = store.getState(); - // UI should maintain reference equality if not repaired expect(stateAfter.ui.currentPageId).toBe(stateBefore.ui.currentPageId); }); }); - describe('invariant repair on deletion', () => { - it('should clear selection when selected shape is deleted', () => { + describe("invariant repair on deletion", () => { + it("should clear selection when selected shape is deleted", () => { const store = new Store(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); const shape1 = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); - page.shapeIds = ['shape1']; + page.shapeIds = ["shape1"]; store.setState((state) => ({ ...state, - doc: { - ...state.doc, - pages: { page1: page }, - shapes: { shape1 }, - }, - ui: { - ...state.ui, - currentPageId: 'page1', - selectionIds: ['shape1'], - }, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1 } }, + ui: { ...state.ui, currentPageId: "page1", selectionIds: ["shape1"] }, })); - // Delete the shape store.setState((state) => { const newPage = { ...state.doc.pages.page1, shapeIds: [] }; - return { - ...state, - doc: { - ...state.doc, - pages: { page1: newPage }, - shapes: {}, - }, - }; + return { ...state, doc: { ...state.doc, pages: { page1: newPage }, shapes: {} } }; }); const state = store.getState(); expect(state.ui.selectionIds).toEqual([]); }); - it('should update currentPageId when current page is deleted', () => { + it("should update currentPageId when current page is deleted", () => { const store = new Store(); - const page1 = PageRecord.create('Page 1', 'page1'); - const page2 = PageRecord.create('Page 2', 'page2'); + const page1 = PageRecord.create("Page 1", "page1"); + const page2 = PageRecord.create("Page 2", "page2"); store.setState((state) => ({ ...state, - doc: { - ...state.doc, - pages: { page1, page2 }, - }, - ui: { - ...state.ui, - currentPageId: 'page1', - }, + doc: { ...state.doc, pages: { page1, page2 } }, + ui: { ...state.ui, currentPageId: "page1" }, })); - // Delete page1 - store.setState((state) => ({ - ...state, - doc: { - ...state.doc, - pages: { page2 }, - }, - })); + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page2 } } })); const state = store.getState(); - expect(state.ui.currentPageId).toBe('page2'); + expect(state.ui.currentPageId).toBe("page2"); }); }); }); -describe('Selectors', () => { - describe('getCurrentPage', () => { - it('should return null when no page is selected', () => { +describe("Selectors", () => { + describe("getCurrentPage", () => { + it("should return null when no page is selected", () => { const state = EditorStateOps.create(); const result = getCurrentPage(state); expect(result).toBeNull(); }); - it('should return current page', () => { + it("should return current page", () => { const state = EditorStateOps.create(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); state.doc.pages = { page1: page }; - state.ui.currentPageId = 'page1'; + state.ui.currentPageId = "page1"; const result = getCurrentPage(state); expect(result).toBe(page); - expect(result?.name).toBe('Page 1'); + expect(result?.name).toBe("Page 1"); }); - it('should return null when currentPageId does not exist', () => { + it("should return null when currentPageId does not exist", () => { const state = EditorStateOps.create(); - state.ui.currentPageId = 'nonexistent'; + state.ui.currentPageId = "nonexistent"; const result = getCurrentPage(state); @@ -619,42 +509,48 @@ describe('Selectors', () => { }); }); - describe('getShapesOnCurrentPage', () => { - it('should return empty array when no page is selected', () => { + describe("getShapesOnCurrentPage", () => { + it("should return empty array when no page is selected", () => { const state = EditorStateOps.create(); const result = getShapesOnCurrentPage(state); expect(result).toEqual([]); }); - it('should return empty array for empty page', () => { + it("should return empty array for empty page", () => { const state = EditorStateOps.create(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); state.doc.pages = { page1: page }; - state.ui.currentPageId = 'page1'; + state.ui.currentPageId = "page1"; const result = getShapesOnCurrentPage(state); expect(result).toEqual([]); }); - it('should return all shapes on current page', () => { + it("should return all shapes on current page", () => { const state = EditorStateOps.create(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); const shape1 = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + const shape2 = ShapeRecord.createEllipse( + "page1", + 50, + 50, + { w: 75, h: 75, fill: "#000", stroke: "#fff" }, + "shape2", ); - const shape2 = ShapeRecord.createEllipse('page1', 50, 50, { w: 75, h: 75, fill: '#000', stroke: '#fff' }, 'shape2'); - page.shapeIds = ['shape1', 'shape2']; + page.shapeIds = ["shape1", "shape2"]; state.doc.pages = { page1: page }; state.doc.shapes = { shape1, shape2 }; - state.ui.currentPageId = 'page1'; + state.ui.currentPageId = "page1"; const result = getShapesOnCurrentPage(state); @@ -663,21 +559,21 @@ describe('Selectors', () => { expect(result[1]).toBe(shape2); }); - it('should filter out undefined shapes', () => { + it("should filter out undefined shapes", () => { const state = EditorStateOps.create(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); const shape1 = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); - page.shapeIds = ['shape1', 'nonexistent']; + page.shapeIds = ["shape1", "nonexistent"]; state.doc.pages = { page1: page }; state.doc.shapes = { shape1 }; - state.ui.currentPageId = 'page1'; + state.ui.currentPageId = "page1"; const result = getShapesOnCurrentPage(state); @@ -685,32 +581,32 @@ describe('Selectors', () => { expect(result[0]).toBe(shape1); }); - it('should not include shapes from other pages', () => { + it("should not include shapes from other pages", () => { const state = EditorStateOps.create(); - const page1 = PageRecord.create('Page 1', 'page1'); - const page2 = PageRecord.create('Page 2', 'page2'); + const page1 = PageRecord.create("Page 1", "page1"); + const page2 = PageRecord.create("Page 2", "page2"); const shape1 = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); const shape2 = ShapeRecord.createRect( - 'page2', + "page2", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape2', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape2", ); - page1.shapeIds = ['shape1']; - page2.shapeIds = ['shape2']; + page1.shapeIds = ["shape1"]; + page2.shapeIds = ["shape2"]; state.doc.pages = { page1, page2 }; state.doc.shapes = { shape1, shape2 }; - state.ui.currentPageId = 'page1'; + state.ui.currentPageId = "page1"; const result = getShapesOnCurrentPage(state); @@ -719,38 +615,43 @@ describe('Selectors', () => { }); }); - describe('getSelectedShapes', () => { - it('should return empty array when no selection', () => { + describe("getSelectedShapes", () => { + it("should return empty array when no selection", () => { const state = EditorStateOps.create(); const result = getSelectedShapes(state); expect(result).toEqual([]); }); - it('should return selected shapes', () => { + it("should return selected shapes", () => { const state = EditorStateOps.create(); - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); const shape1 = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); - const shape2 = ShapeRecord.createEllipse('page1', 50, 50, { w: 75, h: 75, fill: '#000', stroke: '#fff' }, 'shape2'); - const shape3 = ShapeRecord.createLine( - 'page1', - 100, - 100, - { a: { x: 0, y: 0 }, b: { x: 100, y: 0 }, stroke: '#000', width: 2 }, - 'shape3', + const shape2 = ShapeRecord.createEllipse( + "page1", + 50, + 50, + { w: 75, h: 75, fill: "#000", stroke: "#fff" }, + "shape2", ); - - page.shapeIds = ['shape1', 'shape2', 'shape3']; + const shape3 = ShapeRecord.createLine("page1", 100, 100, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 0 }, + stroke: "#000", + width: 2, + }, "shape3"); + + page.shapeIds = ["shape1", "shape2", "shape3"]; state.doc.pages = { page1: page }; state.doc.shapes = { shape1, shape2, shape3 }; - state.ui.currentPageId = 'page1'; - state.ui.selectionIds = ['shape1', 'shape3']; + state.ui.currentPageId = "page1"; + state.ui.selectionIds = ["shape1", "shape3"]; const result = getSelectedShapes(state); @@ -759,18 +660,18 @@ describe('Selectors', () => { expect(result[1]).toBe(shape3); }); - it('should filter out undefined shapes', () => { + it("should filter out undefined shapes", () => { const state = EditorStateOps.create(); const shape1 = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); state.doc.shapes = { shape1 }; - state.ui.selectionIds = ['shape1', 'nonexistent']; + state.ui.selectionIds = ["shape1", "nonexistent"]; const result = getSelectedShapes(state); @@ -779,42 +680,42 @@ describe('Selectors', () => { }); }); - describe('isShapeSelected', () => { - it('should return false when shape is not selected', () => { + describe("isShapeSelected", () => { + it("should return false when shape is not selected", () => { const state = EditorStateOps.create(); - state.ui.selectionIds = ['shape1', 'shape2']; + state.ui.selectionIds = ["shape1", "shape2"]; - expect(isShapeSelected(state, 'shape3')).toBe(false); + expect(isShapeSelected(state, "shape3")).toBe(false); }); - it('should return true when shape is selected', () => { + it("should return true when shape is selected", () => { const state = EditorStateOps.create(); - state.ui.selectionIds = ['shape1', 'shape2']; + state.ui.selectionIds = ["shape1", "shape2"]; - expect(isShapeSelected(state, 'shape1')).toBe(true); - expect(isShapeSelected(state, 'shape2')).toBe(true); + expect(isShapeSelected(state, "shape1")).toBe(true); + expect(isShapeSelected(state, "shape2")).toBe(true); }); - it('should return false for empty selection', () => { + it("should return false for empty selection", () => { const state = EditorStateOps.create(); state.ui.selectionIds = []; - expect(isShapeSelected(state, 'shape1')).toBe(false); + expect(isShapeSelected(state, "shape1")).toBe(false); }); }); - describe('getAllPages', () => { - it('should return empty array when no pages', () => { + describe("getAllPages", () => { + it("should return empty array when no pages", () => { const state = EditorStateOps.create(); const result = getAllPages(state); expect(result).toEqual([]); }); - it('should return all pages', () => { + it("should return all pages", () => { const state = EditorStateOps.create(); - const page1 = PageRecord.create('Page 1', 'page1'); - const page2 = PageRecord.create('Page 2', 'page2'); + const page1 = PageRecord.create("Page 1", "page1"); + const page2 = PageRecord.create("Page 2", "page2"); state.doc.pages = { page1, page2 }; @@ -826,105 +727,79 @@ describe('Selectors', () => { }); }); - describe('getShape', () => { - it('should return undefined for non-existent shape', () => { + describe("getShape", () => { + it("should return undefined for non-existent shape", () => { const state = EditorStateOps.create(); - const result = getShape(state, 'nonexistent'); + const result = getShape(state, "nonexistent"); expect(result).toBeUndefined(); }); - it('should return shape by ID', () => { + it("should return shape by ID", () => { const state = EditorStateOps.create(); const shape = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); state.doc.shapes = { shape1: shape }; - const result = getShape(state, 'shape1'); + const result = getShape(state, "shape1"); expect(result).toBe(shape); }); }); }); -describe('Integration scenarios', () => { - it('should handle complete workflow: create page, add shapes, select shapes', () => { +describe("Integration scenarios", () => { + it("should handle complete workflow: create page, add shapes, select shapes", () => { const store = new Store(); - // Create page - const page = PageRecord.create('Page 1', 'page1'); + const page = PageRecord.create("Page 1", "page1"); store.setState((state) => ({ ...state, - doc: { - ...state.doc, - pages: { page1: page }, - }, - ui: { - ...state.ui, - currentPageId: 'page1', - }, + doc: { ...state.doc, pages: { page1: page } }, + ui: { ...state.ui, currentPageId: "page1" }, })); let state = store.getState(); - expect(getCurrentPage(state)?.name).toBe('Page 1'); + expect(getCurrentPage(state)?.name).toBe("Page 1"); - // Add shapes const shape1 = ShapeRecord.createRect( - 'page1', + "page1", 0, 0, - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, - 'shape1', + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", ); - const shape2 = ShapeRecord.createEllipse('page1', 50, 50, { w: 75, h: 75, fill: '#000', stroke: '#fff' }, 'shape2'); + const shape2 = ShapeRecord.createEllipse("page1", 50, 50, { w: 75, h: 75, fill: "#000", stroke: "#fff" }, "shape2"); store.setState((state) => { - const updatedPage = { ...state.doc.pages.page1, shapeIds: ['shape1', 'shape2'] }; - - return { - ...state, - doc: { - ...state.doc, - pages: { page1: updatedPage }, - shapes: { shape1, shape2 }, - }, - }; + const updatedPage = { ...state.doc.pages.page1, shapeIds: ["shape1", "shape2"] }; + return { ...state, doc: { ...state.doc, pages: { page1: updatedPage }, shapes: { shape1, shape2 } } }; }); state = store.getState(); expect(getShapesOnCurrentPage(state)).toHaveLength(2); - // Select shapes - store.setState((state) => ({ - ...state, - ui: { - ...state.ui, - selectionIds: ['shape1', 'shape2'], - }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, selectionIds: ["shape1", "shape2"] } })); state = store.getState(); expect(getSelectedShapes(state)).toHaveLength(2); - expect(isShapeSelected(state, 'shape1')).toBe(true); + expect(isShapeSelected(state, "shape1")).toBe(true); }); - it('should handle camera operations while maintaining state', () => { + it("should handle camera operations while maintaining state", () => { const store = new Store(); const listener = vi.fn(); store.subscribe(listener); listener.mockClear(); - store.setState((state) => ({ - ...state, - camera: Camera.pan(state.camera, { x: 100, y: 50 }), - })); + store.setState((state) => ({ ...state, camera: Camera.pan(state.camera, { x: 100, y: 50 }) })); expect(listener).toHaveBeenCalledTimes(1); @@ -933,23 +808,14 @@ describe('Integration scenarios', () => { expect(state.camera.y).toBe(-50); }); - it('should handle tool switching', () => { + it("should handle tool switching", () => { const store = new Store(); + expect(store.getState().ui.toolId).toBe("select"); - expect(store.getState().ui.toolId).toBe('select'); - - store.setState((state) => ({ - ...state, - ui: { ...state.ui, toolId: 'rect' }, - })); - - expect(store.getState().ui.toolId).toBe('rect'); - - store.setState((state) => ({ - ...state, - ui: { ...state.ui, toolId: 'ellipse' }, - })); + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "rect" } })); + expect(store.getState().ui.toolId).toBe("rect"); - expect(store.getState().ui.toolId).toBe('ellipse'); + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "ellipse" } })); + expect(store.getState().ui.toolId).toBe("ellipse"); }); }); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 7970df3..7bc2f46 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -1,6 +1,3 @@ -import { defineConfig } from 'tsdown' +import { defineConfig } from "tsdown"; -export default defineConfig({ - exports: true, - // ...config options -}) +export default defineConfig({ exports: true }); diff --git a/packages/renderer/package.json b/packages/renderer/package.json index 6612461..d4f663d 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -1,28 +1,18 @@ { - "name": "tsdown-starter", + "name": "inkfinite-renderer", "type": "module", "version": "0.0.0", "description": "A starter for creating a TypeScript package.", "author": "Author Name ", "license": "MIT", "homepage": "https://github.com/author/library#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/author/library.git" - }, - "bugs": { - "url": "https://github.com/author/library/issues" - }, - "exports": { - ".": "./dist/index.mjs", - "./package.json": "./package.json" - }, + "repository": { "type": "git", "url": "git+https://github.com/author/library.git" }, + "bugs": { "url": "https://github.com/author/library/issues" }, + "exports": { ".": "./dist/index.mjs", "./package.json": "./package.json" }, "main": "./dist/index.mjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "build": "tsdown", "dev": "tsdown --watch", @@ -31,10 +21,13 @@ "prepublishOnly": "pnpm run build" }, "devDependencies": { + "@types/jsdom": "^27.0.0", "@types/node": "^25.0.3", "bumpp": "^10.3.2", + "jsdom": "^27.3.0", "tsdown": "^0.18.1", "typescript": "^5.9.3", "vitest": "^4.0.16" - } + }, + "dependencies": { "inkfinite-core": "workspace:*" } } diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index ff0fb70..a748246 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -1,3 +1,403 @@ -export function fn() { - return 'Hello, tsdown!' +import type { + ArrowShape, + Camera, + EditorState, + EllipseShape, + LineShape, + RectShape, + ShapeRecord, + Store, + TextShape, + Viewport, +} from "inkfinite-core"; +import { getShapesOnCurrentPage } from "inkfinite-core"; + +export interface Renderer { + /** + * Clean up the renderer and stop rendering + */ + dispose(): void; + + /** + * Force a redraw on the next frame + */ + markDirty(): void; +} + +/** + * Create a canvas renderer + * + * The renderer subscribes to the store and redraws the canvas + * whenever the state changes. It uses requestAnimationFrame with + * a dirty flag to optimize rendering. + * + * @param canvas - The HTMLCanvasElement to render to + * @param store - The editor state store + * @returns Renderer instance with dispose method + */ +export function createRenderer(canvas: HTMLCanvasElement, store: Store): Renderer { + const maybeContext = canvas.getContext("2d"); + if (!maybeContext) { + throw new Error("Failed to get 2D context from canvas"); + } + const context: CanvasRenderingContext2D = maybeContext; + + let isDirty = true; + let animationFrameId: number | null = null; + let isDisposed = false; + + /** + * Mark the canvas as needing a redraw + */ + function markDirty() { + if (isDisposed) return; + isDirty = true; + if (animationFrameId === null) { + scheduleRender(); + } + } + + /** + * Schedule a render on the next animation frame + */ + function scheduleRender() { + animationFrameId = requestAnimationFrame(() => { + animationFrameId = null; + if (isDirty && !isDisposed) { + render(); + isDirty = false; + scheduleRender(); + } + }); + } + + /** + * Render the current state to the canvas + */ + function render() { + const state = store.getState(); + + setupCanvas(canvas, context); + + const viewport: Viewport = { width: canvas.width / getPixelRatio(), height: canvas.height / getPixelRatio() }; + + drawScene(context, state, viewport); + } + + /** + * Subscribe to store updates and mark dirty + */ + const unsubscribe = store.subscribe(() => { + markDirty(); + }); + + /** + * Dispose the renderer + */ + function dispose() { + isDisposed = true; + unsubscribe(); + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + } + + markDirty(); + + return { dispose, markDirty }; +} + +/** + * Setup canvas with proper pixel ratio for sharp rendering + */ +function setupCanvas(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D) { + const pixelRatio = getPixelRatio(); + const rect = canvas.getBoundingClientRect(); + + canvas.width = rect.width * pixelRatio; + canvas.height = rect.height * pixelRatio; + + context.scale(pixelRatio, pixelRatio); +} + +/** + * Get device pixel ratio for sharp rendering on high-DPI displays + */ +function getPixelRatio(): number { + return globalThis.window !== undefined && window.devicePixelRatio ? window.devicePixelRatio : 1; +} + +/** + * Draw the entire scene + */ +function drawScene(context: CanvasRenderingContext2D, state: EditorState, viewport: Viewport) { + context.clearRect(0, 0, viewport.width, viewport.height); + + context.save(); + + applyCameraTransform(context, state.camera, viewport); + + const shapes = getShapesOnCurrentPage(state); + for (const shape of shapes) { + drawShape(context, shape); + } + + drawSelection(context, state, shapes); + + context.restore(); +} + +/** + * Apply camera transform to the canvas context + * + * This transforms the coordinate system so that drawing in world + * coordinates appears correctly on screen. + */ +function applyCameraTransform(context: CanvasRenderingContext2D, camera: Camera, viewport: Viewport) { + context.translate(viewport.width / 2, viewport.height / 2); + + context.scale(camera.zoom, camera.zoom); + + context.translate(-camera.x, -camera.y); +} + +/** + * Draw a single shape + */ +function drawShape(context: CanvasRenderingContext2D, shape: ShapeRecord) { + context.save(); + + context.translate(shape.x, shape.y); + if (shape.rot !== 0) { + context.rotate(shape.rot); + } + + switch (shape.type) { + case "rect": { + drawRect(context, shape); + break; + } + case "ellipse": { + drawEllipse(context, shape); + break; + } + case "line": { + drawLine(context, shape); + break; + } + case "arrow": { + drawArrow(context, shape); + break; + } + case "text": { + drawText(context, shape); + break; + } + } + + context.restore(); +} + +/** + * Draw a rectangle shape + */ +function drawRect(context: CanvasRenderingContext2D, shape: RectShape) { + const { w, h, fill, stroke, radius } = shape.props; + + context.beginPath(); + if (radius > 0) { + const r = Math.min(radius, w / 2, h / 2); + context.moveTo(r, 0); + context.lineTo(w - r, 0); + context.arcTo(w, 0, w, r, r); + context.lineTo(w, h - r); + context.arcTo(w, h, w - r, h, r); + context.lineTo(r, h); + context.arcTo(0, h, 0, h - r, r); + context.lineTo(0, r); + context.arcTo(0, 0, r, 0, r); + context.closePath(); + } else { + context.rect(0, 0, w, h); + } + + if (fill) { + context.fillStyle = fill; + context.fill(); + } + + if (stroke) { + context.strokeStyle = stroke; + context.lineWidth = 2; + context.stroke(); + } +} + +/** + * Draw an ellipse shape + */ +function drawEllipse(context: CanvasRenderingContext2D, shape: EllipseShape) { + const { w, h, fill, stroke } = shape.props; + + context.beginPath(); + context.ellipse(w / 2, h / 2, w / 2, h / 2, 0, 0, Math.PI * 2); + + if (fill) { + context.fillStyle = fill; + context.fill(); + } + + if (stroke) { + context.strokeStyle = stroke; + context.lineWidth = 2; + context.stroke(); + } +} + +/** + * Draw a line shape + */ +function drawLine(context: CanvasRenderingContext2D, shape: LineShape) { + const { a, b, stroke, width } = shape.props; + + context.beginPath(); + context.moveTo(a.x, a.y); + context.lineTo(b.x, b.y); + + context.strokeStyle = stroke; + context.lineWidth = width; + context.stroke(); +} + +/** + * Draw an arrow shape + */ +function drawArrow(context: CanvasRenderingContext2D, shape: ArrowShape) { + const { a, b, stroke, width } = shape.props; + + context.beginPath(); + context.moveTo(a.x, a.y); + context.lineTo(b.x, b.y); + + context.strokeStyle = stroke; + context.lineWidth = width; + context.stroke(); + + const angle = Math.atan2(b.y - a.y, b.x - a.x); + const arrowLength = 15; + const arrowAngle = Math.PI / 6; + + context.beginPath(); + context.moveTo(b.x, b.y); + context.lineTo(b.x - arrowLength * Math.cos(angle - arrowAngle), b.y - arrowLength * Math.sin(angle - arrowAngle)); + context.moveTo(b.x, b.y); + context.lineTo(b.x - arrowLength * Math.cos(angle + arrowAngle), b.y - arrowLength * Math.sin(angle + arrowAngle)); + + context.strokeStyle = stroke; + context.lineWidth = width; + context.stroke(); +} + +/** + * Draw a text shape + */ +function drawText(context: CanvasRenderingContext2D, shape: TextShape) { + const { text, fontSize, fontFamily, color, w } = shape.props; + + context.font = `${fontSize}px ${fontFamily}`; + context.fillStyle = color; + context.textBaseline = "top"; + + if (w === undefined) { + context.fillText(text, 0, 0); + } else { + const lines = wrapText(context, text, w); + for (const [index, line] of lines.entries()) { + context.fillText(line, 0, index * fontSize * 1.2); + } + } +} + +/** + * Wrap text to fit within a given width + */ +function wrapText(context: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { + const words = text.split(" "); + const lines: string[] = []; + let currentLine = ""; + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + const metrics = context.measureText(testLine); + + if (metrics.width > maxWidth && currentLine) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + } + + if (currentLine) { + lines.push(currentLine); + } + + return lines; +} + +/** + * Draw selection outlines for selected shapes + */ +function drawSelection(context: CanvasRenderingContext2D, state: EditorState, shapes: ShapeRecord[]) { + const selectedIds = new Set(state.ui.selectionIds); + + for (const shape of shapes) { + if (!selectedIds.has(shape.id)) continue; + + context.save(); + context.translate(shape.x, shape.y); + if (shape.rot !== 0) { + context.rotate(shape.rot); + } + + context.strokeStyle = "#0066ff"; + context.lineWidth = 2 / state.camera.zoom; + context.setLineDash([4 / state.camera.zoom, 4 / state.camera.zoom]); + + switch (shape.type) { + case "rect": { + const { w, h } = shape.props; + context.strokeRect(0, 0, w, h); + break; + } + case "ellipse": { + const { w, h } = shape.props; + context.strokeRect(0, 0, w, h); + break; + } + case "line": + case "arrow": { + const { a, b } = shape.props; + const minX = Math.min(a.x, b.x); + const minY = Math.min(a.y, b.y); + const maxX = Math.max(a.x, b.x); + const maxY = Math.max(a.y, b.y); + const padding = 5; + context.strokeRect(minX - padding, minY - padding, maxX - minX + padding * 2, maxY - minY + padding * 2); + break; + } + case "text": { + const { fontSize, fontFamily, text, w } = shape.props; + context.font = `${fontSize}px ${fontFamily}`; + const metrics = context.measureText(text); + const width = w ?? metrics.width; + const height = fontSize * 1.2; + context.strokeRect(0, 0, width, height); + break; + } + } + + context.restore(); + } } diff --git a/packages/renderer/tests/index.test.ts b/packages/renderer/tests/index.test.ts index c7d6b2e..8c0120d 100644 --- a/packages/renderer/tests/index.test.ts +++ b/packages/renderer/tests/index.test.ts @@ -1,6 +1,470 @@ -import { expect, test } from "vitest"; -import { fn } from "../src"; +import { PageRecord, ShapeRecord, Store } from "inkfinite-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRenderer } from "../src"; -test("fn", () => { - expect(fn()).toBe("Hello, tsdown!"); +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(), + 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(), + 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(() => []), + 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("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 = PageRecord.create("Page 1", "page:1"); + const rect = ShapeRecord.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 = PageRecord.create("Page 1", "page:1"); + const ellipse = ShapeRecord.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("should render scene with line shape", async () => { + const store = new Store(); + + const page = PageRecord.create("Page 1", "page:1"); + const line = ShapeRecord.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 = PageRecord.create("Page 1", "page:1"); + const arrow = ShapeRecord.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 = PageRecord.create("Page 1", "page:1"); + const text = ShapeRecord.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 = PageRecord.create("Page 1", "page:1"); + const text = ShapeRecord.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 = PageRecord.create("Page 1", "page:1"); + const rect = ShapeRecord.createRect("page:1", 100, 100, { + w: 200, + h: 100, + fill: "#ff0000", + stroke: "#000000", + radius: 0, + }, "shape:1"); + const ellipse = ShapeRecord.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("should render selection outline for selected shapes", async () => { + const store = new Store(); + + const page = PageRecord.create("Page 1", "page:1"); + const rect = ShapeRecord.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)); + + renderer.dispose(); + }); + + it("should update render when store changes", async () => { + const store = new Store(); + + const page = PageRecord.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 = ShapeRecord.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 = PageRecord.create("Page 1", "page:1"); + const rect = ShapeRecord.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 = PageRecord.create("Page 1", "page:1"); + const rect = ShapeRecord.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 = PageRecord.create("Page 1", "page:1"); + const rect = ShapeRecord.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); + }); + }); }); diff --git a/packages/renderer/tsconfig.json b/packages/renderer/tsconfig.json index 77b838a..36a7a2f 100644 --- a/packages/renderer/tsconfig.json +++ b/packages/renderer/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "esnext", - "lib": ["es2023"], + "lib": ["es2023", "dom"], "moduleDetection": "force", "module": "preserve", "moduleResolution": "bundler", diff --git a/packages/renderer/tsdown.config.ts b/packages/renderer/tsdown.config.ts index 7970df3..7bc2f46 100644 --- a/packages/renderer/tsdown.config.ts +++ b/packages/renderer/tsdown.config.ts @@ -1,6 +1,3 @@ -import { defineConfig } from 'tsdown' +import { defineConfig } from "tsdown"; -export default defineConfig({ - exports: true, - // ...config options -}) +export default defineConfig({ exports: true }); diff --git a/packages/renderer/vitest.config.ts b/packages/renderer/vitest.config.ts new file mode 100644 index 0000000..b1a03e1 --- /dev/null +++ b/packages/renderer/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ test: { environment: "jsdom", globals: true } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e9c9ba..8ebec73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,16 +53,26 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.16 - version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2) + version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.3.0)(yaml@2.8.2) packages/renderer: + dependencies: + inkfinite-core: + specifier: workspace:* + version: link:../core devDependencies: + '@types/jsdom': + specifier: ^27.0.0 + version: 27.0.0 '@types/node': specifier: ^25.0.3 version: 25.0.3 bumpp: specifier: ^10.3.2 version: 10.3.2 + jsdom: + specifier: ^27.3.0 + version: 27.3.0 tsdown: specifier: ^0.18.1 version: 0.18.1(typescript@5.9.3) @@ -71,10 +81,22 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.16 - version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2) + version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.3.0)(yaml@2.8.2) packages: + '@acemir/cssom@0.9.29': + resolution: {integrity: sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==} + + '@asamuzakjp/css-color@4.1.1': + resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} + + '@asamuzakjp/dom-selector@6.7.6': + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} @@ -96,6 +118,38 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.22': + resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@dprint/darwin-arm64@0.50.2': resolution: {integrity: sha512-4d08INZlTxbPW9LK9W8+93viN543/qA2Kxn4azVnPW/xCb2Im03UqJBz8mMm3nJZdtNnK3uTVG3ib1VW+XJisw==} cpu: [arm64] @@ -587,12 +641,18 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/jsdom@27.0.0': + resolution: {integrity: sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@typescript-eslint/eslint-plugin@8.50.0': resolution: {integrity: sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -691,6 +751,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -723,6 +787,9 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} @@ -815,6 +882,18 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssstyle@5.3.5: + resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==} + engines: {node: '>=20'} + + data-urls@6.0.0: + resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} + engines: {node: '>=20'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -824,6 +903,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -857,6 +939,10 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1003,6 +1089,22 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1039,6 +1141,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1050,6 +1155,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@27.3.0: + resolution: {integrity: sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1081,9 +1195,16 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1138,6 +1259,12 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1196,6 +1323,10 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1235,6 +1366,13 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} @@ -1273,6 +1411,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1288,6 +1429,21 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1445,6 +1601,26 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.0: + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} + engines: {node: '>=20'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1459,6 +1635,25 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -1470,6 +1665,26 @@ packages: snapshots: + '@acemir/cssom@0.9.29': {} + + '@asamuzakjp/css-color@4.1.1': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.4 + + '@asamuzakjp/dom-selector@6.7.6': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.28.5 @@ -1491,6 +1706,28 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.22': {} + + '@csstools/css-tokenizer@3.0.4': {} + '@dprint/darwin-arm64@0.50.2': optional: true @@ -1821,12 +2058,20 @@ snapshots: '@types/estree@1.0.8': {} + '@types/jsdom@27.0.0': + dependencies: + '@types/node': 25.0.3 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + '@types/json-schema@7.0.15': {} '@types/node@25.0.3': dependencies: undici-types: 7.16.0 + '@types/tough-cookie@4.0.5': {} + '@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1963,6 +2208,8 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -1991,6 +2238,10 @@ snapshots: baseline-browser-mapping@2.9.11: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + birpc@4.0.0: {} brace-expansion@1.1.12: @@ -2094,10 +2345,28 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssstyle@5.3.5: + dependencies: + '@asamuzakjp/css-color': 4.1.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.22 + css-tree: 3.1.0 + + data-urls@6.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} defu@6.1.4: {} @@ -2124,6 +2393,8 @@ snapshots: empathic@2.0.0: {} + entities@6.0.1: {} + es-module-lexer@1.7.0: {} esbuild@0.27.2: @@ -2315,6 +2586,28 @@ snapshots: hookable@5.5.3: {} + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2340,6 +2633,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} jiti@2.6.1: {} @@ -2348,6 +2643,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@27.3.0: + dependencies: + '@acemir/cssom': 0.9.29 + '@asamuzakjp/dom-selector': 6.7.6 + cssstyle: 5.3.5 + data-urls: 6.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -2373,10 +2695,14 @@ snapshots: lodash.merge@4.6.2: {} + lru-cache@11.2.4: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mdn-data@2.12.2: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -2430,6 +2756,14 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -2475,6 +2809,8 @@ snapshots: dependencies: jsesc: 3.1.0 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -2546,6 +2882,12 @@ snapshots: dependencies: tslib: 2.8.1 + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@7.7.3: {} shebang-command@2.0.0: @@ -2570,6 +2912,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -2581,6 +2925,20 @@ snapshots: tinyrainbow@3.0.3: {} + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} ts-api-utils@2.1.0(typescript@5.9.3): @@ -2670,7 +3028,7 @@ snapshots: jiti: 2.6.1 yaml: 2.8.2 - vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2): + vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.3.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.16 '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)) @@ -2694,6 +3052,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.0.3 + jsdom: 27.3.0 transitivePeerDependencies: - jiti - less @@ -2707,6 +3066,23 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.0 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2718,6 +3094,12 @@ snapshots: word-wrap@1.2.5: {} + ws@8.18.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yaml@2.8.2: {} yocto-queue@0.1.0: {} -- 2.51.2