├── .github └── workflows │ └── playwright.yml ├── .gitignore ├── .prettierrc.json ├── .storybook ├── main.js └── preview.js ├── .swcrc ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── playwright.config.ts ├── src ├── index.ts └── stories │ ├── assets │ ├── code-brackets.svg │ ├── colors.svg │ ├── comments.svg │ ├── direction.svg │ ├── flow.svg │ ├── plugin.svg │ ├── repo.svg │ └── stackalt.svg │ ├── components │ ├── g.stories.ts │ ├── horizontalLine.stories.ts │ ├── img.stories.ts │ ├── layer.stories.ts │ ├── line.stories.ts │ ├── rect.stories.ts │ ├── svgPath.stories.ts │ ├── text.stories.ts │ └── verticalLine.stories.ts │ ├── examples │ └── custom-components.stories.ts │ ├── tests │ └── rect.stories.ts │ └── util.ts ├── tests ├── rect.spec.ts └── rect.spec.ts-snapshots │ ├── basic-properties-1-chromium-linux.png │ ├── basic-properties-1-firefox-linux.png │ └── basic-properties-1-webkit-linux.png └── tsconfig.json /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [ main, master ] 5 | pull_request: 6 | branches: [ main, master ] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 16 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Install Playwright Browsers 19 | run: npx playwright install --with-deps 20 | - name: Run Playwright tests 21 | run: npx playwright test 22 | - uses: actions/upload-artifact@v3 23 | if: always() 24 | with: 25 | name: playwright-report 26 | path: playwright-report/ 27 | retention-days: 30 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | /test-results/ 4 | /playwright-report/ 5 | /playwright/.cache/ 6 | logs/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 3 | addons: [ 4 | '@storybook/addon-links', 5 | '@storybook/addon-essentials', 6 | '@storybook/addon-interactions', 7 | '@storybook/addon-knobs', 8 | ], 9 | framework: '@storybook/html', 10 | }; 11 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: '^on[A-Z].*' }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript", 6 | "tsx": false, 7 | "decorators": false, 8 | "dynamicImport": false 9 | }, 10 | "target": "es2022" 11 | }, 12 | "module": { 13 | "type": "es6" 14 | }, 15 | "sourceMaps": true 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ben Lesh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Canvas Render Components (alpha) 2 | 3 | # NO LONGER MAINTAINED. 4 | 5 | **Super duper alpha.. Use at your own risk. lol** 6 | 7 | The basic idea here is a "react-like" API that will create canvas "components" such that it handles: 8 | 9 | 1. Events over very specific areas of of the canvas, as defined by components. 10 | 2. Communicating state changes between components. 11 | 3. Ensuring that only what needs to be rendered is actually rendered. 12 | 13 | There's a [playground link here on Stackblitz](https://stackblitz.com/fork/canvas-render-components). 14 | 15 | ## Known Issues and Missing Features 16 | 17 | - **THERE ARE NO TESTS!! (duh, huge red flag!)** 18 | - Missing events: 19 | - onMouseDown 20 | - onMouseUp 21 | - onKeyPress 22 | - onKeyDown 23 | - onKeyUp 24 | - touch events? 25 | - Have yet to figure out focus management scheme 26 | - Screen reader updates 27 | - Components: 28 | - Ellipse? Circle? 29 | - Do I want to allow transformations (scale, rotate, etc) on other existing components? 30 | 31 | ## Getting Started 32 | 33 | See storybook examples for usage. 34 | 35 | Basically: 36 | 37 | 1. Define a component 38 | 39 | ```ts 40 | import { defineComp, rect, text } from 'canvas-render-components'; 41 | 42 | function MyComp(props: MyCompProps, ctx: CanvasRenderingContext2D) => { 43 | // There are hooks like React, just prefixed with `crc` instead of use: 44 | const [count, setCount] = crcState(0); 45 | 46 | // You can return other crc elements, like react, but JSX is annoying to hook up 47 | // So I don't have that in this example: 48 | return [ 49 | rect({ 50 | x: 10, 51 | y: 10, 52 | width: 100, 53 | height: 100, 54 | fillStyle: 'blue', 55 | onClick: () => setCount(count + 1) 56 | }), 57 | text({ 58 | x: 10, 59 | y: 10, 60 | width: 100, 61 | text: 'Click Me', 62 | fillStyle: 'white' 63 | }) 64 | ]; 65 | } 66 | 67 | export const myComp = defineComp(MyComp) 68 | ``` 69 | 70 | 2. Mount the component to an existing `HTMLCanvasElement`: 71 | 72 | ```ts 73 | import { myComp } from './MyComp'; 74 | import { crc } from 'canvas-render-components'; 75 | 76 | const canvas = document.querySelector('#my-canvas-id'); 77 | crc(canvas, myComp); 78 | ``` 79 | 80 | # API List 81 | 82 | Sorry, this isn't really documentation, just the basic idea: 83 | 84 | ## Utilities 85 | 86 | - `crc(canvasElement, crcElement)` - Mount or update an existing canvas element with a crc element 87 | - `defineComp(compFn)` - used to create a more ergonomic means of consuming crc components and returning crc elements when setting up JSX is too annoying (it's always too annoying). 88 | 89 | ## Components 90 | 91 | - `path`: Renders an arbitrary `Path2D` 92 | - `rect`: Renders a rectangle 93 | - `text`: Text rendering (including multiline, singleline, ellipsis overflow, etc) 94 | - `line`: Renders a series of coordinates as connected line segments 95 | - `verticalLine`/`horizontalLine` special components for rendering vertical or horizontal line segments, which includes a bit called `alignToPixelGrid` that allows you to ensure 1px lines are _really_ 1px. (it's a canvas quirk) 96 | - `svgPath`: Renders svg path data as a shape 97 | - `img`: Loads and renders an image 98 | - `g`: A grouping component that allows the group application of transformations such as scale, rotation, etc. 99 | - `clip`: A grouping component that applies a clipping path to everything rendered in its `children`. It ALSO will "clip" events. 100 | - `layer`: A component for memoizing another component as a unit of render. Basically, if the props of the `CompEl` passed to `render` change, or if the `width` or `height` of the layer change (it will default to the canvas width and height), it will re-render itself. Otherwise, it will render a cached image. See the storybook for example. Note that it does a shallow, reference check on the props of the element passed to render. 101 | 102 | ## Hooks 103 | 104 | - `crcRef` - basically a simplified version of react's `useRef` 105 | - `crcState` - A simplified version of react's `useState` 106 | - `crcMemo` - Basically react's `useMemo`. This is VERY useful for memoizing `Path2D` objects that need to be passed to other hooks. Strongly recommended for that use case. 107 | - `crcWhenChanged` - Looks like react's `useEffect`.. it is **NOT**. It takes a callback that will execute _SYNCHRONOUSLY_ when dependencies change. It also allows the return of a teardown. This is specifically for use cases where one might need to execute some logic only when some dependencies change. **DO NOT USE if you need to _synchronously update some \_state_ when dependencies change, use `crcMemo` instead**. 108 | - `crcCursor` - A hook to allow the setup of CSS cursor (pointer) changes when hovering a given `Path2D`. 109 | - `crcEvent` - A hook for setting up events related to a particular `Path2D` (or if no path is provided, the entire canvas) 110 | - `crcRectPath` - A simplified hook that returns a memoized `Path2D` for a rectangle (A common task). 111 | - `crcLinePath` - A hook for memoized `Path2D` objects from coordinates. 112 | - `crcSvgPath` - A hook for memoized `Path2D` objects from svg path data strings. 113 | 114 | # Tips 115 | 116 | ## Events coming through other rendered things 117 | 118 | If you're seeing events "bleeding through" things you've rendered over top of them, you need to use the `clip` component to constrain where the event is allowed to fire. Events are registered separate from rendering anything, they operate on a 2d plain of their own and don't "know" about what pixels are rendered where. The `clip` component keeps track of clipping paths in a context that it will apply to events registered underneath it. Basically, if your event is registered against a path as part of a `clip` components children or descendants, for the event to fire, it must match the event path AND the clipping path. Think of the clipping path as a "mask" of where events underneath it are "allowed" to fire. (It also clips what is rendered) 119 | 120 | ## Order matters! 121 | 122 | The last thing in a list of children will be rendered "on top". Remember that as you're rendering things. 123 | 124 | ## Perf: More events, more problems. 125 | 126 | Events are register and/or unregistered on every render. They're associated with `Path2D` objects, functions that are handlers, and probably some closure and other things. **The more events you have, the slower your render will be**. Full stop. So, "event delegation" can be a useful tool for you. Perhaps what you need to do is use the `crcEvent` hook and register one event against a compound path of some sort. Or maybe register an event against the whole canvas by passing a `undefined` path to `crcEvent`, and then do some of your own math to figure out if it's a hit or not. In any case, if you're registering and unregistring 1,000 event handlers on each render, it's going to add up. Don't do that. 127 | 128 | ## Perf: Canvas layering 129 | 130 | Another technique is to have more than one canvas, one on top of the other. In this way, you can have elements of your scene that are rarely updated rendered on the "bottom" canvas, while your more frequently updated elements are rendered on the "top" canvas. This means less code being executed on each pass. 131 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvas-render-components", 3 | "version": "0.0.17", 4 | "description": "HTML Canvas Componentized Rendering", 5 | "types": "./dist/types/index.d.ts", 6 | "main": "./dist/cjs/index.js", 7 | "module": "./dist/esm/index.js", 8 | "exports": { 9 | "import": "./dist/esm/index.js", 10 | "require": "./dist/cjs/index.js", 11 | "types": "./dist/types/index.d.ts" 12 | }, 13 | "files": [ 14 | "dist/" 15 | ], 16 | "scripts": { 17 | "build:esm": "rm -rf dist/esm && swc src/ -d dist/esm -C minify=true", 18 | "build:cjs": "rm -rf dist/cjs && swc src/ -d dist/cjs -C module.type=commonjs", 19 | "build:types": "rm -rf dist/types && tsc", 20 | "build": "npm run build:types && npm run build:esm && npm run build:cjs", 21 | "prepublish": "", 22 | "storybook": "start-storybook -p 6006", 23 | "build-storybook": "build-storybook", 24 | "test": "npx playwright test" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/benlesh/canvas-render-components.git" 29 | }, 30 | "keywords": [ 31 | "HTML", 32 | "Canvas", 33 | "Components" 34 | ], 35 | "author": "Ben Lesh ", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/benlesh/canvas-render-components/issues" 39 | }, 40 | "homepage": "https://github.com/benlesh/canvas-render-components#readme", 41 | "devDependencies": { 42 | "@babel/core": "^7.20.7", 43 | "@playwright/test": "^1.30.0", 44 | "@storybook/addon-actions": "^6.5.16", 45 | "@storybook/addon-essentials": "^6.5.16", 46 | "@storybook/addon-interactions": "^6.5.16", 47 | "@storybook/addon-links": "^6.5.16", 48 | "@storybook/builder-webpack4": "^6.5.16", 49 | "@storybook/html": "^6.5.16", 50 | "@storybook/manager-webpack4": "^6.5.16", 51 | "@storybook/testing-library": "^0.0.13", 52 | "@swc/cli": "^0.1.57", 53 | "@swc/core": "^1.3.24", 54 | "@types/offscreencanvas": "^2019.7.0", 55 | "babel-loader": "^8.3.0", 56 | "prettier": "^2.8.1", 57 | "typescript": "^4.9.4" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | import { devices } from '@playwright/test'; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: './tests', 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000, 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 0, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: 'html', 34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 35 | use: { 36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 37 | actionTimeout: 0, 38 | /* Base URL to use in actions like `await page.goto('/')`. */ 39 | baseURL: 'http://localhost:6006', 40 | 41 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 42 | trace: 'on-first-retry', 43 | }, 44 | 45 | /* Configure projects for major browsers */ 46 | projects: [ 47 | { 48 | name: 'chromium', 49 | use: { 50 | ...devices['Desktop Chrome'], 51 | }, 52 | }, 53 | 54 | { 55 | name: 'firefox', 56 | use: { 57 | ...devices['Desktop Firefox'], 58 | }, 59 | }, 60 | 61 | { 62 | name: 'webkit', 63 | use: { 64 | ...devices['Desktop Safari'], 65 | }, 66 | }, 67 | 68 | /* Test against mobile viewports. */ 69 | // { 70 | // name: 'Mobile Chrome', 71 | // use: { 72 | // ...devices['Pixel 5'], 73 | // }, 74 | // }, 75 | // { 76 | // name: 'Mobile Safari', 77 | // use: { 78 | // ...devices['iPhone 12'], 79 | // }, 80 | // }, 81 | 82 | /* Test against branded browsers. */ 83 | // { 84 | // name: 'Microsoft Edge', 85 | // use: { 86 | // channel: 'msedge', 87 | // }, 88 | // }, 89 | // { 90 | // name: 'Google Chrome', 91 | // use: { 92 | // channel: 'chrome', 93 | // }, 94 | // }, 95 | ], 96 | 97 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 98 | // outputDir: 'test-results/', 99 | 100 | /* Run your local dev server before starting the tests */ 101 | webServer: { 102 | command: 'npx start-storybook -p 6006', 103 | // @ts-expect-error 104 | waitFor: waitForStorybook, 105 | url: 'http://localhost:6006', 106 | }, 107 | }; 108 | 109 | export default config; 110 | 111 | function waitForStorybook({ childProcess }) { 112 | return new Promise((resolve) => { 113 | childProcess.stdout.on('data', (data) => { 114 | if (data.toString().test(/Storybook .*? for Html started/g)) { 115 | resolve(); 116 | } 117 | }); 118 | }); 119 | } 120 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CRC - Canvas Render Components (alpha) 3 | * 4 | * MIT License 5 | * 6 | * Copyright 2020 Ben Lesh 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 9 | * documentation files (the "Software"), to deal in the Software without restriction, including without limitation 10 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 11 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions 14 | * of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 18 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 19 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 20 | * IN THE SOFTWARE. 21 | */ 22 | 23 | export interface CompEl

{ 24 | type: CompFn

; 25 | props: P; 26 | } 27 | 28 | type FalsyNodes = undefined | false | null | '' | 0; 29 | 30 | export type CRCNode = CompEl | void | FalsyNodes; 31 | 32 | export type Canvas = HTMLCanvasElement | OffscreenCanvas; 33 | export type RenderingContext2D = 34 | | CanvasRenderingContext2D 35 | | OffscreenCanvasRenderingContext2D; 36 | 37 | export type CompFn

= ( 38 | props: P, 39 | ctx: RenderingContext2D, 40 | ) => CRCNode[] | CRCNode | void; 41 | 42 | interface CompRefs

{ 43 | id: string; 44 | parent: CompRefs | undefined; 45 | element: CompEl

; 46 | refs: any[]; 47 | teardowns: Teardowns; 48 | onAfterRender: (() => void) | undefined; 49 | } 50 | 51 | export type CRCMouseEventListener = (e: CRCMouseEvent) => void; 52 | 53 | interface CRCEventRegistry { 54 | click: Set<(e: MouseEvent) => void>; 55 | dblclick: Set<(e: MouseEvent) => void>; 56 | mousemove: Set<(e: MouseEvent) => void>; 57 | mouseover: Set<(e: MouseEvent) => void>; 58 | mouseout: Set<(e: MouseEvent) => void>; 59 | mousedown: Set<(e: MouseEvent) => void>; 60 | mouseup: Set<(e: MouseEvent) => void>; 61 | contextmenu: Set<(e: MouseEvent) => void>; 62 | } 63 | 64 | interface CRCInstance { 65 | root: CompEl; 66 | refs: Map>; 67 | animationFrameId: number; 68 | renderTimestamp: number; 69 | mainTeardowns: Teardowns; 70 | events: CRCEventRegistry; 71 | parent: Canvas | undefined; 72 | } 73 | 74 | export type PixelGridAlignment = 'none' | 'round' | 'ceil' | 'floor'; 75 | 76 | //////////////////////////////////////////////////////////////////////////////////////// 77 | // Shared State 78 | //////////////////////////////////////////////////////////////////////////////////////// 79 | 80 | let crcInstances = new Map(); 81 | let _currentCRCInstance: CRCInstance | undefined = undefined; 82 | let _componentRefs: CompRefs | undefined = undefined; 83 | let _canvas: Canvas | undefined = undefined; 84 | let _componentIsMounting = false; 85 | let _componentHookIndex = 0; 86 | let _renderContext: RenderingContext2D | undefined = undefined; 87 | let _unseenIds: Set | undefined = undefined; 88 | let _seenIds: Set | undefined = undefined; 89 | let _mouseTransform: DOMMatrix | undefined = undefined; 90 | let clippingStack: { 91 | path: Path2D; 92 | fillRule: CanvasFillRule; 93 | transform: DOMMatrix; 94 | }[] = []; 95 | 96 | let anonElementNames = new WeakMap(); 97 | 98 | let anon = 0; 99 | 100 | /** 101 | * Used to get a name for an anonymous function. 102 | * @param fn The anonymous function to get a name for 103 | * @returns A name for the anonymous function 104 | */ 105 | function getAnonElemName(fn: any) { 106 | if (!anonElementNames.has(fn)) { 107 | anonElementNames.set(fn, `Anon${anon++}`); 108 | } 109 | return anonElementNames.get(fn)!; 110 | } 111 | 112 | /** 113 | * Useful for testing and HMR. 114 | * DO NOT USE IN PROD 115 | */ 116 | export function clearSharedState() { 117 | for (const crcInstance of crcInstances.values()) { 118 | cleanupCRCInstance(crcInstance); 119 | } 120 | crcInstances.clear(); 121 | clippingStack = []; 122 | anonElementNames = new WeakMap(); 123 | clearTempState(); 124 | } 125 | 126 | function cleanupCRCInstance(crcInstance: CRCInstance) { 127 | crcInstance.mainTeardowns.execute(); 128 | } 129 | 130 | function clearTempState() { 131 | _unseenIds = undefined; 132 | _seenIds = undefined; 133 | _canvas = undefined; 134 | _renderContext = undefined; 135 | clearComponentState(); 136 | } 137 | 138 | function clearComponentState() { 139 | _componentRefs = undefined; 140 | _componentIsMounting = false; 141 | _componentHookIndex = 0; 142 | } 143 | 144 | //////////////////////////////////////////////////////////////////////////////////////// 145 | // Core 146 | //////////////////////////////////////////////////////////////////////////////////////// 147 | 148 | /** 149 | * Mounts or updates a canvas render component on a given HTMLCanvasElement 150 | * @param canvas The canvas to render on 151 | * @param element The CRC element to render 152 | */ 153 | export function crc

( 154 | canvas: HTMLCanvasElement, 155 | element: CompEl

, 156 | config?: { signal?: AbortSignal }, 157 | ) { 158 | if (!crcInstances.has(canvas)) { 159 | mountCRC

(canvas, element, config); 160 | } 161 | 162 | update(canvas, element); 163 | } 164 | 165 | function mountCRC

( 166 | canvas: Canvas, 167 | element: CompEl

, 168 | config?: { signal?: AbortSignal; parent?: Canvas }, 169 | ) { 170 | const mainTeardowns = new Teardowns(); 171 | 172 | const signal = config?.signal; 173 | 174 | const cleanup = () => { 175 | crcInstances.delete(canvas); 176 | mainTeardowns.execute(); 177 | }; 178 | 179 | if (signal) { 180 | signal.addEventListener('abort', cleanup, { 181 | once: true, 182 | }); 183 | mainTeardowns.add(() => { 184 | signal.removeEventListener('abort', cleanup); 185 | }); 186 | } 187 | 188 | const parent = config?.parent; 189 | 190 | const crcInstance: CRCInstance = { 191 | root: element, 192 | refs: new Map(), 193 | animationFrameId: 0, 194 | renderTimestamp: 0, 195 | mainTeardowns, 196 | events: { 197 | click: new Set(), 198 | dblclick: new Set(), 199 | mousemove: new Set(), 200 | mouseover: new Set(), 201 | mouseout: new Set(), 202 | mousedown: new Set(), 203 | mouseup: new Set(), 204 | contextmenu: new Set(), 205 | }, 206 | parent, 207 | }; 208 | 209 | if (parent) { 210 | const parentCRCInstance = crcInstances.get(parent); 211 | 212 | for (const [type, handlers] of Object.entries(crcInstance.events)) { 213 | const handler = (e: MouseEvent) => { 214 | _mouseTransform = createMouseEventTransform( 215 | e.target as HTMLCanvasElement, 216 | ); 217 | for (const handler of handlers) { 218 | try { 219 | handler(e); 220 | } catch (err) { 221 | reportError(err); 222 | } 223 | } 224 | _mouseTransform = undefined; 225 | }; 226 | 227 | parentCRCInstance.events[type].add(handler); 228 | 229 | mainTeardowns.add(() => { 230 | parentCRCInstance.events[type].delete(handler); 231 | }); 232 | } 233 | } else { 234 | // Only the parent canvas wires up events. 235 | for (const [type, handlers] of Object.entries(crcInstance.events)) { 236 | canvas.addEventListener( 237 | type, 238 | (e) => { 239 | _mouseTransform = createMouseEventTransform( 240 | canvas as HTMLCanvasElement, 241 | ); 242 | for (const handler of handlers) { 243 | try { 244 | handler(e); 245 | } catch (err) { 246 | reportError(err); 247 | } 248 | } 249 | _mouseTransform = undefined; 250 | }, 251 | { signal }, 252 | ); 253 | } 254 | } 255 | 256 | crcInstances.set(canvas, crcInstance); 257 | } 258 | 259 | /** 260 | * Creates a simple structure representing a component to be rendered. 261 | * DOES NOT RENDER. 262 | * @param comp The render function for a component 263 | * @param props The props to pass to that render function on render 264 | * @returns A structure that represents a component to be mounted or updated during render 265 | */ 266 | export function createElement

(comp: CompFn

, props: P): CompEl

{ 267 | return { type: comp, props }; 268 | } 269 | 270 | /** 271 | * Used to convert a component render function to a function that will return a component element. 272 | * This is useful for now because setting up JSX is still annoying. 273 | * @param compFn A component to create a simplfied function for 274 | */ 275 | export function defineComp

(compFn: CompFn

) { 276 | return (props: P) => createElement(compFn, props); 277 | } 278 | 279 | function createMouseEventTransform(canvas: HTMLCanvasElement) { 280 | const { width, height } = canvas; 281 | const bounds = canvas.getBoundingClientRect(); 282 | const xScale = width / bounds.width; 283 | const yScale = height / bounds.height; 284 | if (xScale !== 1 || yScale !== 1) { 285 | return new DOMMatrix().scale(xScale, yScale); 286 | } 287 | } 288 | 289 | /** 290 | * Gets the root element for a canvas and renders it. This is the 291 | * entry point for rendering the entire tree. 292 | * @param canvas The canvas to render on 293 | */ 294 | function executeRender( 295 | canvas: Canvas, 296 | renderTimestamp: number, 297 | updatedRoot?: CompEl, 298 | updateParents = false, 299 | ) { 300 | if (!canvas) { 301 | throw new Error('No canvas element provided'); 302 | } 303 | 304 | const crcInstance = crcInstances.get(canvas); 305 | 306 | if (!crcInstance) { 307 | throw new Error('CRC is not mounted on this element'); 308 | } 309 | 310 | crcInstance.animationFrameId = 0; 311 | 312 | if (crcInstance.mainTeardowns.closed) return; 313 | 314 | const prevCurrentCRCInstance = _currentCRCInstance; 315 | const prevCanvas = _canvas; 316 | const prevRenderContext = _renderContext; 317 | const prevMouseTransform = _mouseTransform; 318 | const prevSeenIds = _seenIds; 319 | const prevUnseendIds = _unseenIds; 320 | const prevComponentRefs = _componentRefs; 321 | try { 322 | _currentCRCInstance = crcInstance; 323 | _currentCRCInstance.renderTimestamp = renderTimestamp; 324 | _canvas = canvas; 325 | const rootElement = updatedRoot ?? _currentCRCInstance.root; 326 | _currentCRCInstance.root = rootElement; 327 | const ctx = get2dContext(canvas); 328 | _renderContext = ctx; 329 | 330 | _unseenIds = new Set(_currentCRCInstance.refs.keys()); 331 | _seenIds = new Set(); 332 | const id = 'root'; 333 | _unseenIds.delete(id); 334 | _seenIds.add(id); 335 | ctx.clearRect(0, 0, canvas.width, canvas.height); 336 | 337 | render(rootElement, id, undefined); 338 | 339 | for (const id of _unseenIds) { 340 | // HACK: I want to be more efficient here, but this is fine for now 341 | _currentCRCInstance.refs.get(id)?.teardowns.execute(); 342 | _currentCRCInstance.refs.delete(id); 343 | } 344 | } finally { 345 | const { parent } = _currentCRCInstance; 346 | clearTempState(); 347 | if (updateParents) { 348 | if (parent) { 349 | executeRender(parent, renderTimestamp); 350 | } 351 | } 352 | _currentCRCInstance = prevCurrentCRCInstance; 353 | _canvas = prevCanvas; 354 | _renderContext = prevRenderContext; 355 | _mouseTransform = prevMouseTransform; 356 | _seenIds = prevSeenIds; 357 | _unseenIds = prevUnseendIds; 358 | _componentRefs = prevComponentRefs; 359 | } 360 | } 361 | 362 | /** 363 | * 364 | * @param canvas The canvas with CRC mounted on it to update 365 | * @param element The optional element to update as the root element of that canvas. 366 | */ 367 | function update(canvas: Canvas, rootElement?: CompEl, updateParents = false) { 368 | const crcInstance = crcInstances.get(canvas)!; 369 | 370 | if (!crcInstance) { 371 | throw new Error('Canvas does not have CRC mounted.'); 372 | } 373 | 374 | if (crcInstance.animationFrameId) { 375 | cancelAnimationFrame(crcInstance.animationFrameId); 376 | } 377 | 378 | crcInstance.animationFrameId = requestAnimationFrame((ts) => 379 | executeRender(canvas, ts, rootElement, updateParents), 380 | ); 381 | } 382 | 383 | function getElementTypeName(element: CompEl): string { 384 | return ( 385 | element.props.key || element.type.name || getAnonElemName(element.type) 386 | ); 387 | } 388 | 389 | function render

( 390 | element: CompEl

, 391 | parentId: string, 392 | parent: CompRefs | undefined, 393 | ): void { 394 | try { 395 | const typeName = getElementTypeName(element); 396 | let id = ensureUniqueId(parentId, typeName); 397 | _unseenIds!.delete(id); 398 | _seenIds!.add(id); 399 | _componentIsMounting = !_currentCRCInstance!.refs.has(id); 400 | if (_componentIsMounting) { 401 | const teardowns = new Teardowns(); 402 | teardowns.follow(_currentCRCInstance!.mainTeardowns); 403 | _currentCRCInstance!.refs.set(id, { 404 | id, 405 | parent, 406 | element, 407 | refs: [], 408 | teardowns, 409 | onAfterRender: undefined, 410 | }); 411 | } 412 | 413 | _componentRefs = _currentCRCInstance!.refs.get(id)!; 414 | _componentHookIndex = 0; 415 | 416 | _componentRefs.element = element; 417 | 418 | const ctx = _renderContext!; 419 | ctx.save(); 420 | const result = _componentRefs.element.type( 421 | _componentRefs.element.props, 422 | ctx, 423 | ); 424 | 425 | const onAfterRender = _componentRefs.onAfterRender; 426 | _componentRefs.onAfterRender = undefined; 427 | 428 | if (result) { 429 | if (Array.isArray(result)) { 430 | for (let i = 0; i < result.length; i++) { 431 | const child = result[i]; 432 | if (child) { 433 | ctx.save(); 434 | render(child, id, _componentRefs); 435 | ctx.restore(); 436 | } 437 | } 438 | } else { 439 | ctx.save(); 440 | render(result, id, _componentRefs); 441 | ctx.restore(); 442 | } 443 | } 444 | 445 | onAfterRender?.(); 446 | 447 | ctx.restore(); 448 | } finally { 449 | clearComponentState(); 450 | } 451 | } 452 | 453 | export interface ClipProps { 454 | path: Path2D; 455 | fillRule?: CanvasFillRule; 456 | children: CRCNode[]; 457 | } 458 | 459 | export function Clip(props: ClipProps, ctx: RenderingContext2D) { 460 | const { path, fillRule = 'nonzero' } = props; 461 | 462 | _componentRefs!.onAfterRender = () => { 463 | clippingStack.pop(); 464 | }; 465 | 466 | clippingStack.push({ path, fillRule, transform: ctx.getTransform() }); 467 | ctx.clip(path, fillRule); 468 | 469 | return props.children; 470 | } 471 | 472 | export const clip = defineComp(Clip); 473 | 474 | //////////////////////////////////////////////////////////////////////////////////////// 475 | // Hooks 476 | //////////////////////////////////////////////////////////////////////////////////////// 477 | 478 | interface CRCRef { 479 | current: T | undefined; 480 | } 481 | 482 | function ensureUniqueId(parentId: string, typeName: string) { 483 | let id = parentId + '/' + typeName; 484 | let n = 1; 485 | while (_seenIds!.has(id)) { 486 | id = parentId + '/' + typeName + '_' + n++; 487 | } 488 | return id; 489 | } 490 | 491 | /** 492 | * Hook: Creates a reference object, similar to React's useRef. 493 | * @param init The initial reference value 494 | * @returns A reference with a `current` property containing the value 495 | */ 496 | export function crcRef(init?: T): CRCRef { 497 | if (_componentIsMounting) { 498 | _componentRefs!.refs.push({ 499 | type: 'ref', 500 | current: init, 501 | }); 502 | } 503 | 504 | return _componentRefs!.refs[_componentHookIndex++]; 505 | } 506 | 507 | export type StateUpdater = (update: T | ((oldValue: T) => T)) => void; 508 | 509 | /** 510 | * Hook: creates a tuple of state and a state setter. Setting the state with the 511 | * state setter will cause the entire canvas CRC instance to be scheduled for 512 | * rerender. If you set the state multiple times before the next animation frame, 513 | * it will only rerender with whatever the most recent state is as of that animation frame. 514 | * @param init The initial state 515 | * @returns A tuple with the state, and a setter function 516 | */ 517 | export function crcState(init?: T): readonly [T, StateUpdater] { 518 | if (_componentIsMounting) { 519 | const ref = { 520 | type: 'state', 521 | value: init, 522 | setter, 523 | }; 524 | 525 | const canvas = _canvas; 526 | 527 | function setter(newValueOrFactory: T | ((oldValue: T) => T)) { 528 | ref.value = 529 | typeof newValueOrFactory === 'function' 530 | ? (newValueOrFactory as any)(ref.value) 531 | : newValueOrFactory; 532 | update(canvas!, undefined, true); 533 | } 534 | 535 | _componentRefs!.refs.push(ref); 536 | } 537 | 538 | const ref = _componentRefs!.refs[_componentHookIndex++]; 539 | return [ref.value, ref.setter] as const; 540 | } 541 | 542 | /** 543 | * Hook: creates a memoized value, similar to React's `useMemo`. 544 | * @param create The factory to create the memoized value. 545 | * @param deps The deps to check to see if the memoized value needs to be updated. 546 | * @returns The memoized value 547 | */ 548 | export function crcMemo(create: () => T, deps: any[]): T { 549 | const componentRefs = _componentRefs!; 550 | const hookIndex = _componentHookIndex++; 551 | const refs = componentRefs.refs; 552 | 553 | if (_componentIsMounting) { 554 | refs[hookIndex] = { value: create(), deps }; 555 | } else { 556 | const { deps: lastDeps } = refs[hookIndex]; 557 | if (!deps || !shallowArrayEquals(deps, lastDeps)) { 558 | refs[hookIndex].value = create(); 559 | refs[hookIndex].deps = deps; 560 | } 561 | } 562 | 563 | return refs[hookIndex].value; 564 | } 565 | 566 | /** 567 | * Does a shallow diff check on the deps. If they've changed, it will synchronously 568 | * call the provided call back. This is not like useEffect, but it's useful when 569 | * a developer wants to diff a value. Also allows for a teardown to be returned that 570 | * is called when deps change (very, very similar to useEffect, just ALWAYS synchronous). 571 | * If you have work in here that only sets state, you should be using {@link crcMemo} 572 | * instead. 573 | * @param callback The SYNCHRONOUS callback when the deps have changed 574 | * @param deps The deps to check for changes. 575 | */ 576 | export function crcWhenChanged( 577 | callback: () => (() => void) | void, 578 | deps?: any[], 579 | ) { 580 | if (_componentIsMounting) { 581 | const ref = { 582 | lastDeps: undefined, 583 | teardown: undefined, 584 | }; 585 | 586 | _componentRefs!.refs.push(ref); 587 | } 588 | 589 | const hookIndex = _componentHookIndex++; 590 | const ref = _componentRefs!.refs[hookIndex]; 591 | if (!ref.lastDeps || !deps || !shallowArrayEquals(deps, ref.lastDeps)) { 592 | const lastTeardown = ref.teardown; 593 | const mainTeardowns = _currentCRCInstance!.mainTeardowns; 594 | if (lastTeardown) { 595 | mainTeardowns.add(lastTeardown); 596 | _componentRefs!.teardowns.remove(lastTeardown); 597 | lastTeardown(); 598 | } 599 | ref.lastDeps = deps; 600 | const newTeardown = callback(); 601 | if (newTeardown) { 602 | ref.teardown = newTeardown; 603 | mainTeardowns.add(newTeardown); 604 | _componentRefs!.teardowns.add(newTeardown); 605 | } 606 | } 607 | } 608 | 609 | export interface CRCMouseEvent { 610 | x: number; 611 | y: number; 612 | originalEvent: MouseEvent; 613 | } 614 | 615 | function isHit({ 616 | canvas, 617 | transform, 618 | path, 619 | x, 620 | y, 621 | fill, 622 | lineInteractionWidth, 623 | currentClippingStack, 624 | }: { 625 | canvas: HTMLCanvasElement; 626 | transform: DOMMatrix; 627 | path: Path2D; 628 | x: number; 629 | y: number; 630 | fill: boolean; 631 | lineInteractionWidth: number; 632 | currentClippingStack: typeof clippingStack; 633 | }) { 634 | const ctx = get2dContext(canvas); 635 | ctx.save(); 636 | try { 637 | if (_mouseTransform) { 638 | ({ x, y } = _mouseTransform.transformPoint({ x, y })); 639 | } 640 | 641 | if (isInAtLeastOneClippingPath(currentClippingStack, x, y, ctx)) { 642 | if (!transform.isIdentity) { 643 | ctx.setTransform(transform); 644 | } 645 | 646 | if (fill && ctx.isPointInPath(path, x, y)) { 647 | return true; 648 | } 649 | if (lineInteractionWidth > 0) { 650 | ctx.lineWidth = lineInteractionWidth; 651 | if (ctx.isPointInStroke(path, x, y)) { 652 | return true; 653 | } 654 | } 655 | } 656 | return false; 657 | } finally { 658 | ctx.restore(); 659 | } 660 | } 661 | 662 | function isInAtLeastOneClippingPath( 663 | currentClippingStack: typeof clippingStack, 664 | x: number, 665 | y: number, 666 | ctx: RenderingContext2D, 667 | ) { 668 | if (currentClippingStack.length === 0) { 669 | return true; 670 | } 671 | 672 | for (const { path, fillRule, transform } of currentClippingStack) { 673 | if (!transform.isIdentity) { 674 | ctx.save(); 675 | ctx.setTransform(transform); 676 | } 677 | try { 678 | if (ctx.isPointInPath(path, x, y, fillRule)) { 679 | return true; 680 | } 681 | } finally { 682 | if (!transform.isIdentity) { 683 | ctx.restore(); 684 | } 685 | } 686 | } 687 | 688 | return false; 689 | } 690 | 691 | /** 692 | * Hook: For wiring up changes to the CSS cursor style while over a corresponding path. 693 | * NOTE: If the path reference changes between, the click event will be 694 | * torn down and re-registered. Be sure use the same instance of a Path if 695 | * it doesn't change. {@link crcMemo} may be useful for this. 696 | */ 697 | export function crcCursor({ 698 | path, 699 | style, 700 | fill = false, 701 | lineInteractionWidth = 0, 702 | }: { 703 | path?: Path2D; 704 | style?: string; 705 | fill?: boolean; 706 | lineInteractionWidth?: number; 707 | }) { 708 | crcEvent( 709 | 'mousemove', 710 | style 711 | ? (e) => { 712 | const canvas = e.originalEvent.target as HTMLCanvasElement; 713 | if (!canvas.style.cursor) { 714 | // If something has already set it, don't confuse it. 715 | canvas.style.cursor = style; 716 | } 717 | } 718 | : undefined, 719 | path, 720 | fill, 721 | lineInteractionWidth, 722 | ); 723 | 724 | crcEvent( 725 | 'mouseout', 726 | style 727 | ? (e) => { 728 | const canvas = e.originalEvent.target as HTMLCanvasElement; 729 | canvas.style.removeProperty('cursor'); 730 | } 731 | : undefined, 732 | path, 733 | fill, 734 | lineInteractionWidth, 735 | ); 736 | } 737 | 738 | /** 739 | * Creates a memoized Path2D for a rectangle. Useful when 740 | * defining events efficiently. 741 | * @returns A memoized Path2D 742 | */ 743 | export function crcRectPath( 744 | x: number, 745 | y: number, 746 | width: number, 747 | height: number, 748 | config?: { alignToPixelGrid?: PixelGridAlignment; lineWidth?: number }, 749 | ): Path2D { 750 | const alignToPixelGrid = config?.alignToPixelGrid; 751 | 752 | if (alignToPixelGrid) { 753 | ({ x, y, width, height } = adjustRectangleToPixelGrid( 754 | config?.lineWidth ?? 0, 755 | x, 756 | y, 757 | width, 758 | height, 759 | alignToPixelGrid, 760 | )); 761 | } 762 | 763 | return crcMemo(() => { 764 | const path = new Path2D(); 765 | path.rect(x, y, width, height); 766 | return path; 767 | }, [x, y, width, height]); 768 | } 769 | 770 | function adjustRectangleToPixelGrid( 771 | lineWidth: number, 772 | x: number, 773 | y: number, 774 | width: number, 775 | height: number, 776 | alignment: PixelGridAlignment, 777 | ) { 778 | x = adjustForPixelGrid(x, lineWidth, alignment); 779 | y = adjustForPixelGrid(y, lineWidth, alignment); 780 | const right = x + width; 781 | const adjustedRight = adjustForPixelGrid(right, lineWidth, alignment) + 1; 782 | width = adjustedRight - x; 783 | const bottom = y + height; 784 | const adjustedBottom = adjustForPixelGrid(bottom, lineWidth, alignment) + 1; 785 | height = adjustedBottom - y; 786 | return { x, y, width, height }; 787 | } 788 | 789 | /** 790 | * Creates a memoized Path2D for a set of line coordinates. 791 | * @returns A memoized Path2D 792 | */ 793 | export function crcLinePath( 794 | coords: [number, number][], 795 | config?: { closePath?: boolean }, 796 | ): Path2D { 797 | return crcMemo(() => createLinePath(coords, config), coords.flat()); 798 | } 799 | 800 | /** 801 | * Creates a memoized Path2D for an SVG data string. 802 | * @returns A memoized Path2D 803 | */ 804 | export function crcSvgPath(svgPathData: string): Path2D { 805 | return crcMemo(() => new Path2D(svgPathData), [svgPathData]); 806 | } 807 | 808 | //////////////////////////////////////////////////////////////////////////////////////// 809 | // Components 810 | //////////////////////////////////////////////////////////////////////////////////////// 811 | 812 | export interface CRCBasicMouseEvents { 813 | onClick?: (e: CRCMouseEvent) => void; 814 | onDblClick?: (e: CRCMouseEvent) => void; 815 | onMouseMove?: (e: CRCMouseEvent) => void; 816 | onMouseOver?: (e: CRCMouseEvent) => void; 817 | onMouseOut?: (e: CRCMouseEvent) => void; 818 | onContextMenu?: (e: CRCMouseEvent) => void; 819 | onMouseDown?: (e: CRCMouseEvent) => void; 820 | onMouseUp?: (e: CRCMouseEvent) => void; 821 | } 822 | 823 | export type FillStyle = CanvasRenderingContext2D['fillStyle']; 824 | export type StrokeStyle = CanvasRenderingContext2D['strokeStyle']; 825 | 826 | interface IntrinsicProps { 827 | key?: string; 828 | } 829 | 830 | export interface AlphaProps { 831 | alpha?: number; 832 | } 833 | 834 | export interface StrokeStyleProps { 835 | strokeStyle?: StrokeStyle; 836 | lineWidth?: number; 837 | } 838 | 839 | export interface FillStyleProps { 840 | fillStyle?: FillStyle; 841 | } 842 | 843 | export interface CursorStyleProps { 844 | cursor?: string; 845 | } 846 | 847 | export interface PathProps 848 | extends IntrinsicProps, 849 | CRCBasicMouseEvents, 850 | FillStyleProps, 851 | StrokeStyleProps, 852 | CursorStyleProps, 853 | AlphaProps { 854 | path: Path2D; 855 | lineInteractionWidth?: number; 856 | } 857 | 858 | function Path(props: PathProps, ctx: RenderingContext2D) { 859 | const { 860 | alpha, 861 | path, 862 | fillStyle, 863 | strokeStyle, 864 | lineWidth, 865 | lineInteractionWidth = lineWidth ?? 0, 866 | } = props; 867 | 868 | if (alpha) { 869 | ctx.globalAlpha = ctx.globalAlpha * alpha; 870 | } 871 | 872 | if (fillStyle && !isTransparent(fillStyle)) { 873 | ctx.fillStyle = fillStyle; 874 | ctx.fill(path); 875 | } 876 | 877 | if (strokeStyle && lineWidth && lineWidth > 0) { 878 | (ctx.strokeStyle = strokeStyle), (ctx.lineWidth = lineWidth); 879 | ctx.stroke(path); 880 | } 881 | 882 | const fill = !!fillStyle; 883 | 884 | wireCommonEvents(props, path, fill, lineInteractionWidth); 885 | } 886 | 887 | /** 888 | * Creates a renderable Path component element. 889 | */ 890 | export const path = defineComp(Path); 891 | 892 | export interface RectProps 893 | extends IntrinsicProps, 894 | CRCBasicMouseEvents, 895 | FillStyleProps, 896 | StrokeStyleProps, 897 | AlphaProps, 898 | CursorStyleProps { 899 | x: number; 900 | y: number; 901 | width: number; 902 | height: number; 903 | alignToPixelGrid?: PixelGridAlignment; 904 | } 905 | 906 | export function crcEvent( 907 | type: K, 908 | handler?: CRCMouseEventListener, 909 | path?: Path2D, 910 | fill = false, 911 | lineInteractionWidth = 0, 912 | ) { 913 | const hookIndex = _componentHookIndex++; 914 | const componentRefs = _componentRefs!; 915 | 916 | if (!_componentIsMounting) { 917 | componentRefs.refs[hookIndex]?.cleanup?.(); 918 | } 919 | 920 | if (handler) { 921 | const transform = _renderContext!.getTransform(); 922 | const currentClippingStack = Array.from(clippingStack); 923 | 924 | if (type === 'mouseover' || type === 'mouseout') { 925 | if (!componentRefs.refs[hookIndex]) { 926 | componentRefs.refs[hookIndex] = { isOver: false, cleanup: null }; 927 | } 928 | const state = componentRefs.refs[hookIndex]; 929 | 930 | const mouseMoveHandlers = _currentCRCInstance!.events.mousemove; 931 | const overOrOutMoveHandler = (e: MouseEvent) => { 932 | const canvas = e.target as HTMLCanvasElement; 933 | const wasOver = state.isOver; 934 | const [x, y] = getMouseCoordinates(e); 935 | 936 | const nowOver = 937 | !path || 938 | isHit({ 939 | canvas, 940 | path, 941 | transform, 942 | x, 943 | y, 944 | fill, 945 | lineInteractionWidth, 946 | currentClippingStack, 947 | }); 948 | state.isOver = nowOver; 949 | 950 | if (type === 'mouseover') { 951 | if (!wasOver && nowOver) { 952 | handler({ originalEvent: e, x, y }); 953 | } 954 | } else if (type === 'mouseout') { 955 | if (wasOver && !nowOver) { 956 | handler({ originalEvent: e, x, y }); 957 | } 958 | } 959 | }; 960 | 961 | mouseMoveHandlers.add(overOrOutMoveHandler); 962 | 963 | const mouseOutHandlers = _currentCRCInstance!.events.mouseout; 964 | const canvasMouseOutHandler = (e: MouseEvent) => { 965 | const wasOver = state.isOver; 966 | state.isOver = false; 967 | if (type === 'mouseout' && wasOver) { 968 | const [x, y] = getMouseCoordinates(e); 969 | handler({ originalEvent: e, x, y }); 970 | } 971 | }; 972 | 973 | mouseOutHandlers.add(canvasMouseOutHandler); 974 | 975 | state.cleanup = () => { 976 | mouseOutHandlers.delete(canvasMouseOutHandler); 977 | mouseMoveHandlers.delete(overOrOutMoveHandler); 978 | }; 979 | 980 | componentRefs.teardowns.add(() => { 981 | state.cleanup(); 982 | componentRefs.refs[hookIndex] = null; 983 | }); 984 | } else { 985 | const handlers = _currentCRCInstance!.events[type]; 986 | const actualHandler = (e: MouseEvent) => { 987 | const canvas = e.target as HTMLCanvasElement; 988 | const [x, y] = getMouseCoordinates(e); 989 | if ( 990 | !path || 991 | isHit({ 992 | canvas, 993 | path, 994 | transform, 995 | x, 996 | y, 997 | fill, 998 | lineInteractionWidth, 999 | currentClippingStack, 1000 | }) 1001 | ) { 1002 | handler({ originalEvent: e, x, y }); 1003 | } 1004 | }; 1005 | handlers.add(actualHandler); 1006 | 1007 | const cleanup = () => handlers.delete(actualHandler); 1008 | _componentRefs!.refs[hookIndex] = { cleanup }; 1009 | } 1010 | } else { 1011 | _componentRefs!.refs[hookIndex] = null; 1012 | } 1013 | } 1014 | 1015 | function wireCommonEvents( 1016 | props: CRCBasicMouseEvents & CursorStyleProps, 1017 | path: Path2D | undefined, 1018 | fill: boolean, 1019 | lineInteractionWidth: number, 1020 | ) { 1021 | const { 1022 | onClick, 1023 | onContextMenu, 1024 | onDblClick, 1025 | onMouseMove, 1026 | onMouseOut, 1027 | onMouseOver, 1028 | onMouseDown, 1029 | onMouseUp, 1030 | } = props; 1031 | 1032 | crcEvent('click', onClick, path, fill, lineInteractionWidth); 1033 | crcEvent('contextmenu', onContextMenu, path, fill, lineInteractionWidth); 1034 | crcEvent('dblclick', onDblClick, path, fill, lineInteractionWidth); 1035 | crcEvent('mousemove', onMouseMove, path, fill, lineInteractionWidth); 1036 | crcEvent('mousedown', onMouseDown, path, fill, lineInteractionWidth); 1037 | crcEvent('mouseup', onMouseUp, path, fill, lineInteractionWidth); 1038 | crcEvent('mouseover', onMouseOver, path, fill, lineInteractionWidth); 1039 | crcEvent('mouseout', onMouseOut, path, fill, lineInteractionWidth); 1040 | 1041 | crcCursor({ 1042 | path, 1043 | style: props.cursor, 1044 | fill, 1045 | lineInteractionWidth, 1046 | }); 1047 | } 1048 | 1049 | function Rect(props: RectProps, ctx: RenderingContext2D) { 1050 | let { x, y, width, height, alignToPixelGrid, ...pathProps } = props; 1051 | 1052 | if (alignToPixelGrid) { 1053 | ({ x, y, width, height } = adjustRectangleToPixelGrid( 1054 | props.lineWidth ?? 0, 1055 | x, 1056 | y, 1057 | width, 1058 | height, 1059 | alignToPixelGrid, 1060 | )); 1061 | } 1062 | 1063 | const rectPath = new Path2D(); 1064 | rectPath.rect(x, y, width, height); 1065 | 1066 | Path( 1067 | { 1068 | ...pathProps, 1069 | path: rectPath, 1070 | }, 1071 | ctx, 1072 | ); 1073 | } 1074 | 1075 | /** 1076 | * Creates a renderable Rect component element. 1077 | */ 1078 | export const rect = defineComp(Rect); 1079 | 1080 | export interface LineProps 1081 | extends IntrinsicProps, 1082 | CRCBasicMouseEvents, 1083 | CursorStyleProps, 1084 | StrokeStyleProps { 1085 | coords: [number, number][]; 1086 | lineInteractionWidth?: number; 1087 | } 1088 | 1089 | export function Line(props: LineProps, ctx: RenderingContext2D) { 1090 | const { coords, ...pathProps } = props; 1091 | const linePath = createLinePath(coords); 1092 | 1093 | return Path( 1094 | { 1095 | ...pathProps, 1096 | path: linePath, 1097 | }, 1098 | ctx, 1099 | ); 1100 | } 1101 | 1102 | /** 1103 | * Creates a renderable Line component element. 1104 | */ 1105 | export const line = defineComp(Line); 1106 | 1107 | export interface VerticalLineProps 1108 | extends IntrinsicProps, 1109 | CRCBasicMouseEvents, 1110 | CursorStyleProps, 1111 | StrokeStyleProps { 1112 | x: number; 1113 | top?: number; 1114 | bottom?: number; 1115 | alignToPixelGrid?: PixelGridAlignment; 1116 | lineInteractionWidth?: number; 1117 | } 1118 | 1119 | export function VerticalLine( 1120 | props: VerticalLineProps, 1121 | ctx: RenderingContext2D, 1122 | ) { 1123 | const { 1124 | x: initialX, 1125 | top = 0, 1126 | bottom = ctx.canvas.height, 1127 | alignToPixelGrid = 'none', 1128 | ...lineProps 1129 | } = props; 1130 | const x = adjustForPixelGrid(initialX, props.lineWidth, alignToPixelGrid); 1131 | const coords: [number, number][] = [ 1132 | [x, top], 1133 | [x, bottom], 1134 | ]; 1135 | 1136 | return Line( 1137 | { 1138 | coords, 1139 | ...lineProps, 1140 | }, 1141 | ctx, 1142 | ); 1143 | } 1144 | 1145 | /** 1146 | * Creates a renderable VerticalLine component element. 1147 | */ 1148 | export const verticalLine = defineComp(VerticalLine); 1149 | 1150 | export interface HorizontalLineProps 1151 | extends IntrinsicProps, 1152 | CRCBasicMouseEvents, 1153 | CursorStyleProps, 1154 | StrokeStyleProps { 1155 | y: number; 1156 | left?: number; 1157 | right?: number; 1158 | alignToPixelGrid?: PixelGridAlignment; 1159 | lineInteractionWidth?: number; 1160 | } 1161 | 1162 | export function HorizontalLine( 1163 | props: HorizontalLineProps, 1164 | ctx: RenderingContext2D, 1165 | ) { 1166 | const { 1167 | y: initialY, 1168 | left = 0, 1169 | right = ctx.canvas.width, 1170 | alignToPixelGrid, 1171 | ...lineProps 1172 | } = props; 1173 | const y = alignToPixelGrid 1174 | ? adjustForPixelGrid(initialY, props.lineWidth, alignToPixelGrid) 1175 | : initialY; 1176 | const coords: [number, number][] = [ 1177 | [left, y], 1178 | [right, y], 1179 | ]; 1180 | return Line( 1181 | { 1182 | coords, 1183 | ...lineProps, 1184 | }, 1185 | ctx, 1186 | ); 1187 | } 1188 | 1189 | /** 1190 | * Creates a renderable HorizontalLine component element 1191 | */ 1192 | export const horizontalLine = defineComp(HorizontalLine); 1193 | 1194 | export interface ImgProps 1195 | extends IntrinsicProps, 1196 | CRCBasicMouseEvents, 1197 | AlphaProps, 1198 | CursorStyleProps, 1199 | StrokeStyleProps { 1200 | src: string; 1201 | x: number; 1202 | y: number; 1203 | width?: number; 1204 | height?: number; 1205 | alignToPixelGrid?: PixelGridAlignment; 1206 | } 1207 | 1208 | function Img(props: ImgProps, ctx: RenderingContext2D) { 1209 | let { 1210 | alpha, 1211 | src, 1212 | x, 1213 | y, 1214 | width, 1215 | height, 1216 | lineWidth, 1217 | strokeStyle, 1218 | alignToPixelGrid, 1219 | ...otherProps 1220 | } = props; 1221 | 1222 | if (alpha) { 1223 | ctx.globalAlpha = ctx.globalAlpha * alpha; 1224 | } 1225 | 1226 | const [image, setImage] = crcState(null); 1227 | 1228 | crcWhenChanged(() => { 1229 | const img = new Image(); 1230 | img.onload = () => { 1231 | setImage(img); 1232 | }; 1233 | img.src = src; 1234 | }, [src]); 1235 | 1236 | width = width ?? image?.width ?? 0; 1237 | height = height ?? image?.height ?? 0; 1238 | 1239 | const imagePath = crcRectPath(x, y, width, height, { 1240 | alignToPixelGrid, 1241 | lineWidth, 1242 | }); 1243 | 1244 | crcCursor({ 1245 | path: imagePath, 1246 | style: props.cursor, 1247 | fill: true, 1248 | lineInteractionWidth: props.lineWidth, 1249 | }); 1250 | 1251 | if (image) { 1252 | ctx.drawImage(image, x, y, width, height); 1253 | } 1254 | 1255 | if (strokeStyle && lineWidth) { 1256 | ctx.strokeStyle = strokeStyle; 1257 | ctx.lineWidth = lineWidth; 1258 | ctx.stroke(imagePath); 1259 | } 1260 | 1261 | wireCommonEvents(otherProps, imagePath, true, lineWidth ?? 0); 1262 | } 1263 | 1264 | /** 1265 | * Creates a renderable Img component element. 1266 | */ 1267 | export const img = defineComp(Img); 1268 | 1269 | export interface SvgPathProps 1270 | extends IntrinsicProps, 1271 | CRCBasicMouseEvents, 1272 | FillStyleProps, 1273 | StrokeStyleProps, 1274 | AlphaProps, 1275 | CursorStyleProps { 1276 | d: string; 1277 | } 1278 | 1279 | function SvgPath(props: SvgPathProps, ctx: RenderingContext2D) { 1280 | const { d, ...pathProps } = props; 1281 | const svgPath = crcSvgPath(d); 1282 | return Path( 1283 | { 1284 | ...pathProps, 1285 | path: svgPath, 1286 | }, 1287 | ctx, 1288 | ); 1289 | } 1290 | 1291 | /** 1292 | * Creates a renderable SvgPath component element. 1293 | */ 1294 | export const svgPath = defineComp(SvgPath); 1295 | 1296 | export interface GProps extends IntrinsicProps { 1297 | children: CRCNode[]; 1298 | scaleX?: number; 1299 | scaleY?: number; 1300 | rotate?: number; 1301 | rotateOrigin?: [number, number]; 1302 | x?: number; 1303 | y?: number; 1304 | skewX?: number; 1305 | skewY?: number; 1306 | clipFillRule?: CanvasFillRule; 1307 | } 1308 | 1309 | function G(props: GProps, ctx: RenderingContext2D) { 1310 | let transform = ctx.getTransform(); 1311 | 1312 | const { scaleX = 1, scaleY = 1 } = props; 1313 | if (scaleX !== 1 || scaleY !== 1) { 1314 | transform = transform.scale(scaleX, scaleY); 1315 | } 1316 | 1317 | const { rotate } = props; 1318 | if (rotate) { 1319 | const [rox, roy] = props.rotateOrigin ?? [0, 0]; 1320 | transform = transform 1321 | .translate(rox, roy) 1322 | .rotate(rotate) 1323 | .translate(-rox, -roy); 1324 | } 1325 | 1326 | const { x = 0, y = 0 } = props; 1327 | if (x !== 0 || y !== 0) { 1328 | transform = transform.translate(x, y); 1329 | } 1330 | 1331 | const { skewX } = props; 1332 | if (skewX) { 1333 | transform = transform.skewX(skewX); 1334 | } 1335 | 1336 | const { skewY } = props; 1337 | if (skewY) { 1338 | transform = transform.skewY(skewY); 1339 | } 1340 | 1341 | ctx.setTransform(transform); 1342 | 1343 | return props.children; 1344 | } 1345 | 1346 | export interface TextProps 1347 | extends IntrinsicProps, 1348 | CRCBasicMouseEvents, 1349 | FillStyleProps, 1350 | StrokeStyleProps, 1351 | AlphaProps, 1352 | CursorStyleProps { 1353 | text: string; 1354 | x: number; 1355 | y: number; 1356 | maxWidth?: number; 1357 | maxHeight?: number; 1358 | lineHeight?: number; 1359 | overflow?: 'visible' | 'squish' | 'ellipsis' | 'clip'; 1360 | wordWrap?: boolean; 1361 | font?: string; 1362 | textBaseline?: CanvasTextBaseline; 1363 | textAlign?: CanvasTextAlign; 1364 | } 1365 | 1366 | function checkPropsForEvents(props: CRCBasicMouseEvents) { 1367 | return !!( 1368 | props.onClick || 1369 | props.onContextMenu || 1370 | props.onDblClick || 1371 | props.onMouseDown || 1372 | props.onMouseMove || 1373 | props.onMouseOut || 1374 | props.onMouseOver || 1375 | props.onMouseUp 1376 | ); 1377 | } 1378 | 1379 | export function Text(props: TextProps, ctx: RenderingContext2D) { 1380 | const { 1381 | font = '13px sans-serif', 1382 | textBaseline = 'top', 1383 | textAlign = 'left', 1384 | overflow = 'visible', 1385 | } = props; 1386 | 1387 | ctx.font = font; 1388 | ctx.textBaseline = textBaseline; 1389 | ctx.textAlign = textAlign; 1390 | 1391 | const { 1392 | strokeStyle, 1393 | lineWidth = 0, 1394 | fillStyle, 1395 | x, 1396 | maxWidth = undefined, 1397 | } = props; 1398 | 1399 | const renderText = (txt: string, y: number) => { 1400 | const squishMaxWidth = overflow === 'squish' ? maxWidth : undefined; 1401 | 1402 | if (strokeStyle && lineWidth) { 1403 | ctx.strokeStyle = strokeStyle; 1404 | ctx.lineWidth = lineWidth; 1405 | ctx.strokeText(txt, x, y, squishMaxWidth); 1406 | } 1407 | 1408 | if (fillStyle) { 1409 | ctx.fillStyle = fillStyle; 1410 | ctx.fillText(txt, x, y, squishMaxWidth); 1411 | } 1412 | }; 1413 | 1414 | const { 1415 | y, 1416 | wordWrap, 1417 | text: inputText, 1418 | lineHeight = 13, 1419 | maxHeight = Infinity, 1420 | } = props; 1421 | 1422 | const hasAnyEvents = checkPropsForEvents(props); 1423 | 1424 | let textPath: Path2D | undefined; 1425 | 1426 | if (wordWrap) { 1427 | const words = inputText.split(' '); 1428 | let line = ''; 1429 | let lineCount = 0; 1430 | 1431 | let textWidth = 0; 1432 | let textHeight = 0; 1433 | 1434 | const updateTextBounds = (line: string) => { 1435 | const bounds = ctx.measureText(line); 1436 | textWidth = Math.max(bounds.width); 1437 | textHeight = 1438 | lineCount * lineHeight + 1439 | bounds.actualBoundingBoxDescent - 1440 | bounds.actualBoundingBoxAscent; 1441 | }; 1442 | 1443 | if (overflow === 'clip' && maxWidth) { 1444 | const clipPath = new Path2D(); 1445 | clipPath.rect(x, y, maxWidth, maxHeight); 1446 | ctx.clip(clipPath); 1447 | } 1448 | 1449 | while (words.length) { 1450 | const currentYOffset = lineCount * lineHeight; 1451 | const nextYOffset = currentYOffset + lineHeight; 1452 | if (maxHeight < nextYOffset + lineHeight) { 1453 | const remaining = words.join(' '); 1454 | const lastLine = 1455 | overflow === 'ellipsis' && maxWidth 1456 | ? getEllipsisText(ctx, remaining, maxWidth) 1457 | : remaining; 1458 | if (hasAnyEvents) { 1459 | updateTextBounds(lastLine); 1460 | } 1461 | renderText(lastLine, y + currentYOffset); 1462 | break; 1463 | } else { 1464 | if ((maxWidth ?? Infinity) < ctx.measureText(line + words[0]).width) { 1465 | if (hasAnyEvents) { 1466 | updateTextBounds(line); 1467 | } 1468 | renderText(line, y + currentYOffset); 1469 | line = ''; 1470 | lineCount++; 1471 | } 1472 | line += words.shift() + ' '; 1473 | } 1474 | } 1475 | 1476 | if (hasAnyEvents) { 1477 | textPath = new Path2D(); 1478 | textPath.rect(x, y, textWidth, textHeight); 1479 | } 1480 | } else { 1481 | const text = 1482 | overflow === 'ellipsis' && maxWidth 1483 | ? getEllipsisText(ctx, inputText, maxWidth) 1484 | : inputText; 1485 | 1486 | if (overflow === 'clip' && maxWidth) { 1487 | const clipPath = new Path2D(); 1488 | const xd = 1489 | textAlign === 'end' || textAlign === 'right' 1490 | ? -1 1491 | : textAlign === 'center' 1492 | ? 0.5 1493 | : 1; 1494 | const yd = 1495 | textBaseline === 'bottom' ? -1 : textBaseline === 'middle' ? 0.5 : 1; 1496 | const pw = maxWidth * xd; 1497 | const ph = maxHeight * yd; 1498 | clipPath.rect(x, y, pw, ph); 1499 | ctx.clip(clipPath); 1500 | } 1501 | 1502 | renderText(text, y); 1503 | 1504 | if (hasAnyEvents) { 1505 | const bounds = ctx.measureText(text); 1506 | const textWidth = bounds.width; 1507 | const textHeight = 1508 | bounds.actualBoundingBoxDescent - bounds.actualBoundingBoxAscent; 1509 | 1510 | textPath = new Path2D(); 1511 | textPath.rect(x, y, textWidth, textHeight); 1512 | } 1513 | } 1514 | 1515 | wireCommonEvents(props, textPath, true, lineWidth); 1516 | } 1517 | 1518 | export const text = defineComp(Text); 1519 | 1520 | function getEllipsisText( 1521 | renderContext: { measureText(text: string): TextMetrics }, 1522 | text: string, 1523 | maxWidth: number, 1524 | ) { 1525 | const metrics = renderContext.measureText(text); 1526 | 1527 | if (metrics.width < maxWidth) { 1528 | return text; 1529 | } 1530 | 1531 | let low = -1; 1532 | let high = text.length; 1533 | 1534 | while (1 + low < high) { 1535 | const mid = low + ((high - low) >> 1); 1536 | if ( 1537 | isTextTooWide(renderContext, text.substring(0, mid) + '...', maxWidth) 1538 | ) { 1539 | high = mid; 1540 | } else { 1541 | low = mid; 1542 | } 1543 | } 1544 | 1545 | const properLength = high - 1; 1546 | return text.substring(0, properLength) + '...'; 1547 | } 1548 | 1549 | function isTextTooWide( 1550 | renderContext: { measureText(text: string): TextMetrics }, 1551 | text: string, 1552 | maxWidth: number, 1553 | ) { 1554 | const metrics = renderContext.measureText(text); 1555 | return maxWidth < metrics.width; 1556 | } 1557 | 1558 | /** 1559 | * Creates a group element that can be used to define transformations on a set 1560 | * of related CRC elements. 1561 | */ 1562 | export const g = defineComp(G); 1563 | 1564 | export interface LayerProps extends IntrinsicProps { 1565 | render: CompEl; 1566 | width?: number; 1567 | height?: number; 1568 | } 1569 | 1570 | export function Layer(props: LayerProps, ctx: RenderingContext2D) { 1571 | const { width = _canvas.width, height = _canvas.height } = props; 1572 | 1573 | if (_componentIsMounting) { 1574 | console.log('mounting layer'); 1575 | const offscreenCanvas = new OffscreenCanvas(width, height); 1576 | mountCRC(offscreenCanvas, props.render, { parent: _canvas }); 1577 | _componentRefs.refs[0] = offscreenCanvas; 1578 | } 1579 | 1580 | const offscreenCanvas = _componentRefs.refs[0]; 1581 | const layerCRC = crcInstances.get(offscreenCanvas); 1582 | 1583 | if ( 1584 | _componentIsMounting || 1585 | offscreenCanvas.width !== width || 1586 | offscreenCanvas.height !== height || 1587 | !shallowEquals(layerCRC.root.props, props.render.props) 1588 | ) { 1589 | offscreenCanvas.width = width; 1590 | offscreenCanvas.height = height; 1591 | console.log('rendering'); 1592 | executeRender( 1593 | offscreenCanvas, 1594 | _currentCRCInstance.renderTimestamp, 1595 | layerCRC.root, 1596 | false, 1597 | ); 1598 | } 1599 | 1600 | ctx.drawImage(offscreenCanvas, 0, 0); 1601 | } 1602 | 1603 | export const layer = defineComp(Layer); 1604 | 1605 | //////////////////////////////////////////////////////////////////////////////////////// 1606 | // Utility functions 1607 | //////////////////////////////////////////////////////////////////////////////////////// 1608 | 1609 | function shallowEquals(obj1: any, obj2: any): boolean { 1610 | if (obj1 === obj2) { 1611 | return true; 1612 | } 1613 | const keys1 = Object.keys(obj1); 1614 | const keys2 = Object.keys(obj2); 1615 | if (keys1.length !== keys2.length) { 1616 | return false; 1617 | } 1618 | for (const key of keys1) { 1619 | if (obj1[key] !== obj2[key]) { 1620 | return false; 1621 | } 1622 | } 1623 | return true; 1624 | } 1625 | 1626 | const recentCoords = new WeakMap(); 1627 | 1628 | function getMouseCoordinates(event: MouseEvent): readonly [number, number] { 1629 | if (recentCoords.has(event)) { 1630 | return recentCoords.get(event)!; 1631 | } 1632 | const target = event.target as HTMLElement; 1633 | const bounds = target.getBoundingClientRect(); 1634 | const x = event.clientX - bounds.left; 1635 | const y = event.clientY - bounds.top; 1636 | const result = [x, y] as const; 1637 | recentCoords.set(event, result); 1638 | return result; 1639 | } 1640 | 1641 | function shallowArrayEquals(arr1: T[], arr2: T[]): boolean { 1642 | if (arr1.length !== arr2.length) { 1643 | return false; 1644 | } 1645 | for (let i = 0; i < arr1.length; i++) { 1646 | if (arr1[i] !== arr2[i]) { 1647 | return false; 1648 | } 1649 | } 1650 | return true; 1651 | } 1652 | 1653 | function get2dContext(canvas: Canvas): RenderingContext2D { 1654 | const ctx = canvas.getContext('2d'); 1655 | if (!ctx) { 1656 | throw new Error('Unable to get 2d context!'); 1657 | } 1658 | return ctx as any; 1659 | } 1660 | 1661 | function calculatePixelGridOffset(lineWidth: number) { 1662 | return ((lineWidth ?? 0) / 2) % 1; 1663 | } 1664 | 1665 | function adjustForPixelGrid( 1666 | value: number, 1667 | lineWidth: number | undefined, 1668 | alignment: undefined | PixelGridAlignment, 1669 | ) { 1670 | if (!alignment || alignment === 'none') { 1671 | return value; 1672 | } 1673 | 1674 | return Math[alignment](value) - calculatePixelGridOffset(lineWidth ?? 0); 1675 | } 1676 | 1677 | function getScaleMatrix(matrix: DOMMatrix) { 1678 | const { m11, m22 } = matrix; 1679 | return new DOMMatrix([m11, 0, 0, 0, 0, m22, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); 1680 | } 1681 | 1682 | const TRANSPARENT_REGEXP = /^(hsla|rgba)\(.*,\s*0\s*\)$/; 1683 | 1684 | function isTransparent(style: string | CanvasGradient | CanvasPattern) { 1685 | return ( 1686 | typeof style === 'string' && 1687 | (style === 'transparent' || TRANSPARENT_REGEXP.test(style)) 1688 | ); 1689 | } 1690 | 1691 | function createLinePath( 1692 | coords: [number, number][], 1693 | config?: { closePath?: boolean }, 1694 | ) { 1695 | const path = new Path2D(); 1696 | for (let i = 0; i < coords.length; i++) { 1697 | const [x, y] = coords[i]; 1698 | if (i === 0) { 1699 | path.moveTo(x, y); 1700 | } else { 1701 | path.lineTo(x, y); 1702 | } 1703 | } 1704 | if (config?.closePath) { 1705 | path.closePath(); 1706 | } 1707 | return path; 1708 | } 1709 | 1710 | /** 1711 | * AbortSignal is SLOW AS A DEAD TURTLE to deal with because of 1712 | * addEventListener and removeEventListener. Strong avoid. 1713 | */ 1714 | class Teardowns { 1715 | private readonly _teardowns = new Set<() => void>(); 1716 | private _closed = false; 1717 | 1718 | get closed() { 1719 | return this._closed; 1720 | } 1721 | 1722 | follow(parentTeardown: Teardowns) { 1723 | const handler = () => { 1724 | this.execute(); 1725 | }; 1726 | parentTeardown.add(handler); 1727 | this.add(() => parentTeardown.remove(handler)); 1728 | } 1729 | 1730 | add(teardown: () => void) { 1731 | if (this._closed) { 1732 | teardown(); 1733 | } else { 1734 | this._teardowns.add(teardown); 1735 | } 1736 | } 1737 | 1738 | remove(teardown: () => void) { 1739 | this._teardowns.delete(teardown); 1740 | } 1741 | 1742 | execute() { 1743 | if (!this._closed) { 1744 | this._closed = true; 1745 | for (const teardown of this._teardowns) { 1746 | teardown(); 1747 | } 1748 | this._teardowns.clear(); 1749 | } 1750 | } 1751 | } 1752 | -------------------------------------------------------------------------------- /src/stories/assets/code-brackets.svg: -------------------------------------------------------------------------------- 1 | illustration/code-brackets -------------------------------------------------------------------------------- /src/stories/assets/colors.svg: -------------------------------------------------------------------------------- 1 | illustration/colors -------------------------------------------------------------------------------- /src/stories/assets/comments.svg: -------------------------------------------------------------------------------- 1 | illustration/comments -------------------------------------------------------------------------------- /src/stories/assets/direction.svg: -------------------------------------------------------------------------------- 1 | illustration/direction -------------------------------------------------------------------------------- /src/stories/assets/flow.svg: -------------------------------------------------------------------------------- 1 | illustration/flow -------------------------------------------------------------------------------- /src/stories/assets/plugin.svg: -------------------------------------------------------------------------------- 1 | illustration/plugin -------------------------------------------------------------------------------- /src/stories/assets/repo.svg: -------------------------------------------------------------------------------- 1 | illustration/repo -------------------------------------------------------------------------------- /src/stories/assets/stackalt.svg: -------------------------------------------------------------------------------- 1 | illustration/stackalt -------------------------------------------------------------------------------- /src/stories/components/g.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/html'; 2 | import { g, rect, img, GProps } from '../../index'; 3 | import { createTemplate } from '../util'; 4 | 5 | export default { 6 | title: 'Components/g', 7 | } as Meta; 8 | 9 | const template = createTemplate(groupExample); 10 | 11 | export const BasicProperties = template({ 12 | x: 100, 13 | y: 100, 14 | rotate: 20, 15 | rotateOrigin: [0, 0], 16 | scaleX: 1, 17 | scaleY: 1, 18 | skewX: 5, 19 | skewY: 5, 20 | }); 21 | 22 | function groupExample(args: Omit) { 23 | return g({ 24 | ...args, 25 | children: [ 26 | img({ 27 | x: 0, 28 | y: 0, 29 | src: 'https://rxjs.dev/assets/images/logos/logo.png', 30 | }), 31 | rect({ 32 | x: 100, 33 | y: 100, 34 | width: 100, 35 | height: 100, 36 | fillStyle: 'purple', 37 | }), 38 | ], 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/stories/components/horizontalLine.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/html'; 2 | import { horizontalLine } from '../../index'; 3 | import { createTemplate } from './../util'; 4 | 5 | export default { 6 | title: 'Components/horizontalLine', 7 | } as Meta; 8 | 9 | const template = createTemplate(horizontalLine); 10 | 11 | export const BasicProperties = template({ 12 | y: 100, 13 | lineWidth: 1, 14 | strokeStyle: 'blue', 15 | cursor: 'pointer', 16 | lineInteractionWidth: 20, 17 | alignToPixelGrid: 'round', 18 | }); 19 | -------------------------------------------------------------------------------- /src/stories/components/img.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/html'; 2 | import { img } from '../../index'; 3 | import { createTemplate } from './../util'; 4 | 5 | export default { 6 | title: 'Components/img', 7 | } as Meta; 8 | 9 | const template = createTemplate(img); 10 | 11 | export const BasicProperties = template({ 12 | src: 'https://rxjs.dev/assets/images/logos/logo.png', 13 | cursor: 'pointer', 14 | strokeStyle: 'blue', 15 | lineWidth: 2, 16 | x: 10, 17 | y: 10, 18 | width: undefined, 19 | height: undefined, 20 | }); 21 | -------------------------------------------------------------------------------- /src/stories/components/layer.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/html'; 2 | import { 3 | defineComp, 4 | RenderingContext2D, 5 | rect, 6 | text, 7 | crcRef, 8 | crcState, 9 | layer, 10 | crcMemo, 11 | } from '../../index'; 12 | import { createTemplate } from '../util'; 13 | 14 | export default { 15 | title: 'Components/layer', 16 | } as Meta; 17 | 18 | function SimpleButton( 19 | { 20 | x, 21 | y, 22 | width, 23 | height, 24 | label, 25 | onClick, 26 | fillStyle, 27 | }: { 28 | x: number; 29 | y: number; 30 | width: number; 31 | height: number; 32 | label: string; 33 | onClick: () => void; 34 | fillStyle: string; 35 | }, 36 | ctx: RenderingContext2D, 37 | ) { 38 | return [ 39 | rect({ 40 | x, 41 | y, 42 | width, 43 | height, 44 | onClick, 45 | cursor: 'pointer', 46 | fillStyle, 47 | }), 48 | text({ 49 | x: x + width / 2, 50 | y: y + height / 2, 51 | text: label, 52 | textAlign: 'center', 53 | textBaseline: 'middle', 54 | fillStyle: 'black', 55 | }), 56 | ]; 57 | } 58 | 59 | const simpleButton = defineComp(SimpleButton); 60 | 61 | function MyLayer( 62 | { 63 | y, 64 | name, 65 | onUpdateParent, 66 | }: { y: number; name: string; onUpdateParent: () => void }, 67 | ctx: RenderingContext2D, 68 | ) { 69 | const [value, setValue] = crcState(0); 70 | 71 | const renderCountRef = crcRef(0); 72 | const renderCount = ++renderCountRef.current; 73 | 74 | return [ 75 | text({ 76 | x: 10, 77 | y: y + 10, 78 | text: `Layer ${name} render count: ${renderCount}`, 79 | fillStyle: 'black', 80 | }), 81 | simpleButton({ 82 | x: 10, 83 | y: y + 40, 84 | width: 100, 85 | height: 30, 86 | label: `Update ${name}`, 87 | onClick: () => { 88 | setValue(value + 1); 89 | }, 90 | fillStyle: 'rgba(220, 220, 220, 1)', 91 | }), 92 | simpleButton({ 93 | x: 10, 94 | y: y + 80, 95 | width: 100, 96 | height: 30, 97 | label: `Update parent of ${name}`, 98 | onClick: onUpdateParent, 99 | fillStyle: 'rgba(220, 220, 220, 1)', 100 | }), 101 | ]; 102 | } 103 | 104 | const myLayer = defineComp(MyLayer); 105 | 106 | interface LayerDemoProps { 107 | layerAWidth: number; 108 | layerAHeight: number; 109 | layerBWidth: number; 110 | layerBHeight: number; 111 | } 112 | 113 | function LayerDemo( 114 | { layerAWidth, layerAHeight, layerBWidth, layerBHeight }: LayerDemoProps, 115 | ctx: RenderingContext2D, 116 | ) { 117 | const [value, setValue] = crcState(0); 118 | 119 | const renderCountRef = crcRef(0); 120 | const renderCount = ++renderCountRef.current; 121 | 122 | const handleUpdateParent = crcMemo( 123 | () => () => { 124 | setValue(value + 1); 125 | }, 126 | [], 127 | ); 128 | 129 | return [ 130 | text({ 131 | x: 10, 132 | y: 10, 133 | text: `Parent render count: ${renderCount}`, 134 | fillStyle: 'black', 135 | }), 136 | layer({ 137 | key: 'Layer A', 138 | width: layerAWidth, 139 | height: layerAHeight, 140 | render: myLayer({ 141 | y: 30, 142 | name: 'A', 143 | onUpdateParent: handleUpdateParent, 144 | }), 145 | }), 146 | layer({ 147 | key: 'Layer B', 148 | width: layerBWidth, 149 | height: layerBHeight, 150 | render: myLayer({ 151 | y: 200, 152 | name: 'B', 153 | onUpdateParent: handleUpdateParent, 154 | }), 155 | }), 156 | ]; 157 | } 158 | 159 | const layerDemo = defineComp(LayerDemo); 160 | 161 | const template = createTemplate(layerDemo); 162 | 163 | export const BasicProperties = template({ 164 | layerAWidth: 500, 165 | layerAHeight: 500, 166 | layerBWidth: 500, 167 | layerBHeight: 500, 168 | }); 169 | -------------------------------------------------------------------------------- /src/stories/components/line.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/html'; 2 | import { line } from '../../index'; 3 | import { createTemplate } from './../util'; 4 | 5 | export default { 6 | title: 'Components/line', 7 | } as Meta; 8 | 9 | const template = createTemplate(line); 10 | 11 | export const BasicProperties = template({ 12 | coords: [ 13 | [0, 0], 14 | [100, 100], 15 | [200, 0], 16 | [300, 100], 17 | [400, 0], 18 | [500, 100], 19 | ], 20 | lineWidth: 2, 21 | strokeStyle: 'blue', 22 | cursor: 'pointer', 23 | lineInteractionWidth: 100, 24 | }); 25 | -------------------------------------------------------------------------------- /src/stories/components/rect.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/html'; 2 | import { rect } from '../../index'; 3 | import { createTemplate } from './../util'; 4 | 5 | export default { 6 | title: 'Components/rect', 7 | } as Meta; 8 | 9 | const template = createTemplate(rect); 10 | 11 | export const BasicProperties = template({ 12 | x: 10, 13 | y: 10, 14 | width: 100, 15 | height: 100, 16 | fillStyle: 'red', 17 | lineWidth: 2, 18 | strokeStyle: 'blue', 19 | cursor: 'pointer', 20 | }); 21 | -------------------------------------------------------------------------------- /src/stories/components/svgPath.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/html'; 2 | import { svgPath } from '../../index'; 3 | import { createTemplate } from './../util'; 4 | 5 | export default { 6 | title: 'Components/svgPath', 7 | } as Meta; 8 | 9 | const template = createTemplate(svgPath); 10 | 11 | const pathData = 12 | 'M160 0C71.6 0 0 71.6 0 160c0 70.8 45.8 130.6 109.4 151.8 8 1.4 11-3.4 11-7.6 0-3.8-.2-16.4-.2-29.8-40.2 7.4-50.6-9.8-53.8-18.8-1.8-4.6-9.6-18.8-16.4-22.6-5.6-3-13.6-10.4-.2-10.6 12.6-.2 21.6 11.6 24.6 16.4 14.4 24.2 37.4 17.4 46.6 13.2 1.4-10.4 5.6-17.4 10.2-21.4-35.6-4-72.8-17.8-72.8-79 0-17.4 6.2-31.8 16.4-43-1.6-4-7.2-20.4 1.6-42.4 0 0 13.4-4.2 44 16.4 12.8-3.6 26.4-5.4 40-5.4 13.6 0 27.2 1.8 40 5.4 30.6-20.8 44-16.4 44-16.4 8.8 22 3.2 38.4 1.6 42.4 10.2 11.2 16.4 25.4 16.4 43 0 61.4-37.4 75-73 79 5.8 5 10.8 14.6 10.8 29.6 0 21.4-.2 38.6-.2 44 0 4.2 3 9.2 11 7.6C274.2 290.6 320 230.6 320 160 320 71.6 248.4 0 160 0z'; 13 | 14 | export const BasicProperties = template({ 15 | d: pathData, 16 | fillStyle: 'black', 17 | lineWidth: 2, 18 | strokeStyle: 'lime', 19 | cursor: 'pointer', 20 | }); 21 | -------------------------------------------------------------------------------- /src/stories/components/text.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/html'; 2 | import { text } from '../../index'; 3 | import { createTemplate } from './../util'; 4 | 5 | export default { 6 | title: 'Components/text', 7 | } as Meta; 8 | 9 | const template = createTemplate(text); 10 | 11 | export const BasicProperties = template({ 12 | x: 10, 13 | y: 10, 14 | maxWidth: 400, 15 | maxHeight: 250, 16 | text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', 17 | font: '20px Arial', 18 | lineHeight: 30, 19 | fillStyle: 'black', 20 | lineWidth: 2, 21 | strokeStyle: 'purple', 22 | cursor: 'pointer', 23 | overflow: 'ellipsis', 24 | wordWrap: true, 25 | textAlign: 'left', 26 | textBaseline: 'bottom', 27 | }); 28 | -------------------------------------------------------------------------------- /src/stories/components/verticalLine.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/html'; 2 | import { verticalLine } from '../../index'; 3 | import { createTemplate } from './../util'; 4 | 5 | export default { 6 | title: 'Components/verticalLine', 7 | } as Meta; 8 | 9 | const template = createTemplate(verticalLine); 10 | 11 | export const BasicProperties = template({ 12 | x: 100, 13 | lineWidth: 1, 14 | strokeStyle: 'blue', 15 | cursor: 'pointer', 16 | lineInteractionWidth: 20, 17 | alignToPixelGrid: 'round', 18 | }); 19 | -------------------------------------------------------------------------------- /src/stories/examples/custom-components.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/html'; 2 | import { CRCMouseEvent, crcState, defineComp, rect, text } from '../../index'; 3 | import { createTemplate } from '../util'; 4 | 5 | export default { 6 | title: 'Example/custom-components', 7 | } as Meta; 8 | 9 | const customButton = defineComp(CustomButton); 10 | const counterComponent = defineComp<{}>(CounterComponent); 11 | 12 | const template = createTemplate(counterComponent); 13 | 14 | export const CounterComponentExample = template({}); 15 | 16 | interface CustomButtonProps { 17 | x: number; 18 | y: number; 19 | width: number; 20 | height: number; 21 | label: string; 22 | onClick?: (e: CRCMouseEvent) => void; 23 | } 24 | 25 | function CustomButton(props: CustomButtonProps) { 26 | const { x, y, width, height } = props; 27 | 28 | const [state, setState] = crcState<'idle' | 'hover'>('idle'); 29 | 30 | const fillStyle = state === 'idle' ? 'gray' : 'blue'; 31 | const lineWidth = 2; 32 | const strokeStyle = state === 'idle' ? 'black' : 'white'; 33 | 34 | return [ 35 | rect({ 36 | x, 37 | y, 38 | width, 39 | height, 40 | fillStyle, 41 | lineWidth, 42 | strokeStyle, 43 | cursor: 'pointer', 44 | onClick: props.onClick, 45 | onMouseOver: () => setState('hover'), 46 | onMouseOut: () => setState('idle'), 47 | }), 48 | text({ 49 | x: x + width / 2, 50 | y: y + height / 2, 51 | text: props.label, 52 | textAlign: 'center', 53 | textBaseline: 'middle', 54 | fillStyle: strokeStyle, 55 | font: '20px Arial', 56 | }), 57 | ]; 58 | } 59 | 60 | function CounterComponent() { 61 | const [count, setCount] = crcState(0); 62 | 63 | return customButton({ 64 | x: 10, 65 | y: 10, 66 | width: 100, 67 | height: 40, 68 | label: count.toString(), 69 | onClick: () => { 70 | setCount((c) => c + 1); 71 | }, 72 | }); 73 | } 74 | -------------------------------------------------------------------------------- /src/stories/tests/rect.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/html'; 2 | import { defineComp, rect, RenderingContext2D } from '../../index'; 3 | import { createTemplate } from './../util'; 4 | 5 | export default { 6 | title: 'Tests/rect', 7 | } as Meta; 8 | 9 | const test = defineComp(Test); 10 | 11 | const template = createTemplate(test); 12 | 13 | export const BasicProperties = template({}); 14 | 15 | function Test(props: {}, ctx: RenderingContext2D) { 16 | return [ 17 | rect({ 18 | x: 10, 19 | y: 10, 20 | width: 100, 21 | height: 100, 22 | fillStyle: 'rgba(200, 200, 200, 1)', 23 | lineWidth: 1, 24 | strokeStyle: 'darkblue', 25 | }), 26 | rect({ 27 | x: 120, 28 | y: 10, 29 | width: 100, 30 | height: 100, 31 | fillStyle: 'rgba(200, 200, 200, 1)', 32 | }), 33 | rect({ 34 | x: 230, 35 | y: 10, 36 | width: 100, 37 | height: 100, 38 | strokeStyle: 'darkblue', 39 | lineWidth: 3, 40 | }), 41 | 42 | rect({ 43 | x: 10, 44 | y: 120, 45 | width: 100, 46 | height: 100, 47 | fillStyle: 'rgba(200, 200, 200, 1)', 48 | lineWidth: 1, 49 | strokeStyle: 'darkblue', 50 | alignToPixelGrid: 'ceil', 51 | }), 52 | rect({ 53 | x: 120, 54 | y: 120, 55 | width: 100, 56 | height: 100, 57 | fillStyle: 'rgba(200, 200, 200, 1)', 58 | alignToPixelGrid: 'ceil', 59 | }), 60 | rect({ 61 | x: 230, 62 | y: 120, 63 | width: 100, 64 | height: 100, 65 | strokeStyle: 'darkblue', 66 | lineWidth: 3, 67 | alignToPixelGrid: 'ceil', 68 | }), 69 | 70 | rect({ 71 | x: 10, 72 | y: 230, 73 | width: 100, 74 | height: 100, 75 | fillStyle: 'rgba(200, 200, 200, 1)', 76 | lineWidth: 1, 77 | strokeStyle: 'darkblue', 78 | alignToPixelGrid: 'floor', 79 | }), 80 | rect({ 81 | x: 120, 82 | y: 230, 83 | width: 100, 84 | height: 100, 85 | fillStyle: 'rgba(200, 200, 200, 1)', 86 | alignToPixelGrid: 'floor', 87 | }), 88 | rect({ 89 | x: 230, 90 | y: 230, 91 | width: 100, 92 | height: 100, 93 | strokeStyle: 'darkblue', 94 | lineWidth: 3, 95 | alignToPixelGrid: 'floor', 96 | }), 97 | 98 | rect({ 99 | x: 10, 100 | y: 340, 101 | width: 100, 102 | height: 100, 103 | fillStyle: 'rgba(200, 200, 200, 1)', 104 | lineWidth: 1, 105 | strokeStyle: 'darkblue', 106 | alignToPixelGrid: 'round', 107 | }), 108 | rect({ 109 | x: 120, 110 | y: 340, 111 | width: 100, 112 | height: 100, 113 | fillStyle: 'rgba(200, 200, 200, 1)', 114 | alignToPixelGrid: 'round', 115 | }), 116 | rect({ 117 | x: 230, 118 | y: 340, 119 | width: 100, 120 | height: 100, 121 | strokeStyle: 'darkblue', 122 | lineWidth: 3, 123 | alignToPixelGrid: 'round', 124 | }), 125 | ]; 126 | } 127 | -------------------------------------------------------------------------------- /src/stories/util.ts: -------------------------------------------------------------------------------- 1 | import { CompEl, crc } from '../index'; 2 | import { Story } from '@storybook/html'; 3 | 4 | export function createTemplate

(comp: (props: P) => CompEl

) { 5 | const Template: Story

= function (args: P) { 6 | const canvas = document.createElement('canvas'); 7 | canvas.width = 1000; 8 | canvas.height = 700; 9 | crc(canvas, comp(args)); 10 | return canvas; 11 | }; 12 | 13 | Template.parameters = {}; 14 | 15 | return (args: P) => { 16 | const boundTemplate: Story

= Template.bind({}); 17 | boundTemplate.args = args; 18 | return boundTemplate; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /tests/rect.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('basic properties', async ({ page }) => { 4 | await page.goto('/?path=/story/tests-rect--basic-properties'); 5 | 6 | // Expect a title "to contain" a substring. 7 | await expect(page).toHaveTitle(/Tests \/ rect/); 8 | 9 | await expect(page).toHaveScreenshot(); 10 | }); 11 | 12 | // test('get started link', async ({ page }) => { 13 | // await page.goto('https://playwright.dev/'); 14 | 15 | // // Click the get started link. 16 | // await page.getByRole('link', { name: 'Get started' }).click(); 17 | 18 | // // Expects the URL to contain intro. 19 | // await expect(page).toHaveURL(/.*intro/); 20 | // }); 21 | -------------------------------------------------------------------------------- /tests/rect.spec.ts-snapshots/basic-properties-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlesh/canvas-render-components/cf66f14fb96b9a44fb7d958a317672c6c5d9023e/tests/rect.spec.ts-snapshots/basic-properties-1-chromium-linux.png -------------------------------------------------------------------------------- /tests/rect.spec.ts-snapshots/basic-properties-1-firefox-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlesh/canvas-render-components/cf66f14fb96b9a44fb7d958a317672c6c5d9023e/tests/rect.spec.ts-snapshots/basic-properties-1-firefox-linux.png -------------------------------------------------------------------------------- /tests/rect.spec.ts-snapshots/basic-properties-1-webkit-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlesh/canvas-render-components/cf66f14fb96b9a44fb7d958a317672c6c5d9023e/tests/rect.spec.ts-snapshots/basic-properties-1-webkit-linux.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "ES2022"], 5 | "target": "ES2022", 6 | "emitDeclarationOnly": true, 7 | "declaration": true, 8 | "outDir": "dist/types", 9 | "moduleResolution": "node", 10 | "allowSyntheticDefaultImports": true 11 | } 12 | } --------------------------------------------------------------------------------