web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement browser canvas renderer

+1545 -433
+5 -5
TODO.txt
··· 164 164 Goal: draw the document from state, no interactivity yet. 165 165 166 166 Renderer (/packages/renderer): 167 - [ ] createRenderer(canvas, store) -> { dispose() } 168 - [ ] Implement render loop strategy: 167 + [x] createRenderer(canvas, store) -> { dispose() } 168 + [x] Implement render loop strategy: 169 169 - requestAnimationFrame redraw on "dirty" flag 170 170 - mark dirty on store updates 171 171 172 - [ ] Implement draw pipeline: 172 + [x] Implement draw pipeline: 173 173 - clear canvas 174 174 - apply camera transform 175 175 - draw shapes (rect/ellipse/line/arrow/text) 176 176 - draw selection outline if selectionIds non-empty 177 177 178 - [ ] Implement text measurement fallback: 178 + [x] Implement text measurement fallback: 179 179 - if text shape has w? else measureText and derive bounds 180 180 181 - [ ] Implement pixel ratio handling: 181 + [x] Implement pixel ratio handling: 182 182 - set canvas width/height by devicePixelRatio 183 183 - scale context accordingly 184 184
+9
eslint.config.js
··· 11 11 eslintPluginUnicorn.configs.recommended, 12 12 [{ 13 13 rules: { 14 + "@typescript-eslint/no-unused-vars": ["error", { 15 + "args": "all", 16 + "argsIgnorePattern": "^_", 17 + "caughtErrors": "all", 18 + "caughtErrorsIgnorePattern": "^_", 19 + "destructuredArrayIgnorePattern": "^_", 20 + "varsIgnorePattern": "^_", 21 + "ignoreRestSiblings": true, 22 + }], 14 23 "unicorn/no-null": "off", 15 24 "unicorn/prevent-abbreviations": ["error", { "replacements": { "i": false, "props": false, "doc": false } }], 16 25 },
+4 -3
packages/core/src/index.ts
··· 1 - export function fn() { 2 - return 'Hello, tsdown!' 3 - } 1 + export * from "./camera"; 2 + export * from "./math"; 3 + export * from "./model"; 4 + export * from "./reactivity";
+255 -389
packages/core/tests/reactivity.test.ts
··· 1 - import { describe, expect, it, vi } from 'vitest'; 2 - import { Camera } from '../src/camera'; 3 - import { PageRecord, ShapeRecord } from '../src/model'; 1 + import { describe, expect, it, vi } from "vitest"; 2 + import { Camera } from "../src/camera"; 3 + import { PageRecord, ShapeRecord } from "../src/model"; 4 4 import { 5 5 EditorState as EditorStateOps, 6 6 getAllPages, ··· 10 10 getShapesOnCurrentPage, 11 11 isShapeSelected, 12 12 Store, 13 - } from '../src/reactivity'; 13 + } from "../src/reactivity"; 14 14 15 - describe('EditorState', () => { 16 - describe('create', () => { 17 - it('should create initial editor state', () => { 15 + describe("EditorState", () => { 16 + describe("create", () => { 17 + it("should create initial editor state", () => { 18 18 const state = EditorStateOps.create(); 19 19 20 20 expect(state.doc.pages).toEqual({}); ··· 22 22 expect(state.doc.bindings).toEqual({}); 23 23 expect(state.ui.currentPageId).toBeNull(); 24 24 expect(state.ui.selectionIds).toEqual([]); 25 - expect(state.ui.toolId).toBe('select'); 25 + expect(state.ui.toolId).toBe("select"); 26 26 expect(state.camera).toEqual({ x: 0, y: 0, zoom: 1 }); 27 27 }); 28 28 }); 29 29 30 - describe('clone', () => { 31 - it('should deep clone editor state', () => { 30 + describe("clone", () => { 31 + it("should deep clone editor state", () => { 32 32 const state = EditorStateOps.create(); 33 - const page = PageRecord.create('Page 1', 'page1'); 33 + const page = PageRecord.create("Page 1", "page1"); 34 34 const shape = ShapeRecord.createRect( 35 - 'page1', 35 + "page1", 36 36 0, 37 37 0, 38 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 39 - 'shape1', 38 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 39 + "shape1", 40 40 ); 41 41 42 - page.shapeIds = ['shape1']; 42 + page.shapeIds = ["shape1"]; 43 43 state.doc.pages = { page1: page }; 44 44 state.doc.shapes = { shape1: shape }; 45 - state.ui.currentPageId = 'page1'; 46 - state.ui.selectionIds = ['shape1']; 45 + state.ui.currentPageId = "page1"; 46 + state.ui.selectionIds = ["shape1"]; 47 47 state.camera = { x: 100, y: 200, zoom: 1.5 }; 48 48 49 49 const cloned = EditorStateOps.clone(state); ··· 58 58 }); 59 59 }); 60 60 61 - describe('Store', () => { 62 - describe('constructor', () => { 63 - it('should create store with default initial state', () => { 61 + describe("Store", () => { 62 + describe("constructor", () => { 63 + it("should create store with default initial state", () => { 64 64 const store = new Store(); 65 65 const state = store.getState(); 66 66 67 67 expect(state.doc.pages).toEqual({}); 68 68 expect(state.ui.currentPageId).toBeNull(); 69 69 expect(state.ui.selectionIds).toEqual([]); 70 - expect(state.ui.toolId).toBe('select'); 70 + expect(state.ui.toolId).toBe("select"); 71 71 }); 72 72 73 - it('should create store with custom initial state', () => { 73 + it("should create store with custom initial state", () => { 74 74 const initialState = EditorStateOps.create(); 75 - initialState.ui.toolId = 'rect'; 75 + initialState.ui.toolId = "rect"; 76 76 initialState.camera = { x: 100, y: 200, zoom: 2 }; 77 77 78 78 const store = new Store(initialState); 79 79 const state = store.getState(); 80 80 81 - expect(state.ui.toolId).toBe('rect'); 81 + expect(state.ui.toolId).toBe("rect"); 82 82 expect(state.camera.x).toBe(100); 83 83 expect(state.camera.y).toBe(200); 84 84 expect(state.camera.zoom).toBe(2); 85 85 }); 86 86 }); 87 87 88 - describe('getState', () => { 89 - it('should return current state', () => { 88 + describe("getState", () => { 89 + it("should return current state", () => { 90 90 const store = new Store(); 91 91 const state = store.getState(); 92 92 ··· 96 96 expect(state.camera).toBeDefined(); 97 97 }); 98 98 99 - it('should return same state on multiple calls if no updates', () => { 99 + it("should return same state on multiple calls if no updates", () => { 100 100 const store = new Store(); 101 101 const state1 = store.getState(); 102 102 const state2 = store.getState(); ··· 105 105 }); 106 106 }); 107 107 108 - describe('setState', () => { 109 - it('should update state using updater function', () => { 108 + describe("setState", () => { 109 + it("should update state using updater function", () => { 110 110 const store = new Store(); 111 111 112 - store.setState((state) => ({ 113 - ...state, 114 - ui: { ...state.ui, toolId: 'ellipse' }, 115 - })); 112 + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "ellipse" } })); 116 113 117 114 const state = store.getState(); 118 - expect(state.ui.toolId).toBe('ellipse'); 115 + expect(state.ui.toolId).toBe("ellipse"); 119 116 }); 120 117 121 - it('should update camera position', () => { 118 + it("should update camera position", () => { 122 119 const store = new Store(); 123 120 124 - store.setState((state) => ({ 125 - ...state, 126 - camera: Camera.pan(state.camera, { x: 50, y: 30 }), 127 - })); 121 + store.setState((state) => ({ ...state, camera: Camera.pan(state.camera, { x: 50, y: 30 }) })); 128 122 129 123 const state = store.getState(); 130 124 expect(state.camera.x).toBe(-50); 131 125 expect(state.camera.y).toBe(-30); 132 126 }); 133 127 134 - it('should update document', () => { 128 + it("should update document", () => { 135 129 const store = new Store(); 136 - const page = PageRecord.create('Page 1', 'page1'); 130 + const page = PageRecord.create("Page 1", "page1"); 137 131 138 - store.setState((state) => ({ 139 - ...state, 140 - doc: { 141 - ...state.doc, 142 - pages: { page1: page }, 143 - }, 144 - })); 132 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page1: page } } })); 145 133 146 134 const state = store.getState(); 147 135 expect(state.doc.pages.page1).toBeDefined(); 148 - expect(state.doc.pages.page1.name).toBe('Page 1'); 136 + expect(state.doc.pages.page1.name).toBe("Page 1"); 149 137 }); 150 138 }); 151 139 152 - describe('subscribe', () => { 153 - it('should call listener immediately with current state', () => { 140 + describe("subscribe", () => { 141 + it("should call listener immediately with current state", () => { 154 142 const store = new Store(); 155 143 const listener = vi.fn(); 156 144 ··· 160 148 expect(listener).toHaveBeenCalledWith(store.getState()); 161 149 }); 162 150 163 - it('should call listener on state change', () => { 151 + it("should call listener on state change", () => { 164 152 const store = new Store(); 165 153 const listener = vi.fn(); 166 154 167 155 store.subscribe(listener); 168 156 listener.mockClear(); 169 157 170 - store.setState((state) => ({ 171 - ...state, 172 - ui: { ...state.ui, toolId: 'rect' }, 173 - })); 158 + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "rect" } })); 174 159 175 160 expect(listener).toHaveBeenCalledTimes(1); 176 161 }); 177 162 178 - it('should call listener exactly once per setState', () => { 163 + it("should call listener exactly once per setState", () => { 179 164 const store = new Store(); 180 165 const listener = vi.fn(); 181 166 182 167 store.subscribe(listener); 183 168 listener.mockClear(); 184 169 185 - store.setState((state) => ({ 186 - ...state, 187 - ui: { ...state.ui, toolId: 'rect' }, 188 - })); 170 + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "rect" } })); 189 171 190 172 expect(listener).toHaveBeenCalledTimes(1); 191 173 192 174 listener.mockClear(); 193 - store.setState((state) => ({ 194 - ...state, 195 - ui: { ...state.ui, toolId: 'ellipse' }, 196 - })); 175 + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "ellipse" } })); 197 176 198 177 expect(listener).toHaveBeenCalledTimes(1); 199 178 }); 200 179 201 - it('should support multiple subscribers', () => { 180 + it("should support multiple subscribers", () => { 202 181 const store = new Store(); 203 182 const listener1 = vi.fn(); 204 183 const listener2 = vi.fn(); ··· 209 188 listener1.mockClear(); 210 189 listener2.mockClear(); 211 190 212 - store.setState((state) => ({ 213 - ...state, 214 - ui: { ...state.ui, toolId: 'arrow' }, 215 - })); 191 + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "arrow" } })); 216 192 217 193 expect(listener1).toHaveBeenCalledTimes(1); 218 194 expect(listener2).toHaveBeenCalledTimes(1); 219 195 }); 220 196 221 - it('should unsubscribe when unsubscribe function is called', () => { 197 + it("should unsubscribe when unsubscribe function is called", () => { 222 198 const store = new Store(); 223 199 const listener = vi.fn(); 224 200 ··· 227 203 228 204 unsubscribe(); 229 205 230 - store.setState((state) => ({ 231 - ...state, 232 - ui: { ...state.ui, toolId: 'line' }, 233 - })); 206 + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "line" } })); 234 207 235 208 expect(listener).not.toHaveBeenCalled(); 236 209 }); 237 210 238 - it('should handle multiple subscriptions and unsubscriptions independently', () => { 211 + it("should handle multiple subscriptions and unsubscriptions independently", () => { 239 212 const store = new Store(); 240 213 const listener1 = vi.fn(); 241 214 const listener2 = vi.fn(); ··· 248 221 249 222 unsubscribe1(); 250 223 251 - store.setState((state) => ({ 252 - ...state, 253 - ui: { ...state.ui, toolId: 'text' }, 254 - })); 224 + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "text" } })); 255 225 256 226 expect(listener1).not.toHaveBeenCalled(); 257 227 expect(listener2).toHaveBeenCalledTimes(1); ··· 259 229 listener2.mockClear(); 260 230 unsubscribe2(); 261 231 262 - store.setState((state) => ({ 263 - ...state, 264 - ui: { ...state.ui, toolId: 'pen' }, 265 - })); 232 + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "pen" } })); 266 233 267 234 expect(listener2).not.toHaveBeenCalled(); 268 235 }); 269 236 }); 270 237 271 - describe('getObservable', () => { 272 - it('should return RxJS observable', () => { 238 + describe("getObservable", () => { 239 + it("should return RxJS observable", () => { 273 240 const store = new Store(); 274 241 const observable = store.getObservable(); 275 242 276 243 expect(observable).toBeDefined(); 277 - expect(typeof observable.subscribe).toBe('function'); 244 + expect(typeof observable.subscribe).toBe("function"); 278 245 }); 279 246 280 - it('should emit values on state changes', () => { 247 + it("should emit values on state changes", () => { 281 248 const store = new Store(); 282 249 const observable = store.getObservable(); 283 250 ··· 286 253 states.push(state.ui.toolId); 287 254 }); 288 255 289 - store.setState((state) => ({ 290 - ...state, 291 - ui: { ...state.ui, toolId: 'rect' }, 292 - })); 256 + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "rect" } })); 293 257 294 - expect(states).toEqual(['select', 'rect']); 258 + expect(states).toEqual(["select", "rect"]); 295 259 }); 296 260 }); 297 261 }); 298 262 299 - describe('Invariants', () => { 300 - describe('currentPageId invariant', () => { 301 - it('should repair invalid currentPageId to null when page does not exist', () => { 263 + describe("Invariants", () => { 264 + describe("currentPageId invariant", () => { 265 + it("should repair invalid currentPageId to null when page does not exist", () => { 302 266 const store = new Store(); 303 267 304 - store.setState((state) => ({ 305 - ...state, 306 - ui: { ...state.ui, currentPageId: 'nonexistent' }, 307 - })); 268 + store.setState((state) => ({ ...state, ui: { ...state.ui, currentPageId: "nonexistent" } })); 308 269 309 270 const state = store.getState(); 310 271 expect(state.ui.currentPageId).toBeNull(); 311 272 }); 312 273 313 - it('should repair invalid currentPageId to first page when pages exist', () => { 274 + it("should repair invalid currentPageId to first page when pages exist", () => { 314 275 const store = new Store(); 315 - const page1 = PageRecord.create('Page 1', 'page1'); 316 - const page2 = PageRecord.create('Page 2', 'page2'); 276 + const page1 = PageRecord.create("Page 1", "page1"); 277 + const page2 = PageRecord.create("Page 2", "page2"); 317 278 318 279 store.setState((state) => ({ 319 280 ...state, 320 - doc: { 321 - ...state.doc, 322 - pages: { page1, page2 }, 323 - }, 324 - ui: { ...state.ui, currentPageId: 'nonexistent' }, 281 + doc: { ...state.doc, pages: { page1, page2 } }, 282 + ui: { ...state.ui, currentPageId: "nonexistent" }, 325 283 })); 326 284 327 285 const state = store.getState(); 328 - expect(state.ui.currentPageId).toBe('page1'); 286 + expect(state.ui.currentPageId).toBe("page1"); 329 287 }); 330 288 331 - it('should keep valid currentPageId', () => { 289 + it("should keep valid currentPageId", () => { 332 290 const store = new Store(); 333 - const page = PageRecord.create('Page 1', 'page1'); 291 + const page = PageRecord.create("Page 1", "page1"); 334 292 335 293 store.setState((state) => ({ 336 294 ...state, 337 - doc: { 338 - ...state.doc, 339 - pages: { page1: page }, 340 - }, 341 - ui: { ...state.ui, currentPageId: 'page1' }, 295 + doc: { ...state.doc, pages: { page1: page } }, 296 + ui: { ...state.ui, currentPageId: "page1" }, 342 297 })); 343 298 344 299 const state = store.getState(); 345 - expect(state.ui.currentPageId).toBe('page1'); 300 + expect(state.ui.currentPageId).toBe("page1"); 346 301 }); 347 302 }); 348 303 349 - describe('selectionIds invariant', () => { 350 - it('should clear selection when currentPageId is null', () => { 304 + describe("selectionIds invariant", () => { 305 + it("should clear selection when currentPageId is null", () => { 351 306 const store = new Store(); 352 307 353 308 store.setState((state) => ({ 354 309 ...state, 355 - ui: { ...state.ui, currentPageId: null, selectionIds: ['shape1', 'shape2'] }, 310 + ui: { ...state.ui, currentPageId: null, selectionIds: ["shape1", "shape2"] }, 356 311 })); 357 312 358 313 const state = store.getState(); 359 314 expect(state.ui.selectionIds).toEqual([]); 360 315 }); 361 316 362 - it('should remove non-existent shapes from selection', () => { 317 + it("should remove non-existent shapes from selection", () => { 363 318 const store = new Store(); 364 - const page = PageRecord.create('Page 1', 'page1'); 319 + const page = PageRecord.create("Page 1", "page1"); 365 320 const shape = ShapeRecord.createRect( 366 - 'page1', 321 + "page1", 367 322 0, 368 323 0, 369 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 370 - 'shape1', 324 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 325 + "shape1", 371 326 ); 372 327 373 - page.shapeIds = ['shape1']; 328 + page.shapeIds = ["shape1"]; 374 329 375 330 store.setState((state) => ({ 376 331 ...state, 377 - doc: { 378 - ...state.doc, 379 - pages: { page1: page }, 380 - shapes: { shape1: shape }, 381 - }, 382 - ui: { 383 - ...state.ui, 384 - currentPageId: 'page1', 385 - selectionIds: ['shape1', 'nonexistent', 'shape2'], 386 - }, 332 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, 333 + ui: { ...state.ui, currentPageId: "page1", selectionIds: ["shape1", "nonexistent", "shape2"] }, 387 334 })); 388 335 389 336 const state = store.getState(); 390 - expect(state.ui.selectionIds).toEqual(['shape1']); 337 + expect(state.ui.selectionIds).toEqual(["shape1"]); 391 338 }); 392 339 393 - it('should remove shapes not on current page from selection', () => { 340 + it("should remove shapes not on current page from selection", () => { 394 341 const store = new Store(); 395 - const page1 = PageRecord.create('Page 1', 'page1'); 396 - const page2 = PageRecord.create('Page 2', 'page2'); 342 + const page1 = PageRecord.create("Page 1", "page1"); 343 + const page2 = PageRecord.create("Page 2", "page2"); 397 344 398 345 const shape1 = ShapeRecord.createRect( 399 - 'page1', 346 + "page1", 400 347 0, 401 348 0, 402 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 403 - 'shape1', 349 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 350 + "shape1", 404 351 ); 405 352 const shape2 = ShapeRecord.createRect( 406 - 'page2', 353 + "page2", 407 354 0, 408 355 0, 409 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 410 - 'shape2', 356 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 357 + "shape2", 411 358 ); 412 359 413 - page1.shapeIds = ['shape1']; 414 - page2.shapeIds = ['shape2']; 360 + page1.shapeIds = ["shape1"]; 361 + page2.shapeIds = ["shape2"]; 415 362 416 363 store.setState((state) => ({ 417 364 ...state, 418 - doc: { 419 - ...state.doc, 420 - pages: { page1, page2 }, 421 - shapes: { shape1, shape2 }, 422 - }, 423 - ui: { 424 - ...state.ui, 425 - currentPageId: 'page1', 426 - selectionIds: ['shape1', 'shape2'], 427 - }, 365 + doc: { ...state.doc, pages: { page1, page2 }, shapes: { shape1, shape2 } }, 366 + ui: { ...state.ui, currentPageId: "page1", selectionIds: ["shape1", "shape2"] }, 428 367 })); 429 368 430 369 const state = store.getState(); 431 - expect(state.ui.selectionIds).toEqual(['shape1']); 370 + expect(state.ui.selectionIds).toEqual(["shape1"]); 432 371 }); 433 372 434 - it('should keep valid selection', () => { 373 + it("should keep valid selection", () => { 435 374 const store = new Store(); 436 - const page = PageRecord.create('Page 1', 'page1'); 375 + const page = PageRecord.create("Page 1", "page1"); 437 376 const shape1 = ShapeRecord.createRect( 438 - 'page1', 377 + "page1", 439 378 0, 440 379 0, 441 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 442 - 'shape1', 380 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 381 + "shape1", 443 382 ); 444 383 const shape2 = ShapeRecord.createRect( 445 - 'page1', 384 + "page1", 446 385 50, 447 386 50, 448 - { w: 75, h: 75, fill: '#000', stroke: '#fff', radius: 0 }, 449 - 'shape2', 387 + { w: 75, h: 75, fill: "#000", stroke: "#fff", radius: 0 }, 388 + "shape2", 450 389 ); 451 390 452 - page.shapeIds = ['shape1', 'shape2']; 391 + page.shapeIds = ["shape1", "shape2"]; 453 392 454 393 store.setState((state) => ({ 455 394 ...state, 456 - doc: { 457 - ...state.doc, 458 - pages: { page1: page }, 459 - shapes: { shape1, shape2 }, 460 - }, 461 - ui: { 462 - ...state.ui, 463 - currentPageId: 'page1', 464 - selectionIds: ['shape1', 'shape2'], 465 - }, 395 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1, shape2 } }, 396 + ui: { ...state.ui, currentPageId: "page1", selectionIds: ["shape1", "shape2"] }, 466 397 })); 467 398 468 399 const state = store.getState(); 469 - expect(state.ui.selectionIds).toEqual(['shape1', 'shape2']); 400 + expect(state.ui.selectionIds).toEqual(["shape1", "shape2"]); 470 401 }); 471 402 472 - it('should maintain reference equality when no repair needed', () => { 403 + it("should maintain reference equality when no repair needed", () => { 473 404 const store = new Store(); 474 - const page = PageRecord.create('Page 1', 'page1'); 405 + const page = PageRecord.create("Page 1", "page1"); 475 406 const shape = ShapeRecord.createRect( 476 - 'page1', 407 + "page1", 477 408 0, 478 409 0, 479 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 480 - 'shape1', 410 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 411 + "shape1", 481 412 ); 482 413 483 - page.shapeIds = ['shape1']; 414 + page.shapeIds = ["shape1"]; 484 415 485 416 store.setState((state) => ({ 486 417 ...state, 487 - doc: { 488 - ...state.doc, 489 - pages: { page1: page }, 490 - shapes: { shape1: shape }, 491 - }, 492 - ui: { 493 - ...state.ui, 494 - currentPageId: 'page1', 495 - selectionIds: ['shape1'], 496 - }, 418 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, 419 + ui: { ...state.ui, currentPageId: "page1", selectionIds: ["shape1"] }, 497 420 })); 498 421 499 422 const stateBefore = store.getState(); 500 423 501 - store.setState((state) => ({ 502 - ...state, 503 - camera: { ...state.camera, x: 100 }, 504 - })); 424 + store.setState((state) => ({ ...state, camera: { ...state.camera, x: 100 } })); 505 425 506 426 const stateAfter = store.getState(); 507 427 508 - // UI should maintain reference equality if not repaired 509 428 expect(stateAfter.ui.currentPageId).toBe(stateBefore.ui.currentPageId); 510 429 }); 511 430 }); 512 431 513 - describe('invariant repair on deletion', () => { 514 - it('should clear selection when selected shape is deleted', () => { 432 + describe("invariant repair on deletion", () => { 433 + it("should clear selection when selected shape is deleted", () => { 515 434 const store = new Store(); 516 - const page = PageRecord.create('Page 1', 'page1'); 435 + const page = PageRecord.create("Page 1", "page1"); 517 436 const shape1 = ShapeRecord.createRect( 518 - 'page1', 437 + "page1", 519 438 0, 520 439 0, 521 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 522 - 'shape1', 440 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 441 + "shape1", 523 442 ); 524 443 525 - page.shapeIds = ['shape1']; 444 + page.shapeIds = ["shape1"]; 526 445 527 446 store.setState((state) => ({ 528 447 ...state, 529 - doc: { 530 - ...state.doc, 531 - pages: { page1: page }, 532 - shapes: { shape1 }, 533 - }, 534 - ui: { 535 - ...state.ui, 536 - currentPageId: 'page1', 537 - selectionIds: ['shape1'], 538 - }, 448 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1 } }, 449 + ui: { ...state.ui, currentPageId: "page1", selectionIds: ["shape1"] }, 539 450 })); 540 451 541 - // Delete the shape 542 452 store.setState((state) => { 543 453 const newPage = { ...state.doc.pages.page1, shapeIds: [] }; 544 - return { 545 - ...state, 546 - doc: { 547 - ...state.doc, 548 - pages: { page1: newPage }, 549 - shapes: {}, 550 - }, 551 - }; 454 + return { ...state, doc: { ...state.doc, pages: { page1: newPage }, shapes: {} } }; 552 455 }); 553 456 554 457 const state = store.getState(); 555 458 expect(state.ui.selectionIds).toEqual([]); 556 459 }); 557 460 558 - it('should update currentPageId when current page is deleted', () => { 461 + it("should update currentPageId when current page is deleted", () => { 559 462 const store = new Store(); 560 - const page1 = PageRecord.create('Page 1', 'page1'); 561 - const page2 = PageRecord.create('Page 2', 'page2'); 463 + const page1 = PageRecord.create("Page 1", "page1"); 464 + const page2 = PageRecord.create("Page 2", "page2"); 562 465 563 466 store.setState((state) => ({ 564 467 ...state, 565 - doc: { 566 - ...state.doc, 567 - pages: { page1, page2 }, 568 - }, 569 - ui: { 570 - ...state.ui, 571 - currentPageId: 'page1', 572 - }, 468 + doc: { ...state.doc, pages: { page1, page2 } }, 469 + ui: { ...state.ui, currentPageId: "page1" }, 573 470 })); 574 471 575 - // Delete page1 576 - store.setState((state) => ({ 577 - ...state, 578 - doc: { 579 - ...state.doc, 580 - pages: { page2 }, 581 - }, 582 - })); 472 + store.setState((state) => ({ ...state, doc: { ...state.doc, pages: { page2 } } })); 583 473 584 474 const state = store.getState(); 585 - expect(state.ui.currentPageId).toBe('page2'); 475 + expect(state.ui.currentPageId).toBe("page2"); 586 476 }); 587 477 }); 588 478 }); 589 479 590 - describe('Selectors', () => { 591 - describe('getCurrentPage', () => { 592 - it('should return null when no page is selected', () => { 480 + describe("Selectors", () => { 481 + describe("getCurrentPage", () => { 482 + it("should return null when no page is selected", () => { 593 483 const state = EditorStateOps.create(); 594 484 const result = getCurrentPage(state); 595 485 596 486 expect(result).toBeNull(); 597 487 }); 598 488 599 - it('should return current page', () => { 489 + it("should return current page", () => { 600 490 const state = EditorStateOps.create(); 601 - const page = PageRecord.create('Page 1', 'page1'); 491 + const page = PageRecord.create("Page 1", "page1"); 602 492 603 493 state.doc.pages = { page1: page }; 604 - state.ui.currentPageId = 'page1'; 494 + state.ui.currentPageId = "page1"; 605 495 606 496 const result = getCurrentPage(state); 607 497 608 498 expect(result).toBe(page); 609 - expect(result?.name).toBe('Page 1'); 499 + expect(result?.name).toBe("Page 1"); 610 500 }); 611 501 612 - it('should return null when currentPageId does not exist', () => { 502 + it("should return null when currentPageId does not exist", () => { 613 503 const state = EditorStateOps.create(); 614 - state.ui.currentPageId = 'nonexistent'; 504 + state.ui.currentPageId = "nonexistent"; 615 505 616 506 const result = getCurrentPage(state); 617 507 ··· 619 509 }); 620 510 }); 621 511 622 - describe('getShapesOnCurrentPage', () => { 623 - it('should return empty array when no page is selected', () => { 512 + describe("getShapesOnCurrentPage", () => { 513 + it("should return empty array when no page is selected", () => { 624 514 const state = EditorStateOps.create(); 625 515 const result = getShapesOnCurrentPage(state); 626 516 627 517 expect(result).toEqual([]); 628 518 }); 629 519 630 - it('should return empty array for empty page', () => { 520 + it("should return empty array for empty page", () => { 631 521 const state = EditorStateOps.create(); 632 - const page = PageRecord.create('Page 1', 'page1'); 522 + const page = PageRecord.create("Page 1", "page1"); 633 523 634 524 state.doc.pages = { page1: page }; 635 - state.ui.currentPageId = 'page1'; 525 + state.ui.currentPageId = "page1"; 636 526 637 527 const result = getShapesOnCurrentPage(state); 638 528 639 529 expect(result).toEqual([]); 640 530 }); 641 531 642 - it('should return all shapes on current page', () => { 532 + it("should return all shapes on current page", () => { 643 533 const state = EditorStateOps.create(); 644 - const page = PageRecord.create('Page 1', 'page1'); 534 + const page = PageRecord.create("Page 1", "page1"); 645 535 const shape1 = ShapeRecord.createRect( 646 - 'page1', 536 + "page1", 647 537 0, 648 538 0, 649 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 650 - 'shape1', 539 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 540 + "shape1", 651 541 ); 652 - const shape2 = ShapeRecord.createEllipse('page1', 50, 50, { w: 75, h: 75, fill: '#000', stroke: '#fff' }, 'shape2'); 542 + const shape2 = ShapeRecord.createEllipse( 543 + "page1", 544 + 50, 545 + 50, 546 + { w: 75, h: 75, fill: "#000", stroke: "#fff" }, 547 + "shape2", 548 + ); 653 549 654 - page.shapeIds = ['shape1', 'shape2']; 550 + page.shapeIds = ["shape1", "shape2"]; 655 551 state.doc.pages = { page1: page }; 656 552 state.doc.shapes = { shape1, shape2 }; 657 - state.ui.currentPageId = 'page1'; 553 + state.ui.currentPageId = "page1"; 658 554 659 555 const result = getShapesOnCurrentPage(state); 660 556 ··· 663 559 expect(result[1]).toBe(shape2); 664 560 }); 665 561 666 - it('should filter out undefined shapes', () => { 562 + it("should filter out undefined shapes", () => { 667 563 const state = EditorStateOps.create(); 668 - const page = PageRecord.create('Page 1', 'page1'); 564 + const page = PageRecord.create("Page 1", "page1"); 669 565 const shape1 = ShapeRecord.createRect( 670 - 'page1', 566 + "page1", 671 567 0, 672 568 0, 673 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 674 - 'shape1', 569 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 570 + "shape1", 675 571 ); 676 572 677 - page.shapeIds = ['shape1', 'nonexistent']; 573 + page.shapeIds = ["shape1", "nonexistent"]; 678 574 state.doc.pages = { page1: page }; 679 575 state.doc.shapes = { shape1 }; 680 - state.ui.currentPageId = 'page1'; 576 + state.ui.currentPageId = "page1"; 681 577 682 578 const result = getShapesOnCurrentPage(state); 683 579 ··· 685 581 expect(result[0]).toBe(shape1); 686 582 }); 687 583 688 - it('should not include shapes from other pages', () => { 584 + it("should not include shapes from other pages", () => { 689 585 const state = EditorStateOps.create(); 690 - const page1 = PageRecord.create('Page 1', 'page1'); 691 - const page2 = PageRecord.create('Page 2', 'page2'); 586 + const page1 = PageRecord.create("Page 1", "page1"); 587 + const page2 = PageRecord.create("Page 2", "page2"); 692 588 693 589 const shape1 = ShapeRecord.createRect( 694 - 'page1', 590 + "page1", 695 591 0, 696 592 0, 697 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 698 - 'shape1', 593 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 594 + "shape1", 699 595 ); 700 596 const shape2 = ShapeRecord.createRect( 701 - 'page2', 597 + "page2", 702 598 0, 703 599 0, 704 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 705 - 'shape2', 600 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 601 + "shape2", 706 602 ); 707 603 708 - page1.shapeIds = ['shape1']; 709 - page2.shapeIds = ['shape2']; 604 + page1.shapeIds = ["shape1"]; 605 + page2.shapeIds = ["shape2"]; 710 606 711 607 state.doc.pages = { page1, page2 }; 712 608 state.doc.shapes = { shape1, shape2 }; 713 - state.ui.currentPageId = 'page1'; 609 + state.ui.currentPageId = "page1"; 714 610 715 611 const result = getShapesOnCurrentPage(state); 716 612 ··· 719 615 }); 720 616 }); 721 617 722 - describe('getSelectedShapes', () => { 723 - it('should return empty array when no selection', () => { 618 + describe("getSelectedShapes", () => { 619 + it("should return empty array when no selection", () => { 724 620 const state = EditorStateOps.create(); 725 621 const result = getSelectedShapes(state); 726 622 727 623 expect(result).toEqual([]); 728 624 }); 729 625 730 - it('should return selected shapes', () => { 626 + it("should return selected shapes", () => { 731 627 const state = EditorStateOps.create(); 732 - const page = PageRecord.create('Page 1', 'page1'); 628 + const page = PageRecord.create("Page 1", "page1"); 733 629 const shape1 = ShapeRecord.createRect( 734 - 'page1', 630 + "page1", 735 631 0, 736 632 0, 737 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 738 - 'shape1', 633 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 634 + "shape1", 739 635 ); 740 - const shape2 = ShapeRecord.createEllipse('page1', 50, 50, { w: 75, h: 75, fill: '#000', stroke: '#fff' }, 'shape2'); 741 - const shape3 = ShapeRecord.createLine( 742 - 'page1', 743 - 100, 744 - 100, 745 - { a: { x: 0, y: 0 }, b: { x: 100, y: 0 }, stroke: '#000', width: 2 }, 746 - 'shape3', 636 + const shape2 = ShapeRecord.createEllipse( 637 + "page1", 638 + 50, 639 + 50, 640 + { w: 75, h: 75, fill: "#000", stroke: "#fff" }, 641 + "shape2", 747 642 ); 643 + const shape3 = ShapeRecord.createLine("page1", 100, 100, { 644 + a: { x: 0, y: 0 }, 645 + b: { x: 100, y: 0 }, 646 + stroke: "#000", 647 + width: 2, 648 + }, "shape3"); 748 649 749 - page.shapeIds = ['shape1', 'shape2', 'shape3']; 650 + page.shapeIds = ["shape1", "shape2", "shape3"]; 750 651 state.doc.pages = { page1: page }; 751 652 state.doc.shapes = { shape1, shape2, shape3 }; 752 - state.ui.currentPageId = 'page1'; 753 - state.ui.selectionIds = ['shape1', 'shape3']; 653 + state.ui.currentPageId = "page1"; 654 + state.ui.selectionIds = ["shape1", "shape3"]; 754 655 755 656 const result = getSelectedShapes(state); 756 657 ··· 759 660 expect(result[1]).toBe(shape3); 760 661 }); 761 662 762 - it('should filter out undefined shapes', () => { 663 + it("should filter out undefined shapes", () => { 763 664 const state = EditorStateOps.create(); 764 665 const shape1 = ShapeRecord.createRect( 765 - 'page1', 666 + "page1", 766 667 0, 767 668 0, 768 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 769 - 'shape1', 669 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 670 + "shape1", 770 671 ); 771 672 772 673 state.doc.shapes = { shape1 }; 773 - state.ui.selectionIds = ['shape1', 'nonexistent']; 674 + state.ui.selectionIds = ["shape1", "nonexistent"]; 774 675 775 676 const result = getSelectedShapes(state); 776 677 ··· 779 680 }); 780 681 }); 781 682 782 - describe('isShapeSelected', () => { 783 - it('should return false when shape is not selected', () => { 683 + describe("isShapeSelected", () => { 684 + it("should return false when shape is not selected", () => { 784 685 const state = EditorStateOps.create(); 785 - state.ui.selectionIds = ['shape1', 'shape2']; 686 + state.ui.selectionIds = ["shape1", "shape2"]; 786 687 787 - expect(isShapeSelected(state, 'shape3')).toBe(false); 688 + expect(isShapeSelected(state, "shape3")).toBe(false); 788 689 }); 789 690 790 - it('should return true when shape is selected', () => { 691 + it("should return true when shape is selected", () => { 791 692 const state = EditorStateOps.create(); 792 - state.ui.selectionIds = ['shape1', 'shape2']; 693 + state.ui.selectionIds = ["shape1", "shape2"]; 793 694 794 - expect(isShapeSelected(state, 'shape1')).toBe(true); 795 - expect(isShapeSelected(state, 'shape2')).toBe(true); 695 + expect(isShapeSelected(state, "shape1")).toBe(true); 696 + expect(isShapeSelected(state, "shape2")).toBe(true); 796 697 }); 797 698 798 - it('should return false for empty selection', () => { 699 + it("should return false for empty selection", () => { 799 700 const state = EditorStateOps.create(); 800 701 state.ui.selectionIds = []; 801 702 802 - expect(isShapeSelected(state, 'shape1')).toBe(false); 703 + expect(isShapeSelected(state, "shape1")).toBe(false); 803 704 }); 804 705 }); 805 706 806 - describe('getAllPages', () => { 807 - it('should return empty array when no pages', () => { 707 + describe("getAllPages", () => { 708 + it("should return empty array when no pages", () => { 808 709 const state = EditorStateOps.create(); 809 710 const result = getAllPages(state); 810 711 811 712 expect(result).toEqual([]); 812 713 }); 813 714 814 - it('should return all pages', () => { 715 + it("should return all pages", () => { 815 716 const state = EditorStateOps.create(); 816 - const page1 = PageRecord.create('Page 1', 'page1'); 817 - const page2 = PageRecord.create('Page 2', 'page2'); 717 + const page1 = PageRecord.create("Page 1", "page1"); 718 + const page2 = PageRecord.create("Page 2", "page2"); 818 719 819 720 state.doc.pages = { page1, page2 }; 820 721 ··· 826 727 }); 827 728 }); 828 729 829 - describe('getShape', () => { 830 - it('should return undefined for non-existent shape', () => { 730 + describe("getShape", () => { 731 + it("should return undefined for non-existent shape", () => { 831 732 const state = EditorStateOps.create(); 832 - const result = getShape(state, 'nonexistent'); 733 + const result = getShape(state, "nonexistent"); 833 734 834 735 expect(result).toBeUndefined(); 835 736 }); 836 737 837 - it('should return shape by ID', () => { 738 + it("should return shape by ID", () => { 838 739 const state = EditorStateOps.create(); 839 740 const shape = ShapeRecord.createRect( 840 - 'page1', 741 + "page1", 841 742 0, 842 743 0, 843 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 844 - 'shape1', 744 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 745 + "shape1", 845 746 ); 846 747 847 748 state.doc.shapes = { shape1: shape }; 848 749 849 - const result = getShape(state, 'shape1'); 750 + const result = getShape(state, "shape1"); 850 751 851 752 expect(result).toBe(shape); 852 753 }); 853 754 }); 854 755 }); 855 756 856 - describe('Integration scenarios', () => { 857 - it('should handle complete workflow: create page, add shapes, select shapes', () => { 757 + describe("Integration scenarios", () => { 758 + it("should handle complete workflow: create page, add shapes, select shapes", () => { 858 759 const store = new Store(); 859 760 860 - // Create page 861 - const page = PageRecord.create('Page 1', 'page1'); 761 + const page = PageRecord.create("Page 1", "page1"); 862 762 store.setState((state) => ({ 863 763 ...state, 864 - doc: { 865 - ...state.doc, 866 - pages: { page1: page }, 867 - }, 868 - ui: { 869 - ...state.ui, 870 - currentPageId: 'page1', 871 - }, 764 + doc: { ...state.doc, pages: { page1: page } }, 765 + ui: { ...state.ui, currentPageId: "page1" }, 872 766 })); 873 767 874 768 let state = store.getState(); 875 - expect(getCurrentPage(state)?.name).toBe('Page 1'); 769 + expect(getCurrentPage(state)?.name).toBe("Page 1"); 876 770 877 - // Add shapes 878 771 const shape1 = ShapeRecord.createRect( 879 - 'page1', 772 + "page1", 880 773 0, 881 774 0, 882 - { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 883 - 'shape1', 775 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 776 + "shape1", 884 777 ); 885 - const shape2 = ShapeRecord.createEllipse('page1', 50, 50, { w: 75, h: 75, fill: '#000', stroke: '#fff' }, 'shape2'); 778 + const shape2 = ShapeRecord.createEllipse("page1", 50, 50, { w: 75, h: 75, fill: "#000", stroke: "#fff" }, "shape2"); 886 779 887 780 store.setState((state) => { 888 - const updatedPage = { ...state.doc.pages.page1, shapeIds: ['shape1', 'shape2'] }; 889 - 890 - return { 891 - ...state, 892 - doc: { 893 - ...state.doc, 894 - pages: { page1: updatedPage }, 895 - shapes: { shape1, shape2 }, 896 - }, 897 - }; 781 + const updatedPage = { ...state.doc.pages.page1, shapeIds: ["shape1", "shape2"] }; 782 + return { ...state, doc: { ...state.doc, pages: { page1: updatedPage }, shapes: { shape1, shape2 } } }; 898 783 }); 899 784 900 785 state = store.getState(); 901 786 expect(getShapesOnCurrentPage(state)).toHaveLength(2); 902 787 903 - // Select shapes 904 - store.setState((state) => ({ 905 - ...state, 906 - ui: { 907 - ...state.ui, 908 - selectionIds: ['shape1', 'shape2'], 909 - }, 910 - })); 788 + store.setState((state) => ({ ...state, ui: { ...state.ui, selectionIds: ["shape1", "shape2"] } })); 911 789 912 790 state = store.getState(); 913 791 expect(getSelectedShapes(state)).toHaveLength(2); 914 - expect(isShapeSelected(state, 'shape1')).toBe(true); 792 + expect(isShapeSelected(state, "shape1")).toBe(true); 915 793 }); 916 794 917 - it('should handle camera operations while maintaining state', () => { 795 + it("should handle camera operations while maintaining state", () => { 918 796 const store = new Store(); 919 797 const listener = vi.fn(); 920 798 921 799 store.subscribe(listener); 922 800 listener.mockClear(); 923 801 924 - store.setState((state) => ({ 925 - ...state, 926 - camera: Camera.pan(state.camera, { x: 100, y: 50 }), 927 - })); 802 + store.setState((state) => ({ ...state, camera: Camera.pan(state.camera, { x: 100, y: 50 }) })); 928 803 929 804 expect(listener).toHaveBeenCalledTimes(1); 930 805 ··· 933 808 expect(state.camera.y).toBe(-50); 934 809 }); 935 810 936 - it('should handle tool switching', () => { 811 + it("should handle tool switching", () => { 937 812 const store = new Store(); 938 - 939 - expect(store.getState().ui.toolId).toBe('select'); 940 - 941 - store.setState((state) => ({ 942 - ...state, 943 - ui: { ...state.ui, toolId: 'rect' }, 944 - })); 945 - 946 - expect(store.getState().ui.toolId).toBe('rect'); 813 + expect(store.getState().ui.toolId).toBe("select"); 947 814 948 - store.setState((state) => ({ 949 - ...state, 950 - ui: { ...state.ui, toolId: 'ellipse' }, 951 - })); 815 + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "rect" } })); 816 + expect(store.getState().ui.toolId).toBe("rect"); 952 817 953 - expect(store.getState().ui.toolId).toBe('ellipse'); 818 + store.setState((state) => ({ ...state, ui: { ...state.ui, toolId: "ellipse" } })); 819 + expect(store.getState().ui.toolId).toBe("ellipse"); 954 820 }); 955 821 });
+2 -5
packages/core/tsdown.config.ts
··· 1 - import { defineConfig } from 'tsdown' 1 + import { defineConfig } from "tsdown"; 2 2 3 - export default defineConfig({ 4 - exports: true, 5 - // ...config options 6 - }) 3 + export default defineConfig({ exports: true });
+9 -16
packages/renderer/package.json
··· 1 1 { 2 - "name": "tsdown-starter", 2 + "name": "inkfinite-renderer", 3 3 "type": "module", 4 4 "version": "0.0.0", 5 5 "description": "A starter for creating a TypeScript package.", 6 6 "author": "Author Name <author.name@mail.com>", 7 7 "license": "MIT", 8 8 "homepage": "https://github.com/author/library#readme", 9 - "repository": { 10 - "type": "git", 11 - "url": "git+https://github.com/author/library.git" 12 - }, 13 - "bugs": { 14 - "url": "https://github.com/author/library/issues" 15 - }, 16 - "exports": { 17 - ".": "./dist/index.mjs", 18 - "./package.json": "./package.json" 19 - }, 9 + "repository": { "type": "git", "url": "git+https://github.com/author/library.git" }, 10 + "bugs": { "url": "https://github.com/author/library/issues" }, 11 + "exports": { ".": "./dist/index.mjs", "./package.json": "./package.json" }, 20 12 "main": "./dist/index.mjs", 21 13 "module": "./dist/index.mjs", 22 14 "types": "./dist/index.d.mts", 23 - "files": [ 24 - "dist" 25 - ], 15 + "files": ["dist"], 26 16 "scripts": { 27 17 "build": "tsdown", 28 18 "dev": "tsdown --watch", ··· 31 21 "prepublishOnly": "pnpm run build" 32 22 }, 33 23 "devDependencies": { 24 + "@types/jsdom": "^27.0.0", 34 25 "@types/node": "^25.0.3", 35 26 "bumpp": "^10.3.2", 27 + "jsdom": "^27.3.0", 36 28 "tsdown": "^0.18.1", 37 29 "typescript": "^5.9.3", 38 30 "vitest": "^4.0.16" 39 - } 31 + }, 32 + "dependencies": { "inkfinite-core": "workspace:*" } 40 33 }
+402 -2
packages/renderer/src/index.ts
··· 1 - export function fn() { 2 - return 'Hello, tsdown!' 1 + import type { 2 + ArrowShape, 3 + Camera, 4 + EditorState, 5 + EllipseShape, 6 + LineShape, 7 + RectShape, 8 + ShapeRecord, 9 + Store, 10 + TextShape, 11 + Viewport, 12 + } from "inkfinite-core"; 13 + import { getShapesOnCurrentPage } from "inkfinite-core"; 14 + 15 + export interface Renderer { 16 + /** 17 + * Clean up the renderer and stop rendering 18 + */ 19 + dispose(): void; 20 + 21 + /** 22 + * Force a redraw on the next frame 23 + */ 24 + markDirty(): void; 25 + } 26 + 27 + /** 28 + * Create a canvas renderer 29 + * 30 + * The renderer subscribes to the store and redraws the canvas 31 + * whenever the state changes. It uses requestAnimationFrame with 32 + * a dirty flag to optimize rendering. 33 + * 34 + * @param canvas - The HTMLCanvasElement to render to 35 + * @param store - The editor state store 36 + * @returns Renderer instance with dispose method 37 + */ 38 + export function createRenderer(canvas: HTMLCanvasElement, store: Store): Renderer { 39 + const maybeContext = canvas.getContext("2d"); 40 + if (!maybeContext) { 41 + throw new Error("Failed to get 2D context from canvas"); 42 + } 43 + const context: CanvasRenderingContext2D = maybeContext; 44 + 45 + let isDirty = true; 46 + let animationFrameId: number | null = null; 47 + let isDisposed = false; 48 + 49 + /** 50 + * Mark the canvas as needing a redraw 51 + */ 52 + function markDirty() { 53 + if (isDisposed) return; 54 + isDirty = true; 55 + if (animationFrameId === null) { 56 + scheduleRender(); 57 + } 58 + } 59 + 60 + /** 61 + * Schedule a render on the next animation frame 62 + */ 63 + function scheduleRender() { 64 + animationFrameId = requestAnimationFrame(() => { 65 + animationFrameId = null; 66 + if (isDirty && !isDisposed) { 67 + render(); 68 + isDirty = false; 69 + scheduleRender(); 70 + } 71 + }); 72 + } 73 + 74 + /** 75 + * Render the current state to the canvas 76 + */ 77 + function render() { 78 + const state = store.getState(); 79 + 80 + setupCanvas(canvas, context); 81 + 82 + const viewport: Viewport = { width: canvas.width / getPixelRatio(), height: canvas.height / getPixelRatio() }; 83 + 84 + drawScene(context, state, viewport); 85 + } 86 + 87 + /** 88 + * Subscribe to store updates and mark dirty 89 + */ 90 + const unsubscribe = store.subscribe(() => { 91 + markDirty(); 92 + }); 93 + 94 + /** 95 + * Dispose the renderer 96 + */ 97 + function dispose() { 98 + isDisposed = true; 99 + unsubscribe(); 100 + if (animationFrameId !== null) { 101 + cancelAnimationFrame(animationFrameId); 102 + animationFrameId = null; 103 + } 104 + } 105 + 106 + markDirty(); 107 + 108 + return { dispose, markDirty }; 109 + } 110 + 111 + /** 112 + * Setup canvas with proper pixel ratio for sharp rendering 113 + */ 114 + function setupCanvas(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D) { 115 + const pixelRatio = getPixelRatio(); 116 + const rect = canvas.getBoundingClientRect(); 117 + 118 + canvas.width = rect.width * pixelRatio; 119 + canvas.height = rect.height * pixelRatio; 120 + 121 + context.scale(pixelRatio, pixelRatio); 122 + } 123 + 124 + /** 125 + * Get device pixel ratio for sharp rendering on high-DPI displays 126 + */ 127 + function getPixelRatio(): number { 128 + return globalThis.window !== undefined && window.devicePixelRatio ? window.devicePixelRatio : 1; 129 + } 130 + 131 + /** 132 + * Draw the entire scene 133 + */ 134 + function drawScene(context: CanvasRenderingContext2D, state: EditorState, viewport: Viewport) { 135 + context.clearRect(0, 0, viewport.width, viewport.height); 136 + 137 + context.save(); 138 + 139 + applyCameraTransform(context, state.camera, viewport); 140 + 141 + const shapes = getShapesOnCurrentPage(state); 142 + for (const shape of shapes) { 143 + drawShape(context, shape); 144 + } 145 + 146 + drawSelection(context, state, shapes); 147 + 148 + context.restore(); 149 + } 150 + 151 + /** 152 + * Apply camera transform to the canvas context 153 + * 154 + * This transforms the coordinate system so that drawing in world 155 + * coordinates appears correctly on screen. 156 + */ 157 + function applyCameraTransform(context: CanvasRenderingContext2D, camera: Camera, viewport: Viewport) { 158 + context.translate(viewport.width / 2, viewport.height / 2); 159 + 160 + context.scale(camera.zoom, camera.zoom); 161 + 162 + context.translate(-camera.x, -camera.y); 163 + } 164 + 165 + /** 166 + * Draw a single shape 167 + */ 168 + function drawShape(context: CanvasRenderingContext2D, shape: ShapeRecord) { 169 + context.save(); 170 + 171 + context.translate(shape.x, shape.y); 172 + if (shape.rot !== 0) { 173 + context.rotate(shape.rot); 174 + } 175 + 176 + switch (shape.type) { 177 + case "rect": { 178 + drawRect(context, shape); 179 + break; 180 + } 181 + case "ellipse": { 182 + drawEllipse(context, shape); 183 + break; 184 + } 185 + case "line": { 186 + drawLine(context, shape); 187 + break; 188 + } 189 + case "arrow": { 190 + drawArrow(context, shape); 191 + break; 192 + } 193 + case "text": { 194 + drawText(context, shape); 195 + break; 196 + } 197 + } 198 + 199 + context.restore(); 200 + } 201 + 202 + /** 203 + * Draw a rectangle shape 204 + */ 205 + function drawRect(context: CanvasRenderingContext2D, shape: RectShape) { 206 + const { w, h, fill, stroke, radius } = shape.props; 207 + 208 + context.beginPath(); 209 + if (radius > 0) { 210 + const r = Math.min(radius, w / 2, h / 2); 211 + context.moveTo(r, 0); 212 + context.lineTo(w - r, 0); 213 + context.arcTo(w, 0, w, r, r); 214 + context.lineTo(w, h - r); 215 + context.arcTo(w, h, w - r, h, r); 216 + context.lineTo(r, h); 217 + context.arcTo(0, h, 0, h - r, r); 218 + context.lineTo(0, r); 219 + context.arcTo(0, 0, r, 0, r); 220 + context.closePath(); 221 + } else { 222 + context.rect(0, 0, w, h); 223 + } 224 + 225 + if (fill) { 226 + context.fillStyle = fill; 227 + context.fill(); 228 + } 229 + 230 + if (stroke) { 231 + context.strokeStyle = stroke; 232 + context.lineWidth = 2; 233 + context.stroke(); 234 + } 235 + } 236 + 237 + /** 238 + * Draw an ellipse shape 239 + */ 240 + function drawEllipse(context: CanvasRenderingContext2D, shape: EllipseShape) { 241 + const { w, h, fill, stroke } = shape.props; 242 + 243 + context.beginPath(); 244 + context.ellipse(w / 2, h / 2, w / 2, h / 2, 0, 0, Math.PI * 2); 245 + 246 + if (fill) { 247 + context.fillStyle = fill; 248 + context.fill(); 249 + } 250 + 251 + if (stroke) { 252 + context.strokeStyle = stroke; 253 + context.lineWidth = 2; 254 + context.stroke(); 255 + } 256 + } 257 + 258 + /** 259 + * Draw a line shape 260 + */ 261 + function drawLine(context: CanvasRenderingContext2D, shape: LineShape) { 262 + const { a, b, stroke, width } = shape.props; 263 + 264 + context.beginPath(); 265 + context.moveTo(a.x, a.y); 266 + context.lineTo(b.x, b.y); 267 + 268 + context.strokeStyle = stroke; 269 + context.lineWidth = width; 270 + context.stroke(); 271 + } 272 + 273 + /** 274 + * Draw an arrow shape 275 + */ 276 + function drawArrow(context: CanvasRenderingContext2D, shape: ArrowShape) { 277 + const { a, b, stroke, width } = shape.props; 278 + 279 + context.beginPath(); 280 + context.moveTo(a.x, a.y); 281 + context.lineTo(b.x, b.y); 282 + 283 + context.strokeStyle = stroke; 284 + context.lineWidth = width; 285 + context.stroke(); 286 + 287 + const angle = Math.atan2(b.y - a.y, b.x - a.x); 288 + const arrowLength = 15; 289 + const arrowAngle = Math.PI / 6; 290 + 291 + context.beginPath(); 292 + context.moveTo(b.x, b.y); 293 + context.lineTo(b.x - arrowLength * Math.cos(angle - arrowAngle), b.y - arrowLength * Math.sin(angle - arrowAngle)); 294 + context.moveTo(b.x, b.y); 295 + context.lineTo(b.x - arrowLength * Math.cos(angle + arrowAngle), b.y - arrowLength * Math.sin(angle + arrowAngle)); 296 + 297 + context.strokeStyle = stroke; 298 + context.lineWidth = width; 299 + context.stroke(); 300 + } 301 + 302 + /** 303 + * Draw a text shape 304 + */ 305 + function drawText(context: CanvasRenderingContext2D, shape: TextShape) { 306 + const { text, fontSize, fontFamily, color, w } = shape.props; 307 + 308 + context.font = `${fontSize}px ${fontFamily}`; 309 + context.fillStyle = color; 310 + context.textBaseline = "top"; 311 + 312 + if (w === undefined) { 313 + context.fillText(text, 0, 0); 314 + } else { 315 + const lines = wrapText(context, text, w); 316 + for (const [index, line] of lines.entries()) { 317 + context.fillText(line, 0, index * fontSize * 1.2); 318 + } 319 + } 320 + } 321 + 322 + /** 323 + * Wrap text to fit within a given width 324 + */ 325 + function wrapText(context: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { 326 + const words = text.split(" "); 327 + const lines: string[] = []; 328 + let currentLine = ""; 329 + 330 + for (const word of words) { 331 + const testLine = currentLine ? `${currentLine} ${word}` : word; 332 + const metrics = context.measureText(testLine); 333 + 334 + if (metrics.width > maxWidth && currentLine) { 335 + lines.push(currentLine); 336 + currentLine = word; 337 + } else { 338 + currentLine = testLine; 339 + } 340 + } 341 + 342 + if (currentLine) { 343 + lines.push(currentLine); 344 + } 345 + 346 + return lines; 347 + } 348 + 349 + /** 350 + * Draw selection outlines for selected shapes 351 + */ 352 + function drawSelection(context: CanvasRenderingContext2D, state: EditorState, shapes: ShapeRecord[]) { 353 + const selectedIds = new Set(state.ui.selectionIds); 354 + 355 + for (const shape of shapes) { 356 + if (!selectedIds.has(shape.id)) continue; 357 + 358 + context.save(); 359 + context.translate(shape.x, shape.y); 360 + if (shape.rot !== 0) { 361 + context.rotate(shape.rot); 362 + } 363 + 364 + context.strokeStyle = "#0066ff"; 365 + context.lineWidth = 2 / state.camera.zoom; 366 + context.setLineDash([4 / state.camera.zoom, 4 / state.camera.zoom]); 367 + 368 + switch (shape.type) { 369 + case "rect": { 370 + const { w, h } = shape.props; 371 + context.strokeRect(0, 0, w, h); 372 + break; 373 + } 374 + case "ellipse": { 375 + const { w, h } = shape.props; 376 + context.strokeRect(0, 0, w, h); 377 + break; 378 + } 379 + case "line": 380 + case "arrow": { 381 + const { a, b } = shape.props; 382 + const minX = Math.min(a.x, b.x); 383 + const minY = Math.min(a.y, b.y); 384 + const maxX = Math.max(a.x, b.x); 385 + const maxY = Math.max(a.y, b.y); 386 + const padding = 5; 387 + context.strokeRect(minX - padding, minY - padding, maxX - minX + padding * 2, maxY - minY + padding * 2); 388 + break; 389 + } 390 + case "text": { 391 + const { fontSize, fontFamily, text, w } = shape.props; 392 + context.font = `${fontSize}px ${fontFamily}`; 393 + const metrics = context.measureText(text); 394 + const width = w ?? metrics.width; 395 + const height = fontSize * 1.2; 396 + context.strokeRect(0, 0, width, height); 397 + break; 398 + } 399 + } 400 + 401 + context.restore(); 402 + } 3 403 }
+468 -4
packages/renderer/tests/index.test.ts
··· 1 - import { expect, test } from "vitest"; 2 - import { fn } from "../src"; 1 + import { PageRecord, ShapeRecord, Store } from "inkfinite-core"; 2 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { createRenderer } from "../src"; 4 + 5 + describe("Renderer", () => { 6 + let canvas: HTMLCanvasElement; 7 + let context: CanvasRenderingContext2D; 8 + 9 + beforeEach(() => { 10 + canvas = document.createElement("canvas"); 11 + canvas.width = 800; 12 + canvas.height = 600; 13 + 14 + context = { 15 + canvas, 16 + save: vi.fn(), 17 + restore: vi.fn(), 18 + scale: vi.fn(), 19 + translate: vi.fn(), 20 + rotate: vi.fn(), 21 + clearRect: vi.fn(), 22 + fillRect: vi.fn(), 23 + strokeRect: vi.fn(), 24 + fillText: vi.fn(), 25 + strokeText: vi.fn(), 26 + measureText: vi.fn(() => ({ width: 100 })), 27 + beginPath: vi.fn(), 28 + moveTo: vi.fn(), 29 + lineTo: vi.fn(), 30 + arc: vi.fn(), 31 + arcTo: vi.fn(), 32 + ellipse: vi.fn(), 33 + rect: vi.fn(), 34 + closePath: vi.fn(), 35 + fill: vi.fn(), 36 + stroke: vi.fn(), 37 + setLineDash: vi.fn(), 38 + getLineDash: vi.fn(() => []), 39 + fillStyle: "", 40 + strokeStyle: "", 41 + lineWidth: 1, 42 + font: "", 43 + textBaseline: "alphabetic", 44 + } as unknown as CanvasRenderingContext2D; 45 + 46 + vi.spyOn(canvas, "getContext").mockReturnValue(context); 3 47 4 - test("fn", () => { 5 - expect(fn()).toBe("Hello, tsdown!"); 48 + Object.defineProperty(canvas, "getBoundingClientRect", { 49 + value: () => ({ width: 800, height: 600, top: 0, left: 0, right: 800, bottom: 600 }), 50 + }); 51 + 52 + globalThis.requestAnimationFrame = vi.fn((callback) => { 53 + setTimeout(callback, 16); 54 + return 1; 55 + }); 56 + 57 + globalThis.cancelAnimationFrame = vi.fn(); 58 + }); 59 + 60 + afterEach(() => { 61 + vi.restoreAllMocks(); 62 + }); 63 + 64 + describe("createRenderer", () => { 65 + it("should create renderer with dispose method", () => { 66 + const store = new Store(); 67 + const renderer = createRenderer(canvas, store); 68 + 69 + expect(renderer).toBeDefined(); 70 + expect(renderer.dispose).toBeInstanceOf(Function); 71 + expect(renderer.markDirty).toBeInstanceOf(Function); 72 + 73 + renderer.dispose(); 74 + }); 75 + 76 + it("should throw error if canvas context is not available", () => { 77 + const badCanvas = document.createElement("canvas"); 78 + vi.spyOn(badCanvas, "getContext").mockReturnValue(null); 79 + 80 + const store = new Store(); 81 + 82 + expect(() => createRenderer(badCanvas, store)).toThrow("Failed to get 2D context from canvas"); 83 + }); 84 + 85 + it("should mark dirty on initial render", () => { 86 + const store = new Store(); 87 + const renderer = createRenderer(canvas, store); 88 + 89 + expect(globalThis.requestAnimationFrame).toHaveBeenCalled(); 90 + 91 + renderer.dispose(); 92 + }); 93 + 94 + it("should unsubscribe from store on dispose", () => { 95 + const store = new Store(); 96 + const renderer = createRenderer(canvas, store); 97 + 98 + const _unsubscribeSpy = vi.spyOn(store, "subscribe"); 99 + 100 + renderer.dispose(); 101 + 102 + expect(globalThis.cancelAnimationFrame).toHaveBeenCalled(); 103 + }); 104 + }); 105 + 106 + describe("rendering", () => { 107 + it("should render empty scene with no shapes", async () => { 108 + const store = new Store(); 109 + const renderer = createRenderer(canvas, store); 110 + 111 + await new Promise((resolve) => setTimeout(resolve, 50)); 112 + 113 + renderer.dispose(); 114 + }); 115 + 116 + it("should render scene with rect shape", async () => { 117 + const store = new Store(); 118 + 119 + const page = PageRecord.create("Page 1", "page:1"); 120 + const rect = ShapeRecord.createRect("page:1", 100, 100, { 121 + w: 200, 122 + h: 100, 123 + fill: "#ff0000", 124 + stroke: "#000000", 125 + radius: 0, 126 + }, "shape:1"); 127 + 128 + store.setState((state) => ({ 129 + ...state, 130 + doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, 131 + ui: { ...state.ui, currentPageId: page.id }, 132 + })); 133 + 134 + const renderer = createRenderer(canvas, store); 135 + 136 + await new Promise((resolve) => setTimeout(resolve, 50)); 137 + 138 + renderer.dispose(); 139 + }); 140 + 141 + it("should render scene with ellipse shape", async () => { 142 + const store = new Store(); 143 + 144 + const page = PageRecord.create("Page 1", "page:1"); 145 + const ellipse = ShapeRecord.createEllipse("page:1", 100, 100, { 146 + w: 200, 147 + h: 100, 148 + fill: "#00ff00", 149 + stroke: "#000000", 150 + }, "shape:1"); 151 + 152 + store.setState((state) => ({ 153 + ...state, 154 + doc: { 155 + pages: { [page.id]: { ...page, shapeIds: [ellipse.id] } }, 156 + shapes: { [ellipse.id]: ellipse }, 157 + bindings: {}, 158 + }, 159 + ui: { ...state.ui, currentPageId: page.id }, 160 + })); 161 + 162 + const renderer = createRenderer(canvas, store); 163 + 164 + await new Promise((resolve) => setTimeout(resolve, 50)); 165 + 166 + renderer.dispose(); 167 + }); 168 + 169 + it("should render scene with line shape", async () => { 170 + const store = new Store(); 171 + 172 + const page = PageRecord.create("Page 1", "page:1"); 173 + const line = ShapeRecord.createLine("page:1", 0, 0, { 174 + a: { x: 0, y: 0 }, 175 + b: { x: 100, y: 100 }, 176 + stroke: "#000000", 177 + width: 2, 178 + }, "shape:1"); 179 + 180 + store.setState((state) => ({ 181 + ...state, 182 + doc: { pages: { [page.id]: { ...page, shapeIds: [line.id] } }, shapes: { [line.id]: line }, bindings: {} }, 183 + ui: { ...state.ui, currentPageId: page.id }, 184 + })); 185 + 186 + const renderer = createRenderer(canvas, store); 187 + 188 + await new Promise((resolve) => setTimeout(resolve, 50)); 189 + 190 + renderer.dispose(); 191 + }); 192 + 193 + it("should render scene with arrow shape", async () => { 194 + const store = new Store(); 195 + 196 + const page = PageRecord.create("Page 1", "page:1"); 197 + const arrow = ShapeRecord.createArrow("page:1", 0, 0, { 198 + a: { x: 0, y: 0 }, 199 + b: { x: 100, y: 100 }, 200 + stroke: "#000000", 201 + width: 2, 202 + }, "shape:1"); 203 + 204 + store.setState((state) => ({ 205 + ...state, 206 + doc: { pages: { [page.id]: { ...page, shapeIds: [arrow.id] } }, shapes: { [arrow.id]: arrow }, bindings: {} }, 207 + ui: { ...state.ui, currentPageId: page.id }, 208 + })); 209 + 210 + const renderer = createRenderer(canvas, store); 211 + 212 + await new Promise((resolve) => setTimeout(resolve, 50)); 213 + 214 + renderer.dispose(); 215 + }); 216 + 217 + it("should render scene with text shape", async () => { 218 + const store = new Store(); 219 + 220 + const page = PageRecord.create("Page 1", "page:1"); 221 + const text = ShapeRecord.createText("page:1", 100, 100, { 222 + text: "Hello World", 223 + fontSize: 16, 224 + fontFamily: "Arial", 225 + color: "#000000", 226 + }, "shape:1"); 227 + 228 + store.setState((state) => ({ 229 + ...state, 230 + doc: { pages: { [page.id]: { ...page, shapeIds: [text.id] } }, shapes: { [text.id]: text }, bindings: {} }, 231 + ui: { ...state.ui, currentPageId: page.id }, 232 + })); 233 + 234 + const renderer = createRenderer(canvas, store); 235 + 236 + await new Promise((resolve) => setTimeout(resolve, 50)); 237 + 238 + renderer.dispose(); 239 + }); 240 + 241 + it("should render text shape with word wrapping", async () => { 242 + const store = new Store(); 243 + 244 + const page = PageRecord.create("Page 1", "page:1"); 245 + const text = ShapeRecord.createText("page:1", 100, 100, { 246 + text: "Hello World this is a long text", 247 + fontSize: 16, 248 + fontFamily: "Arial", 249 + color: "#000000", 250 + w: 100, 251 + }, "shape:1"); 252 + 253 + store.setState((state) => ({ 254 + ...state, 255 + doc: { pages: { [page.id]: { ...page, shapeIds: [text.id] } }, shapes: { [text.id]: text }, bindings: {} }, 256 + ui: { ...state.ui, currentPageId: page.id }, 257 + })); 258 + 259 + const renderer = createRenderer(canvas, store); 260 + 261 + await new Promise((resolve) => setTimeout(resolve, 50)); 262 + 263 + renderer.dispose(); 264 + }); 265 + 266 + it("should render multiple shapes", async () => { 267 + const store = new Store(); 268 + 269 + const page = PageRecord.create("Page 1", "page:1"); 270 + const rect = ShapeRecord.createRect("page:1", 100, 100, { 271 + w: 200, 272 + h: 100, 273 + fill: "#ff0000", 274 + stroke: "#000000", 275 + radius: 0, 276 + }, "shape:1"); 277 + const ellipse = ShapeRecord.createEllipse("page:1", 400, 200, { 278 + w: 150, 279 + h: 100, 280 + fill: "#00ff00", 281 + stroke: "#000000", 282 + }, "shape:2"); 283 + 284 + store.setState((state) => ({ 285 + ...state, 286 + doc: { 287 + pages: { [page.id]: { ...page, shapeIds: [rect.id, ellipse.id] } }, 288 + shapes: { [rect.id]: rect, [ellipse.id]: ellipse }, 289 + bindings: {}, 290 + }, 291 + ui: { ...state.ui, currentPageId: page.id }, 292 + })); 293 + 294 + const renderer = createRenderer(canvas, store); 295 + 296 + await new Promise((resolve) => setTimeout(resolve, 50)); 297 + 298 + renderer.dispose(); 299 + }); 300 + 301 + it("should render selection outline for selected shapes", async () => { 302 + const store = new Store(); 303 + 304 + const page = PageRecord.create("Page 1", "page:1"); 305 + const rect = ShapeRecord.createRect("page:1", 100, 100, { 306 + w: 200, 307 + h: 100, 308 + fill: "#ff0000", 309 + stroke: "#000000", 310 + radius: 0, 311 + }, "shape:1"); 312 + 313 + store.setState((state) => ({ 314 + ...state, 315 + doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, 316 + ui: { ...state.ui, currentPageId: page.id, selectionIds: [rect.id] }, 317 + })); 318 + 319 + const renderer = createRenderer(canvas, store); 320 + 321 + await new Promise((resolve) => setTimeout(resolve, 50)); 322 + 323 + renderer.dispose(); 324 + }); 325 + 326 + it("should update render when store changes", async () => { 327 + const store = new Store(); 328 + 329 + const page = PageRecord.create("Page 1", "page:1"); 330 + 331 + store.setState((state) => ({ 332 + ...state, 333 + doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, 334 + ui: { ...state.ui, currentPageId: page.id }, 335 + })); 336 + 337 + const renderer = createRenderer(canvas, store); 338 + 339 + await new Promise((resolve) => setTimeout(resolve, 50)); 340 + 341 + const rect = ShapeRecord.createRect("page:1", 100, 100, { 342 + w: 200, 343 + h: 100, 344 + fill: "#ff0000", 345 + stroke: "#000000", 346 + radius: 0, 347 + }, "shape:1"); 348 + 349 + store.setState((state) => ({ 350 + ...state, 351 + doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, 352 + })); 353 + 354 + await new Promise((resolve) => setTimeout(resolve, 50)); 355 + 356 + renderer.dispose(); 357 + }); 358 + 359 + it("should apply camera transform correctly", async () => { 360 + const store = new Store(); 361 + 362 + const page = PageRecord.create("Page 1", "page:1"); 363 + const rect = ShapeRecord.createRect("page:1", 0, 0, { 364 + w: 100, 365 + h: 100, 366 + fill: "#ff0000", 367 + stroke: "#000000", 368 + radius: 0, 369 + }, "shape:1"); 370 + 371 + store.setState((state) => ({ 372 + ...state, 373 + doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, 374 + ui: { ...state.ui, currentPageId: page.id }, 375 + camera: { x: 100, y: 100, zoom: 2 }, 376 + })); 377 + 378 + const renderer = createRenderer(canvas, store); 379 + 380 + await new Promise((resolve) => setTimeout(resolve, 50)); 381 + 382 + renderer.dispose(); 383 + }); 384 + 385 + it("should handle rounded rectangle", async () => { 386 + const store = new Store(); 387 + 388 + const page = PageRecord.create("Page 1", "page:1"); 389 + const rect = ShapeRecord.createRect("page:1", 100, 100, { 390 + w: 200, 391 + h: 100, 392 + fill: "#ff0000", 393 + stroke: "#000000", 394 + radius: 10, 395 + }, "shape:1"); 396 + 397 + store.setState((state) => ({ 398 + ...state, 399 + doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, 400 + ui: { ...state.ui, currentPageId: page.id }, 401 + })); 402 + 403 + const renderer = createRenderer(canvas, store); 404 + 405 + await new Promise((resolve) => setTimeout(resolve, 50)); 406 + 407 + renderer.dispose(); 408 + }); 409 + 410 + it("should render shapes with rotation", async () => { 411 + const store = new Store(); 412 + 413 + const page = PageRecord.create("Page 1", "page:1"); 414 + const rect = ShapeRecord.createRect("page:1", 100, 100, { 415 + w: 200, 416 + h: 100, 417 + fill: "#ff0000", 418 + stroke: "#000000", 419 + radius: 0, 420 + }, "shape:1"); 421 + rect.rot = Math.PI / 4; 422 + 423 + store.setState((state) => ({ 424 + ...state, 425 + doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, 426 + ui: { ...state.ui, currentPageId: page.id }, 427 + })); 428 + 429 + const renderer = createRenderer(canvas, store); 430 + 431 + await new Promise((resolve) => setTimeout(resolve, 50)); 432 + 433 + renderer.dispose(); 434 + }); 435 + }); 436 + 437 + describe("markDirty", () => { 438 + it("should allow manual dirty marking", async () => { 439 + const store = new Store(); 440 + const renderer = createRenderer(canvas, store); 441 + 442 + await new Promise((resolve) => setTimeout(resolve, 50)); 443 + 444 + const rafSpy = vi.spyOn(globalThis, "requestAnimationFrame"); 445 + 446 + renderer.markDirty(); 447 + 448 + expect(rafSpy).toHaveBeenCalled(); 449 + 450 + renderer.dispose(); 451 + }); 452 + 453 + it("should not mark dirty after dispose", async () => { 454 + const store = new Store(); 455 + const renderer = createRenderer(canvas, store); 456 + 457 + await new Promise((resolve) => setTimeout(resolve, 50)); 458 + 459 + renderer.dispose(); 460 + 461 + // @ts-expect-error mocked 462 + const rafCallCount = globalThis.requestAnimationFrame.mock.calls.length; 463 + 464 + renderer.markDirty(); 465 + 466 + // @ts-expect-error mocked 467 + expect(globalThis.requestAnimationFrame.mock.calls.length).toBe(rafCallCount); 468 + }); 469 + }); 6 470 });
+1 -1
packages/renderer/tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 3 "target": "esnext", 4 - "lib": ["es2023"], 4 + "lib": ["es2023", "dom"], 5 5 "moduleDetection": "force", 6 6 "module": "preserve", 7 7 "moduleResolution": "bundler",
+2 -5
packages/renderer/tsdown.config.ts
··· 1 - import { defineConfig } from 'tsdown' 1 + import { defineConfig } from "tsdown"; 2 2 3 - export default defineConfig({ 4 - exports: true, 5 - // ...config options 6 - }) 3 + export default defineConfig({ exports: true });
+3
packages/renderer/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ test: { environment: "jsdom", globals: true } });
+385 -3
pnpm-lock.yaml
··· 53 53 version: 5.9.3 54 54 vitest: 55 55 specifier: ^4.0.16 56 - version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2) 56 + version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.3.0)(yaml@2.8.2) 57 57 58 58 packages/renderer: 59 + dependencies: 60 + inkfinite-core: 61 + specifier: workspace:* 62 + version: link:../core 59 63 devDependencies: 64 + '@types/jsdom': 65 + specifier: ^27.0.0 66 + version: 27.0.0 60 67 '@types/node': 61 68 specifier: ^25.0.3 62 69 version: 25.0.3 63 70 bumpp: 64 71 specifier: ^10.3.2 65 72 version: 10.3.2 73 + jsdom: 74 + specifier: ^27.3.0 75 + version: 27.3.0 66 76 tsdown: 67 77 specifier: ^0.18.1 68 78 version: 0.18.1(typescript@5.9.3) ··· 71 81 version: 5.9.3 72 82 vitest: 73 83 specifier: ^4.0.16 74 - version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2) 84 + version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.3.0)(yaml@2.8.2) 75 85 76 86 packages: 77 87 88 + '@acemir/cssom@0.9.29': 89 + resolution: {integrity: sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==} 90 + 91 + '@asamuzakjp/css-color@4.1.1': 92 + resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} 93 + 94 + '@asamuzakjp/dom-selector@6.7.6': 95 + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} 96 + 97 + '@asamuzakjp/nwsapi@2.3.9': 98 + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} 99 + 78 100 '@babel/generator@7.28.5': 79 101 resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} 80 102 engines: {node: '>=6.9.0'} ··· 96 118 resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} 97 119 engines: {node: '>=6.9.0'} 98 120 121 + '@csstools/color-helpers@5.1.0': 122 + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} 123 + engines: {node: '>=18'} 124 + 125 + '@csstools/css-calc@2.1.4': 126 + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} 127 + engines: {node: '>=18'} 128 + peerDependencies: 129 + '@csstools/css-parser-algorithms': ^3.0.5 130 + '@csstools/css-tokenizer': ^3.0.4 131 + 132 + '@csstools/css-color-parser@3.1.0': 133 + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} 134 + engines: {node: '>=18'} 135 + peerDependencies: 136 + '@csstools/css-parser-algorithms': ^3.0.5 137 + '@csstools/css-tokenizer': ^3.0.4 138 + 139 + '@csstools/css-parser-algorithms@3.0.5': 140 + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} 141 + engines: {node: '>=18'} 142 + peerDependencies: 143 + '@csstools/css-tokenizer': ^3.0.4 144 + 145 + '@csstools/css-syntax-patches-for-csstree@1.0.22': 146 + resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} 147 + engines: {node: '>=18'} 148 + 149 + '@csstools/css-tokenizer@3.0.4': 150 + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} 151 + engines: {node: '>=18'} 152 + 99 153 '@dprint/darwin-arm64@0.50.2': 100 154 resolution: {integrity: sha512-4d08INZlTxbPW9LK9W8+93viN543/qA2Kxn4azVnPW/xCb2Im03UqJBz8mMm3nJZdtNnK3uTVG3ib1VW+XJisw==} 101 155 cpu: [arm64] ··· 587 641 '@types/estree@1.0.8': 588 642 resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 589 643 644 + '@types/jsdom@27.0.0': 645 + resolution: {integrity: sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==} 646 + 590 647 '@types/json-schema@7.0.15': 591 648 resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} 592 649 593 650 '@types/node@25.0.3': 594 651 resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} 652 + 653 + '@types/tough-cookie@4.0.5': 654 + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} 595 655 596 656 '@typescript-eslint/eslint-plugin@8.50.0': 597 657 resolution: {integrity: sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==} ··· 690 750 resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 691 751 engines: {node: '>=0.4.0'} 692 752 hasBin: true 753 + 754 + agent-base@7.1.4: 755 + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} 756 + engines: {node: '>= 14'} 693 757 694 758 ajv@6.12.6: 695 759 resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} ··· 723 787 resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} 724 788 hasBin: true 725 789 790 + bidi-js@1.0.3: 791 + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} 792 + 726 793 birpc@4.0.0: 727 794 resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} 728 795 ··· 815 882 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 816 883 engines: {node: '>= 8'} 817 884 885 + css-tree@3.1.0: 886 + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} 887 + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} 888 + 889 + cssstyle@5.3.5: 890 + resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==} 891 + engines: {node: '>=20'} 892 + 893 + data-urls@6.0.0: 894 + resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} 895 + engines: {node: '>=20'} 896 + 818 897 debug@4.4.3: 819 898 resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} 820 899 engines: {node: '>=6.0'} ··· 823 902 peerDependenciesMeta: 824 903 supports-color: 825 904 optional: true 905 + 906 + decimal.js@10.6.0: 907 + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} 826 908 827 909 deep-is@0.1.4: 828 910 resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} ··· 856 938 empathic@2.0.0: 857 939 resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} 858 940 engines: {node: '>=14'} 941 + 942 + entities@6.0.1: 943 + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} 944 + engines: {node: '>=0.12'} 859 945 860 946 es-module-lexer@1.7.0: 861 947 resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} ··· 1003 1089 hookable@5.5.3: 1004 1090 resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} 1005 1091 1092 + html-encoding-sniffer@4.0.0: 1093 + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} 1094 + engines: {node: '>=18'} 1095 + 1096 + http-proxy-agent@7.0.2: 1097 + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} 1098 + engines: {node: '>= 14'} 1099 + 1100 + https-proxy-agent@7.0.6: 1101 + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} 1102 + engines: {node: '>= 14'} 1103 + 1104 + iconv-lite@0.6.3: 1105 + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} 1106 + engines: {node: '>=0.10.0'} 1107 + 1006 1108 ignore@5.3.2: 1007 1109 resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} 1008 1110 engines: {node: '>= 4'} ··· 1039 1141 resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} 1040 1142 engines: {node: '>=0.10.0'} 1041 1143 1144 + is-potential-custom-element-name@1.0.1: 1145 + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} 1146 + 1042 1147 isexe@2.0.0: 1043 1148 resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 1044 1149 ··· 1050 1155 resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} 1051 1156 hasBin: true 1052 1157 1158 + jsdom@27.3.0: 1159 + resolution: {integrity: sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==} 1160 + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} 1161 + peerDependencies: 1162 + canvas: ^3.0.0 1163 + peerDependenciesMeta: 1164 + canvas: 1165 + optional: true 1166 + 1053 1167 jsesc@3.1.0: 1054 1168 resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} 1055 1169 engines: {node: '>=6'} ··· 1080 1194 1081 1195 lodash.merge@4.6.2: 1082 1196 resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} 1197 + 1198 + lru-cache@11.2.4: 1199 + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} 1200 + engines: {node: 20 || >=22} 1083 1201 1084 1202 magic-string@0.30.21: 1085 1203 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 1086 1204 1205 + mdn-data@2.12.2: 1206 + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} 1207 + 1087 1208 minimatch@3.1.2: 1088 1209 resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} 1089 1210 ··· 1138 1259 resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} 1139 1260 engines: {node: '>=6'} 1140 1261 1262 + parse5@7.3.0: 1263 + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} 1264 + 1265 + parse5@8.0.0: 1266 + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} 1267 + 1141 1268 path-exists@4.0.0: 1142 1269 resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} 1143 1270 engines: {node: '>=8'} ··· 1196 1323 resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} 1197 1324 hasBin: true 1198 1325 1326 + require-from-string@2.0.2: 1327 + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} 1328 + engines: {node: '>=0.10.0'} 1329 + 1199 1330 resolve-from@4.0.0: 1200 1331 resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} 1201 1332 engines: {node: '>=4'} ··· 1235 1366 rxjs@7.8.2: 1236 1367 resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} 1237 1368 1369 + safer-buffer@2.1.2: 1370 + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} 1371 + 1372 + saxes@6.0.0: 1373 + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} 1374 + engines: {node: '>=v12.22.7'} 1375 + 1238 1376 semver@7.7.3: 1239 1377 resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} 1240 1378 engines: {node: '>=10'} ··· 1273 1411 resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} 1274 1412 engines: {node: '>=8'} 1275 1413 1414 + symbol-tree@3.2.4: 1415 + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} 1416 + 1276 1417 tinybench@2.9.0: 1277 1418 resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} 1278 1419 ··· 1288 1429 resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} 1289 1430 engines: {node: '>=14.0.0'} 1290 1431 1432 + tldts-core@7.0.19: 1433 + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} 1434 + 1435 + tldts@7.0.19: 1436 + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} 1437 + hasBin: true 1438 + 1439 + tough-cookie@6.0.0: 1440 + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} 1441 + engines: {node: '>=16'} 1442 + 1443 + tr46@6.0.0: 1444 + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} 1445 + engines: {node: '>=20'} 1446 + 1291 1447 tree-kill@1.2.2: 1292 1448 resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} 1293 1449 hasBin: true ··· 1445 1601 jsdom: 1446 1602 optional: true 1447 1603 1604 + w3c-xmlserializer@5.0.0: 1605 + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} 1606 + engines: {node: '>=18'} 1607 + 1608 + webidl-conversions@8.0.0: 1609 + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} 1610 + engines: {node: '>=20'} 1611 + 1612 + whatwg-encoding@3.1.1: 1613 + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} 1614 + engines: {node: '>=18'} 1615 + 1616 + whatwg-mimetype@4.0.0: 1617 + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} 1618 + engines: {node: '>=18'} 1619 + 1620 + whatwg-url@15.1.0: 1621 + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} 1622 + engines: {node: '>=20'} 1623 + 1448 1624 which@2.0.2: 1449 1625 resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 1450 1626 engines: {node: '>= 8'} ··· 1459 1635 resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} 1460 1636 engines: {node: '>=0.10.0'} 1461 1637 1638 + ws@8.18.3: 1639 + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} 1640 + engines: {node: '>=10.0.0'} 1641 + peerDependencies: 1642 + bufferutil: ^4.0.1 1643 + utf-8-validate: '>=5.0.2' 1644 + peerDependenciesMeta: 1645 + bufferutil: 1646 + optional: true 1647 + utf-8-validate: 1648 + optional: true 1649 + 1650 + xml-name-validator@5.0.0: 1651 + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} 1652 + engines: {node: '>=18'} 1653 + 1654 + xmlchars@2.2.0: 1655 + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} 1656 + 1462 1657 yaml@2.8.2: 1463 1658 resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} 1464 1659 engines: {node: '>= 14.6'} ··· 1470 1665 1471 1666 snapshots: 1472 1667 1668 + '@acemir/cssom@0.9.29': {} 1669 + 1670 + '@asamuzakjp/css-color@4.1.1': 1671 + dependencies: 1672 + '@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) 1673 + '@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) 1674 + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) 1675 + '@csstools/css-tokenizer': 3.0.4 1676 + lru-cache: 11.2.4 1677 + 1678 + '@asamuzakjp/dom-selector@6.7.6': 1679 + dependencies: 1680 + '@asamuzakjp/nwsapi': 2.3.9 1681 + bidi-js: 1.0.3 1682 + css-tree: 3.1.0 1683 + is-potential-custom-element-name: 1.0.1 1684 + lru-cache: 11.2.4 1685 + 1686 + '@asamuzakjp/nwsapi@2.3.9': {} 1687 + 1473 1688 '@babel/generator@7.28.5': 1474 1689 dependencies: 1475 1690 '@babel/parser': 7.28.5 ··· 1490 1705 dependencies: 1491 1706 '@babel/helper-string-parser': 7.27.1 1492 1707 '@babel/helper-validator-identifier': 7.28.5 1708 + 1709 + '@csstools/color-helpers@5.1.0': {} 1710 + 1711 + '@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)': 1712 + dependencies: 1713 + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) 1714 + '@csstools/css-tokenizer': 3.0.4 1715 + 1716 + '@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)': 1717 + dependencies: 1718 + '@csstools/color-helpers': 5.1.0 1719 + '@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) 1720 + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) 1721 + '@csstools/css-tokenizer': 3.0.4 1722 + 1723 + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': 1724 + dependencies: 1725 + '@csstools/css-tokenizer': 3.0.4 1726 + 1727 + '@csstools/css-syntax-patches-for-csstree@1.0.22': {} 1728 + 1729 + '@csstools/css-tokenizer@3.0.4': {} 1493 1730 1494 1731 '@dprint/darwin-arm64@0.50.2': 1495 1732 optional: true ··· 1821 2058 1822 2059 '@types/estree@1.0.8': {} 1823 2060 2061 + '@types/jsdom@27.0.0': 2062 + dependencies: 2063 + '@types/node': 25.0.3 2064 + '@types/tough-cookie': 4.0.5 2065 + parse5: 7.3.0 2066 + 1824 2067 '@types/json-schema@7.0.15': {} 1825 2068 1826 2069 '@types/node@25.0.3': 1827 2070 dependencies: 1828 2071 undici-types: 7.16.0 2072 + 2073 + '@types/tough-cookie@4.0.5': {} 1829 2074 1830 2075 '@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)': 1831 2076 dependencies: ··· 1963 2208 1964 2209 acorn@8.15.0: {} 1965 2210 2211 + agent-base@7.1.4: {} 2212 + 1966 2213 ajv@6.12.6: 1967 2214 dependencies: 1968 2215 fast-deep-equal: 3.1.3 ··· 1990 2237 balanced-match@1.0.2: {} 1991 2238 1992 2239 baseline-browser-mapping@2.9.11: {} 2240 + 2241 + bidi-js@1.0.3: 2242 + dependencies: 2243 + require-from-string: 2.0.2 1993 2244 1994 2245 birpc@4.0.0: {} 1995 2246 ··· 2094 2345 shebang-command: 2.0.0 2095 2346 which: 2.0.2 2096 2347 2348 + css-tree@3.1.0: 2349 + dependencies: 2350 + mdn-data: 2.12.2 2351 + source-map-js: 1.2.1 2352 + 2353 + cssstyle@5.3.5: 2354 + dependencies: 2355 + '@asamuzakjp/css-color': 4.1.1 2356 + '@csstools/css-syntax-patches-for-csstree': 1.0.22 2357 + css-tree: 3.1.0 2358 + 2359 + data-urls@6.0.0: 2360 + dependencies: 2361 + whatwg-mimetype: 4.0.0 2362 + whatwg-url: 15.1.0 2363 + 2097 2364 debug@4.4.3: 2098 2365 dependencies: 2099 2366 ms: 2.1.3 2367 + 2368 + decimal.js@10.6.0: {} 2100 2369 2101 2370 deep-is@0.1.4: {} 2102 2371 ··· 2123 2392 electron-to-chromium@1.5.267: {} 2124 2393 2125 2394 empathic@2.0.0: {} 2395 + 2396 + entities@6.0.1: {} 2126 2397 2127 2398 es-module-lexer@1.7.0: {} 2128 2399 ··· 2315 2586 2316 2587 hookable@5.5.3: {} 2317 2588 2589 + html-encoding-sniffer@4.0.0: 2590 + dependencies: 2591 + whatwg-encoding: 3.1.1 2592 + 2593 + http-proxy-agent@7.0.2: 2594 + dependencies: 2595 + agent-base: 7.1.4 2596 + debug: 4.4.3 2597 + transitivePeerDependencies: 2598 + - supports-color 2599 + 2600 + https-proxy-agent@7.0.6: 2601 + dependencies: 2602 + agent-base: 7.1.4 2603 + debug: 4.4.3 2604 + transitivePeerDependencies: 2605 + - supports-color 2606 + 2607 + iconv-lite@0.6.3: 2608 + dependencies: 2609 + safer-buffer: 2.1.2 2610 + 2318 2611 ignore@5.3.2: {} 2319 2612 2320 2613 ignore@7.0.5: {} ··· 2340 2633 dependencies: 2341 2634 is-extglob: 2.1.1 2342 2635 2636 + is-potential-custom-element-name@1.0.1: {} 2637 + 2343 2638 isexe@2.0.0: {} 2344 2639 2345 2640 jiti@2.6.1: {} ··· 2348 2643 dependencies: 2349 2644 argparse: 2.0.1 2350 2645 2646 + jsdom@27.3.0: 2647 + dependencies: 2648 + '@acemir/cssom': 0.9.29 2649 + '@asamuzakjp/dom-selector': 6.7.6 2650 + cssstyle: 5.3.5 2651 + data-urls: 6.0.0 2652 + decimal.js: 10.6.0 2653 + html-encoding-sniffer: 4.0.0 2654 + http-proxy-agent: 7.0.2 2655 + https-proxy-agent: 7.0.6 2656 + is-potential-custom-element-name: 1.0.1 2657 + parse5: 8.0.0 2658 + saxes: 6.0.0 2659 + symbol-tree: 3.2.4 2660 + tough-cookie: 6.0.0 2661 + w3c-xmlserializer: 5.0.0 2662 + webidl-conversions: 8.0.0 2663 + whatwg-encoding: 3.1.1 2664 + whatwg-mimetype: 4.0.0 2665 + whatwg-url: 15.1.0 2666 + ws: 8.18.3 2667 + xml-name-validator: 5.0.0 2668 + transitivePeerDependencies: 2669 + - bufferutil 2670 + - supports-color 2671 + - utf-8-validate 2672 + 2351 2673 jsesc@3.1.0: {} 2352 2674 2353 2675 json-buffer@3.0.1: {} ··· 2372 2694 p-locate: 5.0.0 2373 2695 2374 2696 lodash.merge@4.6.2: {} 2697 + 2698 + lru-cache@11.2.4: {} 2375 2699 2376 2700 magic-string@0.30.21: 2377 2701 dependencies: 2378 2702 '@jridgewell/sourcemap-codec': 1.5.5 2379 2703 2704 + mdn-data@2.12.2: {} 2705 + 2380 2706 minimatch@3.1.2: 2381 2707 dependencies: 2382 2708 brace-expansion: 1.1.12 ··· 2430 2756 dependencies: 2431 2757 callsites: 3.1.0 2432 2758 2759 + parse5@7.3.0: 2760 + dependencies: 2761 + entities: 6.0.1 2762 + 2763 + parse5@8.0.0: 2764 + dependencies: 2765 + entities: 6.0.1 2766 + 2433 2767 path-exists@4.0.0: {} 2434 2768 2435 2769 path-key@3.1.1: {} ··· 2474 2808 regjsparser@0.13.0: 2475 2809 dependencies: 2476 2810 jsesc: 3.1.0 2811 + 2812 + require-from-string@2.0.2: {} 2477 2813 2478 2814 resolve-from@4.0.0: {} 2479 2815 ··· 2546 2882 dependencies: 2547 2883 tslib: 2.8.1 2548 2884 2885 + safer-buffer@2.1.2: {} 2886 + 2887 + saxes@6.0.0: 2888 + dependencies: 2889 + xmlchars: 2.2.0 2890 + 2549 2891 semver@7.7.3: {} 2550 2892 2551 2893 shebang-command@2.0.0: ··· 2570 2912 dependencies: 2571 2913 has-flag: 4.0.0 2572 2914 2915 + symbol-tree@3.2.4: {} 2916 + 2573 2917 tinybench@2.9.0: {} 2574 2918 2575 2919 tinyexec@1.0.2: {} ··· 2580 2924 picomatch: 4.0.3 2581 2925 2582 2926 tinyrainbow@3.0.3: {} 2927 + 2928 + tldts-core@7.0.19: {} 2929 + 2930 + tldts@7.0.19: 2931 + dependencies: 2932 + tldts-core: 7.0.19 2933 + 2934 + tough-cookie@6.0.0: 2935 + dependencies: 2936 + tldts: 7.0.19 2937 + 2938 + tr46@6.0.0: 2939 + dependencies: 2940 + punycode: 2.3.1 2583 2941 2584 2942 tree-kill@1.2.2: {} 2585 2943 ··· 2670 3028 jiti: 2.6.1 2671 3029 yaml: 2.8.2 2672 3030 2673 - vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2): 3031 + vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.3.0)(yaml@2.8.2): 2674 3032 dependencies: 2675 3033 '@vitest/expect': 4.0.16 2676 3034 '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2)) ··· 2694 3052 why-is-node-running: 2.3.0 2695 3053 optionalDependencies: 2696 3054 '@types/node': 25.0.3 3055 + jsdom: 27.3.0 2697 3056 transitivePeerDependencies: 2698 3057 - jiti 2699 3058 - less ··· 2707 3066 - tsx 2708 3067 - yaml 2709 3068 3069 + w3c-xmlserializer@5.0.0: 3070 + dependencies: 3071 + xml-name-validator: 5.0.0 3072 + 3073 + webidl-conversions@8.0.0: {} 3074 + 3075 + whatwg-encoding@3.1.1: 3076 + dependencies: 3077 + iconv-lite: 0.6.3 3078 + 3079 + whatwg-mimetype@4.0.0: {} 3080 + 3081 + whatwg-url@15.1.0: 3082 + dependencies: 3083 + tr46: 6.0.0 3084 + webidl-conversions: 8.0.0 3085 + 2710 3086 which@2.0.2: 2711 3087 dependencies: 2712 3088 isexe: 2.0.0 ··· 2717 3093 stackback: 0.0.2 2718 3094 2719 3095 word-wrap@1.2.5: {} 3096 + 3097 + ws@8.18.3: {} 3098 + 3099 + xml-name-validator@5.0.0: {} 3100 + 3101 + xmlchars@2.2.0: {} 2720 3102 2721 3103 yaml@2.8.2: {} 2722 3104