├── .eslintrc.js ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── documentation.md │ ├── feature.md │ └── testing.md └── workflows │ └── main.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .vscode ├── settings.json ├── snippets.code-snippets └── tasks.json ├── LICENSE ├── README.md ├── assets ├── icons.png ├── perfect-freehand-card.png ├── perfect-freehand-logo.svg └── process.gif ├── build.mjs ├── icons.png ├── lerna.json ├── package.json ├── packages ├── dev │ ├── README.md │ ├── decs.d.ts │ ├── esbuild.config.mjs │ ├── package.json │ ├── src │ │ ├── app.tsx │ │ ├── components │ │ │ ├── checkbox │ │ │ │ ├── checkbox.module.css │ │ │ │ ├── checkbox.tsx │ │ │ │ └── index.ts │ │ │ ├── colors │ │ │ │ ├── colors.module.css │ │ │ │ ├── colors.tsx │ │ │ │ └── index.ts │ │ │ ├── controls │ │ │ │ ├── controls.module.css │ │ │ │ ├── controls.tsx │ │ │ │ └── index.ts │ │ │ ├── editor │ │ │ │ ├── editor.module.css │ │ │ │ ├── editor.tsx │ │ │ │ └── index.ts │ │ │ ├── panel │ │ │ │ ├── index.ts │ │ │ │ ├── panel.module.css │ │ │ │ └── panel.tsx │ │ │ ├── select │ │ │ │ ├── index.ts │ │ │ │ ├── select.module.css │ │ │ │ └── select.tsx │ │ │ └── slider │ │ │ │ ├── index.ts │ │ │ │ ├── slider.module.css │ │ │ │ └── slider.tsx │ │ ├── hooks │ │ │ ├── index.ts │ │ │ └── useKeyboardShortcuts.ts │ │ ├── index.html │ │ ├── index.tsx │ │ ├── state │ │ │ ├── corners.json │ │ │ ├── easings.ts │ │ │ ├── excalidraw.json │ │ │ ├── flash.json │ │ │ ├── index.ts │ │ │ ├── sample.json │ │ │ ├── shapes │ │ │ │ ├── draw.tsx │ │ │ │ └── index.ts │ │ │ ├── state.ts │ │ │ └── utils.ts │ │ ├── styles.css │ │ ├── test.html │ │ └── types.ts │ ├── tsconfig.build.json │ └── tsconfig.json └── perfect-freehand │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── assets │ ├── icons.png │ ├── perfect-freehand-card.png │ ├── perfect-freehand-logo.svg │ └── process.gif │ ├── package.json │ ├── scripts │ ├── build.js │ └── dev.js │ ├── src │ ├── getStroke.ts │ ├── getStrokeOutlinePoints.ts │ ├── getStrokePoints.ts │ ├── getStrokeRadius.ts │ ├── index.ts │ ├── test │ │ ├── __snapshots__ │ │ │ ├── getStroke.spec.ts.snap │ │ │ ├── getStrokeOutlinePoints.spec.ts.snap │ │ │ └── getStrokePoints.spec.ts.snap │ │ ├── getStroke.spec.ts │ │ ├── getStrokeOutlinePoints.spec.ts │ │ ├── getStrokePoints.spec.ts │ │ ├── getStrokeRadius.spec.ts │ │ └── inputs.json │ ├── types.ts │ └── vec.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── perfect-freehand-card.png ├── perfect-freehand-logo.svg ├── process.gif ├── setupTests.ts ├── tsconfig.base.json ├── tsconfig.json ├── tsconfig.tsbuildinfo ├── tutorial └── script.md └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 6 | overrides: [ 7 | { 8 | // enable the rule specifically for TypeScript files 9 | files: ['*.ts', '*.tsx'], 10 | rules: { 11 | '@typescript-eslint/explicit-module-boundary-types': [0], 12 | }, 13 | }, 14 | ], 15 | } 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [steveruizok] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Writing and other documentation. 4 | title: '[Bug] Bug description' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Writing and other documentation. 4 | title: '[documentation] Content' 5 | labels: documentation 6 | assignees: '' 7 | --- 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 3 | about: Begin discussion of a new feature. 4 | title: '[feature] Feature or improvement' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/testing.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Testing 3 | about: Tests that need to be written. 4 | title: '[tests] Test' 5 | labels: testing 6 | assignees: '' 7 | --- 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | # install modules 9 | - name: Install modules 10 | run: yarn 11 | # build 12 | - name: Build 13 | run: yarn build:packages 14 | # run unit tests 15 | - name: Jest Annotations & Coverage 16 | uses: mattallty/jest-github-action@v1.0.3 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | with: 20 | test-command: 'yarn test' 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | lib/ 4 | dist/ 5 | docs/ 6 | .idea/* 7 | 8 | .DS_Store 9 | coverage 10 | *.log 11 | 12 | .vercel 13 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.github/ 2 | /.vscode/ 3 | /node_modules/ 4 | /build/ 5 | /tmp/ 6 | .idea/* 7 | /docs/ 8 | 9 | coverage 10 | *.log 11 | .gitlab-ci.yml 12 | 13 | package-lock.json 14 | /*.tgz 15 | /tmp* 16 | /mnt/ 17 | /package/ 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "createComment": { 3 | "scope": "typescript,typescriptreact", 4 | "prefix": "ccc", 5 | "body": [ 6 | "/**", 7 | " * ${1:description}", 8 | " *", 9 | " * ### Example", 10 | " *", 11 | " *```ts", 12 | " * ${2:example}", 13 | " *```" 14 | ], 15 | "description": "comment" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Check for type errors", 6 | "type": "typescript", 7 | "tsconfig": "tsconfig.json", 8 | "option": "watch", 9 | "problemMatcher": ["$tsc-watch"], 10 | "group": "build" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Stephen Ruiz Ltd 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 | # ![Screenshot](assets/perfect-freehand-logo.svg 'Perfect Freehand') 2 | 3 | Draw perfect pressure-sensitive freehand lines. 4 | 5 | 🔗 Curious? Try out a [demo](https://perfect-freehand-example.vercel.app/). 6 | 7 | 💅 Designer? Check out the [Figma Plugin](https://www.figma.com/community/plugin/950892731860805817). 8 | 9 | 🕊 Flutterer? There's now a [dart version](https://pub.dev/packages/perfect_freehand) of this library, too. 10 | 11 | 💕 Love this library? Consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok). 12 | 13 | ## Table of Contents 14 | 15 | - [Installation](#installation) 16 | - [Usage](#usage) 17 | - [Documentation](#documentation) 18 | - [Community](#community) 19 | - [Author](#author) 20 | 21 | ## Installation 22 | 23 | ```bash 24 | npm install perfect-freehand 25 | ``` 26 | 27 | or 28 | 29 | ```bash 30 | yarn add perfect-freehand 31 | ``` 32 | 33 | ## Introduction 34 | 35 | This package exports a function named `getStroke` that will generate the points for a polygon based on an array of points. 36 | 37 | ![Screenshot](assets/process.gif 'A GIF showing a stroke with input points, outline points, and a curved path connecting these points') 38 | 39 | To do this work, `getStroke` first creates a set of spline points (red) based on the input points (grey) and then creates outline points (blue). You can render the result any way you like, using whichever technology you prefer. 40 | 41 | [![Edit perfect-freehand-example](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/perfect-freehand-example-biwyi?fontsize=14&hidenavigation=1&theme=dark) 42 | 43 | ## Usage 44 | 45 | To use this library, import the `getStroke` function and pass it an array of **input points**, such as those recorded from a user's mouse movement. The `getStroke` function will return a new array of **outline points**. These outline points will form a polygon (called a "stroke") that surrounds the input points. 46 | 47 | ```js 48 | import { getStroke } from 'perfect-freehand' 49 | 50 | const inputPoints = [ 51 | [0, 0], 52 | [10, 5], 53 | [20, 8], 54 | // ... 55 | ] 56 | 57 | const outlinePoints = getStroke(inputPoints) 58 | ``` 59 | 60 | You then can **render** your stroke points using your technology of choice. See the [Rendering](#rendering) section for examples in SVG and HTML Canvas. 61 | 62 | You can **customize** the appearance of the stroke shape by passing `getStroke` a second parameter: an options object containing one or more options. See the [Options](#options) section for a full list of available options. 63 | 64 | ```js 65 | const stroke = getStroke(myPoints, { 66 | size: 32, 67 | thinning: 0.7, 68 | }) 69 | ``` 70 | 71 | The appearance of a stroke is effected by the **pressure** associated with each input point. By default, the `getStroke` function will simulate pressure based on the distance between input points. 72 | 73 | To use **real pressure**, such as that from a pen or stylus, provide the pressure as the third number for each input point, and set the `simulatePressure` option to `false`. 74 | 75 | ```js 76 | const inputPoints = [ 77 | [0, 0, 0.5], 78 | [10, 5, 0.7], 79 | [20, 8, 0.8], 80 | // ... 81 | ] 82 | 83 | const outlinePoints = getStroke(inputPoints, { 84 | simulatePressure: false, 85 | }) 86 | ``` 87 | 88 | In addition to providing points as an array of arrays, you may also provide your points as an **array of objects** as show in the example below. In both cases, the value for pressure is optional (it will default to `.5`). 89 | 90 | ```js 91 | const inputPoints = [ 92 | { x: 0, y: 0, pressure: 0.5 }, 93 | { x: 10, y: 5, pressure: 0.7 }, 94 | { x: 20, y: 8, pressure: 0.8 }, 95 | // ... 96 | ] 97 | 98 | const outlinePoints = getStroke(inputPoints, { 99 | simulatePressure: false, 100 | }) 101 | ``` 102 | 103 | **Note:** Internally, the `getStroke` function will convert your object points to array points, which will have an effect on performance. If you're using this library ambitiously and want to format your points as objects, consider modifying this library's `getStrokeOutlinePoints` to use the object syntax instead (e.g. replacing all `[0]` with `.x`, `[1]` with `.y`, and `[2]` with `.pressure`). 104 | 105 | ## Example 106 | 107 | ```jsx 108 | import * as React from 'react' 109 | import { getStroke } from 'perfect-freehand' 110 | import { getSvgPathFromStroke } from './utils' 111 | 112 | export default function Example() { 113 | const [points, setPoints] = React.useState([]) 114 | 115 | function handlePointerDown(e) { 116 | e.target.setPointerCapture(e.pointerId) 117 | setPoints([[e.pageX, e.pageY, e.pressure]]) 118 | } 119 | 120 | function handlePointerMove(e) { 121 | if (e.buttons !== 1) return 122 | setPoints([...points, [e.pageX, e.pageY, e.pressure]]) 123 | } 124 | 125 | const stroke = getStroke(points, { 126 | size: 16, 127 | thinning: 0.5, 128 | smoothing: 0.5, 129 | streamline: 0.5, 130 | }) 131 | 132 | const pathData = getSvgPathFromStroke(stroke) 133 | 134 | return ( 135 | 140 | {points && } 141 | 142 | ) 143 | } 144 | ``` 145 | 146 | > **Tip:** For implementations in Typescript, see the example project included in this repository. 147 | 148 | [![Edit perfect-freehand-example](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/perfect-freehand-example-biwyi?fontsize=14&hidenavigation=1&theme=dark) 149 | 150 | ## Documentation 151 | 152 | ### Options 153 | 154 | The options object is optional, as are each of its properties. 155 | 156 | | Property | Type | Default | Description | 157 | | ------------------ | -------- | ------- | ----------------------------------------------------- | 158 | | `size` | number | 8 | The base size (diameter) of the stroke. | 159 | | `thinning` | number | .5 | The effect of pressure on the stroke's size. | 160 | | `smoothing` | number | .5 | How much to soften the stroke's edges. | 161 | | `streamline` | number | .5 | How much to streamline the stroke. | 162 | | `simulatePressure` | boolean | true | Whether to simulate pressure based on velocity. | 163 | | `easing` | function | t => t | An easing function to apply to each point's pressure. | 164 | | `start` | { } | | Tapering options for the start of the line. | 165 | | `end` | { } | | Tapering options for the end of the line. | 166 | | `last` | boolean | true | Whether the stroke is complete. | 167 | 168 | **Note:** When the `last` property is `true`, the line's end will be drawn at the last input point, rather than slightly behind it. 169 | 170 | The `start` and `end` options accept an object: 171 | 172 | | Property | Type | Default | Description | 173 | | -------- | ----------------- | ------- | ---------------------------------------------------------------------------------------- | 174 | | `cap` | boolean | true | Whether to draw a cap. | 175 | | `taper` | number or boolean | 0 | The distance to taper. If set to true, the taper will be the total length of the stroke. | 176 | | `easing` | function | t => t | An easing function for the tapering effect. | 177 | 178 | **Note:** The `cap` property has no effect when `taper` is more than zero. 179 | 180 | ```js 181 | getStroke(myPoints, { 182 | size: 8, 183 | thinning: 0.5, 184 | smoothing: 0.5, 185 | streamline: 0.5, 186 | easing: (t) => t, 187 | simulatePressure: true, 188 | last: true, 189 | start: { 190 | cap: true, 191 | taper: 0, 192 | easing: (t) => t, 193 | }, 194 | end: { 195 | cap: true, 196 | taper: 0, 197 | easing: (t) => t, 198 | }, 199 | }) 200 | ``` 201 | 202 | > **Tip:** To create a stroke with a steady line, set the `thinning` option to `0`. 203 | 204 | > **Tip:** To create a stroke that gets thinner with pressure instead of thicker, use a negative number for the `thinning` option. 205 | 206 | ### Other Exports 207 | 208 | For advanced usage, the library also exports smaller functions that `getStroke` uses to generate its outline points. 209 | 210 | #### `getStrokePoints` 211 | 212 | A function that accepts an array of points (formatted either as `[x, y, pressure]` or `{ x: number, y: number, pressure: number}`) and (optionally) an options object. Returns a set of adjusted points as `{ point, pressure, vector, distance, runningLength }`. The path's total length will be the `runningLength` of the last point in the array. 213 | 214 | ```js 215 | import { getStrokePoints } from 'perfect-freehand' 216 | import samplePoints from "./samplePoints.json' 217 | 218 | const strokePoints = getStrokePoints(samplePoints) 219 | ``` 220 | 221 | #### `getOutlinePoints` 222 | 223 | A function that accepts an array of points (formatted as `{ point, pressure, vector, distance, runningLength }`, i.e. the output of `getStrokePoints`) and (optionally) an options object, and returns an array of points (`[x, y]`) defining the outline of a pressure-sensitive stroke. 224 | 225 | ```js 226 | import { getStrokePoints, getOutlinePoints } from 'perfect-freehand' 227 | import samplePoints from "./samplePoints.json' 228 | 229 | const strokePoints = getStrokePoints(samplePoints) 230 | 231 | const outlinePoints = getOutlinePoints(strokePoints) 232 | ``` 233 | 234 | **Note:** Internally, the `getStroke` function passes the result of `getStrokePoints` to `getStrokeOutlinePoints`, just as shown in this example. This means that, in this example, the result of `outlinePoints` will be the same as if the `samplePoints` array had been passed to `getStroke`. 235 | 236 | #### `StrokeOptions` 237 | 238 | A TypeScript type for the options object. Useful if you're defining your options outside of the `getStroke` function. 239 | 240 | ```ts 241 | import { StrokeOptions, getStroke } from 'perfect-freehand' 242 | 243 | const options: StrokeOptions = { 244 | size: 16, 245 | } 246 | 247 | const stroke = getStroke(options) 248 | ``` 249 | 250 | ## Tips & Tricks 251 | 252 | ### Freehand Anything 253 | 254 | While this library was designed for rendering the types of input points generated by the movement of a human hand, you can pass any set of points into the library's functions. For example, here's what you get when running [Feather Icons](https://feathericons.com/) through `getStroke`. 255 | 256 | ![Icons](assets/icons.png) 257 | 258 | ### Rendering 259 | 260 | While `getStroke` returns an array of points representing the outline of a stroke, it's up to you to decide how you will render these points. 261 | 262 | The function below will turn the points returned by `getStroke` into SVG path data. 263 | 264 | ```js 265 | const average = (a, b) => (a + b) / 2 266 | 267 | function getSvgPathFromStroke(points, closed = true) { 268 | const len = points.length 269 | 270 | if (len < 4) { 271 | return `` 272 | } 273 | 274 | let a = points[0] 275 | let b = points[1] 276 | const c = points[2] 277 | 278 | let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed( 279 | 2 280 | )},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average( 281 | b[1], 282 | c[1] 283 | ).toFixed(2)} T` 284 | 285 | for (let i = 2, max = len - 1; i < max; i++) { 286 | a = points[i] 287 | b = points[i + 1] 288 | result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed( 289 | 2 290 | )} ` 291 | } 292 | 293 | if (closed) { 294 | result += 'Z' 295 | } 296 | 297 | return result 298 | } 299 | ``` 300 | 301 | To use this function, first run your input points through `getStroke`, then pass the result to `getSvgPathFromStroke`. 302 | 303 | ```js 304 | const outlinePoints = getStroke(inputPoints) 305 | 306 | const pathData = getSvgPathFromStroke(outlinePoints) 307 | ``` 308 | 309 | You could then pass this string of SVG path data either to an [SVG path](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d) element: 310 | 311 | ```jsx 312 | 313 | ``` 314 | 315 | Or, if you are rendering with HTML Canvas, you can pass the string to a [`Path2D` constructor](https://developer.mozilla.org/en-US/docs/Web/API/Path2D/Path2D#using_svg_paths)). 316 | 317 | ```js 318 | const myPath = new Path2D(pathData) 319 | 320 | ctx.fill(myPath) 321 | ``` 322 | 323 | ### Flattening 324 | 325 | By default, the polygon's paths include self-crossings. You may wish to remove these crossings and render a stroke as a "flattened" polygon. To do this, install the [`polygon-clipping`](https://github.com/mfogel/polygon-clipping) package and use the following function together with the `getSvgPathFromStroke`. 326 | 327 | ```js 328 | import polygonClipping from 'polygon-clipping' 329 | 330 | function getFlatSvgPathFromStroke(stroke) { 331 | const faces = polygonClipping.union([stroke]) 332 | 333 | const d = [] 334 | 335 | faces.forEach((face) => 336 | face.forEach((points) => { 337 | d.push(getSvgPathFromStroke(points)) 338 | }) 339 | ) 340 | 341 | return d.join(' ') 342 | } 343 | ``` 344 | 345 | ## Development & Contributions 346 | 347 | To work on this library: 348 | 349 | - clone this repo 350 | - run `yarn` in the folder root to install dependencies 351 | - run `yarn start` to start the local development server 352 | 353 | The development server is located at `packages/dev`. The library and its tests are located at `packages/perfect-freehand`. 354 | 355 | Pull requests are very welcome! 356 | 357 | ## Community 358 | 359 | ### Support 360 | 361 | Need help? Please [open an issue](https://github.com/steveruizok/perfect-freehand/issues/new) for support. 362 | 363 | ### Discussion 364 | 365 | Have an idea or casual question? Visit the [discussion page](https://github.com/steveruizok/perfect-freehand/discussions). 366 | 367 | ### License 368 | 369 | - MIT 370 | - ...but if you're using `perfect-freehand` in a commercial product, consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok). 💰 371 | 372 | ## Author 373 | 374 | - [@steveruizok](https://twitter.com/steveruizok) 375 | -------------------------------------------------------------------------------- /assets/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/perfect-freehand/9b369e4d74c9f45748e3dfd7507a7c3f97021acb/assets/icons.png -------------------------------------------------------------------------------- /assets/perfect-freehand-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/perfect-freehand/9b369e4d74c9f45748e3dfd7507a7c3f97021acb/assets/perfect-freehand-card.png -------------------------------------------------------------------------------- /assets/process.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/perfect-freehand/9b369e4d74c9f45748e3dfd7507a7c3f97021acb/assets/process.gif -------------------------------------------------------------------------------- /build.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | 3 | // This project has two "identical" READMEs, one at the package root 4 | // and another in the perfect-freehand package folder. When we build 5 | // the project, we want to replace the older README with the newer. 6 | 7 | const files = [ 8 | 'README.md', 9 | 'assets/process.gif', 10 | 'assets/icons.png', 11 | 'assets/perfect-freehand-card.png', 12 | 'assets/perfect-freehand-logo.svg', 13 | ] 14 | 15 | for (const file of files) { 16 | const pathA = file 17 | const pathB = `./packages/perfect-freehand/${file}` 18 | if ( 19 | new Date(fs.statSync(pathA).mtime).getTime() > 20 | new Date(fs.statSync(pathB).mtime).getTime() 21 | ) { 22 | // A is newer; remove B and replace with A 23 | fs.rmSync(pathB) 24 | fs.copyFileSync(pathA, pathB) 25 | } else { 26 | // B is newer; remove A and replace with B 27 | fs.rmSync(pathA) 28 | fs.copyFileSync(pathB, pathA) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/perfect-freehand/9b369e4d74c9f45748e3dfd7507a7c3f97021acb/icons.png -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.2.0", 3 | "registry": "https://registry.npmjs.org/", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "npmClient": "yarn", 8 | "useWorkspaces": true 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "version": "1.1.0", 4 | "name": "perfect-freehand-repo", 5 | "author": { 6 | "name": "Steve Ruiz", 7 | "url": "https://twitter.com/steveruizok" 8 | }, 9 | "repository": "https://github.com/steveruizok/perfect-freehand", 10 | "keywords": [ 11 | "ink", 12 | "draw", 13 | "paint", 14 | "signature", 15 | "handwriting", 16 | "text", 17 | "drawing", 18 | "painting" 19 | ], 20 | "license": "MIT", 21 | "workspaces": [ 22 | "packages/*" 23 | ], 24 | "scripts": { 25 | "test": "jest", 26 | "lerna": "lerna", 27 | "start": "lerna run start --stream --parallel", 28 | "build": "yarn build:packages && cd packages/dev && yarn build", 29 | "build:packages": "node build.mjs && cd packages/perfect-freehand && yarn build", 30 | "publish:patch": "yarn build:packages && lerna publish patch", 31 | "postinstall": "husky install" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.15.0", 35 | "@babel/plugin-syntax-import-meta": "^7.10.4", 36 | "@babel/preset-env": "^7.15.0", 37 | "@babel/preset-react": "^7.14.5", 38 | "@babel/preset-typescript": "^7.15.0", 39 | "@testing-library/jest-dom": "^5.14.1", 40 | "@testing-library/react": "^12.0.0", 41 | "@types/jest": "^27.0.1", 42 | "@types/node": "^15.0.1", 43 | "@typescript-eslint/eslint-plugin": "^4.19.0", 44 | "@typescript-eslint/parser": "^4.19.0", 45 | "babel-jest": "^27.1.0", 46 | "eslint": "^7.32.0", 47 | "fake-indexeddb": "^3.1.3", 48 | "jest": "^27.1.0", 49 | "lerna": "^3.15.0", 50 | "ts-jest": "^27.0.5", 51 | "tslib": "^2.3.0", 52 | "typedoc": "^0.21.9", 53 | "typescript": "^4.4.2" 54 | }, 55 | "dependencies": {}, 56 | "prettier": { 57 | "trailingComma": "es5", 58 | "singleQuote": true, 59 | "semi": false, 60 | "printWidth": 80 61 | }, 62 | "jest": { 63 | "preset": "ts-jest", 64 | "setupFilesAfterEnv": [ 65 | "/setupTests.ts" 66 | ], 67 | "transform": { 68 | "^.+\\.(tsx|jsx|ts|js)?$": "ts-jest" 69 | }, 70 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 71 | "moduleFileExtensions": [ 72 | "ts", 73 | "tsx", 74 | "js", 75 | "jsx", 76 | "json", 77 | "node" 78 | ], 79 | "globals": { 80 | "ts-jest": { 81 | "tsconfig": "tsconfig.json", 82 | "babelConfig": { 83 | "presets": [ 84 | [ 85 | "@babel/preset-env", 86 | { 87 | "targets": { 88 | "esmodules": true 89 | } 90 | } 91 | ], 92 | [ 93 | "@babel/preset-react" 94 | ], 95 | "@babel/preset-typescript" 96 | ], 97 | "plugins": [ 98 | "@babel/plugin-syntax-import-meta" 99 | ] 100 | } 101 | } 102 | }, 103 | "testEnvironment": "jsdom", 104 | "modulePathIgnorePatterns": [ 105 | "/packages/perfect-freehand/build/", 106 | "/packages/dev/build/" 107 | ], 108 | "moduleNameMapper": { 109 | "perfect-freehand": "/packages/perfect-freehand/src" 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /packages/dev/README.md: -------------------------------------------------------------------------------- 1 | # Dev Server 2 | 3 | Dev server. 4 | -------------------------------------------------------------------------------- /packages/dev/decs.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.css' 2 | -------------------------------------------------------------------------------- /packages/dev/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import fs from 'fs' 3 | import esbuild from 'esbuild' 4 | import cssModulesPlugin from 'esbuild-css-modules-plugin' 5 | 6 | import serve, { error, log } from 'create-serve' 7 | 8 | const isDevServer = process.argv.includes('--dev') 9 | 10 | if (!fs.existsSync('./dist')) { 11 | fs.mkdirSync('./dist') 12 | } 13 | 14 | fs.copyFile('./src/index.html', './dist/index.html', (err) => { 15 | if (err) throw err 16 | }) 17 | 18 | esbuild 19 | .build({ 20 | entryPoints: ['src/index.tsx'], 21 | bundle: true, 22 | outfile: 'dist/bundle.js', 23 | minify: false, 24 | sourcemap: true, 25 | incremental: isDevServer, 26 | target: ['chrome58', 'firefox57', 'safari11', 'edge18'], 27 | define: { 28 | 'process.env.NODE_ENV': isDevServer ? '"development"' : '"production"', 29 | }, 30 | watch: isDevServer && { 31 | onRebuild(err) { 32 | serve.update() 33 | err ? error('❌ Failed') : log('✅ Updated dev.') 34 | }, 35 | }, 36 | plugins: [cssModulesPlugin()], 37 | }) 38 | .catch(() => process.exit(1)) 39 | 40 | if (isDevServer) { 41 | serve.start({ 42 | port: 5420, 43 | root: './dist', 44 | live: true, 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /packages/dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.2.0", 3 | "private": true, 4 | "name": "dev", 5 | "description": "An example project for perfect-freehand.", 6 | "author": { 7 | "name": "Steve Ruiz", 8 | "url": "https://twitter.com/steveruizok" 9 | }, 10 | "repository": "https://github.com/steveruizok/perfect-freehand", 11 | "keywords": [ 12 | "ink", 13 | "draw", 14 | "paint", 15 | "signature", 16 | "handwriting", 17 | "text", 18 | "drawing", 19 | "painting" 20 | ], 21 | "license": "MIT", 22 | "scripts": { 23 | "start": "node ./esbuild.config.mjs --dev tsc --watch", 24 | "build": "yarn clean && node ./esbuild.config.mjs && tsc --project tsconfig.build.json --emitDeclarationOnly --outDir dist/types", 25 | "clean": "rm -rf dist" 26 | }, 27 | "files": [ 28 | "src" 29 | ], 30 | "devDependencies": { 31 | "@modulz/radix-icons": "^4.0.0", 32 | "@radix-ui/react-checkbox": "^0.1.0", 33 | "@radix-ui/react-icons": "^1.0.3", 34 | "@radix-ui/react-label": "^0.1.0", 35 | "@radix-ui/react-slider": "^0.1.0", 36 | "@tldraw/core": "^0.1.19", 37 | "@tldraw/intersect": "^0.1.3", 38 | "@tldraw/vec": "^0.1.3", 39 | "@types/node": "^14.14.35", 40 | "@types/react": "^18.0.10", 41 | "@types/react-dom": "^18.0.5", 42 | "concurrently": "6.0.1", 43 | "create-serve": "1.0.1", 44 | "css-tree": "^1.1.3", 45 | "esbuild": "^0.12.21", 46 | "esbuild-css-modules-plugin": "^2.0.8", 47 | "husky": "^7.0.0", 48 | "jest": "^27.0.6", 49 | "perfect-freehand": "^1.2.0", 50 | "react": "^18.1.0", 51 | "react-dom": "^18.1.0", 52 | "react-hotkeys-hook": "^3.4.0", 53 | "rimraf": "3.0.2", 54 | "rko": "^0.5.25", 55 | "tslib": "^2.3.1", 56 | "typescript": "^4.3.5", 57 | "zustand": "^4.0.0-rc.1" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/dev/src/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Editor } from 'components/editor' 3 | import { Controls } from 'components/controls' 4 | import { Panel } from 'components/panel' 5 | import { useKeyboardShortcuts } from 'hooks' 6 | 7 | function App(): JSX.Element { 8 | useKeyboardShortcuts() 9 | 10 | return ( 11 |
12 | 13 | 14 | 15 |
16 | ) 17 | } 18 | 19 | const AppWrapper: React.FC = () => { 20 | return 21 | } 22 | 23 | export default AppWrapper 24 | -------------------------------------------------------------------------------- /packages/dev/src/components/checkbox/checkbox.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | position: relative; 3 | flex-grow: 2; 4 | max-width: 200px; 5 | display: flex; 6 | height: 16px; 7 | background: none; 8 | border: none; 9 | cursor: pointer; 10 | padding: 0; 11 | } 12 | 13 | .root:disabled { 14 | opacity: 0.5; 15 | } 16 | .root:disabled::after { 17 | background-color: gainsboro; 18 | opacity: 1; 19 | } 20 | 21 | .root:focus { 22 | outline: none; 23 | } 24 | 25 | .root:focus::after { 26 | border: 2px solid dodgerblue; 27 | } 28 | 29 | .root::after { 30 | content: ''; 31 | position: absolute; 32 | left: 0px; 33 | top: 0px; 34 | bottom: 0px; 35 | width: 16px; 36 | height: 16px; 37 | border: 1px solid gainsboro; 38 | border-radius: 2px; 39 | box-sizing: border-box; 40 | } 41 | 42 | .indicator { 43 | position: initial; 44 | height: 16px; 45 | width: 16px; 46 | border-radius: 2px; 47 | box-sizing: border-box; 48 | background-color: dodgerblue; 49 | border: 1px solid dodgerblue; 50 | z-index: 2; 51 | } 52 | -------------------------------------------------------------------------------- /packages/dev/src/components/checkbox/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as Label from '@radix-ui/react-label' 3 | import { 4 | Root, 5 | Indicator, 6 | CheckboxProps as CheckboxOwnProps, 7 | } from '@radix-ui/react-checkbox' 8 | import styles from './checkbox.module.css' 9 | 10 | interface CheckboxProps extends CheckboxOwnProps { 11 | name: string 12 | } 13 | 14 | export function Checkbox(props: CheckboxProps) { 15 | return ( 16 | <> 17 | 18 | {props.name} 19 | 20 | 21 | 22 | 23 |
24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /packages/dev/src/components/checkbox/index.ts: -------------------------------------------------------------------------------- 1 | export * from './checkbox' 2 | -------------------------------------------------------------------------------- /packages/dev/src/components/colors/colors.module.css: -------------------------------------------------------------------------------- 1 | .grid { 2 | display: flex; 3 | grid-column: span 2; 4 | margin-left: -4px; 5 | } 6 | 7 | .color { 8 | cursor: pointer; 9 | flex-grow: 2; 10 | border: none; 11 | height: 24px; 12 | width: 100%; 13 | padding: none; 14 | } 15 | 16 | .selected { 17 | border-bottom: 5px solid dodgerblue; 18 | } 19 | -------------------------------------------------------------------------------- /packages/dev/src/components/colors/colors.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as Label from '@radix-ui/react-label' 3 | import styles from './colors.module.css' 4 | 5 | interface ColorsProps { 6 | name: string 7 | colors: string[] 8 | color: string 9 | onChange: (color: string) => void 10 | } 11 | 12 | export function Colors(props: ColorsProps) { 13 | return ( 14 | <> 15 | {props.name} 16 |
17 | {props.colors.map((color) => { 18 | return ( 19 |
34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /packages/dev/src/components/colors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './colors' 2 | -------------------------------------------------------------------------------- /packages/dev/src/components/controls/controls.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: fixed; 3 | width: min(100%, 320px); 4 | top: 48px; 5 | left: 0px; 6 | padding: 12px; 7 | background-color: rgba(255, 255, 255, 0.95); 8 | border-bottom-right-radius: 16px; 9 | box-shadow: 0px 0px 8px -4px rgba(0, 0, 0, 0.16), 10 | 0px 0px 36px 0px rgba(0, 0, 0, 0.08); 11 | z-index: 100; 12 | transform: translate(-100%, 0px); 13 | } 14 | 15 | .open { 16 | transform: translate(0%, 0px); 17 | transition: transform 0.2s; 18 | } 19 | 20 | .inputs { 21 | display: grid; 22 | grid-template-columns: 96px 1fr auto; 23 | grid-auto-rows: 32px; 24 | align-items: center; 25 | column-gap: 16px; 26 | } 27 | 28 | .colors { 29 | padding: 12px 0; 30 | } 31 | 32 | hr { 33 | grid-column: 1 / span 3; 34 | height: 1px; 35 | border: none; 36 | background-color: gainsboro; 37 | width: calc(100% + 24px); 38 | margin-left: -12px; 39 | } 40 | 41 | .buttonsRow { 42 | display: flex; 43 | height: 40px; 44 | } 45 | 46 | .rowButton { 47 | cursor: pointer; 48 | flex-grow: 2; 49 | font-family: 'Recursive', sans-serif; 50 | border: none; 51 | background-color: transparent; 52 | border: 2px solid transparent; 53 | } 54 | 55 | .rowButton:hover { 56 | color: dodgerblue; 57 | } 58 | 59 | .rowButton:focus { 60 | outline: none; 61 | border: 2px solid dodgerblue; 62 | } 63 | -------------------------------------------------------------------------------- /packages/dev/src/components/controls/controls.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Colors } from 'components/colors' 3 | import { Checkbox } from 'components/checkbox' 4 | import { Select } from 'components/select' 5 | import { Slider } from 'components/slider' 6 | import styles from './controls.module.css' 7 | import { app, useAppState } from 'state' 8 | import type { Easing, State } from 'types' 9 | 10 | const COLORS = [ 11 | '#000000', 12 | '#ffc107', 13 | '#ff5722', 14 | '#e91e63', 15 | '#673ab7', 16 | '#00bcd4', 17 | '#efefef', 18 | ] 19 | 20 | const EASINGS = [ 21 | 'linear', 22 | 'easeInQuad', 23 | 'easeOutQuad', 24 | 'easeInOutQuad', 25 | 'easeInCubic', 26 | 'easeOutCubic', 27 | 'easeInOutCubic', 28 | 'easeInQuart', 29 | 'easeOutQuart', 30 | 'easeInOutQuart', 31 | 'easeInQuint', 32 | 'easeOutQuint', 33 | 'easeInOutQuint', 34 | 'easeInSine', 35 | 'easeOutSine', 36 | 'easeInOutSine', 37 | 'easeInExpo', 38 | 'easeOutExpo', 39 | 'easeInOutExpo', 40 | ] 41 | 42 | const appStateSelector = (s: State) => s.appState 43 | 44 | export function Controls() { 45 | const appState = useAppState(appStateSelector) 46 | const { style } = appState 47 | 48 | const handleSizeChangeStart = React.useCallback(() => { 49 | app.setSnapshot() 50 | }, []) 51 | 52 | const handleSizeChange = React.useCallback((v: number[]) => { 53 | app.patchStyleForAllShapes({ size: v[0] }) 54 | }, []) 55 | 56 | const handleStrokeWidthChangeStart = React.useCallback(() => { 57 | app.setSnapshot() 58 | }, []) 59 | 60 | const handleStrokeWidthChange = React.useCallback((v: number[]) => { 61 | app.patchStyleForAllShapes({ strokeWidth: v[0] }) 62 | }, []) 63 | 64 | const handleThinningChangeStart = React.useCallback(() => { 65 | app.setSnapshot() 66 | }, []) 67 | 68 | const handleThinningChange = React.useCallback((v: number[]) => { 69 | app.patchStyleForAllShapes({ thinning: v[0] }) 70 | }, []) 71 | 72 | const handleStreamlineChangeStart = React.useCallback(() => { 73 | app.setSnapshot() 74 | }, []) 75 | 76 | const handleStreamlineChange = React.useCallback((v: number[]) => { 77 | app.patchStyleForAllShapes({ streamline: v[0] }) 78 | }, []) 79 | 80 | const handleSmoothingChangeStart = React.useCallback(() => { 81 | app.setSnapshot() 82 | }, []) 83 | 84 | const handleSmoothingChange = React.useCallback((v: number[]) => { 85 | app.patchStyleForAllShapes({ smoothing: v[0] }) 86 | }, []) 87 | 88 | const handleEasingChange = React.useCallback((easing: string) => { 89 | app.patchStyleForAllShapes({ easing: easing as Easing }) 90 | }, []) 91 | 92 | const handleCapStartChange = React.useCallback( 93 | (v: boolean | 'indeterminate') => { 94 | app.setNextStyleForAllShapes({ capStart: !!v }) 95 | }, 96 | [] 97 | ) 98 | 99 | const handleTaperStartChangeStart = React.useCallback(() => { 100 | app.setSnapshot() 101 | }, []) 102 | 103 | const handleTaperStartChange = React.useCallback((v: number[]) => { 104 | app.patchStyleForAllShapes({ taperStart: v[0] === 100 ? true : v[0] }) 105 | }, []) 106 | 107 | const handleEasingStartChange = React.useCallback((easing: string) => { 108 | app.patchStyleForAllShapes({ easingStart: easing as Easing }) 109 | }, []) 110 | 111 | const handleCapEndChange = React.useCallback( 112 | (v: boolean | 'indeterminate') => { 113 | app.setNextStyleForAllShapes({ capEnd: !!v }) 114 | }, 115 | [] 116 | ) 117 | 118 | const handleTaperEndChangeStart = React.useCallback(() => { 119 | app.setSnapshot() 120 | }, []) 121 | 122 | const handleTaperEndChange = React.useCallback((v: number[]) => { 123 | app.patchStyleForAllShapes({ taperEnd: v[0] === 100 ? true : v[0] }) 124 | }, []) 125 | 126 | const handleEasingEndChange = React.useCallback((easing: string) => { 127 | app.patchStyleForAllShapes({ easingEnd: easing as Easing }) 128 | }, []) 129 | 130 | const handleIsFilledChange = React.useCallback( 131 | (v: boolean | 'indeterminate') => { 132 | app.setNextStyleForAllShapes({ isFilled: !!v }) 133 | }, 134 | [] 135 | ) 136 | 137 | const handleStyleChangeComplete = React.useCallback(() => { 138 | app.finishStyleUpdate() 139 | }, []) 140 | 141 | const handleStrokeColorChange = React.useCallback((color: string) => { 142 | app.patchStyle({ stroke: color }) 143 | }, []) 144 | 145 | const handleFillColorChange = React.useCallback((color: string) => { 146 | app.patchStyle({ fill: color }) 147 | }, []) 148 | 149 | // Resets 150 | 151 | const handleResetSize = React.useCallback(() => { 152 | app.resetStyle('size') 153 | }, []) 154 | 155 | const handleResetThinning = React.useCallback(() => { 156 | app.resetStyle('thinning') 157 | }, []) 158 | 159 | const handleResetStreamline = React.useCallback(() => { 160 | app.resetStyle('streamline') 161 | }, []) 162 | 163 | const handleResetSmoothing = React.useCallback(() => { 164 | app.resetStyle('smoothing') 165 | }, []) 166 | 167 | const handleResetEasing = React.useCallback(() => { 168 | app.resetStyle('easing') 169 | }, []) 170 | 171 | const handleResetTaperStart = React.useCallback(() => { 172 | app.resetStyle('taperStart') 173 | }, []) 174 | 175 | const handleResetEasingStart = React.useCallback(() => { 176 | app.resetStyle('easingStart') 177 | }, []) 178 | 179 | const handleResetTaperEnd = React.useCallback(() => { 180 | app.resetStyle('taperEnd') 181 | }, []) 182 | 183 | const handleResetEasingEnd = React.useCallback(() => { 184 | app.resetStyle('easingEnd') 185 | }, []) 186 | 187 | const handleResetStrokeWidth = React.useCallback(() => { 188 | app.resetStyle('strokeWidth') 189 | }, []) 190 | 191 | return ( 192 |
198 |
199 | 210 | 221 | 232 | 243 | 255 |
256 | 284 | {style.taperStart <= 0 && ( 285 | 0} 288 | checked={style.taperStart === 0 && style.capStart} 289 | onCheckedChange={handleCapStartChange} 290 | /> 291 | )} 292 | {style.taperStart > 0 && ( 293 | 305 | )} 306 |
307 | 335 | {style.taperEnd <= 0 && ( 336 | 0} 339 | checked={style.taperEnd === 0 && style.capEnd} 340 | onCheckedChange={handleCapEndChange} 341 | /> 342 | )} 343 | {style.taperEnd > 0 && ( 344 | 356 | )} 357 |
358 | 363 | {style.isFilled && ( 364 | 370 | )} 371 | 382 | {style.strokeWidth > 0 && ( 383 | 389 | )} 390 |
391 |
392 |
393 | 396 | 399 |
400 |
401 |
402 | 405 |
406 |
407 | ) 408 | } 409 | -------------------------------------------------------------------------------- /packages/dev/src/components/controls/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controls' 2 | -------------------------------------------------------------------------------- /packages/dev/src/components/editor/editor.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | z-index: 1; 8 | } 9 | -------------------------------------------------------------------------------- /packages/dev/src/components/editor/editor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Renderer } from '@tldraw/core' 3 | import { app, useAppState } from 'state' 4 | import styles from './editor.module.css' 5 | 6 | export function Editor(): JSX.Element { 7 | const { 8 | onPinch, 9 | onPinchStart, 10 | onPinchEnd, 11 | onPan, 12 | onPointerDown, 13 | onPointerMove, 14 | onPointerUp, 15 | shapeUtils, 16 | } = app 17 | const { page, pageState } = useAppState() 18 | 19 | React.useEffect(() => { 20 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 21 | // @ts-ignore 22 | window.freehand = app 23 | }, []) 24 | 25 | return ( 26 |
27 | 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /packages/dev/src/components/editor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './editor' 2 | -------------------------------------------------------------------------------- /packages/dev/src/components/panel/index.ts: -------------------------------------------------------------------------------- 1 | export * from './panel' 2 | -------------------------------------------------------------------------------- /packages/dev/src/components/panel/panel.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | display: flex; 4 | z-index: 20; 5 | width: fit-content; 6 | user-select: none; 7 | } 8 | 9 | .container a, 10 | .container button { 11 | border: none; 12 | height: 40px; 13 | width: 40px; 14 | display: flex; 15 | align-items: center; 16 | justify-content: center; 17 | color: black; 18 | font-family: 'Recursive', sans-serif; 19 | user-select: none; 20 | background-color: rgba(255, 255, 255, 1); 21 | text-shadow: 1px 1px 2px rgba(255, 255, 255, 1), 22 | 1px 1px 3px rgba(255, 255, 255, 1), 1px 1px 8px rgba(255, 255, 255, 1); 23 | } 24 | 25 | .container button { 26 | padding: 16px 40px; 27 | } 28 | 29 | .container a:hover, 30 | .container button:hover, 31 | .container button[data-active='true'] { 32 | color: dodgerblue; 33 | background-color: white; 34 | } 35 | 36 | .bottom { 37 | bottom: 0px; 38 | } 39 | 40 | .left { 41 | left: 0px; 42 | } 43 | 44 | .right { 45 | right: 0px; 46 | } 47 | 48 | .top { 49 | top: 0px; 50 | } 51 | 52 | .center { 53 | position: fixed; 54 | width: 100%; 55 | height: 40px; 56 | display: flex; 57 | align-items: center; 58 | justify-content: center; 59 | background-color: none; 60 | z-index: 10; 61 | } 62 | 63 | .center a { 64 | color: black; 65 | text-decoration: none; 66 | } 67 | -------------------------------------------------------------------------------- /packages/dev/src/components/panel/panel.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styles from './panel.module.css' 3 | import { app, useAppState } from 'state' 4 | import { GitHubLogoIcon, HamburgerMenuIcon } from '@radix-ui/react-icons' 5 | 6 | export function Panel() { 7 | const tool = useAppState((s) => s.appState.tool) 8 | 9 | return ( 10 | <> 11 | 20 |
21 | 22 | 23 | 24 |
25 |
26 | 31 | 32 | 33 |
34 |
35 | 41 | {/* */} 47 |
48 |
51 | 52 | 53 | 54 |
55 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /packages/dev/src/components/select/index.ts: -------------------------------------------------------------------------------- 1 | export * from './select' 2 | -------------------------------------------------------------------------------- /packages/dev/src/components/select/select.module.css: -------------------------------------------------------------------------------- 1 | .select { 2 | grid-column: 2 / span 2; 3 | padding: 0; 4 | } 5 | 6 | .select select { 7 | position: relative; 8 | display: flex; 9 | height: 30px; 10 | width: calc(100% + 6px); 11 | background: none; 12 | border: none; 13 | cursor: pointer; 14 | border-radius: none; 15 | padding: 0; 16 | margin-left: -6px; 17 | outline: none; 18 | font: inherit; 19 | border: 2px solid transparent; 20 | } 21 | 22 | .select select:focus { 23 | outline: none; 24 | border: 2px solid dodgerblue; 25 | } 26 | -------------------------------------------------------------------------------- /packages/dev/src/components/select/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as Label from '@radix-ui/react-label' 3 | import styles from './select.module.css' 4 | 5 | interface SelectProps { 6 | name: string 7 | value: string 8 | children: React.ReactNode 9 | onDoubleClick: () => void 10 | onValueChange: (value: string) => void 11 | } 12 | 13 | export function Select({ 14 | onValueChange, 15 | onDoubleClick, 16 | name, 17 | value, 18 | children, 19 | }: SelectProps) { 20 | const handleValueChange = React.useCallback( 21 | (e: React.ChangeEvent) => { 22 | onValueChange?.(e.currentTarget.value) 23 | }, 24 | [onValueChange] 25 | ) 26 | 27 | return ( 28 | <> 29 | 30 | {name} 31 | 32 |
33 | 36 |
37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /packages/dev/src/components/slider/index.ts: -------------------------------------------------------------------------------- 1 | export * from './slider' 2 | -------------------------------------------------------------------------------- /packages/dev/src/components/slider/slider.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | max-width: 200px; 3 | height: 16px; 4 | position: relative; 5 | display: flex; 6 | align-items: center; 7 | flex-shrink: 0; 8 | flex-grow: 1; 9 | user-select: none; 10 | touch-action: none; 11 | } 12 | 13 | .range { 14 | position: absolute; 15 | height: 3px; 16 | background-color: dodgerblue; 17 | border-radius: 5px; 18 | } 19 | 20 | .track { 21 | position: relative; 22 | height: 3px; 23 | flex-grow: 1; 24 | background-color: gainsboro; 25 | border-radius: 5px; 26 | } 27 | 28 | .thumb { 29 | position: relative; 30 | height: 4px; 31 | width: 4px; 32 | outline: none; 33 | } 34 | 35 | .thumbBall { 36 | display: block; 37 | width: 40px; 38 | height: 40px; 39 | display: flex; 40 | align-items: center; 41 | justify-content: center; 42 | } 43 | 44 | .thumbBall::after { 45 | display: block; 46 | content: ''; 47 | width: 12px; 48 | height: 12px; 49 | border-radius: 100%; 50 | background-color: white; 51 | border: 2px solid dodgerblue; 52 | transform: scale(1); 53 | transition: all 0.12s; 54 | } 55 | 56 | .thumb:focus > .thumbBall::after { 57 | background-color: dodgerblue; 58 | } 59 | 60 | .numberInput { 61 | font-family: inherit; 62 | font-size: 12px; 63 | border: none; 64 | width: 40px; 65 | text-align: right; 66 | padding: 4px 4px; 67 | } 68 | 69 | .numberInput::-webkit-outer-spin-button, 70 | .numberInput::-webkit-inner-spin-button { 71 | -webkit-appearance: none; 72 | margin: 0; 73 | } 74 | 75 | .numberInput:focus { 76 | border-radius: 2px; 77 | outline: 2px solid dodgerblue; 78 | } 79 | -------------------------------------------------------------------------------- /packages/dev/src/components/slider/slider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as Label from '@radix-ui/react-label' 3 | import { 4 | Root, 5 | Track, 6 | Range, 7 | Thumb, 8 | SliderProps as SliderOwnProps, 9 | } from '@radix-ui/react-slider' 10 | import styles from './slider.module.css' 11 | 12 | interface SliderProps extends SliderOwnProps { 13 | value: number[] 14 | onPointerDown: () => void 15 | onPointerUp: () => void 16 | onDoubleClick: () => void 17 | label?: string 18 | } 19 | 20 | export function Slider({ 21 | onDoubleClick, 22 | onPointerUp, 23 | onPointerDown, 24 | onValueChange, 25 | min, 26 | max, 27 | step, 28 | label, 29 | value = [0], 30 | ...props 31 | }: SliderProps) { 32 | const handleValueChange = React.useCallback( 33 | (e: React.ChangeEvent) => { 34 | onValueChange?.([+e.currentTarget.value]) 35 | }, 36 | [onValueChange] 37 | ) 38 | 39 | return ( 40 | <> 41 | 42 | {props.name} 43 | 44 | 56 | 57 | 58 | 59 | {value.map((_, i) => ( 60 | 61 |
62 | 63 | ))} 64 | 65 | {label ? ( 66 | {label} 67 | ) : ( 68 | 77 | )} 78 | 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /packages/dev/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useKeyboardShortcuts' 2 | -------------------------------------------------------------------------------- /packages/dev/src/hooks/useKeyboardShortcuts.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useHotkeys } from 'react-hotkeys-hook' 3 | import { app } from 'state' 4 | 5 | export function useKeyboardShortcuts() { 6 | useHotkeys('command+z,ctrl+z', () => { 7 | app.undo() 8 | }) 9 | 10 | useHotkeys('command+shift+z,ctrl+shift+z', () => { 11 | app.redo() 12 | }) 13 | 14 | useHotkeys('command+c,ctrl+c', () => { 15 | app.copySvg() 16 | }) 17 | 18 | useHotkeys('command+shift+c,ctrl+shift+c', () => { 19 | app.copyStyles() 20 | }) 21 | 22 | useHotkeys('e,backspace', () => { 23 | app.resetDoc() 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /packages/dev/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | perfect-freehand 9 | 10 | 11 |
12 | 13 | 14 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /packages/dev/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './app' 4 | import './styles.css' 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ) 12 | -------------------------------------------------------------------------------- /packages/dev/src/state/easings.ts: -------------------------------------------------------------------------------- 1 | import type { Easing } from 'types' 2 | 3 | export const EASINGS: Record number> = { 4 | linear: (t) => t, 5 | easeInQuad: (t) => t * t, 6 | easeOutQuad: (t) => t * (2 - t), 7 | easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t), 8 | easeInCubic: (t) => t * t * t, 9 | easeOutCubic: (t) => --t * t * t + 1, 10 | easeInOutCubic: (t) => 11 | t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1, 12 | easeInQuart: (t) => t * t * t * t, 13 | easeOutQuart: (t) => 1 - --t * t * t * t, 14 | easeInOutQuart: (t) => 15 | t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t, 16 | easeInQuint: (t) => t * t * t * t * t, 17 | easeOutQuint: (t) => 1 + --t * t * t * t * t, 18 | easeInOutQuint: (t) => 19 | t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t, 20 | easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2), 21 | easeOutSine: (t) => Math.sin((t * Math.PI) / 2), 22 | easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2, 23 | easeInExpo: (t) => (t <= 0 ? 0 : Math.pow(2, 10 * t - 10)), 24 | easeOutExpo: (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)), 25 | easeInOutExpo: (t) => 26 | t <= 0 27 | ? 0 28 | : t >= 1 29 | ? 1 30 | : t < 0.5 31 | ? Math.pow(2, 20 * t - 10) / 2 32 | : (2 - Math.pow(2, -20 * t + 10)) / 2, 33 | } 34 | 35 | export const EASING_STRINGS: Record = { 36 | linear: `(t) => t`, 37 | easeInQuad: `(t) => t * t`, 38 | easeOutQuad: `(t) => t * (2 - t)`, 39 | easeInOutQuad: `(t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t)`, 40 | easeInCubic: `(t) => t * t * t`, 41 | easeOutCubic: `(t) => --t * t * t + 1`, 42 | easeInOutCubic: `(t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1`, 43 | easeInQuart: `(t) => t * t * t * t`, 44 | easeOutQuart: `(t) => 1 - --t * t * t * t`, 45 | easeInOutQuart: `(t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t`, 46 | easeInQuint: `(t) => t * t * t * t * t`, 47 | easeOutQuint: `(t) => 1 + --t * t * t * t * t`, 48 | easeInOutQuint: `(t) => t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t`, 49 | easeInSine: `(t) => 1 - Math.cos((t * Math.PI) / 2)`, 50 | easeOutSine: `(t) => Math.sin((t * Math.PI) / 2)`, 51 | easeInOutSine: `(t) => -(Math.cos(Math.PI * t) - 1) / 2`, 52 | easeInExpo: `(t) => (t <= 0 ? 0 : Math.pow(2, 10 * t - 10))`, 53 | easeOutExpo: `(t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t))`, 54 | easeInOutExpo: `(t) => 55 | t <= 0 56 | ? 0 57 | : t >= 1 58 | ? 1 59 | : t < 0.5 60 | ? Math.pow(2, 20 * t - 10) / 2 61 | : (2 - Math.pow(2, -20 * t + 10)) / 2`, 62 | } 63 | -------------------------------------------------------------------------------- /packages/dev/src/state/excalidraw.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "excalidraw", 3 | "version": 2, 4 | "source": "https://excalidraw.com", 5 | "elements": [ 6 | { 7 | "type": "rectangle", 8 | "version": 165, 9 | "versionNonce": 1437958102, 10 | "isDeleted": false, 11 | "id": "Ha4HSXfRj44CKv2p3Rj7I", 12 | "fillStyle": "solid", 13 | "strokeWidth": 2, 14 | "strokeStyle": "solid", 15 | "roughness": 0, 16 | "opacity": 100, 17 | "angle": 0, 18 | "x": 202.17364760843054, 19 | "y": 639.5270876466532, 20 | "strokeColor": "#c92a2a", 21 | "backgroundColor": "transparent", 22 | "width": 112.85714285714278, 23 | "height": 94.28571428571446, 24 | "seed": 114976664, 25 | "groupIds": [], 26 | "strokeSharpness": "sharp", 27 | "boundElementIds": [ 28 | "-EHziqjGHk4B1NuZHwn03" 29 | ] 30 | }, 31 | { 32 | "type": "rectangle", 33 | "version": 82, 34 | "versionNonce": 709070986, 35 | "isDeleted": false, 36 | "id": "8071Py40CDL4zmvT3CWCx", 37 | "fillStyle": "solid", 38 | "strokeWidth": 2, 39 | "strokeStyle": "solid", 40 | "roughness": 0, 41 | "opacity": 100, 42 | "angle": 0, 43 | "x": 433.602219037002, 44 | "y": 666.6699447895103, 45 | "strokeColor": "#c92a2a", 46 | "backgroundColor": "transparent", 47 | "width": 112.85714285714278, 48 | "height": 71.42857142857156, 49 | "seed": 1283029736, 50 | "groupIds": [], 51 | "strokeSharpness": "sharp", 52 | "boundElementIds": [ 53 | "dwe3nNsrVJUtqhWKpk2nX" 54 | ] 55 | }, 56 | { 57 | "type": "freedraw", 58 | "version": 131, 59 | "versionNonce": 971183382, 60 | "isDeleted": false, 61 | "id": "2JHVHq5EaNVmLBkR9pqjK", 62 | "fillStyle": "hachure", 63 | "strokeWidth": 1, 64 | "strokeStyle": "solid", 65 | "roughness": 1, 66 | "opacity": 100, 67 | "angle": 0, 68 | "x": 735.9285714285713, 69 | "y": 1201.5714285714284, 70 | "strokeColor": "#000000", 71 | "backgroundColor": "transparent", 72 | "width": 302, 73 | "height": 70, 74 | "seed": 2069284399, 75 | "groupIds": [], 76 | "strokeSharpness": "round", 77 | "boundElementIds": [], 78 | "points": [ 79 | [ 80 | 0, 81 | 0 82 | ], 83 | [ 84 | -302, 85 | -70 86 | ] 87 | ], 88 | "lastCommittedPoint": null, 89 | "simulatePressure": false, 90 | "pressures": [ 91 | 0, 92 | 0.07999999821186066 93 | ] 94 | }, 95 | { 96 | "type": "freedraw", 97 | "version": 138, 98 | "versionNonce": 424996682, 99 | "isDeleted": false, 100 | "id": "g99sDQcNlf8-GgLnumbNB", 101 | "fillStyle": "hachure", 102 | "strokeWidth": 1, 103 | "strokeStyle": "solid", 104 | "roughness": 1, 105 | "opacity": 100, 106 | "angle": 0, 107 | "x": 275.2792207792206, 108 | "y": 617.9155844155848, 109 | "strokeColor": "#000000", 110 | "backgroundColor": "transparent", 111 | "width": 66.36363636363637, 112 | "height": 209.09090909090912, 113 | "seed": 106595169, 114 | "groupIds": [], 115 | "strokeSharpness": "round", 116 | "boundElementIds": [], 117 | "points": [ 118 | [ 119 | 0, 120 | 0 121 | ], 122 | [ 123 | 6.363636363636374, 124 | -7.272727272727252 125 | ], 126 | [ 127 | 9.090909090909122, 128 | -12.727272727272748 129 | ], 130 | [ 131 | -57.27272727272725, 132 | 196.36363636363637 133 | ] 134 | ], 135 | "lastCommittedPoint": null, 136 | "simulatePressure": false, 137 | "pressures": [ 138 | 0, 139 | 0, 140 | 0, 141 | 0 142 | ] 143 | }, 144 | { 145 | "type": "freedraw", 146 | "version": 100, 147 | "versionNonce": 103547478, 148 | "isDeleted": false, 149 | "id": "9wzgTRRnUxWPfXOgXr5DX", 150 | "fillStyle": "hachure", 151 | "strokeWidth": 1, 152 | "strokeStyle": "solid", 153 | "roughness": 1, 154 | "opacity": 100, 155 | "angle": 0, 156 | "x": 492.5843016632492, 157 | "y": 1106.4079516974261, 158 | "strokeColor": "#000000", 159 | "backgroundColor": "transparent", 160 | "width": 102.63157894736838, 161 | "height": 211.40350877192986, 162 | "seed": 1072775183, 163 | "groupIds": [], 164 | "strokeSharpness": "round", 165 | "boundElementIds": [], 166 | "points": [ 167 | [ 168 | 0, 169 | 0 170 | ], 171 | [ 172 | -102.63157894736838, 173 | 211.40350877192986 174 | ] 175 | ], 176 | "lastCommittedPoint": null, 177 | "simulatePressure": false, 178 | "pressures": [ 179 | 0, 180 | 0 181 | ] 182 | }, 183 | { 184 | "type": "freedraw", 185 | "version": 337, 186 | "versionNonce": 1145861642, 187 | "isDeleted": false, 188 | "id": "xZQ3jUF5qYSqzH3M0fkDp", 189 | "fillStyle": "hachure", 190 | "strokeWidth": 1, 191 | "strokeStyle": "solid", 192 | "roughness": 1, 193 | "opacity": 100, 194 | "angle": 0, 195 | "x": 286.8242019670836, 196 | "y": 1124.9102638154604, 197 | "strokeColor": "#000000", 198 | "backgroundColor": "transparent", 199 | "width": 168.98734177215186, 200 | "height": 70.8860759493673, 201 | "seed": 1116285377, 202 | "groupIds": [], 203 | "strokeSharpness": "round", 204 | "boundElementIds": [], 205 | "points": [ 206 | [ 207 | 0, 208 | 0 209 | ], 210 | [ 211 | -168.98734177215186, 212 | -70.8860759493673 213 | ] 214 | ], 215 | "lastCommittedPoint": null, 216 | "simulatePressure": false, 217 | "pressures": [ 218 | 0, 219 | 0.07999999821186066 220 | ] 221 | }, 222 | { 223 | "type": "freedraw", 224 | "version": 220, 225 | "versionNonce": 1298483094, 226 | "isDeleted": false, 227 | "id": "MmgnGuXoRWGIteFP3Bsmp", 228 | "fillStyle": "hachure", 229 | "strokeWidth": 1, 230 | "strokeStyle": "solid", 231 | "roughness": 1, 232 | "opacity": 100, 233 | "angle": 0, 234 | "x": 604.8606641324292, 235 | "y": 1320.7823529254492, 236 | "strokeColor": "#000000", 237 | "backgroundColor": "transparent", 238 | "width": 178.48101265822777, 239 | "height": 73.41772151898749, 240 | "seed": 1174808833, 241 | "groupIds": [], 242 | "strokeSharpness": "round", 243 | "boundElementIds": [], 244 | "points": [ 245 | [ 246 | 0, 247 | 0 248 | ], 249 | [ 250 | -178.48101265822777, 251 | -73.41772151898749 252 | ] 253 | ], 254 | "lastCommittedPoint": null, 255 | "simulatePressure": false, 256 | "pressures": [ 257 | 0, 258 | 0.07999999821186066 259 | ] 260 | }, 261 | { 262 | "type": "freedraw", 263 | "version": 154, 264 | "versionNonce": 472196298, 265 | "isDeleted": false, 266 | "id": "lK97Vc4zX-NPkVUq_bjDR", 267 | "fillStyle": "hachure", 268 | "strokeWidth": 1, 269 | "strokeStyle": "solid", 270 | "roughness": 1, 271 | "opacity": 100, 272 | "angle": 0, 273 | "x": 457.9838763006346, 274 | "y": 1247.7911080505105, 275 | "strokeColor": "#000000", 276 | "backgroundColor": "transparent", 277 | "width": 156.96202531645565, 278 | "height": 48.7341772151899, 279 | "seed": 760161519, 280 | "groupIds": [], 281 | "strokeSharpness": "round", 282 | "boundElementIds": [], 283 | "points": [ 284 | [ 285 | 0, 286 | 0 287 | ], 288 | [ 289 | -156.96202531645565, 290 | -48.7341772151899 291 | ] 292 | ], 293 | "lastCommittedPoint": null, 294 | "simulatePressure": false, 295 | "pressures": [ 296 | 0, 297 | 0.07999999821186066 298 | ] 299 | }, 300 | { 301 | "type": "freedraw", 302 | "version": 487, 303 | "versionNonce": 445895062, 304 | "isDeleted": false, 305 | "id": "0GsgMd4useUUTrlidzxo5", 306 | "fillStyle": "hachure", 307 | "strokeWidth": 1, 308 | "strokeStyle": "solid", 309 | "roughness": 1, 310 | "opacity": 100, 311 | "angle": 0, 312 | "x": 549.0159772321532, 313 | "y": 749.1012883038112, 314 | "strokeColor": "#000000", 315 | "backgroundColor": "transparent", 316 | "width": 141.77215189873408, 317 | "height": 84.17721518987355, 318 | "seed": 1481097153, 319 | "groupIds": [], 320 | "strokeSharpness": "round", 321 | "boundElementIds": [], 322 | "points": [ 323 | [ 324 | 0, 325 | 0 326 | ], 327 | [ 328 | 5.696202531645554, 329 | -5.6962025316456675 330 | ], 331 | [ 332 | 6.962025316455652, 333 | -5.6962025316456675 334 | ], 335 | [ 336 | 7.5949367088607005, 337 | -5.6962025316456675 338 | ], 339 | [ 340 | -134.17721518987338, 341 | -84.17721518987355 342 | ] 343 | ], 344 | "lastCommittedPoint": null, 345 | "simulatePressure": false, 346 | "pressures": [ 347 | 0, 348 | 0, 349 | 0, 350 | 0, 351 | 0.07999999821186066 352 | ] 353 | }, 354 | { 355 | "type": "freedraw", 356 | "version": 101, 357 | "versionNonce": 981072778, 358 | "isDeleted": false, 359 | "id": "wyGBWwVfwh9aLxV31vThB", 360 | "fillStyle": "hachure", 361 | "strokeWidth": 1, 362 | "strokeStyle": "solid", 363 | "roughness": 1, 364 | "opacity": 100, 365 | "angle": 0, 366 | "x": 218.01242236024854, 367 | "y": 1288.2608695652175, 368 | "strokeColor": "#000000", 369 | "backgroundColor": "transparent", 370 | "width": 73.29192546583846, 371 | "height": 152.17391304347825, 372 | "seed": 1973316175, 373 | "groupIds": [], 374 | "strokeSharpness": "round", 375 | "boundElementIds": [], 376 | "points": [ 377 | [ 378 | 0, 379 | 0 380 | ], 381 | [ 382 | 73.29192546583846, 383 | -152.17391304347825 384 | ] 385 | ], 386 | "lastCommittedPoint": null, 387 | "simulatePressure": false, 388 | "pressures": [ 389 | 0, 390 | 0 391 | ] 392 | }, 393 | { 394 | "type": "arrow", 395 | "version": 36, 396 | "versionNonce": 1054661142, 397 | "isDeleted": false, 398 | "id": "-EHziqjGHk4B1NuZHwn03", 399 | "fillStyle": "solid", 400 | "strokeWidth": 2, 401 | "strokeStyle": "solid", 402 | "roughness": 0, 403 | "opacity": 100, 404 | "angle": 0, 405 | "x": 53.60221903700187, 406 | "y": 772.3842305037962, 407 | "strokeColor": "#c92a2a", 408 | "backgroundColor": "transparent", 409 | "width": 144.28571428571433, 410 | "height": 70, 411 | "seed": 1939710696, 412 | "groupIds": [], 413 | "strokeSharpness": "round", 414 | "boundElementIds": [], 415 | "startBinding": null, 416 | "endBinding": { 417 | "elementId": "Ha4HSXfRj44CKv2p3Rj7I", 418 | "focus": 0.18439783619625968, 419 | "gap": 4.285714285714334 420 | }, 421 | "lastCommittedPoint": null, 422 | "startArrowhead": null, 423 | "endArrowhead": "arrow", 424 | "points": [ 425 | [ 426 | 0, 427 | 0 428 | ], 429 | [ 430 | 144.28571428571433, 431 | -70 432 | ] 433 | ] 434 | }, 435 | { 436 | "type": "arrow", 437 | "version": 48, 438 | "versionNonce": 1300625994, 439 | "isDeleted": false, 440 | "id": "dwe3nNsrVJUtqhWKpk2nX", 441 | "fillStyle": "solid", 442 | "strokeWidth": 2, 443 | "strokeStyle": "solid", 444 | "roughness": 0, 445 | "opacity": 100, 446 | "angle": 0, 447 | "x": 55.03079046557332, 448 | "y": 772.3842305037962, 449 | "strokeColor": "#c92a2a", 450 | "backgroundColor": "transparent", 451 | "width": 370.8053915248326, 452 | "height": 30.658310758948005, 453 | "seed": 1450736792, 454 | "groupIds": [], 455 | "strokeSharpness": "round", 456 | "boundElementIds": [], 457 | "startBinding": null, 458 | "endBinding": { 459 | "elementId": "8071Py40CDL4zmvT3CWCx", 460 | "focus": -0.8428482198625827, 461 | "gap": 8.571428571428442 462 | }, 463 | "lastCommittedPoint": null, 464 | "startArrowhead": null, 465 | "endArrowhead": "arrow", 466 | "points": [ 467 | [ 468 | 0, 469 | 0 470 | ], 471 | [ 472 | 370.8053915248326, 473 | -30.658310758948005 474 | ] 475 | ] 476 | }, 477 | { 478 | "type": "text", 479 | "version": 89, 480 | "versionNonce": 832907094, 481 | "isDeleted": false, 482 | "id": "pjDUyZCfkGTg9pUjp2yld", 483 | "fillStyle": "solid", 484 | "strokeWidth": 2, 485 | "strokeStyle": "solid", 486 | "roughness": 0, 487 | "opacity": 100, 488 | "angle": 0, 489 | "x": -144.96920953442674, 490 | "y": 757.0270876466533, 491 | "strokeColor": "#c92a2a", 492 | "backgroundColor": "transparent", 493 | "width": 486, 494 | "height": 105, 495 | "seed": 778801896, 496 | "groupIds": [], 497 | "strokeSharpness": "sharp", 498 | "boundElementIds": [], 499 | "fontSize": 28, 500 | "fontFamily": 1, 501 | "text": "hits are not\nregistered when clicking on the lines\nin these areas", 502 | "baseline": 95, 503 | "textAlign": "left", 504 | "verticalAlign": "top" 505 | }, 506 | { 507 | "type": "rectangle", 508 | "version": 95, 509 | "versionNonce": 69476618, 510 | "isDeleted": false, 511 | "id": "mxulMZ1UspGrmJ3KAAOcj", 512 | "fillStyle": "solid", 513 | "strokeWidth": 2, 514 | "strokeStyle": "solid", 515 | "roughness": 0, 516 | "opacity": 100, 517 | "angle": 0, 518 | "x": 72.17364760843031, 519 | "y": 1035.2413733609392, 520 | "strokeColor": "#c92a2a", 521 | "backgroundColor": "transparent", 522 | "width": 755.7142857142857, 523 | "height": 324.28571428571433, 524 | "seed": 1544799464, 525 | "groupIds": [], 526 | "strokeSharpness": "sharp", 527 | "boundElementIds": [] 528 | }, 529 | { 530 | "type": "text", 531 | "version": 168, 532 | "versionNonce": 1483852950, 533 | "isDeleted": false, 534 | "id": "nDVtZAb_udCraafndCv2z", 535 | "fillStyle": "solid", 536 | "strokeWidth": 2, 537 | "strokeStyle": "solid", 538 | "roughness": 0, 539 | "opacity": 100, 540 | "angle": 0, 541 | "x": 205.03079046557343, 542 | "y": 1394.1699447895103, 543 | "strokeColor": "#c92a2a", 544 | "backgroundColor": "transparent", 545 | "width": 563, 546 | "height": 70, 547 | "seed": 1386975128, 548 | "groupIds": [], 549 | "strokeSharpness": "sharp", 550 | "boundElementIds": [], 551 | "fontSize": 28, 552 | "fontFamily": 1, 553 | "text": "all these \"points\" are actually lines.\nSelect them to see their bounding boxes.", 554 | "baseline": 60, 555 | "textAlign": "left", 556 | "verticalAlign": "top" 557 | }, 558 | { 559 | "type": "arrow", 560 | "version": 80, 561 | "versionNonce": 2043637706, 562 | "isDeleted": false, 563 | "id": "Z0n4R_iIfG53FrM11qr_B", 564 | "fillStyle": "solid", 565 | "strokeWidth": 2, 566 | "strokeStyle": "solid", 567 | "roughness": 0, 568 | "opacity": 100, 569 | "angle": 0, 570 | "x": 679.3165047512875, 571 | "y": 587.059592198745, 572 | "strokeColor": "#c92a2a", 573 | "backgroundColor": "transparent", 574 | "width": 385.71428571428567, 575 | "height": 15.324638305051167, 576 | "seed": 163460760, 577 | "groupIds": [], 578 | "strokeSharpness": "round", 579 | "boundElementIds": [], 580 | "startBinding": { 581 | "elementId": "nTqjnH6DHFK71QBkXyb4D", 582 | "focus": 0.32284915686334503, 583 | "gap": 9.999999999999773 584 | }, 585 | "endBinding": null, 586 | "lastCommittedPoint": null, 587 | "startArrowhead": null, 588 | "endArrowhead": "arrow", 589 | "points": [ 590 | [ 591 | 0, 592 | 0 593 | ], 594 | [ 595 | -385.71428571428567, 596 | 15.324638305051167 597 | ] 598 | ] 599 | }, 600 | { 601 | "type": "arrow", 602 | "version": 67, 603 | "versionNonce": 1626851798, 604 | "isDeleted": false, 605 | "id": "JdK63HbC67f4clxAgYQB8", 606 | "fillStyle": "solid", 607 | "strokeWidth": 2, 608 | "strokeStyle": "solid", 609 | "roughness": 0, 610 | "opacity": 100, 611 | "angle": 0, 612 | "x": 679.3165047512875, 613 | "y": 598.8788619447587, 614 | "strokeColor": "#c92a2a", 615 | "backgroundColor": "transparent", 616 | "width": 112.85714285714278, 617 | "height": 136.36251141618027, 618 | "seed": 1581204376, 619 | "groupIds": [], 620 | "strokeSharpness": "round", 621 | "boundElementIds": [], 622 | "startBinding": { 623 | "elementId": "nTqjnH6DHFK71QBkXyb4D", 624 | "focus": 0.8806145565415848, 625 | "gap": 9.999999999999773 626 | }, 627 | "endBinding": null, 628 | "lastCommittedPoint": null, 629 | "startArrowhead": null, 630 | "endArrowhead": "arrow", 631 | "points": [ 632 | [ 633 | 0, 634 | 0 635 | ], 636 | [ 637 | -112.85714285714278, 638 | 136.36251141618027 639 | ] 640 | ] 641 | }, 642 | { 643 | "type": "text", 644 | "version": 115, 645 | "versionNonce": 581101194, 646 | "isDeleted": false, 647 | "id": "nTqjnH6DHFK71QBkXyb4D", 648 | "fillStyle": "solid", 649 | "strokeWidth": 2, 650 | "strokeStyle": "solid", 651 | "roughness": 0, 652 | "opacity": 100, 653 | "angle": 0, 654 | "x": 689.3165047512873, 655 | "y": 558.4556590752247, 656 | "strokeColor": "#c92a2a", 657 | "backgroundColor": "transparent", 658 | "width": 335, 659 | "height": 70, 660 | "seed": 783218152, 661 | "groupIds": [], 662 | "strokeSharpness": "sharp", 663 | "boundElementIds": [ 664 | "JdK63HbC67f4clxAgYQB8", 665 | "Z0n4R_iIfG53FrM11qr_B" 666 | ], 667 | "fontSize": 28, 668 | "fontFamily": 1, 669 | "text": "you can select them\nby clicking on these ends", 670 | "baseline": 60, 671 | "textAlign": "left", 672 | "verticalAlign": "top" 673 | } 674 | ], 675 | "appState": { 676 | "gridSize": null, 677 | "viewBackgroundColor": "#ffffff" 678 | } 679 | } -------------------------------------------------------------------------------- /packages/dev/src/state/flash.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | 0, 4 | 49.93, 5 | 0.5, 6 | 0 7 | ], 8 | [ 9 | 1, 10 | 50.93, 11 | 0.5, 12 | 0 13 | ], 14 | [ 15 | 0, 16 | 49.71, 17 | 0.5, 18 | 239 19 | ], 20 | [ 21 | 0, 22 | 49.05, 23 | 0.5, 24 | 244 25 | ], 26 | [ 27 | 0, 28 | 48.39, 29 | 0.5, 30 | 246 31 | ], 32 | [ 33 | 0, 34 | 47.25, 35 | 0.5, 36 | 258 37 | ], 38 | [ 39 | 0, 40 | 46.59, 41 | 0.5, 42 | 263 43 | ], 44 | [ 45 | 0, 46 | 45.45, 47 | 0.5, 48 | 271 49 | ], 50 | [ 51 | 0, 52 | 44.79, 53 | 0.5, 54 | 279 55 | ], 56 | [ 57 | 0, 58 | 43.66, 59 | 0.5, 60 | 287 61 | ], 62 | [ 63 | 0.75, 64 | 42.52, 65 | 0.5, 66 | 295 67 | ], 68 | [ 69 | 1.62, 70 | 40.78, 71 | 0.5, 72 | 303 73 | ], 74 | [ 75 | 2.48, 76 | 39.05, 77 | 0.5, 78 | 312 79 | ], 80 | [ 81 | 3.46, 82 | 36.61, 83 | 0.5, 84 | 320 85 | ], 86 | [ 87 | 4.92, 88 | 34.65, 89 | 0.5, 90 | 328 91 | ], 92 | [ 93 | 5.89, 94 | 32.21, 95 | 0.5, 96 | 336 97 | ], 98 | [ 99 | 6.86, 100 | 29.77, 101 | 0.5, 102 | 344 103 | ], 104 | [ 105 | 8.32, 106 | 27.33, 107 | 0.5, 108 | 352 109 | ], 110 | [ 111 | 9.19, 112 | 25.59, 113 | 0.5, 114 | 360 115 | ], 116 | [ 117 | 9.57, 118 | 24.45, 119 | 0.5, 120 | 368 121 | ], 122 | [ 123 | 10.33, 124 | 23.3, 125 | 0.5, 126 | 377 127 | ], 128 | [ 129 | 10.7, 130 | 22.16, 131 | 0.5, 132 | 384 133 | ], 134 | [ 135 | 11.03, 136 | 21.51, 137 | 0.5, 138 | 393 139 | ], 140 | [ 141 | 11.3, 142 | 21.23, 143 | 0.5, 144 | 401 145 | ], 146 | [ 147 | 11.63, 148 | 20.57, 149 | 0.5, 150 | 409 151 | ], 152 | [ 153 | 11.9, 154 | 20.3, 155 | 0.5, 156 | 417 157 | ], 158 | [ 159 | 12.22, 160 | 19.64, 161 | 0.5, 162 | 426 163 | ], 164 | [ 165 | 12.98, 166 | 18.5, 167 | 0.5, 168 | 433 169 | ], 170 | [ 171 | 13.74, 172 | 17.35, 173 | 0.5, 174 | 443 175 | ], 176 | [ 177 | 14.61, 178 | 15.61, 179 | 0.5, 180 | 450 181 | ], 182 | [ 183 | 15.36, 184 | 14.47, 185 | 0.5, 186 | 460 187 | ], 188 | [ 189 | 16.66, 190 | 13.16, 191 | 0.5, 192 | 466 193 | ], 194 | [ 195 | 18.12, 196 | 11.22, 197 | 0.5, 198 | 476 199 | ], 200 | [ 201 | 18.88, 202 | 10.07, 203 | 0.5, 204 | 483 205 | ], 206 | [ 207 | 20.18, 208 | 8.77, 209 | 0.5, 210 | 493 211 | ], 212 | [ 213 | 20.83, 214 | 8.11, 215 | 0.5, 216 | 499 217 | ], 218 | [ 219 | 22.13, 220 | 6.81, 221 | 0.5, 222 | 509 223 | ], 224 | [ 225 | 22.79, 226 | 6.15, 227 | 0.5, 228 | 515 229 | ], 230 | [ 231 | 23.93, 232 | 5.39, 233 | 0.5, 234 | 526 235 | ], 236 | [ 237 | 25.07, 238 | 4.63, 239 | 0.5, 240 | 531 241 | ], 242 | [ 243 | 26.21, 244 | 3.87, 245 | 0.5, 246 | 542 247 | ], 248 | [ 249 | 26.86, 250 | 3.21, 251 | 0.5, 252 | 547 253 | ], 254 | [ 255 | 27.98, 256 | 2.46, 257 | 0.5, 258 | 559 259 | ], 260 | [ 261 | 29.12, 262 | 1.7, 263 | 0.5, 264 | 563 265 | ], 266 | [ 267 | 29.77, 268 | 1.37, 269 | 0.5, 270 | 579 271 | ], 272 | [ 273 | 30.43, 274 | 1.04, 275 | 0.5, 276 | 581 277 | ], 278 | [ 279 | 31.08, 280 | 0.71, 281 | 0.5, 282 | 591 283 | ], 284 | [ 285 | 31.35, 286 | 0.71, 287 | 0.5, 288 | 596 289 | ], 290 | [ 291 | 32, 292 | 0.39, 293 | 0.5, 294 | 608 295 | ], 296 | [ 297 | 33.14, 298 | 0.39, 299 | 0.5, 300 | 612 301 | ], 302 | [ 303 | 34.28, 304 | 0.39, 305 | 0.5, 306 | 620 307 | ], 308 | [ 309 | 35.42, 310 | 0, 311 | 0.5, 312 | 629 313 | ], 314 | [ 315 | 37.160000000000025, 316 | 0, 317 | 0.5, 318 | 637 319 | ], 320 | [ 321 | 38.30000000000001, 322 | 0, 323 | 0.5, 324 | 645 325 | ], 326 | [ 327 | 40.00999999999999, 328 | 0, 329 | 0.5, 330 | 654 331 | ], 332 | [ 333 | 41.75, 334 | 0, 335 | 0.5, 336 | 661 337 | ], 338 | [ 339 | 42.889999999999986, 340 | 0, 341 | 0.5, 342 | 670 343 | ], 344 | [ 345 | 44.620000000000005, 346 | 0, 347 | 0.5, 348 | 678 349 | ], 350 | [ 351 | 45.26999999999998, 352 | 0, 353 | 0.5, 354 | 686 355 | ], 356 | [ 357 | 46.410000000000025, 358 | 0, 359 | 0.5, 360 | 694 361 | ], 362 | [ 363 | 46.68000000000001, 364 | 0, 365 | 0.5, 366 | 702 367 | ], 368 | [ 369 | 47.34000000000003, 370 | 0, 371 | 0.5, 372 | 710 373 | ], 374 | [ 375 | 47.610000000000014, 376 | 0, 377 | 0.5, 378 | 719 379 | ], 380 | [ 381 | 47.879999999999995, 382 | 0, 383 | 0.5, 384 | 726 385 | ], 386 | [ 387 | 48.53000000000003, 388 | 0, 389 | 0.5, 390 | 735 391 | ], 392 | [ 393 | 48.81, 394 | 0, 395 | 0.5, 396 | 743 397 | ], 398 | [ 399 | 49.079999999999984, 400 | 0, 401 | 0.5, 402 | 750 403 | ], 404 | [ 405 | 49.35000000000002, 406 | 0, 407 | 0.5, 408 | 759 409 | ], 410 | [ 411 | 49.629999999999995, 412 | 0, 413 | 0.5, 414 | 768 415 | ], 416 | [ 417 | 49.900000000000034, 418 | 0, 419 | 0.5, 420 | 776 421 | ], 422 | [ 423 | 50.170000000000016, 424 | 0, 425 | 0.5, 426 | 784 427 | ], 428 | [ 429 | 50.44999999999999, 430 | 0.2799999999999727, 431 | 0.5, 432 | 793 433 | ], 434 | [ 435 | 50.44999999999999, 436 | 0.5500000000000114, 437 | 0.5, 438 | 800 439 | ], 440 | [ 441 | 50.72000000000003, 442 | 0.5500000000000114, 443 | 0.5, 444 | 810 445 | ], 446 | [ 447 | 50.72000000000003, 448 | 0.8199999999999932, 449 | 0.5, 450 | 816 451 | ], 452 | [ 453 | 50.99000000000001, 454 | 1.089999999999975, 455 | 0.5, 456 | 827 457 | ], 458 | [ 459 | 51.24000000000001, 460 | 1.339999999999975, 461 | 0.5, 462 | 843 463 | ], 464 | [ 465 | 51.24000000000001, 466 | 1.6100000000000136, 467 | 0.5, 468 | 848 469 | ], 470 | [ 471 | 51.50999999999999, 472 | 1.8899999999999864, 473 | 0.5, 474 | 860 475 | ], 476 | [ 477 | 51.79000000000002, 478 | 2.159999999999968, 479 | 0.5, 480 | 864 481 | ], 482 | [ 483 | 51.79000000000002, 484 | 2.430000000000007, 485 | 0.5, 486 | 876 487 | ], 488 | [ 489 | 51.79000000000002, 490 | 3.089999999999975, 491 | 0.5, 492 | 881 493 | ], 494 | [ 495 | 52.06, 496 | 3.3600000000000136, 497 | 0.5, 498 | 893 499 | ], 500 | [ 501 | 52.06, 502 | 3.6299999999999955, 503 | 0.5, 504 | 897 505 | ], 506 | [ 507 | 52.06, 508 | 4.279999999999973, 509 | 0.5, 510 | 909 511 | ], 512 | [ 513 | 52.329999999999984, 514 | 4.560000000000002, 515 | 0.5, 516 | 913 517 | ], 518 | [ 519 | 52.329999999999984, 520 | 4.829999999999984, 521 | 0.5, 522 | 926 523 | ], 524 | [ 525 | 52.329999999999984, 526 | 5.110000000000014, 527 | 0.5, 528 | 929 529 | ], 530 | [ 531 | 52.329999999999984, 532 | 5.3799999999999955, 533 | 0.5, 534 | 942 535 | ], 536 | [ 537 | 52.329999999999984, 538 | 5.649999999999977, 539 | 0.5, 540 | 946 541 | ], 542 | [ 543 | 52.329999999999984, 544 | 5.930000000000007, 545 | 0.5, 546 | 959 547 | ], 548 | [ 549 | 52.329999999999984, 550 | 6.199999999999989, 551 | 0.5, 552 | 962 553 | ], 554 | [ 555 | 52.329999999999984, 556 | 6.46999999999997, 557 | 0.5, 558 | 975 559 | ], 560 | [ 561 | 52.329999999999984, 562 | 6.75, 563 | 0.5, 564 | 979 565 | ], 566 | [ 567 | 52.329999999999984, 568 | 7.399999999999977, 569 | 0.5, 570 | 987 571 | ], 572 | [ 573 | 52.329999999999984, 574 | 8.050000000000011, 575 | 0.5, 576 | 995 577 | ], 578 | [ 579 | 52.329999999999984, 580 | 8.699999999999989, 581 | 0.5, 582 | 1003 583 | ], 584 | [ 585 | 52.329999999999984, 586 | 10.430000000000007, 587 | 0.5, 588 | 1011 589 | ], 590 | [ 591 | 52.329999999999984, 592 | 11.569999999999993, 593 | 0.5, 594 | 1019 595 | ], 596 | [ 597 | 52.329999999999984, 598 | 13.300000000000011, 599 | 0.5, 600 | 1027 601 | ], 602 | [ 603 | 52.329999999999984, 604 | 15.04000000000002, 605 | 0.5, 606 | 1035 607 | ], 608 | [ 609 | 52.329999999999984, 610 | 16.769999999999982, 611 | 0.5, 612 | 1044 613 | ], 614 | [ 615 | 52.329999999999984, 616 | 17.909999999999968, 617 | 0.5, 618 | 1052 619 | ], 620 | [ 621 | 51.94999999999999, 622 | 19.05000000000001, 623 | 0.5, 624 | 1060 625 | ], 626 | [ 627 | 51.94999999999999, 628 | 20.19999999999999, 629 | 0.5, 630 | 1068 631 | ], 632 | [ 633 | 51.56999999999999, 634 | 21.339999999999975, 635 | 0.5, 636 | 1077 637 | ], 638 | [ 639 | 51.24000000000001, 640 | 21.99000000000001, 641 | 0.5, 642 | 1084 643 | ], 644 | [ 645 | 51.24000000000001, 646 | 22.639999999999986, 647 | 0.5, 648 | 1096 649 | ], 650 | [ 651 | 51.24000000000001, 652 | 22.909999999999968, 653 | 0.5, 654 | 1101 655 | ], 656 | [ 657 | 50.910000000000025, 658 | 23.569999999999993, 659 | 0.5, 660 | 1109 661 | ], 662 | [ 663 | 50.910000000000025, 664 | 23.839999999999975, 665 | 0.5, 666 | 1117 667 | ], 668 | [ 669 | 50.910000000000025, 670 | 24.110000000000014, 671 | 0.5, 672 | 1126 673 | ], 674 | [ 675 | 50.629999999999995, 676 | 24.389999999999986, 677 | 0.5, 678 | 1133 679 | ], 680 | [ 681 | 50.629999999999995, 682 | 25.04000000000002, 683 | 0.5, 684 | 1142 685 | ], 686 | [ 687 | 50.360000000000014, 688 | 25.310000000000002, 689 | 0.5, 690 | 1149 691 | ], 692 | [ 693 | 50.360000000000014, 694 | 25.95999999999998, 695 | 0.5, 696 | 1160 697 | ], 698 | [ 699 | 50.03000000000003, 700 | 26.620000000000005, 701 | 0.5, 702 | 1166 703 | ], 704 | [ 705 | 49.69999999999999, 706 | 27.269999999999982, 707 | 0.5, 708 | 1176 709 | ], 710 | [ 711 | 49.370000000000005, 712 | 27.920000000000016, 713 | 0.5, 714 | 1182 715 | ], 716 | [ 717 | 49.370000000000005, 718 | 28.19999999999999, 719 | 0.5, 720 | 1193 721 | ], 722 | [ 723 | 48.99000000000001, 724 | 29.339999999999975, 725 | 0.5, 726 | 1198 727 | ], 728 | [ 729 | 48.660000000000025, 730 | 29.99000000000001, 731 | 0.5, 732 | 1209 733 | ], 734 | [ 735 | 48.660000000000025, 736 | 30.639999999999986, 737 | 0.5, 738 | 1214 739 | ], 740 | [ 741 | 48.329999999999984, 742 | 31.29000000000002, 743 | 0.5, 744 | 1226 745 | ], 746 | [ 747 | 48.329999999999984, 748 | 31.94999999999999, 749 | 0.5, 750 | 1231 751 | ], 752 | [ 753 | 48, 754 | 32.599999999999966, 755 | 0.5, 756 | 1243 757 | ], 758 | [ 759 | 48, 760 | 32.870000000000005, 761 | 0.5, 762 | 1246 763 | ], 764 | [ 765 | 47.68000000000001, 766 | 33.51999999999998, 767 | 0.5, 768 | 1260 769 | ], 770 | [ 771 | 47.68000000000001, 772 | 33.80000000000001, 773 | 0.5, 774 | 1263 775 | ], 776 | [ 777 | 47.68000000000001, 778 | 34.06999999999999, 779 | 0.5, 780 | 1276 781 | ], 782 | [ 783 | 47.68000000000001, 784 | 34.30000000000001, 785 | 0.5, 786 | 1297 787 | ], 788 | [ 789 | 47.68000000000001, 790 | 34.52999999999997, 791 | 0.5, 792 | 1355 793 | ], 794 | [ 795 | 47.400000000000034, 796 | 34.52999999999997, 797 | 0.5, 798 | 1361 799 | ], 800 | [ 801 | 47.400000000000034, 802 | 34.75999999999999, 803 | 0.5, 804 | 1437 805 | ], 806 | [ 807 | 47.170000000000016, 808 | 34.75999999999999, 809 | 0.5, 810 | 1493 811 | ] 812 | ] -------------------------------------------------------------------------------- /packages/dev/src/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state' 2 | -------------------------------------------------------------------------------- /packages/dev/src/state/shapes/draw.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | TLBounds, 4 | Utils, 5 | TLTransformInfo, 6 | TLShapeUtil, 7 | SVGContainer, 8 | } from '@tldraw/core' 9 | import { 10 | intersectBoundsBounds, 11 | intersectBoundsPolyline, 12 | } from '@tldraw/intersect' 13 | import { Vec } from '@tldraw/vec' 14 | import { getStroke } from 'perfect-freehand' 15 | import type { DrawShape } from '../../types' 16 | import { EASINGS } from 'state/easings' 17 | 18 | type T = DrawShape 19 | type E = SVGSVGElement 20 | 21 | export class DrawUtil extends TLShapeUtil { 22 | type = 'draw' as const 23 | 24 | pointsBoundsCache = new WeakMap([]) 25 | 26 | rotatedCache = new WeakMap([]) 27 | 28 | strokeCache = new WeakMap([]) 29 | 30 | getShape = (props: Partial): T => { 31 | return Utils.deepMerge( 32 | { 33 | id: 'id', 34 | type: 'draw', 35 | name: 'Draw', 36 | parentId: 'page', 37 | childIndex: 1, 38 | point: [0, 0], 39 | points: [[0, 0, 0.5]], 40 | rotation: 0, 41 | isDone: false, 42 | style: { 43 | size: 8, 44 | strokeWidth: 0, 45 | thinning: 0.75, 46 | streamline: 0.5, 47 | smoothing: 0.5, 48 | easing: 'linear', 49 | taperStart: 0, 50 | taperEnd: 0, 51 | capStart: true, 52 | capEnd: true, 53 | easingStart: 'linear', 54 | easingEnd: 'linear', 55 | isFilled: true, 56 | stroke: 'black', 57 | fill: 'black', 58 | }, 59 | }, 60 | props 61 | ) 62 | } 63 | 64 | Component = TLShapeUtil.Component(({ shape, events }, ref) => { 65 | const { 66 | style: { 67 | size, 68 | thinning, 69 | strokeWidth, 70 | streamline, 71 | smoothing, 72 | easing, 73 | taperStart, 74 | taperEnd, 75 | capStart, 76 | capEnd, 77 | easingEnd, 78 | easingStart, 79 | stroke, 80 | fill, 81 | isFilled, 82 | }, 83 | isDone, 84 | } = shape 85 | 86 | const simulatePressure = shape.points[2]?.[2] === 0.5 87 | 88 | const outlinePoints = Utils.getFromCache(this.strokeCache, shape, () => 89 | getStroke(shape.points, { 90 | size, 91 | thinning, 92 | streamline, 93 | easing: EASINGS[easing], 94 | smoothing, 95 | start: { 96 | taper: taperStart, 97 | cap: capStart, 98 | easing: EASINGS[easingStart], 99 | }, 100 | end: { 101 | taper: taperEnd, 102 | cap: capEnd, 103 | easing: EASINGS[easingEnd], 104 | }, 105 | simulatePressure, 106 | last: isDone, 107 | }) 108 | ) 109 | 110 | const drawPathData = getSvgPathFromStroke(outlinePoints) 111 | 112 | return ( 113 | 114 | {strokeWidth ? ( 115 | 125 | ) : null} 126 | { 127 | 0 ? 'transparent' : 'black'} 132 | strokeWidth={isFilled || strokeWidth > 0 ? 0 : 1} 133 | strokeLinejoin="round" 134 | strokeLinecap="round" 135 | pointerEvents="all" 136 | /> 137 | } 138 | 139 | ) 140 | }) 141 | 142 | Indicator = TLShapeUtil.Indicator(() => { 143 | return 144 | }) 145 | 146 | create = (props: { id: string } & Partial) => { 147 | this.refMap.set(props.id, React.createRef()) 148 | return this.getShape(props) 149 | } 150 | 151 | getBounds = (shape: DrawShape): TLBounds => { 152 | const bounds = Utils.translateBounds( 153 | Utils.getFromCache(this.pointsBoundsCache, shape.points, () => 154 | Utils.getBoundsFromPoints(shape.points) 155 | ), 156 | shape.point 157 | ) 158 | 159 | return bounds 160 | } 161 | 162 | hitTestBounds = (shape: DrawShape, brushBounds: TLBounds): boolean => { 163 | // Test axis-aligned shape 164 | if (!shape.rotation) { 165 | const bounds = this.getBounds(shape) 166 | 167 | return ( 168 | Utils.boundsContain(brushBounds, bounds) || 169 | ((Utils.boundsContain(bounds, brushBounds) || 170 | intersectBoundsBounds(bounds, brushBounds).length > 0) && 171 | intersectBoundsPolyline( 172 | Utils.translateBounds(brushBounds, Vec.neg(shape.point)), 173 | shape.points 174 | ).length > 0) 175 | ) 176 | } 177 | 178 | // Test rotated shape 179 | const rBounds = this.getRotatedBounds(shape) 180 | 181 | const rotatedBounds = Utils.getFromCache(this.rotatedCache, shape, () => { 182 | const c = Utils.getBoundsCenter(Utils.getBoundsFromPoints(shape.points)) 183 | return shape.points.map((pt) => Vec.rotWith(pt, c, shape.rotation || 0)) 184 | }) 185 | 186 | return ( 187 | Utils.boundsContain(brushBounds, rBounds) || 188 | intersectBoundsPolyline( 189 | Utils.translateBounds(brushBounds, Vec.neg(shape.point)), 190 | rotatedBounds 191 | ).length > 0 192 | ) 193 | } 194 | 195 | transform = ( 196 | shape: DrawShape, 197 | bounds: TLBounds, 198 | { initialShape, scaleX, scaleY }: TLTransformInfo 199 | ): Partial => { 200 | const initialShapeBounds = Utils.getFromCache( 201 | this.boundsCache, 202 | initialShape, 203 | () => Utils.getBoundsFromPoints(initialShape.points) 204 | ) 205 | 206 | const points = initialShape.points.map(([x, y, r]) => { 207 | return [ 208 | bounds.width * 209 | (scaleX < 0 // * sin? 210 | ? 1 - x / initialShapeBounds.width 211 | : x / initialShapeBounds.width), 212 | bounds.height * 213 | (scaleY < 0 // * cos? 214 | ? 1 - y / initialShapeBounds.height 215 | : y / initialShapeBounds.height), 216 | r, 217 | ] 218 | }) 219 | 220 | const newBounds = Utils.getBoundsFromPoints(shape.points) 221 | 222 | const point = Vec.sub( 223 | [bounds.minX, bounds.minY], 224 | [newBounds.minX, newBounds.minY] 225 | ) 226 | 227 | return { 228 | points, 229 | point, 230 | } 231 | } 232 | } 233 | 234 | const average = (a: number, b: number) => (a + b) / 2 235 | 236 | /** 237 | * Turn an array of points into a path of quadradic curves. 238 | * 239 | * @param points The points returned from perfect-freehand 240 | * @param closed Whether the stroke is closed 241 | */ 242 | export function getSvgPathFromStroke( 243 | points: number[][], 244 | closed = true 245 | ): string { 246 | const len = points.length 247 | 248 | if (len < 4) { 249 | return `` 250 | } 251 | 252 | let a = points[0] 253 | let b = points[1] 254 | const c = points[2] 255 | 256 | let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed( 257 | 2 258 | )},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average( 259 | b[1], 260 | c[1] 261 | ).toFixed(2)} T` 262 | 263 | for (let i = 2, max = len - 1; i < max; i++) { 264 | a = points[i] 265 | b = points[i + 1] 266 | result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed( 267 | 2 268 | )} ` 269 | } 270 | 271 | if (closed) { 272 | result += 'Z' 273 | } 274 | 275 | return result 276 | } 277 | 278 | export function dot([x, y]: number[]) { 279 | return `M ${x - 0.5},${y} a .5,.5 0 1,0 1,0 a .5,.5 0 1,0 -1,0` 280 | } 281 | 282 | export function dots(points: number[][]) { 283 | return points.map(dot).join(' ') 284 | } 285 | 286 | export const draw = new DrawUtil() 287 | -------------------------------------------------------------------------------- /packages/dev/src/state/shapes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './draw' 2 | -------------------------------------------------------------------------------- /packages/dev/src/state/state.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type { Doc, DrawShape, DrawStyles, State } from 'types' 3 | import { 4 | TLPinchEventHandler, 5 | TLPointerEventHandler, 6 | TLShapeUtilsMap, 7 | TLWheelEventHandler, 8 | Utils, 9 | } from '@tldraw/core' 10 | import { Vec } from '@tldraw/vec' 11 | import { StateManager } from 'rko' 12 | import { draw, DrawUtil } from './shapes' 13 | import sample from './sample.json' 14 | import type { StateSelector } from 'zustand' 15 | import { copyTextToClipboard, pointInPolygon } from './utils' 16 | import { EASING_STRINGS } from './easings' 17 | 18 | export const shapeUtils: TLShapeUtilsMap = { 19 | draw: new DrawUtil(), 20 | } 21 | 22 | export const initialDoc: Doc = { 23 | page: { 24 | id: 'page', 25 | shapes: {}, 26 | bindings: {}, 27 | }, 28 | pageState: { 29 | id: 'page', 30 | selectedIds: [], 31 | camera: { 32 | point: [0, 0], 33 | zoom: 1, 34 | }, 35 | }, 36 | } 37 | 38 | export const defaultStyle: DrawStyles = { 39 | size: 16, 40 | strokeWidth: 0, 41 | thinning: 0.5, 42 | streamline: 0.5, 43 | smoothing: 0.5, 44 | easing: 'linear', 45 | taperStart: 0, 46 | taperEnd: 0, 47 | capStart: true, 48 | capEnd: true, 49 | easingStart: 'linear', 50 | easingEnd: 'linear', 51 | isFilled: true, 52 | fill: '#000000', 53 | stroke: '#000000', 54 | } 55 | 56 | export const initialState: State = { 57 | appState: { 58 | status: 'idle', 59 | tool: 'drawing', 60 | editingId: undefined, 61 | style: defaultStyle, 62 | isPanelOpen: true, 63 | }, 64 | ...initialDoc, 65 | } 66 | 67 | export const context = React.createContext({} as AppState) 68 | 69 | export class AppState extends StateManager { 70 | shapeUtils = shapeUtils 71 | 72 | log = false 73 | 74 | currentStroke = { 75 | startTime: 0, 76 | } 77 | 78 | onReady = () => { 79 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 80 | // @ts-ignore 81 | window['app'] = this 82 | 83 | if (Object.values(this.state.page.shapes).length === 0) { 84 | this.addShape({ id: 'sample', points: sample }) 85 | this.centerShape('sample') 86 | } 87 | } 88 | 89 | cleanup = (state: State) => { 90 | for (const id in state.page.shapes) { 91 | if (!state.page.shapes[id]) { 92 | delete state.page.shapes[id] 93 | } 94 | } 95 | 96 | return state 97 | } 98 | 99 | onPointerDown: TLPointerEventHandler = (info) => { 100 | const { state } = this 101 | 102 | switch (state.appState.tool) { 103 | case 'drawing': { 104 | this.createDrawingShape(info.point) 105 | break 106 | } 107 | case 'erasing': { 108 | this.setSnapshot() 109 | this.patchState({ 110 | appState: { 111 | status: 'erasing', 112 | }, 113 | }) 114 | this.erase(info.point) 115 | break 116 | } 117 | } 118 | } 119 | 120 | onPointerMove: TLPointerEventHandler = (info, event) => { 121 | if (event.buttons > 1) return 122 | 123 | const { status, tool } = this.state.appState 124 | 125 | switch (tool) { 126 | case 'drawing': { 127 | if (status === 'drawing') { 128 | const nextShape = this.updateDrawingShape(info.point, info.pressure) 129 | if (nextShape) { 130 | this.patchState({ 131 | page: { 132 | shapes: { 133 | [nextShape.id]: nextShape, 134 | }, 135 | }, 136 | }) 137 | } 138 | } 139 | break 140 | } 141 | case 'erasing': { 142 | if (status === 'erasing') { 143 | this.erase(info.point) 144 | } 145 | break 146 | } 147 | } 148 | } 149 | 150 | onPointerUp: TLPointerEventHandler = () => { 151 | const { state } = this 152 | switch (state.appState.tool) { 153 | case 'drawing': { 154 | this.completeDrawingShape() 155 | break 156 | } 157 | case 'erasing': { 158 | this.setState({ 159 | before: this.snapshot, 160 | after: { 161 | appState: { 162 | status: 'idle', 163 | }, 164 | page: { 165 | shapes: this.state.page.shapes, 166 | }, 167 | }, 168 | }) 169 | break 170 | } 171 | } 172 | } 173 | 174 | pinchZoom = (point: number[], delta: number[], zoom: number): this => { 175 | const { camera } = this.state.pageState 176 | const nextPoint = Vec.sub(camera.point, Vec.div(delta, camera.zoom)) 177 | const nextZoom = zoom 178 | const p0 = Vec.sub(Vec.div(point, camera.zoom), nextPoint) 179 | const p1 = Vec.sub(Vec.div(point, nextZoom), nextPoint) 180 | 181 | return this.patchState({ 182 | pageState: { 183 | camera: { 184 | point: Vec.round(Vec.add(nextPoint, Vec.sub(p1, p0))), 185 | zoom: nextZoom, 186 | }, 187 | }, 188 | }) 189 | } 190 | 191 | onPinchEnd: TLPinchEventHandler = () => { 192 | this.patchState({ 193 | appState: { status: 'idle' }, 194 | }) 195 | } 196 | 197 | onPinch: TLPinchEventHandler = (info, e) => { 198 | if (this.state.appState.status !== 'pinching') return 199 | this.pinchZoom(info.point, info.delta, info.delta[2]) 200 | this.onPointerMove?.(info, e as unknown as React.PointerEvent) 201 | } 202 | 203 | onPan: TLWheelEventHandler = (info) => { 204 | const { state } = this 205 | if (state.appState.status === 'pinching') return this 206 | 207 | const { camera } = state.pageState 208 | const delta = Vec.div(info.delta, camera.zoom) 209 | const prev = camera.point 210 | const next = Vec.sub(prev, delta) 211 | 212 | if (Vec.isEqual(next, prev)) return this 213 | 214 | const point = Vec.round(next) 215 | 216 | if (state.appState.editingId && state.appState.status === 'drawing') { 217 | const shape = state.page.shapes[state.appState.editingId] 218 | const nextShape = this.updateDrawingShape(info.point, info.pressure) 219 | 220 | this.patchState({ 221 | pageState: { 222 | camera: { 223 | point, 224 | }, 225 | }, 226 | page: { 227 | shapes: { 228 | [shape.id]: nextShape, 229 | }, 230 | }, 231 | }) 232 | 233 | if (nextShape) { 234 | this.patchState({ 235 | page: { 236 | shapes: { 237 | [nextShape.id]: nextShape, 238 | }, 239 | }, 240 | }) 241 | } 242 | } 243 | 244 | return this.patchState({ 245 | pageState: { 246 | camera: { 247 | point, 248 | }, 249 | }, 250 | }) 251 | } 252 | 253 | /* --------------------- Methods -------------------- */ 254 | 255 | togglePanelOpen = () => { 256 | const { state } = this 257 | this.patchState({ 258 | appState: { 259 | isPanelOpen: !state.appState.isPanelOpen, 260 | }, 261 | }) 262 | } 263 | 264 | createDrawingShape = (point: number[]) => { 265 | const { state } = this 266 | 267 | const camera = state.pageState.camera 268 | 269 | const pt = Vec.sub(Vec.div(point, camera.zoom), camera.point) 270 | 271 | const shape = draw.create({ 272 | id: Utils.uniqueId(), 273 | point: pt, 274 | style: state.appState.style, 275 | points: [[0, 0, 0.5, 0]], 276 | isDone: false, 277 | }) 278 | 279 | this.currentStroke.startTime = Date.now() 280 | 281 | return this.patchState({ 282 | appState: { 283 | status: 'drawing', 284 | editingId: shape.id, 285 | }, 286 | page: { 287 | shapes: { 288 | [shape.id]: shape, 289 | }, 290 | }, 291 | }) 292 | } 293 | 294 | updateDrawingShape = (point: number[], pressure: number) => { 295 | const { state, currentStroke } = this 296 | if (state.appState.status !== 'drawing') return 297 | if (!state.appState.editingId) return 298 | 299 | const shape = state.page.shapes[state.appState.editingId] 300 | 301 | const camera = state.pageState.camera 302 | 303 | const newPoint = [ 304 | ...Vec.sub( 305 | Vec.round(Vec.sub(Vec.div(point, camera.zoom), camera.point)), 306 | shape.point 307 | ), 308 | pressure, 309 | Date.now() - currentStroke.startTime, 310 | ] 311 | 312 | let shapePoint = shape.point 313 | 314 | let shapePoints = [...shape.points, newPoint] 315 | 316 | // Does the new point create a negative offset? 317 | const offset = [Math.min(newPoint[0], 0), Math.min(newPoint[1], 0)] 318 | 319 | if (offset[0] < 0 || offset[1] < 0) { 320 | // If so, then we need to move the shape to cancel the offset 321 | shapePoint = [ 322 | ...Vec.round(Vec.add(shapePoint, offset)), 323 | shapePoint[2], 324 | shapePoint[3], 325 | ] 326 | 327 | // And we need to move the shape points to cancel the offset 328 | shapePoints = shapePoints.map((pt) => 329 | Vec.round(Vec.sub(pt, offset)).concat(pt[2], pt[3]) 330 | ) 331 | } 332 | 333 | return { 334 | id: shape.id, 335 | point: shapePoint, 336 | points: shapePoints, 337 | } 338 | } 339 | 340 | completeDrawingShape = () => { 341 | const { state } = this 342 | const { shapes } = state.page 343 | if (!state.appState.editingId) return this // Don't erase while drawing 344 | 345 | let shape = shapes[state.appState.editingId] 346 | 347 | shape.isDone = true 348 | 349 | shape = { 350 | ...shape, 351 | } 352 | 353 | return this.setState({ 354 | before: { 355 | appState: { 356 | status: 'idle', 357 | editingId: undefined, 358 | }, 359 | page: { 360 | shapes: { 361 | [shape.id]: undefined, 362 | }, 363 | }, 364 | }, 365 | after: { 366 | appState: { 367 | status: 'idle', 368 | editingId: undefined, 369 | }, 370 | page: { 371 | shapes: { 372 | [shape.id]: shape, 373 | }, 374 | }, 375 | }, 376 | }) 377 | } 378 | 379 | centerShape = (id: string) => { 380 | const shape = this.state.page.shapes[id] 381 | const bounds = shapeUtils.draw.getBounds(this.state.page.shapes[id]) 382 | this.patchState({ 383 | pageState: { 384 | camera: { 385 | point: Vec.add(shape.point, [ 386 | window.innerWidth / 2 - bounds.width / 2, 387 | window.innerHeight / 2 - bounds.height / 2, 388 | ]), 389 | zoom: 1, 390 | }, 391 | }, 392 | }) 393 | } 394 | 395 | replayShape = (points: number[][]) => { 396 | this.eraseAll() 397 | 398 | const newShape = draw.create({ 399 | id: Utils.uniqueId(), 400 | parentId: 'page', 401 | childIndex: 1, 402 | point: [0, 0], 403 | points: [], 404 | style: this.state.appState.style, 405 | }) 406 | 407 | this.patchState({ 408 | page: { 409 | shapes: { 410 | [newShape.id]: newShape, 411 | }, 412 | }, 413 | }) 414 | 415 | this.centerShape(newShape.id) 416 | 417 | points 418 | .map((pt, i) => [...Vec.sub(pt, newShape.point), pt[2], pt[3] || i * 10]) 419 | .forEach((pt, i) => { 420 | setTimeout(() => { 421 | this.patchState({ 422 | page: { 423 | shapes: { 424 | [newShape.id]: { 425 | points: points.slice(0, i), 426 | }, 427 | }, 428 | }, 429 | }) 430 | }, pt[3] * 20) 431 | }) 432 | } 433 | 434 | addShape = (shape: Partial) => { 435 | const newShape = draw.create({ 436 | id: Utils.uniqueId(), 437 | parentId: 'page', 438 | childIndex: 1, 439 | point: [0, 0], 440 | points: [], 441 | style: this.state.appState.style, 442 | ...shape, 443 | }) 444 | 445 | const bounds = Utils.getBoundsFromPoints(newShape.points) 446 | 447 | const topLeft = [bounds.minX, bounds.minY] 448 | 449 | newShape.points = newShape.points.map((pt, i) => 450 | Vec.sub(pt, topLeft).concat(pt[2] || 0.5, pt[3] || i * 10) 451 | ) 452 | 453 | this.patchState({ 454 | page: { 455 | shapes: { 456 | [newShape.id]: newShape, 457 | }, 458 | }, 459 | }) 460 | 461 | this.persist() 462 | 463 | return newShape 464 | } 465 | 466 | erase = (point: number[]) => { 467 | const { state } = this 468 | const camera = state.pageState.camera 469 | const pt = Vec.sub(Vec.div(point, camera.zoom), camera.point) 470 | const { getBounds } = shapeUtils.draw 471 | 472 | return this.patchState({ 473 | page: { 474 | shapes: { 475 | ...Object.fromEntries( 476 | Object.entries(state.page.shapes).map(([id, shape]) => { 477 | const bounds = getBounds(shape) 478 | 479 | if (Vec.dist(pt, shape.point) < 10) { 480 | return [id, undefined] 481 | } 482 | 483 | if (Utils.pointInBounds(pt, bounds)) { 484 | const points = draw.strokeCache.get(shape) 485 | 486 | if ( 487 | (points && 488 | pointInPolygon(Vec.sub(pt, shape.point), points)) || 489 | Vec.dist(pt, shape.point) < 10 490 | ) { 491 | return [id, undefined] 492 | } 493 | } 494 | 495 | return [id, shape] 496 | }) 497 | ), 498 | }, 499 | }, 500 | }) 501 | } 502 | 503 | eraseAll = () => { 504 | const { state } = this 505 | const { shapes } = state.page 506 | 507 | if (state.appState.editingId) return this // Don't erase while drawing 508 | 509 | return this.setState({ 510 | before: { 511 | page: { 512 | shapes, 513 | }, 514 | }, 515 | after: { 516 | page: { 517 | shapes: {}, 518 | }, 519 | }, 520 | }) 521 | } 522 | 523 | startStyleUpdate = () => { 524 | return this.setSnapshot() 525 | } 526 | 527 | patchStyleForAllShapes = (style: Partial) => { 528 | const { shapes } = this.state.page 529 | 530 | return this.patchState({ 531 | appState: { 532 | style, 533 | }, 534 | page: { 535 | shapes: { 536 | ...Object.fromEntries( 537 | Object.keys(shapes).map((id) => [id, { style }]) 538 | ), 539 | }, 540 | }, 541 | }) 542 | } 543 | 544 | patchStyle = (style: Partial) => { 545 | return this.patchState({ 546 | appState: { 547 | style, 548 | }, 549 | }) 550 | } 551 | 552 | finishStyleUpdate = () => { 553 | const { state, snapshot } = this 554 | const { shapes } = state.page 555 | 556 | return this.setState({ 557 | before: snapshot, 558 | after: { 559 | appState: { 560 | style: state.appState.style, 561 | }, 562 | page: { 563 | shapes: { 564 | ...Object.fromEntries( 565 | Object.entries(shapes).map(([id, { style }]) => [id, { style }]) 566 | ), 567 | }, 568 | }, 569 | }, 570 | }) 571 | } 572 | 573 | setNextStyleForAllShapes = (style: Partial) => { 574 | const { shapes } = this.state.page 575 | 576 | return this.setState({ 577 | before: { 578 | appState: { 579 | style: Object.fromEntries( 580 | Object.keys(style).map((key) => [ 581 | key, 582 | this.state.appState.style[key as keyof DrawStyles], 583 | ]) 584 | ), 585 | }, 586 | page: { 587 | shapes: { 588 | ...Object.fromEntries( 589 | Object.entries(shapes).map(([id, shape]) => [ 590 | id, 591 | { 592 | style: Object.fromEntries( 593 | Object.keys(style).map((key) => [ 594 | key, 595 | shape.style[key as keyof DrawStyles], 596 | ]) 597 | ), 598 | }, 599 | ]) 600 | ), 601 | }, 602 | }, 603 | }, 604 | after: { 605 | appState: { 606 | style, 607 | }, 608 | page: { 609 | shapes: { 610 | ...Object.fromEntries( 611 | Object.keys(shapes).map((id) => [id, { style }]) 612 | ), 613 | }, 614 | }, 615 | }, 616 | }) 617 | } 618 | 619 | resetStyle = (prop: keyof DrawStyles) => { 620 | const { shapes } = this.state.page 621 | const { state } = this 622 | 623 | const initialStyle = initialState.appState.style[prop] 624 | 625 | return this.setState({ 626 | before: { 627 | appState: state.appState, 628 | page: { 629 | shapes: { 630 | ...Object.fromEntries( 631 | Object.entries(shapes).map(([id, shape]) => [ 632 | id, 633 | { 634 | style: { [prop]: shape.style[prop] }, 635 | }, 636 | ]) 637 | ), 638 | }, 639 | }, 640 | }, 641 | after: { 642 | appState: { 643 | style: { [prop]: initialStyle }, 644 | }, 645 | page: { 646 | shapes: { 647 | ...Object.fromEntries( 648 | Object.keys(shapes).map((id) => [id, { [prop]: initialStyle }]) 649 | ), 650 | }, 651 | }, 652 | }, 653 | }) 654 | } 655 | 656 | zoomToContent = (): this => { 657 | const shapes = Object.values(this.state.page.shapes) 658 | const pageState = this.state.pageState 659 | 660 | if (shapes.length === 0) { 661 | this.patchState({ 662 | pageState: { 663 | camera: { 664 | zoom: 1, 665 | point: [0, 0], 666 | }, 667 | }, 668 | }) 669 | } 670 | 671 | const bounds = Utils.getCommonBounds( 672 | Object.values(shapes).map(shapeUtils.draw.getBounds) 673 | ) 674 | 675 | const { zoom } = pageState.camera 676 | const mx = (window.innerWidth - bounds.width * zoom) / 2 / zoom 677 | const my = (window.innerHeight - bounds.height * zoom) / 2 / zoom 678 | const point = Vec.round(Vec.add([-bounds.minX, -bounds.minY], [mx, my])) 679 | 680 | return this.patchState({ 681 | pageState: { camera: { point } }, 682 | }) 683 | } 684 | 685 | resetStyles = () => { 686 | const { shapes } = this.state.page 687 | const { state } = this 688 | 689 | const currentAppState = state.appState 690 | const initialAppState = initialState.appState 691 | 692 | return this.setState({ 693 | before: { 694 | appState: currentAppState, 695 | page: { 696 | shapes: { 697 | ...Object.fromEntries( 698 | Object.keys(shapes).map((id) => [ 699 | id, 700 | { 701 | style: currentAppState.style, 702 | }, 703 | ]) 704 | ), 705 | }, 706 | }, 707 | }, 708 | after: { 709 | appState: initialAppState, 710 | page: { 711 | shapes: { 712 | ...Object.fromEntries( 713 | Object.keys(shapes).map((id) => [ 714 | id, 715 | { style: initialAppState.style }, 716 | ]) 717 | ), 718 | }, 719 | }, 720 | pageState: { 721 | camera: { 722 | zoom: 1, 723 | }, 724 | }, 725 | }, 726 | }) 727 | } 728 | 729 | copyStyles = () => { 730 | const { state } = this 731 | const { style } = state.appState 732 | copyTextToClipboard(`{ 733 | size: ${style.size}, 734 | smoothing: ${style.smoothing}, 735 | thinning: ${style.thinning}, 736 | streamline: ${style.streamline}, 737 | easing: ${EASING_STRINGS[style.easing].toString()}, 738 | start: { 739 | taper: ${style.taperStart}, 740 | cap: ${style.capStart}, 741 | }, 742 | end: { 743 | taper: ${style.taperEnd}, 744 | cap: ${style.capEnd}, 745 | }, 746 | }`) 747 | } 748 | 749 | copySvg = () => { 750 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 751 | 752 | const shapes = Object.values(this.state.page.shapes) 753 | 754 | const bounds = Utils.getCommonBounds(shapes.map(draw.getBounds)) 755 | 756 | const padding = 40 757 | 758 | shapes.forEach((shape) => { 759 | const fillElm = document.getElementById('path_' + shape.id) 760 | 761 | if (!fillElm) return 762 | 763 | const fillClone = fillElm.cloneNode(false) as SVGPathElement 764 | 765 | const strokeElm = document.getElementById('path_stroke_' + shape.id) 766 | 767 | if (strokeElm) { 768 | // Create a new group 769 | const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') 770 | 771 | // Translate the group to the shape's point 772 | g.setAttribute( 773 | 'transform', 774 | `translate(${shape.point[0]}, ${shape.point[1]})` 775 | ) 776 | 777 | // Clone the stroke element 778 | const strokeClone = strokeElm.cloneNode(false) as SVGPathElement 779 | 780 | // Append both the stroke element and the fill element to the group 781 | g.appendChild(strokeClone) 782 | g.appendChild(fillClone) 783 | 784 | // Append the group to the SVG 785 | svg.appendChild(g) 786 | } else { 787 | // Translate the fill clone and append it to the SVG 788 | fillClone.setAttribute( 789 | 'transform', 790 | `translate(${shape.point[0]}, ${shape.point[1]})` 791 | ) 792 | 793 | svg.appendChild(fillClone) 794 | } 795 | }) 796 | 797 | // Resize the element to the bounding box 798 | svg.setAttribute( 799 | 'viewBox', 800 | [ 801 | bounds.minX - padding, 802 | bounds.minY - padding, 803 | bounds.width + padding * 2, 804 | bounds.height + padding * 2, 805 | ].join(' ') 806 | ) 807 | 808 | svg.setAttribute('width', String(bounds.width)) 809 | 810 | svg.setAttribute('height', String(bounds.height)) 811 | 812 | const s = new XMLSerializer() 813 | 814 | const svgString = s 815 | .serializeToString(svg) 816 | .replaceAll(' ', '') 817 | .replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1') 818 | 819 | copyTextToClipboard(svgString) 820 | 821 | return svgString 822 | } 823 | 824 | resetDoc = () => { 825 | const { shapes } = this.state.page 826 | 827 | return this.setState({ 828 | before: { 829 | page: { 830 | shapes, 831 | }, 832 | }, 833 | after: { 834 | page: { 835 | shapes: { 836 | ...Object.fromEntries( 837 | Object.keys(shapes).map((key) => [key, undefined]) 838 | ), 839 | }, 840 | }, 841 | pageState: { 842 | camera: { 843 | point: [0, 0], 844 | zoom: 1, 845 | }, 846 | }, 847 | }, 848 | }) 849 | } 850 | 851 | onPinchStart: TLPinchEventHandler = () => { 852 | if (this.state.appState.status !== 'idle') return 853 | 854 | this.patchState({ 855 | appState: { status: 'pinching' }, 856 | }) 857 | } 858 | 859 | selectDrawingTool = () => { 860 | this.patchState({ 861 | appState: { 862 | tool: 'drawing', 863 | }, 864 | }) 865 | } 866 | 867 | selectErasingTool = () => { 868 | this.patchState({ 869 | appState: { 870 | tool: 'erasing', 871 | }, 872 | }) 873 | } 874 | } 875 | 876 | export const app = new AppState( 877 | initialState, 878 | 'perfect-freehand', 879 | 1, 880 | (p, n) => n 881 | ) 882 | 883 | export function useAppState(): State 884 | export function useAppState(selector: StateSelector): K 885 | export function useAppState(selector?: StateSelector) { 886 | if (selector) { 887 | return app.useStore(selector) 888 | } 889 | return app.useStore() 890 | } 891 | -------------------------------------------------------------------------------- /packages/dev/src/state/utils.ts: -------------------------------------------------------------------------------- 1 | function cross(x: number[], y: number[], z: number[]): number { 2 | return (y[0] - x[0]) * (z[1] - x[1]) - (z[0] - x[0]) * (y[1] - x[1]) 3 | } 4 | 5 | export function pointInPolygon(p: number[], points: number[][]): boolean { 6 | let wn = 0 // winding number 7 | 8 | points.forEach((a, i) => { 9 | const b = points[(i + 1) % points.length] 10 | if (a[1] <= p[1]) { 11 | if (b[1] > p[1] && cross(a, b, p) > 0) { 12 | wn += 1 13 | } 14 | } else if (b[1] <= p[1] && cross(a, b, p) < 0) { 15 | wn -= 1 16 | } 17 | }) 18 | 19 | return wn !== 0 20 | } 21 | 22 | export function copyTextToClipboard(string: string) { 23 | try { 24 | navigator.clipboard.writeText(string) 25 | } catch (e) { 26 | const textarea = document.createElement('textarea') 27 | textarea.setAttribute('position', 'fixed') 28 | textarea.setAttribute('top', '0') 29 | textarea.setAttribute('readonly', 'true') 30 | textarea.setAttribute('contenteditable', 'true') 31 | textarea.style.position = 'fixed' 32 | textarea.value = string 33 | document.body.appendChild(textarea) 34 | textarea.focus() 35 | textarea.select() 36 | 37 | try { 38 | const range = document.createRange() 39 | range.selectNodeContents(textarea) 40 | 41 | const sel = window.getSelection() 42 | if (!sel) return 43 | 44 | sel.removeAllRanges() 45 | sel.addRange(range) 46 | 47 | textarea.setSelectionRange(0, textarea.value.length) 48 | } catch (err) { 49 | console.warn('Could not copy to clipboard') 50 | null // Could not copy to clipboard 51 | } finally { 52 | document.body.removeChild(textarea) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/dev/src/styles.css: -------------------------------------------------------------------------------- 1 | *, 2 | html { 3 | box-sizing: border-box; 4 | } 5 | 6 | body { 7 | font-family: 'Recursive'; 8 | font-size: 13px; 9 | } 10 | 11 | span[role='label'] { 12 | user-select: none; 13 | } 14 | 15 | .app { 16 | position: fixed; 17 | top: 0; 18 | right: 0; 19 | bottom: 0; 20 | left: 0; 21 | overflow: hidden; 22 | } 23 | -------------------------------------------------------------------------------- /packages/dev/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { TLBinding, TLPage, TLPageState, TLShape } from '@tldraw/core' 2 | 3 | export type Entries = { 4 | [K in keyof T]: [K, T[K]] 5 | }[keyof T][] 6 | 7 | export type Patch = Partial<{ [P in keyof T]: Patch }> 8 | 9 | export type Command = { 10 | id: string 11 | before: Patch 12 | after: Patch 13 | } 14 | 15 | export interface DrawStyles { 16 | size: number 17 | fill: string 18 | stroke: string 19 | strokeWidth: number 20 | easing: Easing 21 | thinning: number 22 | streamline: number 23 | smoothing: number 24 | taperStart: number | boolean 25 | taperEnd: number | boolean 26 | capStart: boolean 27 | capEnd: boolean 28 | easingStart: Easing 29 | easingEnd: Easing 30 | isFilled: boolean 31 | } 32 | 33 | export interface DrawShape extends TLShape { 34 | type: 'draw' 35 | points: number[][] 36 | style: DrawStyles 37 | isDone: boolean 38 | } 39 | 40 | export interface Doc { 41 | page: TLPage 42 | pageState: TLPageState 43 | } 44 | 45 | export interface State extends Doc { 46 | appState: { 47 | status: 'idle' | 'pinching' | 'drawing' | 'erasing' 48 | tool: 'drawing' | 'erasing' 49 | editingId?: string 50 | style: DrawStyles 51 | isPanelOpen: boolean 52 | } 53 | } 54 | 55 | export interface Data extends State { 56 | createShape: (point: number[]) => void 57 | updateShape: (point: number[], pressure: number) => void 58 | completeShape: (point: number[], pressure: number) => void 59 | updateStyle: (style: Partial) => void 60 | load: (doc: Doc) => void 61 | resetDoc: () => void 62 | undo: () => void 63 | redo: () => void 64 | } 65 | 66 | export type Easing = 67 | | 'linear' 68 | | 'easeInQuad' 69 | | 'easeOutQuad' 70 | | 'easeInOutQuad' 71 | | 'easeInCubic' 72 | | 'easeOutCubic' 73 | | 'easeInOutCubic' 74 | | 'easeInQuart' 75 | | 'easeOutQuart' 76 | | 'easeInOutQuart' 77 | | 'easeInQuint' 78 | | 'easeOutQuint' 79 | | 'easeInOutQuint' 80 | | 'easeInSine' 81 | | 'easeOutSine' 82 | | 'easeInOutSine' 83 | | 'easeInExpo' 84 | | 'easeOutExpo' 85 | | 'easeInOutExpo' 86 | -------------------------------------------------------------------------------- /packages/dev/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "**/*.test.tsx", 6 | "**/*.test.ts", 7 | "**/*.spec.tsx", 8 | "**/*.spec.ts", 9 | "src/test", 10 | "dist" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /packages/dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src", "decs.d.ts"], 4 | "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts", "dist"], 5 | "compilerOptions": { 6 | "baseUrl": "./src" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/perfect-freehand/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.0 4 | 5 | - Add `taper: true` for full-length tapers. 6 | 7 | ## 1.0.15 8 | 9 | - Bug fixes related to very short lines. 10 | 11 | ## 1.0.14 12 | 13 | - Updates README and documentation. 14 | 15 | ## 1.0.13 16 | 17 | - Fixes an issue where default values were incorrectly being inserted for zero pressure points. 18 | 19 | ## 1.0.12 20 | 21 | - Fixes cusp at the end of tapered lines. 22 | 23 | ## 1.0.11 24 | 25 | - Adds correct license. 26 | 27 | ## 1.0.8 28 | 29 | - Removes more unused 30 | - Fixes bug when size was negative 31 | - Adds a few thousand tests 32 | 33 | ## 1.0.8 34 | 35 | - Removes unused code 36 | - Improves start and end caps 37 | 38 | ## 1.0.6 39 | 40 | - Fixes appearance of start caps 41 | - Fix appearance of tapered end cap 42 | - Fix issues where points had more than 2 numbers 43 | - Fix flat caps 44 | - Improves streamlining 45 | - Fix a bug with corner caps on straight lines 46 | - Simplifies algorithm 47 | 48 | ## 1.0.5 49 | 50 | - Improves array methods for a very minor improvement in speed and memory performance. 51 | -------------------------------------------------------------------------------- /packages/perfect-freehand/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Stephen Ruiz Ltd 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 | -------------------------------------------------------------------------------- /packages/perfect-freehand/README.md: -------------------------------------------------------------------------------- 1 | # ![Screenshot](assets/perfect-freehand-logo.svg 'Perfect Freehand') 2 | 3 | Draw perfect pressure-sensitive freehand lines. 4 | 5 | 🔗 Curious? Try out a [demo](https://perfect-freehand-example.vercel.app/). 6 | 7 | 💅 Designer? Check out the [Figma Plugin](https://www.figma.com/community/plugin/950892731860805817). 8 | 9 | 🕊 Flutterer? There's now a [dart version](https://pub.dev/packages/perfect_freehand) of this library, too. 10 | 11 | 💕 Love this library? Consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok). 12 | 13 | ## Table of Contents 14 | 15 | - [Installation](#installation) 16 | - [Usage](#usage) 17 | - [Documentation](#documentation) 18 | - [Community](#community) 19 | - [Author](#author) 20 | 21 | ## Installation 22 | 23 | ```bash 24 | npm install perfect-freehand 25 | ``` 26 | 27 | or 28 | 29 | ```bash 30 | yarn add perfect-freehand 31 | ``` 32 | 33 | ## Introduction 34 | 35 | This package exports a function named `getStroke` that will generate the points for a polygon based on an array of points. 36 | 37 | ![Screenshot](assets/process.gif 'A GIF showing a stroke with input points, outline points, and a curved path connecting these points') 38 | 39 | To do this work, `getStroke` first creates a set of spline points (red) based on the input points (grey) and then creates outline points (blue). You can render the result any way you like, using whichever technology you prefer. 40 | 41 | [![Edit perfect-freehand-example](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/perfect-freehand-example-biwyi?fontsize=14&hidenavigation=1&theme=dark) 42 | 43 | ## Usage 44 | 45 | To use this library, import the `getStroke` function and pass it an array of **input points**, such as those recorded from a user's mouse movement. The `getStroke` function will return a new array of **outline points**. These outline points will form a polygon (called a "stroke") that surrounds the input points. 46 | 47 | ```js 48 | import { getStroke } from 'perfect-freehand' 49 | 50 | const inputPoints = [ 51 | [0, 0], 52 | [10, 5], 53 | [20, 8], 54 | // ... 55 | ] 56 | 57 | const outlinePoints = getStroke(inputPoints) 58 | ``` 59 | 60 | You then can **render** your stroke points using your technology of choice. See the [Rendering](#rendering) section for examples in SVG and HTML Canvas. 61 | 62 | You can **customize** the appearance of the stroke shape by passing `getStroke` a second parameter: an options object containing one or more options. See the [Options](#options) section for a full list of available options. 63 | 64 | ```js 65 | const stroke = getStroke(myPoints, { 66 | size: 32, 67 | thinning: 0.7, 68 | }) 69 | ``` 70 | 71 | The appearance of a stroke is effected by the **pressure** associated with each input point. By default, the `getStroke` function will simulate pressure based on the distance between input points. 72 | 73 | To use **real pressure**, such as that from a pen or stylus, provide the pressure as the third number for each input point, and set the `simulatePressure` option to `false`. 74 | 75 | ```js 76 | const inputPoints = [ 77 | [0, 0, 0.5], 78 | [10, 5, 0.7], 79 | [20, 8, 0.8], 80 | // ... 81 | ] 82 | 83 | const outlinePoints = getStroke(inputPoints, { 84 | simulatePressure: false, 85 | }) 86 | ``` 87 | 88 | In addition to providing points as an array of arrays, you may also provide your points as an **array of objects** as show in the example below. In both cases, the value for pressure is optional (it will default to `.5`). 89 | 90 | ```js 91 | const inputPoints = [ 92 | { x: 0, y: 0, pressure: 0.5 }, 93 | { x: 10, y: 5, pressure: 0.7 }, 94 | { x: 20, y: 8, pressure: 0.8 }, 95 | // ... 96 | ] 97 | 98 | const outlinePoints = getStroke(inputPoints, { 99 | simulatePressure: false, 100 | }) 101 | ``` 102 | 103 | **Note:** Internally, the `getStroke` function will convert your object points to array points, which will have an effect on performance. If you're using this library ambitiously and want to format your points as objects, consider modifying this library's `getStrokeOutlinePoints` to use the object syntax instead (e.g. replacing all `[0]` with `.x`, `[1]` with `.y`, and `[2]` with `.pressure`). 104 | 105 | ## Example 106 | 107 | ```jsx 108 | import * as React from 'react' 109 | import { getStroke } from 'perfect-freehand' 110 | import { getSvgPathFromStroke } from './utils' 111 | 112 | export default function Example() { 113 | const [points, setPoints] = React.useState([]) 114 | 115 | function handlePointerDown(e) { 116 | e.target.setPointerCapture(e.pointerId) 117 | setPoints([[e.pageX, e.pageY, e.pressure]]) 118 | } 119 | 120 | function handlePointerMove(e) { 121 | if (e.buttons !== 1) return 122 | setPoints([...points, [e.pageX, e.pageY, e.pressure]]) 123 | } 124 | 125 | const stroke = getStroke(points, { 126 | size: 16, 127 | thinning: 0.5, 128 | smoothing: 0.5, 129 | streamline: 0.5, 130 | }) 131 | 132 | const pathData = getSvgPathFromStroke(stroke) 133 | 134 | return ( 135 | 140 | {points && } 141 | 142 | ) 143 | } 144 | ``` 145 | 146 | > **Tip:** For implementations in Typescript, see the example project included in this repository. 147 | 148 | [![Edit perfect-freehand-example](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/perfect-freehand-example-biwyi?fontsize=14&hidenavigation=1&theme=dark) 149 | 150 | ## Documentation 151 | 152 | ### Options 153 | 154 | The options object is optional, as are each of its properties. 155 | 156 | | Property | Type | Default | Description | 157 | | ------------------ | -------- | ------- | ----------------------------------------------------- | 158 | | `size` | number | 8 | The base size (diameter) of the stroke. | 159 | | `thinning` | number | .5 | The effect of pressure on the stroke's size. | 160 | | `smoothing` | number | .5 | How much to soften the stroke's edges. | 161 | | `streamline` | number | .5 | How much to streamline the stroke. | 162 | | `simulatePressure` | boolean | true | Whether to simulate pressure based on velocity. | 163 | | `easing` | function | t => t | An easing function to apply to each point's pressure. | 164 | | `start` | { } | | Tapering options for the start of the line. | 165 | | `end` | { } | | Tapering options for the end of the line. | 166 | | `last` | boolean | true | Whether the stroke is complete. | 167 | 168 | **Note:** When the `last` property is `true`, the line's end will be drawn at the last input point, rather than slightly behind it. 169 | 170 | The `start` and `end` options accept an object: 171 | 172 | | Property | Type | Default | Description | 173 | | -------- | ----------------- | ------- | ---------------------------------------------------------------------------------------- | 174 | | `cap` | boolean | true | Whether to draw a cap. | 175 | | `taper` | number or boolean | 0 | The distance to taper. If set to true, the taper will be the total length of the stroke. | 176 | | `easing` | function | t => t | An easing function for the tapering effect. | 177 | 178 | **Note:** The `cap` property has no effect when `taper` is more than zero. 179 | 180 | ```js 181 | getStroke(myPoints, { 182 | size: 8, 183 | thinning: 0.5, 184 | smoothing: 0.5, 185 | streamline: 0.5, 186 | easing: (t) => t, 187 | simulatePressure: true, 188 | last: true, 189 | start: { 190 | cap: true, 191 | taper: 0, 192 | easing: (t) => t, 193 | }, 194 | end: { 195 | cap: true, 196 | taper: 0, 197 | easing: (t) => t, 198 | }, 199 | }) 200 | ``` 201 | 202 | > **Tip:** To create a stroke with a steady line, set the `thinning` option to `0`. 203 | 204 | > **Tip:** To create a stroke that gets thinner with pressure instead of thicker, use a negative number for the `thinning` option. 205 | 206 | ### Other Exports 207 | 208 | For advanced usage, the library also exports smaller functions that `getStroke` uses to generate its outline points. 209 | 210 | #### `getStrokePoints` 211 | 212 | A function that accepts an array of points (formatted either as `[x, y, pressure]` or `{ x: number, y: number, pressure: number}`) and (optionally) an options object. Returns a set of adjusted points as `{ point, pressure, vector, distance, runningLength }`. The path's total length will be the `runningLength` of the last point in the array. 213 | 214 | ```js 215 | import { getStrokePoints } from 'perfect-freehand' 216 | import samplePoints from "./samplePoints.json' 217 | 218 | const strokePoints = getStrokePoints(samplePoints) 219 | ``` 220 | 221 | #### `getStrokeOutlinePoints` 222 | 223 | A function that accepts an array of points (formatted as `{ point, pressure, vector, distance, runningLength }`, i.e. the output of `getStrokePoints`) and (optionally) an options object, and returns an array of points (`[x, y]`) defining the outline of a pressure-sensitive stroke. 224 | 225 | ```js 226 | import { getStrokePoints, getStrokeOutlinePoints } from 'perfect-freehand' 227 | import samplePoints from "./samplePoints.json' 228 | 229 | const strokePoints = getStrokePoints(samplePoints) 230 | 231 | const outlinePoints = getStrokeOutlinePoints(strokePoints) 232 | ``` 233 | 234 | **Note:** Internally, the `getStroke` function passes the result of `getStrokePoints` to `getStrokeOutlinePoints`, just as shown in this example. This means that, in this example, the result of `outlinePoints` will be the same as if the `samplePoints` array had been passed to `getStroke`. 235 | 236 | #### `StrokeOptions` 237 | 238 | A TypeScript type for the options object. Useful if you're defining your options outside of the `getStroke` function. 239 | 240 | ```ts 241 | import { StrokeOptions, getStroke } from 'perfect-freehand' 242 | 243 | const options: StrokeOptions = { 244 | size: 16, 245 | } 246 | 247 | const stroke = getStroke(options) 248 | ``` 249 | 250 | ## Tips & Tricks 251 | 252 | ### Freehand Anything 253 | 254 | While this library was designed for rendering the types of input points generated by the movement of a human hand, you can pass any set of points into the library's functions. For example, here's what you get when running [Feather Icons](https://feathericons.com/) through `getStroke`. 255 | 256 | ![Icons](assets/icons.png) 257 | 258 | ### Rendering 259 | 260 | While `getStroke` returns an array of points representing the outline of a stroke, it's up to you to decide how you will render these points. 261 | 262 | The function below will turn the points returned by `getStroke` into SVG path data. 263 | 264 | ```js 265 | const average = (a, b) => (a + b) / 2 266 | 267 | function getSvgPathFromStroke(points, closed = true) { 268 | const len = points.length 269 | 270 | if (len < 4) { 271 | return `` 272 | } 273 | 274 | let a = points[0] 275 | let b = points[1] 276 | const c = points[2] 277 | 278 | let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed( 279 | 2 280 | )},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average( 281 | b[1], 282 | c[1] 283 | ).toFixed(2)} T` 284 | 285 | for (let i = 2, max = len - 1; i < max; i++) { 286 | a = points[i] 287 | b = points[i + 1] 288 | result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed( 289 | 2 290 | )} ` 291 | } 292 | 293 | if (closed) { 294 | result += 'Z' 295 | } 296 | 297 | return result 298 | } 299 | ``` 300 | 301 | To use this function, first run your input points through `getStroke`, then pass the result to `getSvgPathFromStroke`. 302 | 303 | ```js 304 | const outlinePoints = getStroke(inputPoints) 305 | 306 | const pathData = getSvgPathFromStroke(outlinePoints) 307 | ``` 308 | 309 | You could then pass this string of SVG path data either to an [SVG path](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d) element: 310 | 311 | ```jsx 312 | 313 | ``` 314 | 315 | Or, if you are rendering with HTML Canvas, you can pass the string to a [`Path2D` constructor](https://developer.mozilla.org/en-US/docs/Web/API/Path2D/Path2D#using_svg_paths)). 316 | 317 | ```js 318 | const myPath = new Path2D(pathData) 319 | 320 | ctx.fill(myPath) 321 | ``` 322 | 323 | ### Flattening 324 | 325 | By default, the polygon's paths include self-crossings. You may wish to remove these crossings and render a stroke as a "flattened" polygon. To do this, install the [`polygon-clipping`](https://github.com/mfogel/polygon-clipping) package and use the following function together with the `getSvgPathFromStroke`. 326 | 327 | ```js 328 | import polygonClipping from 'polygon-clipping' 329 | 330 | function getFlatSvgPathFromStroke(stroke) { 331 | const faces = polygonClipping.union([stroke]) 332 | 333 | const d = [] 334 | 335 | faces.forEach((face) => 336 | face.forEach((points) => { 337 | d.push(getSvgPathFromStroke(points)) 338 | }) 339 | ) 340 | 341 | return d.join(' ') 342 | } 343 | ``` 344 | 345 | ## Development & Contributions 346 | 347 | To work on this library: 348 | 349 | - clone this repo 350 | - run `yarn` in the folder root to install dependencies 351 | - run `yarn start` to start the local development server 352 | 353 | The development server is located at `packages/dev`. The library and its tests are located at `packages/perfect-freehand`. 354 | 355 | Pull requests are very welcome! 356 | 357 | ## Community 358 | 359 | ### Support 360 | 361 | Need help? Please [open an issue](https://github.com/steveruizok/perfect-freehand/issues/new) for support. 362 | 363 | ### Discussion 364 | 365 | Have an idea or casual question? Visit the [discussion page](https://github.com/steveruizok/perfect-freehand/discussions). 366 | 367 | ### License 368 | 369 | - MIT 370 | - ...but if you're using `perfect-freehand` in a commercial product, consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok). 💰 371 | 372 | ## Author 373 | 374 | - [@steveruizok](https://twitter.com/steveruizok) 375 | -------------------------------------------------------------------------------- /packages/perfect-freehand/assets/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/perfect-freehand/9b369e4d74c9f45748e3dfd7507a7c3f97021acb/packages/perfect-freehand/assets/icons.png -------------------------------------------------------------------------------- /packages/perfect-freehand/assets/perfect-freehand-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/perfect-freehand/9b369e4d74c9f45748e3dfd7507a7c3f97021acb/packages/perfect-freehand/assets/perfect-freehand-card.png -------------------------------------------------------------------------------- /packages/perfect-freehand/assets/process.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/perfect-freehand/9b369e4d74c9f45748e3dfd7507a7c3f97021acb/packages/perfect-freehand/assets/process.gif -------------------------------------------------------------------------------- /packages/perfect-freehand/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.2.2", 3 | "name": "perfect-freehand", 4 | "private": false, 5 | "description": "Draw perfect pressure-sensitive freehand strokes.", 6 | "author": { 7 | "name": "Steve Ruiz", 8 | "url": "https://twitter.com/steveruizok" 9 | }, 10 | "repository": "https://github.com/steveruizok/perfect-freehand", 11 | "keywords": [ 12 | "ink", 13 | "draw", 14 | "paint", 15 | "signature", 16 | "handwriting", 17 | "text", 18 | "drawing", 19 | "painting" 20 | ], 21 | "files": [ 22 | "dist/**/*" 23 | ], 24 | "exports": { 25 | ".": { 26 | "types": "./dist/types/index.d.ts", 27 | "require": "./dist/cjs/index.js", 28 | "import": "./dist/esm/index.mjs" 29 | } 30 | }, 31 | "license": "MIT", 32 | "main": "./dist/cjs/index.js", 33 | "module": "./dist/esm/index.mjs", 34 | "types": "./dist/types/index.d.ts", 35 | "typings": "./dist/types/index.d.ts", 36 | "scripts": { 37 | "start": "node scripts/dev & tsc --watch --incremental --emitDeclarationOnly --declarationMap --outDir dist/types", 38 | "build": "yarn clean && node scripts/build && tsc --project tsconfig.build.json --emitDeclarationOnly --outDir dist/types", 39 | "lint": "eslint src/ --ext .ts,.tsx", 40 | "clean": "rm -rf dist", 41 | "ts-node": "ts-node", 42 | "docs": "typedoc --entryPoints src/index.ts" 43 | }, 44 | "devDependencies": { 45 | "@babel/core": "^7.15.0", 46 | "@babel/plugin-syntax-import-meta": "^7.10.4", 47 | "@babel/preset-env": "^7.15.0", 48 | "@babel/preset-react": "^7.14.5", 49 | "@babel/preset-typescript": "^7.15.0", 50 | "@testing-library/jest-dom": "^5.14.1", 51 | "@testing-library/react": "^12.0.0", 52 | "@types/jest": "^27.0.1", 53 | "@types/node": "^15.0.1", 54 | "@typescript-eslint/eslint-plugin": "^4.19.0", 55 | "@typescript-eslint/parser": "^4.19.0", 56 | "babel-jest": "^27.1.0", 57 | "eslint": "^7.32.0", 58 | "fake-indexeddb": "^3.1.3", 59 | "jest": "^27.1.0", 60 | "lerna": "^3.15.0", 61 | "ts-jest": "^27.0.5", 62 | "tslib": "^2.3.0", 63 | "typedoc": "^0.21.9", 64 | "typescript": "^4.4.2" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/perfect-freehand/scripts/build.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const fs = require('fs') 3 | const esbuild = require('esbuild') 4 | const { gzip } = require('zlib') 5 | 6 | const name = process.env.npm_package_name || '' 7 | 8 | async function main() { 9 | await esbuild.build({ 10 | entryPoints: ['./src/index.ts'], 11 | outdir: 'dist/cjs', 12 | minify: true, 13 | bundle: true, 14 | format: 'cjs', 15 | target: 'es6', 16 | tsconfig: './tsconfig.build.json', 17 | }) 18 | 19 | const esmResult = await esbuild.build({ 20 | entryPoints: ['./src/index.ts'], 21 | outdir: 'dist/esm', 22 | minify: true, 23 | bundle: true, 24 | format: 'esm', 25 | target: 'es6', 26 | tsconfig: './tsconfig.build.json', 27 | metafile: true, 28 | outExtension: { '.js': '.mjs' }, 29 | }) 30 | 31 | let esmSize = 0 32 | Object.values(esmResult.metafile.outputs).forEach((output) => { 33 | esmSize += output.bytes 34 | }) 35 | 36 | fs.readFile('./dist/esm/index.mjs', (_err, data) => { 37 | gzip(data, (_err, result) => { 38 | console.log( 39 | `✔ ${name}: Built package. ${(esmSize / 1000).toFixed(2)}kb (${( 40 | result.length / 1000 41 | ).toFixed(2)}kb minified)` 42 | ) 43 | }) 44 | }) 45 | } 46 | 47 | main().catch((e) => { 48 | console.log(`× ${name}: Build failed due to an error.`) 49 | console.log(e) 50 | }) 51 | -------------------------------------------------------------------------------- /packages/perfect-freehand/scripts/dev.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const esbuild = require('esbuild') 4 | 5 | const name = process.env.npm_package_name || '' 6 | 7 | async function main() { 8 | esbuild.build({ 9 | entryPoints: ['./src/index.ts'], 10 | outdir: 'dist/esm', 11 | minify: false, 12 | bundle: true, 13 | format: 'esm', 14 | target: 'esnext', 15 | tsconfig: './tsconfig.json', 16 | watch: { 17 | onRebuild(error) { 18 | if (error) { 19 | console.log(`× ${name}: An error in prevented the rebuild.`) 20 | return 21 | } 22 | console.log(`✔ ${name}: Rebuilt perfect-freehand.`) 23 | }, 24 | }, 25 | }) 26 | } 27 | 28 | main() 29 | -------------------------------------------------------------------------------- /packages/perfect-freehand/src/getStroke.ts: -------------------------------------------------------------------------------- 1 | import type { StrokeOptions } from './types' 2 | import { getStrokeOutlinePoints } from './getStrokeOutlinePoints' 3 | import { getStrokePoints } from './getStrokePoints' 4 | 5 | /** 6 | * ## getStroke 7 | * @description Get an array of points describing a polygon that surrounds the input points. 8 | * @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases. 9 | * @param options (optional) An object with options. 10 | * @param options.size The base size (diameter) of the stroke. 11 | * @param options.thinning The effect of pressure on the stroke's size. 12 | * @param options.smoothing How much to soften the stroke's edges. 13 | * @param options.easing An easing function to apply to each point's pressure. 14 | * @param options.simulatePressure Whether to simulate pressure based on velocity. 15 | * @param options.start Cap, taper and easing for the start of the line. 16 | * @param options.end Cap, taper and easing for the end of the line. 17 | * @param options.last Whether to handle the points as a completed stroke. 18 | */ 19 | 20 | export function getStroke( 21 | points: (number[] | { x: number; y: number; pressure?: number })[], 22 | options: StrokeOptions = {} as StrokeOptions 23 | ): number[][] { 24 | return getStrokeOutlinePoints(getStrokePoints(points, options), options) 25 | } 26 | -------------------------------------------------------------------------------- /packages/perfect-freehand/src/getStrokeOutlinePoints.ts: -------------------------------------------------------------------------------- 1 | import { getStrokeRadius } from './getStrokeRadius' 2 | import type { StrokeOptions, StrokePoint } from './types' 3 | import { 4 | add, 5 | dist2, 6 | dpr, 7 | lrp, 8 | mul, 9 | neg, 10 | per, 11 | prj, 12 | rotAround, 13 | sub, 14 | uni, 15 | } from './vec' 16 | 17 | const { min, PI } = Math 18 | 19 | // This is the rate of change for simulated pressure. It could be an option. 20 | const RATE_OF_PRESSURE_CHANGE = 0.275 21 | 22 | // Browser strokes seem to be off if PI is regular, a tiny offset seems to fix it 23 | const FIXED_PI = PI + 0.0001 24 | 25 | /** 26 | * ## getStrokeOutlinePoints 27 | * @description Get an array of points (as `[x, y]`) representing the outline of a stroke. 28 | * @param points An array of StrokePoints as returned from `getStrokePoints`. 29 | * @param options (optional) An object with options. 30 | * @param options.size The base size (diameter) of the stroke. 31 | * @param options.thinning The effect of pressure on the stroke's size. 32 | * @param options.smoothing How much to soften the stroke's edges. 33 | * @param options.easing An easing function to apply to each point's pressure. 34 | * @param options.simulatePressure Whether to simulate pressure based on velocity. 35 | * @param options.start Cap, taper and easing for the start of the line. 36 | * @param options.end Cap, taper and easing for the end of the line. 37 | * @param options.last Whether to handle the points as a completed stroke. 38 | */ 39 | export function getStrokeOutlinePoints( 40 | points: StrokePoint[], 41 | options: Partial = {} as Partial 42 | ): number[][] { 43 | const { 44 | size = 16, 45 | smoothing = 0.5, 46 | thinning = 0.5, 47 | simulatePressure = true, 48 | easing = (t) => t, 49 | start = {}, 50 | end = {}, 51 | last: isComplete = false, 52 | } = options 53 | 54 | const { cap: capStart = true, easing: taperStartEase = (t) => t * (2 - t) } = 55 | start 56 | 57 | const { cap: capEnd = true, easing: taperEndEase = (t) => --t * t * t + 1 } = 58 | end 59 | 60 | // We can't do anything with an empty array or a stroke with negative size. 61 | if (points.length === 0 || size <= 0) { 62 | return [] 63 | } 64 | 65 | // The total length of the line 66 | const totalLength = points[points.length - 1].runningLength 67 | 68 | const taperStart = 69 | start.taper === false 70 | ? 0 71 | : start.taper === true 72 | ? Math.max(size, totalLength) 73 | : (start.taper as number) 74 | 75 | const taperEnd = 76 | end.taper === false 77 | ? 0 78 | : end.taper === true 79 | ? Math.max(size, totalLength) 80 | : (end.taper as number) 81 | 82 | // The minimum allowed distance between points (squared) 83 | const minDistance = Math.pow(size * smoothing, 2) 84 | 85 | // Our collected left and right points 86 | const leftPts: number[][] = [] 87 | const rightPts: number[][] = [] 88 | 89 | // Previous pressure (start with average of first five pressures, 90 | // in order to prevent fat starts for every line. Drawn lines 91 | // almost always start slow! 92 | let prevPressure = points.slice(0, 10).reduce((acc, curr) => { 93 | let pressure = curr.pressure 94 | 95 | if (simulatePressure) { 96 | // Speed of change - how fast should the the pressure changing? 97 | const sp = min(1, curr.distance / size) 98 | // Rate of change - how much of a change is there? 99 | const rp = min(1, 1 - sp) 100 | // Accelerate the pressure 101 | pressure = min(1, acc + (rp - acc) * (sp * RATE_OF_PRESSURE_CHANGE)) 102 | } 103 | 104 | return (acc + pressure) / 2 105 | }, points[0].pressure) 106 | 107 | // The current radius 108 | let radius = getStrokeRadius( 109 | size, 110 | thinning, 111 | points[points.length - 1].pressure, 112 | easing 113 | ) 114 | 115 | // The radius of the first saved point 116 | let firstRadius: number | undefined = undefined 117 | 118 | // Previous vector 119 | let prevVector = points[0].vector 120 | 121 | // Previous left and right points 122 | let pl = points[0].point 123 | let pr = pl 124 | 125 | // Temporary left and right points 126 | let tl = pl 127 | let tr = pr 128 | 129 | // Keep track of whether the previous point is a sharp corner 130 | // ... so that we don't detect the same corner twice 131 | let isPrevPointSharpCorner = false 132 | 133 | // let short = true 134 | 135 | /* 136 | Find the outline's left and right points 137 | 138 | Iterating through the points and populate the rightPts and leftPts arrays, 139 | skipping the first and last pointsm, which will get caps later on. 140 | */ 141 | 142 | for (let i = 0; i < points.length; i++) { 143 | let { pressure } = points[i] 144 | const { point, vector, distance, runningLength } = points[i] 145 | 146 | // Removes noise from the end of the line 147 | if (i < points.length - 1 && totalLength - runningLength < 3) { 148 | continue 149 | } 150 | 151 | /* 152 | Calculate the radius 153 | 154 | If not thinning, the current point's radius will be half the size; or 155 | otherwise, the size will be based on the current (real or simulated) 156 | pressure. 157 | */ 158 | 159 | if (thinning) { 160 | if (simulatePressure) { 161 | // If we're simulating pressure, then do so based on the distance 162 | // between the current point and the previous point, and the size 163 | // of the stroke. Otherwise, use the input pressure. 164 | const sp = min(1, distance / size) 165 | const rp = min(1, 1 - sp) 166 | pressure = min( 167 | 1, 168 | prevPressure + (rp - prevPressure) * (sp * RATE_OF_PRESSURE_CHANGE) 169 | ) 170 | } 171 | 172 | radius = getStrokeRadius(size, thinning, pressure, easing) 173 | } else { 174 | radius = size / 2 175 | } 176 | 177 | if (firstRadius === undefined) { 178 | firstRadius = radius 179 | } 180 | 181 | /* 182 | Apply tapering 183 | 184 | If the current length is within the taper distance at either the 185 | start or the end, calculate the taper strengths. Apply the smaller 186 | of the two taper strengths to the radius. 187 | */ 188 | 189 | const ts = 190 | runningLength < taperStart 191 | ? taperStartEase(runningLength / taperStart) 192 | : 1 193 | 194 | const te = 195 | totalLength - runningLength < taperEnd 196 | ? taperEndEase((totalLength - runningLength) / taperEnd) 197 | : 1 198 | 199 | radius = Math.max(0.01, radius * Math.min(ts, te)) 200 | 201 | /* Add points to left and right */ 202 | 203 | /* 204 | Handle sharp corners 205 | 206 | Find the difference (dot product) between the current and next vector. 207 | If the next vector is at more than a right angle to the current vector, 208 | draw a cap at the current point. 209 | */ 210 | 211 | const nextVector = (i < points.length - 1 ? points[i + 1] : points[i]) 212 | .vector 213 | const nextDpr = i < points.length - 1 ? dpr(vector, nextVector) : 1.0 214 | const prevDpr = dpr(vector, prevVector) 215 | 216 | const isPointSharpCorner = prevDpr < 0 && !isPrevPointSharpCorner 217 | const isNextPointSharpCorner = nextDpr !== null && nextDpr < 0 218 | 219 | if (isPointSharpCorner || isNextPointSharpCorner) { 220 | // It's a sharp corner. Draw a rounded cap and move on to the next point 221 | // Considering saving these and drawing them later? So that we can avoid 222 | // crossing future points. 223 | 224 | const offset = mul(per(prevVector), radius) 225 | 226 | for (let step = 1 / 13, t = 0; t <= 1; t += step) { 227 | tl = rotAround(sub(point, offset), point, FIXED_PI * t) 228 | leftPts.push(tl) 229 | 230 | tr = rotAround(add(point, offset), point, FIXED_PI * -t) 231 | rightPts.push(tr) 232 | } 233 | 234 | pl = tl 235 | pr = tr 236 | 237 | if (isNextPointSharpCorner) { 238 | isPrevPointSharpCorner = true 239 | } 240 | continue 241 | } 242 | 243 | isPrevPointSharpCorner = false 244 | 245 | // Handle the last point 246 | if (i === points.length - 1) { 247 | const offset = mul(per(vector), radius) 248 | leftPts.push(sub(point, offset)) 249 | rightPts.push(add(point, offset)) 250 | continue 251 | } 252 | 253 | /* 254 | Add regular points 255 | 256 | Project points to either side of the current point, using the 257 | calculated size as a distance. If a point's distance to the 258 | previous point on that side greater than the minimum distance 259 | (or if the corner is kinda sharp), add the points to the side's 260 | points array. 261 | */ 262 | 263 | const offset = mul(per(lrp(nextVector, vector, nextDpr)), radius) 264 | 265 | tl = sub(point, offset) 266 | 267 | if (i <= 1 || dist2(pl, tl) > minDistance) { 268 | leftPts.push(tl) 269 | pl = tl 270 | } 271 | 272 | tr = add(point, offset) 273 | 274 | if (i <= 1 || dist2(pr, tr) > minDistance) { 275 | rightPts.push(tr) 276 | pr = tr 277 | } 278 | 279 | // Set variables for next iteration 280 | prevPressure = pressure 281 | prevVector = vector 282 | } 283 | 284 | /* 285 | Drawing caps 286 | 287 | Now that we have our points on either side of the line, we need to 288 | draw caps at the start and end. Tapered lines don't have caps, but 289 | may have dots for very short lines. 290 | */ 291 | 292 | const firstPoint = points[0].point.slice(0, 2) 293 | 294 | const lastPoint = 295 | points.length > 1 296 | ? points[points.length - 1].point.slice(0, 2) 297 | : add(points[0].point, [1, 1]) 298 | 299 | const startCap: number[][] = [] 300 | 301 | const endCap: number[][] = [] 302 | 303 | /* 304 | Draw a dot for very short or completed strokes 305 | 306 | If the line is too short to gather left or right points and if the line is 307 | not tapered on either side, draw a dot. If the line is tapered, then only 308 | draw a dot if the line is both very short and complete. If we draw a dot, 309 | we can just return those points. 310 | */ 311 | 312 | if (points.length === 1) { 313 | if (!(taperStart || taperEnd) || isComplete) { 314 | const start = prj( 315 | firstPoint, 316 | uni(per(sub(firstPoint, lastPoint))), 317 | -(firstRadius || radius) 318 | ) 319 | const dotPts: number[][] = [] 320 | for (let step = 1 / 13, t = step; t <= 1; t += step) { 321 | dotPts.push(rotAround(start, firstPoint, FIXED_PI * 2 * t)) 322 | } 323 | return dotPts 324 | } 325 | } else { 326 | /* 327 | Draw a start cap 328 | 329 | Unless the line has a tapered start, or unless the line has a tapered end 330 | and the line is very short, draw a start cap around the first point. Use 331 | the distance between the second left and right point for the cap's radius. 332 | Finally remove the first left and right points. :psyduck: 333 | */ 334 | 335 | if (taperStart || (taperEnd && points.length === 1)) { 336 | // The start point is tapered, noop 337 | } else if (capStart) { 338 | // Draw the round cap - add thirteen points rotating the right point around the start point to the left point 339 | for (let step = 1 / 13, t = step; t <= 1; t += step) { 340 | const pt = rotAround(rightPts[0], firstPoint, FIXED_PI * t) 341 | startCap.push(pt) 342 | } 343 | } else { 344 | // Draw the flat cap - add a point to the left and right of the start point 345 | const cornersVector = sub(leftPts[0], rightPts[0]) 346 | const offsetA = mul(cornersVector, 0.5) 347 | const offsetB = mul(cornersVector, 0.51) 348 | 349 | startCap.push( 350 | sub(firstPoint, offsetA), 351 | sub(firstPoint, offsetB), 352 | add(firstPoint, offsetB), 353 | add(firstPoint, offsetA) 354 | ) 355 | } 356 | 357 | /* 358 | Draw an end cap 359 | 360 | If the line does not have a tapered end, and unless the line has a tapered 361 | start and the line is very short, draw a cap around the last point. Finally, 362 | remove the last left and right points. Otherwise, add the last point. Note 363 | that This cap is a full-turn-and-a-half: this prevents incorrect caps on 364 | sharp end turns. 365 | */ 366 | 367 | const direction = per(neg(points[points.length - 1].vector)) 368 | 369 | if (taperEnd || (taperStart && points.length === 1)) { 370 | // Tapered end - push the last point to the line 371 | endCap.push(lastPoint) 372 | } else if (capEnd) { 373 | // Draw the round end cap 374 | const start = prj(lastPoint, direction, radius) 375 | for (let step = 1 / 29, t = step; t < 1; t += step) { 376 | endCap.push(rotAround(start, lastPoint, FIXED_PI * 3 * t)) 377 | } 378 | } else { 379 | // Draw the flat end cap 380 | 381 | endCap.push( 382 | add(lastPoint, mul(direction, radius)), 383 | add(lastPoint, mul(direction, radius * 0.99)), 384 | sub(lastPoint, mul(direction, radius * 0.99)), 385 | sub(lastPoint, mul(direction, radius)) 386 | ) 387 | } 388 | } 389 | 390 | /* 391 | Return the points in the correct winding order: begin on the left side, then 392 | continue around the end cap, then come back along the right side, and finally 393 | complete the start cap. 394 | */ 395 | 396 | return leftPts.concat(endCap, rightPts.reverse(), startCap) 397 | } 398 | -------------------------------------------------------------------------------- /packages/perfect-freehand/src/getStrokePoints.ts: -------------------------------------------------------------------------------- 1 | import { add, dist, isEqual, lrp, sub, uni } from './vec' 2 | import type { StrokeOptions, StrokePoint } from './types' 3 | 4 | /** 5 | * ## getStrokePoints 6 | * @description Get an array of points as objects with an adjusted point, pressure, vector, distance, and runningLength. 7 | * @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases. 8 | * @param options (optional) An object with options. 9 | * @param options.size The base size (diameter) of the stroke. 10 | * @param options.thinning The effect of pressure on the stroke's size. 11 | * @param options.smoothing How much to soften the stroke's edges. 12 | * @param options.easing An easing function to apply to each point's pressure. 13 | * @param options.simulatePressure Whether to simulate pressure based on velocity. 14 | * @param options.start Cap, taper and easing for the start of the line. 15 | * @param options.end Cap, taper and easing for the end of the line. 16 | * @param options.last Whether to handle the points as a completed stroke. 17 | */ 18 | export function getStrokePoints< 19 | T extends number[], 20 | K extends { x: number; y: number; pressure?: number } 21 | >(points: (T | K)[], options = {} as StrokeOptions): StrokePoint[] { 22 | const { streamline = 0.5, size = 16, last: isComplete = false } = options 23 | 24 | // If we don't have any points, return an empty array. 25 | if (points.length === 0) return [] 26 | 27 | // Find the interpolation level between points. 28 | const t = 0.15 + (1 - streamline) * 0.85 29 | 30 | // Whatever the input is, make sure that the points are in number[][]. 31 | let pts = Array.isArray(points[0]) 32 | ? (points as T[]) 33 | : (points as K[]).map(({ x, y, pressure = 0.5 }) => [x, y, pressure]) 34 | 35 | // Add extra points between the two, to help avoid "dash" lines 36 | // for strokes with tapered start and ends. Don't mutate the 37 | // input array! 38 | if (pts.length === 2) { 39 | const last = pts[1] 40 | pts = pts.slice(0, -1) 41 | for (let i = 1; i < 5; i++) { 42 | pts.push(lrp(pts[0], last, i / 4)) 43 | } 44 | } 45 | 46 | // If there's only one point, add another point at a 1pt offset. 47 | // Don't mutate the input array! 48 | if (pts.length === 1) { 49 | pts = [...pts, [...add(pts[0], [1, 1]), ...pts[0].slice(2)]] 50 | } 51 | 52 | // The strokePoints array will hold the points for the stroke. 53 | // Start it out with the first point, which needs no adjustment. 54 | const strokePoints: StrokePoint[] = [ 55 | { 56 | point: [pts[0][0], pts[0][1]], 57 | pressure: pts[0][2] >= 0 ? pts[0][2] : 0.25, 58 | vector: [1, 1], 59 | distance: 0, 60 | runningLength: 0, 61 | }, 62 | ] 63 | 64 | // A flag to see whether we've already reached out minimum length 65 | let hasReachedMinimumLength = false 66 | 67 | // We use the runningLength to keep track of the total distance 68 | let runningLength = 0 69 | 70 | // We're set this to the latest point, so we can use it to calculate 71 | // the distance and vector of the next point. 72 | let prev = strokePoints[0] 73 | 74 | const max = pts.length - 1 75 | 76 | // Iterate through all of the points, creating StrokePoints. 77 | for (let i = 1; i < pts.length; i++) { 78 | const point = 79 | isComplete && i === max 80 | ? // If we're at the last point, and `options.last` is true, 81 | // then add the actual input point. 82 | pts[i].slice(0, 2) 83 | : // Otherwise, using the t calculated from the streamline 84 | // option, interpolate a new point between the previous 85 | // point the current point. 86 | lrp(prev.point, pts[i], t) 87 | 88 | // If the new point is the same as the previous point, skip ahead. 89 | if (isEqual(prev.point, point)) continue 90 | 91 | // How far is the new point from the previous point? 92 | const distance = dist(point, prev.point) 93 | 94 | // Add this distance to the total "running length" of the line. 95 | runningLength += distance 96 | 97 | // At the start of the line, we wait until the new point is a 98 | // certain distance away from the original point, to avoid noise 99 | if (i < max && !hasReachedMinimumLength) { 100 | if (runningLength < size) continue 101 | hasReachedMinimumLength = true 102 | // TODO: Backfill the missing points so that tapering works correctly. 103 | } 104 | // Create a new strokepoint (it will be the new "previous" one). 105 | prev = { 106 | // The adjusted point 107 | point, 108 | // The input pressure (or .5 if not specified) 109 | pressure: pts[i][2] >= 0 ? pts[i][2] : 0.5, 110 | // The vector from the current point to the previous point 111 | vector: uni(sub(prev.point, point)), 112 | // The distance between the current point and the previous point 113 | distance, 114 | // The total distance so far 115 | runningLength, 116 | } 117 | 118 | // Push it to the strokePoints array. 119 | strokePoints.push(prev) 120 | } 121 | 122 | // Set the vector of the first point to be the same as the second point. 123 | strokePoints[0].vector = strokePoints[1]?.vector || [0, 0] 124 | 125 | return strokePoints 126 | } 127 | -------------------------------------------------------------------------------- /packages/perfect-freehand/src/getStrokeRadius.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compute a radius based on the pressure. 3 | * @param size 4 | * @param thinning 5 | * @param pressure 6 | * @param easing 7 | * @internal 8 | */ 9 | export function getStrokeRadius( 10 | size: number, 11 | thinning: number, 12 | pressure: number, 13 | easing: (t: number) => number = (t) => t 14 | ) { 15 | return size * easing(0.5 - thinning * (0.5 - pressure)) 16 | } 17 | -------------------------------------------------------------------------------- /packages/perfect-freehand/src/index.ts: -------------------------------------------------------------------------------- 1 | import { getStroke } from './getStroke' 2 | 3 | export default getStroke 4 | 5 | export * from './getStrokeOutlinePoints' 6 | export * from './getStrokePoints' 7 | export * from './getStroke' 8 | export * from './types' 9 | -------------------------------------------------------------------------------- /packages/perfect-freehand/src/test/getStroke.spec.ts: -------------------------------------------------------------------------------- 1 | import type { StrokeOptions } from '../types' 2 | import { getStroke } from '../getStroke' 3 | import inputs from './inputs.json' 4 | import { med } from '../vec' 5 | 6 | const { onePoint, twoPoints, twoEqualPoints, manyPoints, withDuplicates } = 7 | inputs 8 | 9 | function getRng(seed = ''): () => number { 10 | let x = 0 11 | let y = 0 12 | let z = 0 13 | let w = 0 14 | 15 | function next() { 16 | const t = x ^ (x << 11) 17 | ;(x = y), (y = z), (z = w) 18 | w ^= ((w >>> 19) ^ t ^ (t >>> 8)) >>> 0 19 | return w / 0x100000000 20 | } 21 | 22 | for (let k = 0; k < seed.length + 64; k++) { 23 | x ^= seed.charCodeAt(k) | 0 24 | next() 25 | } 26 | 27 | return next 28 | } 29 | 30 | const average = (a: number, b: number) => (a + b) / 2 31 | 32 | function getSvgPathFromStroke(points: number[][]): string { 33 | const len = points.length 34 | 35 | if (!len) { 36 | return '' 37 | } 38 | 39 | const first = points[0] 40 | let result = `M${first[0].toFixed(3)},${first[1].toFixed(3)}Q` 41 | 42 | for (let i = 0, max = len - 1; i < max; i++) { 43 | const a = points[i] 44 | const b = points[i + 1] 45 | result += `${a[0].toFixed(3)},${a[1].toFixed(3)} ${average( 46 | a[0], 47 | b[0] 48 | ).toFixed(3)},${average(a[1], b[1]).toFixed(3)} ` 49 | } 50 | 51 | result += 'Z' 52 | 53 | return result 54 | } 55 | 56 | describe('getStroke', () => { 57 | const rng = getRng('perfect') 58 | 59 | for (const [key, value] of Object.entries(inputs)) { 60 | it(`creates a stroke for "${key}" with default values.`, () => { 61 | const result = getStroke(value) 62 | 63 | expect(result).toMatchSnapshot('default_' + key) 64 | 65 | expect( 66 | result.find((t) => JSON.stringify(t).includes('null')) 67 | ).toBeUndefined() 68 | }) 69 | 70 | describe('when testing random combinations of options', () => { 71 | for (let i = 0; i < 500; i++) { 72 | const options: StrokeOptions = { 73 | size: rng() * 100, 74 | thinning: rng(), 75 | streamline: rng(), 76 | smoothing: rng(), 77 | simulatePressure: rng() > -0.5, 78 | last: rng() > 0.5, 79 | start: { 80 | cap: rng() > 0, 81 | taper: rng() > 0 ? rng() * 100 : 0, 82 | }, 83 | end: { 84 | cap: rng() > 0, 85 | taper: rng() > 0 ? rng() * 100 : 0, 86 | }, 87 | } 88 | 89 | const result = getStroke(value, options) 90 | 91 | const optionsString = JSON.stringify(options, null, 2) 92 | 93 | it(`creates a stroke for "${key}" with options: ${optionsString}`, () => { 94 | expect(JSON.stringify(result).includes('null')).toBeFalsy() 95 | }) 96 | 97 | it(`creates an SVG-pathable stroke for "${key}" with options: ${optionsString}`, () => { 98 | expect(getSvgPathFromStroke(result).includes('null')).toBeFalsy() 99 | }) 100 | } 101 | }) 102 | } 103 | 104 | it('gets stroke from a line with no points', () => { 105 | expect(getStroke([])).toMatchSnapshot('get-stroke-no-points') 106 | }) 107 | 108 | it('gets stroke from a line with a single point', () => { 109 | expect(getStroke(onePoint)).toMatchSnapshot('get-stroke-one-point') 110 | }) 111 | 112 | it('gets stroke from a line with two points', () => { 113 | expect(getStroke(twoPoints)).toMatchSnapshot('get-stroke-two-points') 114 | }) 115 | 116 | it('gets stroke from a line with two equal points', () => { 117 | expect(getStroke(twoEqualPoints)).toMatchSnapshot( 118 | 'get-stroke-two-equal-points' 119 | ) 120 | }) 121 | 122 | it('gets stroke from a line with a many points', () => { 123 | expect(getStroke(manyPoints)).toMatchSnapshot('get-stroke-many-points') 124 | }) 125 | 126 | it('gets stroke from a line with with duplicates', () => { 127 | expect(getStroke(withDuplicates)).toMatchSnapshot( 128 | 'get-stroke-with-duplicates' 129 | ) 130 | }) 131 | 132 | it('Caps points', () => { 133 | expect(getStroke(twoPoints).length > 4).toBeTruthy() 134 | }) 135 | 136 | it('Succeeds on tricky points', () => { 137 | expect(JSON.stringify(getStroke(manyPoints)).includes('null')).toBeFalsy() 138 | }) 139 | 140 | it('Solves a tricky stroke with only one point.', () => { 141 | const stroke = getStroke(onePoint, { 142 | size: 1, 143 | thinning: 0.6, 144 | smoothing: 0.5, 145 | streamline: 0.5, 146 | simulatePressure: true, 147 | last: false, 148 | }) 149 | 150 | expect(stroke).toMatchSnapshot() 151 | 152 | expect(Number.isNaN([0][0])).toBe(false) 153 | }) 154 | }) 155 | -------------------------------------------------------------------------------- /packages/perfect-freehand/src/test/getStrokeOutlinePoints.spec.ts: -------------------------------------------------------------------------------- 1 | import { getStrokePoints } from '../getStrokePoints' 2 | import { getStrokeOutlinePoints } from '../getStrokeOutlinePoints' 3 | import inputs from './inputs.json' 4 | 5 | const { onePoint, twoPoints, twoEqualPoints, manyPoints, withDuplicates } = 6 | inputs 7 | 8 | describe('getStrokeOutlinePoints', () => { 9 | for (const [key, value] of Object.entries(inputs)) { 10 | it(`runs ${key} without generating NaN values`, () => { 11 | expect( 12 | getStrokeOutlinePoints(getStrokePoints(value)).find((t) => 13 | JSON.stringify(t).includes('null') 14 | ) 15 | ).toBeUndefined() 16 | }) 17 | } 18 | 19 | it('gets stroke outline points with a single point', () => { 20 | expect(getStrokeOutlinePoints(getStrokePoints(onePoint))).toMatchSnapshot( 21 | 'get-stroke-outline-points-one-point' 22 | ) 23 | }) 24 | 25 | it('gets stroke outline points with two points', () => { 26 | expect(getStrokeOutlinePoints(getStrokePoints(twoPoints))).toMatchSnapshot( 27 | 'get-stroke-outline-points-two-points' 28 | ) 29 | }) 30 | 31 | it('gets stroke outline points with two equal points', () => { 32 | expect( 33 | getStrokeOutlinePoints(getStrokePoints(twoEqualPoints)).find((t) => 34 | JSON.stringify(t).includes('null') 35 | ) 36 | ).toBeUndefined() 37 | 38 | expect( 39 | getStrokeOutlinePoints(getStrokePoints(twoEqualPoints)) 40 | ).toMatchSnapshot('get-stroke-outline-points-two-equal-points') 41 | }) 42 | 43 | it('gets stroke outline points on a line with a many points', () => { 44 | expect(getStrokeOutlinePoints(getStrokePoints(manyPoints))).toMatchSnapshot( 45 | 'get-stroke-outline-points-many-points' 46 | ) 47 | }) 48 | 49 | it('gets stroke outline points with duplicates', () => { 50 | expect( 51 | getStrokeOutlinePoints(getStrokePoints(withDuplicates)) 52 | ).toMatchSnapshot('get-stroke-outline-points-duplicates') 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /packages/perfect-freehand/src/test/getStrokePoints.spec.ts: -------------------------------------------------------------------------------- 1 | import { getStrokePoints } from '../getStrokePoints' 2 | import inputs from './inputs.json' 3 | 4 | const { 5 | onePoint, 6 | twoPoints, 7 | twoEqualPoints, 8 | numberPairs, 9 | objectPairs, 10 | manyPoints, 11 | withDuplicates, 12 | } = inputs 13 | 14 | describe('getStrokePoints', () => { 15 | for (const [key, value] of Object.entries(inputs)) { 16 | it(`runs ${key} without generating NaN values`, () => { 17 | expect( 18 | getStrokePoints(value).find((t) => JSON.stringify(t).includes('null')) 19 | ).toBeUndefined() 20 | }) 21 | } 22 | 23 | it('get stroke points from a line with no points', () => { 24 | expect(getStrokePoints([])).toMatchSnapshot('get-stroke-points-no-points') 25 | }) 26 | 27 | it('get stroke points from a line with a single point', () => { 28 | expect(getStrokePoints(onePoint)).toMatchSnapshot( 29 | 'get-stroke-points-one-point' 30 | ) 31 | }) 32 | 33 | it('get stroke points from a line with two points', () => { 34 | expect(getStrokePoints(twoPoints)).toMatchSnapshot( 35 | 'get-stroke-points-two-point' 36 | ) 37 | }) 38 | 39 | it('get stroke points from a line with two equal points', () => { 40 | expect(getStrokePoints(twoEqualPoints)).toMatchSnapshot( 41 | 'get-stroke-points-two-equal-points' 42 | ) 43 | }) 44 | 45 | it('get stroke points from a line with a many points', () => { 46 | expect(getStrokePoints(manyPoints)).toMatchSnapshot( 47 | 'get-stroke-points-many-points' 48 | ) 49 | }) 50 | 51 | it('get stroke points from a line with duplicates', () => { 52 | expect(getStrokePoints(withDuplicates)).toMatchSnapshot( 53 | 'get-stroke-points-with-duplicates' 54 | ) 55 | }) 56 | 57 | it('get stroke points from a array input points', () => { 58 | expect(getStrokePoints(numberPairs)).toMatchSnapshot( 59 | 'get-stroke-points-array-pairs' 60 | ) 61 | }) 62 | 63 | it('get stroke points from a object input points', () => { 64 | expect(getStrokePoints(objectPairs)).toMatchSnapshot( 65 | 'get-stroke-points-object-pairs' 66 | ) 67 | }) 68 | 69 | it('computes same result for both input types', () => { 70 | expect(JSON.stringify(getStrokePoints(objectPairs))).toStrictEqual( 71 | JSON.stringify(getStrokePoints(objectPairs)) 72 | ) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /packages/perfect-freehand/src/test/getStrokeRadius.spec.ts: -------------------------------------------------------------------------------- 1 | import { getStrokeRadius } from '../getStrokeRadius' 2 | 3 | describe('getStrokeRadius', () => { 4 | /* 5 | A shape's stroke radius is determined by four parameters: size, thinning, easing, and pressure. At 0 thinning, the stroke radius will always be equal to half of size. If the thinning parameter is positive, then the stroke radius will increase as the pressure parameter increases. If the pressure parameter is negative, then the stroke radius will decrease as the pressure parameter increases. This can be further complicated by the easing function, which may transform a given pressure value into a new pressure. 6 | */ 7 | 8 | describe('when thinning is zero', () => { 9 | it('uses half the size', () => { 10 | expect(getStrokeRadius(100, 0, 0)).toBe(50) 11 | expect(getStrokeRadius(100, 0, 0.25)).toBe(50) 12 | expect(getStrokeRadius(100, 0, 0.5)).toBe(50) 13 | expect(getStrokeRadius(100, 0, 0.75)).toBe(50) 14 | expect(getStrokeRadius(100, 0, 1)).toBe(50) 15 | }) 16 | }) 17 | 18 | describe('when thinning is positive', () => { 19 | it('scales between 25% and 75% at .5 thinning', () => { 20 | expect(getStrokeRadius(100, 0.5, 0)).toBe(25) 21 | expect(getStrokeRadius(100, 0.5, 0.25)).toBe(37.5) 22 | expect(getStrokeRadius(100, 0.5, 0.5)).toBe(50) 23 | expect(getStrokeRadius(100, 0.5, 0.75)).toBe(62.5) 24 | expect(getStrokeRadius(100, 0.5, 1)).toBe(75) 25 | }) 26 | 27 | it('scales between 0% and 100% at 1 thinning', () => { 28 | expect(getStrokeRadius(100, 1, 0)).toBe(0) 29 | expect(getStrokeRadius(100, 1, 0.25)).toBe(25) 30 | expect(getStrokeRadius(100, 1, 0.5)).toBe(50) 31 | expect(getStrokeRadius(100, 1, 0.75)).toBe(75) 32 | expect(getStrokeRadius(100, 1, 1)).toBe(100) 33 | }) 34 | }) 35 | 36 | describe('when thinning is negative', () => { 37 | it('scales between 75% and 25% at -.5 thinning', () => { 38 | expect(getStrokeRadius(100, -0.5, 0)).toBe(75) 39 | expect(getStrokeRadius(100, -0.5, 0.25)).toBe(62.5) 40 | expect(getStrokeRadius(100, -0.5, 0.5)).toBe(50) 41 | expect(getStrokeRadius(100, -0.5, 0.75)).toBe(37.5) 42 | expect(getStrokeRadius(100, -0.5, 1)).toBe(25) 43 | }) 44 | 45 | it('scales between 100% and 0% at -1 thinning', () => { 46 | expect(getStrokeRadius(100, -1, 0)).toBe(100) 47 | expect(getStrokeRadius(100, -1, 0.25)).toBe(75) 48 | expect(getStrokeRadius(100, -1, 0.5)).toBe(50) 49 | expect(getStrokeRadius(100, -1, 0.75)).toBe(25) 50 | expect(getStrokeRadius(100, -1, 1)).toBe(0) 51 | }) 52 | }) 53 | 54 | describe('when easing is exponential', () => { 55 | it('scales between 0% and 100% at 1 thinning', () => { 56 | expect(getStrokeRadius(100, 1, 0, (t) => t * t)).toBe(0) 57 | expect(getStrokeRadius(100, 1, 0.25, (t) => t * t)).toBe(6.25) 58 | expect(getStrokeRadius(100, 1, 0.5, (t) => t * t)).toBe(25) 59 | expect(getStrokeRadius(100, 1, 0.75, (t) => t * t)).toBe(56.25) 60 | expect(getStrokeRadius(100, 1, 1, (t) => t * t)).toBe(100) 61 | }) 62 | 63 | it('scales between 100% and 0% at -1 thinning', () => { 64 | expect(getStrokeRadius(100, -1, 0, (t) => t * t)).toBe(100) 65 | expect(getStrokeRadius(100, -1, 0.25, (t) => t * t)).toBe(56.25) 66 | expect(getStrokeRadius(100, -1, 0.5, (t) => t * t)).toBe(25) 67 | expect(getStrokeRadius(100, -1, 0.75, (t) => t * t)).toBe(6.25) 68 | expect(getStrokeRadius(100, -1, 1, (t) => t * t)).toBe(0) 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /packages/perfect-freehand/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The options object for `getStroke` or `getStrokePoints`. 3 | * @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases. 4 | * @param options (optional) An object with options. 5 | * @param options.size The base size (diameter) of the stroke. 6 | * @param options.thinning The effect of pressure on the stroke's size. 7 | * @param options.smoothing How much to soften the stroke's edges. 8 | * @param options.easing An easing function to apply to each point's pressure. 9 | * @param options.simulatePressure Whether to simulate pressure based on velocity. 10 | * @param options.start Cap, taper and easing for the start of the line. 11 | * @param options.end Cap, taper and easing for the end of the line. 12 | * @param options.last Whether to handle the points as a completed stroke. 13 | */ 14 | export interface StrokeOptions { 15 | size?: number 16 | thinning?: number 17 | smoothing?: number 18 | streamline?: number 19 | easing?: (pressure: number) => number 20 | simulatePressure?: boolean 21 | start?: { 22 | cap?: boolean 23 | taper?: number | boolean 24 | easing?: (distance: number) => number 25 | } 26 | end?: { 27 | cap?: boolean 28 | taper?: number | boolean 29 | easing?: (distance: number) => number 30 | } 31 | // Whether to handle the points as a completed stroke. 32 | last?: boolean 33 | } 34 | 35 | /** 36 | * The points returned by `getStrokePoints`, and the input for `getStrokeOutlinePoints`. 37 | */ 38 | export interface StrokePoint { 39 | point: number[] 40 | pressure: number 41 | distance: number 42 | vector: number[] 43 | runningLength: number 44 | } 45 | -------------------------------------------------------------------------------- /packages/perfect-freehand/src/vec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Negate a vector. 3 | * @param A 4 | * @internal 5 | */ 6 | export function neg(A: number[]) { 7 | return [-A[0], -A[1]] 8 | } 9 | 10 | /** 11 | * Add vectors. 12 | * @param A 13 | * @param B 14 | * @internal 15 | */ 16 | export function add(A: number[], B: number[]) { 17 | return [A[0] + B[0], A[1] + B[1]] 18 | } 19 | 20 | /** 21 | * Subtract vectors. 22 | * @param A 23 | * @param B 24 | * @internal 25 | */ 26 | export function sub(A: number[], B: number[]) { 27 | return [A[0] - B[0], A[1] - B[1]] 28 | } 29 | 30 | /** 31 | * Vector multiplication by scalar 32 | * @param A 33 | * @param n 34 | * @internal 35 | */ 36 | export function mul(A: number[], n: number) { 37 | return [A[0] * n, A[1] * n] 38 | } 39 | 40 | /** 41 | * Vector division by scalar. 42 | * @param A 43 | * @param n 44 | * @internal 45 | */ 46 | export function div(A: number[], n: number) { 47 | return [A[0] / n, A[1] / n] 48 | } 49 | 50 | /** 51 | * Perpendicular rotation of a vector A 52 | * @param A 53 | * @internal 54 | */ 55 | export function per(A: number[]) { 56 | return [A[1], -A[0]] 57 | } 58 | 59 | /** 60 | * Dot product 61 | * @param A 62 | * @param B 63 | * @internal 64 | */ 65 | export function dpr(A: number[], B: number[]) { 66 | return A[0] * B[0] + A[1] * B[1] 67 | } 68 | 69 | /** 70 | * Get whether two vectors are equal. 71 | * @param A 72 | * @param B 73 | * @internal 74 | */ 75 | export function isEqual(A: number[], B: number[]) { 76 | return A[0] === B[0] && A[1] === B[1] 77 | } 78 | 79 | /** 80 | * Length of the vector 81 | * @param A 82 | * @internal 83 | */ 84 | export function len(A: number[]) { 85 | return Math.hypot(A[0], A[1]) 86 | } 87 | 88 | /** 89 | * Length of the vector squared 90 | * @param A 91 | * @internal 92 | */ 93 | export function len2(A: number[]) { 94 | return A[0] * A[0] + A[1] * A[1] 95 | } 96 | 97 | /** 98 | * Dist length from A to B squared. 99 | * @param A 100 | * @param B 101 | * @internal 102 | */ 103 | export function dist2(A: number[], B: number[]) { 104 | return len2(sub(A, B)) 105 | } 106 | 107 | /** 108 | * Get normalized / unit vector. 109 | * @param A 110 | * @internal 111 | */ 112 | export function uni(A: number[]) { 113 | return div(A, len(A)) 114 | } 115 | 116 | /** 117 | * Dist length from A to B 118 | * @param A 119 | * @param B 120 | * @internal 121 | */ 122 | export function dist(A: number[], B: number[]) { 123 | return Math.hypot(A[1] - B[1], A[0] - B[0]) 124 | } 125 | 126 | /** 127 | * Mean between two vectors or mid vector between two vectors 128 | * @param A 129 | * @param B 130 | * @internal 131 | */ 132 | export function med(A: number[], B: number[]) { 133 | return mul(add(A, B), 0.5) 134 | } 135 | 136 | /** 137 | * Rotate a vector around another vector by r (radians) 138 | * @param A vector 139 | * @param C center 140 | * @param r rotation in radians 141 | * @internal 142 | */ 143 | export function rotAround(A: number[], C: number[], r: number) { 144 | const s = Math.sin(r) 145 | const c = Math.cos(r) 146 | 147 | const px = A[0] - C[0] 148 | const py = A[1] - C[1] 149 | 150 | const nx = px * c - py * s 151 | const ny = px * s + py * c 152 | 153 | return [nx + C[0], ny + C[1]] 154 | } 155 | 156 | /** 157 | * Interpolate vector A to B with a scalar t 158 | * @param A 159 | * @param B 160 | * @param t scalar 161 | * @internal 162 | */ 163 | export function lrp(A: number[], B: number[], t: number) { 164 | return add(A, mul(sub(B, A), t)) 165 | } 166 | 167 | /** 168 | * Project a point A in the direction B by a scalar c 169 | * @param A 170 | * @param B 171 | * @param c 172 | * @internal 173 | */ 174 | export function prj(A: number[], B: number[], c: number) { 175 | return add(A, mul(B, c)) 176 | } 177 | -------------------------------------------------------------------------------- /packages/perfect-freehand/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "**/*.test.tsx", 6 | "**/*.test.ts", 7 | "**/*.spec.tsx", 8 | "**/*.spec.ts", 9 | "src/test", 10 | "dist" 11 | ], 12 | "compilerOptions": { 13 | "composite": false, 14 | "incremental": false, 15 | "declarationMap": true, 16 | "sourceMap": true, 17 | "emitDeclarationOnly": true, 18 | "stripInternal": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/perfect-freehand/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"], 5 | "compilerOptions": { 6 | "rootDir": "src", 7 | "outDir": "./dist/types", 8 | "baseUrl": "src" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /perfect-freehand-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/perfect-freehand/9b369e4d74c9f45748e3dfd7507a7c3f97021acb/perfect-freehand-card.png -------------------------------------------------------------------------------- /process.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/perfect-freehand/9b369e4d74c9f45748e3dfd7507a7c3f97021acb/process.gif -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' 2 | import 'fake-indexeddb/auto' 3 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "experimentalDecorators": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "importsNotUsedAsValues": "error", 9 | "incremental": true, 10 | "resolveJsonModule": true, 11 | "importHelpers": true, 12 | "moduleResolution": "node", 13 | "noEmit": false, 14 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 15 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 16 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 17 | "noUnusedLocals": false /* Report errors on unused locals. */, 18 | "noUnusedParameters": false /* Report errors on unused parameters. */, 19 | "skipLibCheck": true, 20 | "sourceMap": true, 21 | "strict": false, 22 | "strictFunctionTypes": true /* Enable strict checking of function types. */, 23 | "strictNullChecks": true /* Enable strict null checks. */, 24 | "target": "es5", 25 | "typeRoots": ["node_modules/@types", "node_modules/jest"], 26 | "types": ["node", "jest"], 27 | "jsx": "preserve", 28 | "lib": ["dom", "esnext"], 29 | "module": "esnext" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "perfect-freehand": ["./packages/perfect-freehand/dist"], 8 | "+*": ["./packages/perfect-freehand/src/*"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tutorial/script.md: -------------------------------------------------------------------------------- 1 | # Perfect Freehand Tutorial (Script) 2 | 3 | `WIP, for a Tutorial Video` 4 | 5 | [Starter File](https://stackblitz.com/edit/js-vtm7xh) 6 | [Easing Functions](https://gist.github.com/steveruizok/c331cd1fb30563aec51f7223e25d0afd) 7 | 8 | Hey, this is Steve Ruiz, author of the perfect-freehand library for JavaScript. 9 | 10 | Perfect-freehand makes it easy to create freehand lines like this one. The library can use real pressure to adjust the width of the line, or it can simulate pressure, too. And there are plenty of ways to customize how a line looks and feels. 11 | 12 | The library is free to use, MIT licensed, and you can find the full source code and docs on Github. Check the link in the video description. 13 | 14 | In this tutorial, I'll show you how to use perfect-freehand, what its different options are, and how you might use in a project. 15 | 16 | Let's get started! 17 | 18 | ## Setup 19 | 20 | To begin, let's take a look at our starter project. 21 | 22 | If you'd like to follow along, you can find a link to the project in the video description. 23 | 24 | In our HTML, we have an SVG element... with some inline styles that make it take up the whole window. 25 | 26 | ```html 27 | 31 | ``` 32 | 33 | We also have a JSON file that contains an array of points—all x and y positions—the same kind we would record from a user drawing with their mouse or trackpad. 34 | 35 | ```json 36 | { 37 | "points": [ 38 | [47.671875, 179.84375], 39 | [48.1171875, 179.8515625] 40 | //... 41 | ] 42 | } 43 | ``` 44 | 45 | In our JavaScript file, we're importing those points. 46 | 47 | ```js 48 | import points from './sample.json' 49 | ``` 50 | 51 | And using them to create a string of SVG path data. 52 | 53 | ```js 54 | const pathData = ['M', points[0], 'L', points.slice(1)].join(' ') 55 | ``` 56 | 57 | Next, we're creating an SVG path element... 58 | 59 | ```js 60 | const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') 61 | ``` 62 | 63 | ...and assigning it this path data—along with some other basic attributes: fill, stroke, stroke width. 64 | 65 | ```js 66 | path.setAttribute('d', pathData) 67 | path.setAttribute('fill', 'none') 68 | path.setAttribute('stroke', 'black') 69 | path.setAttribute('stroke-width', '2') 70 | ``` 71 | 72 | Finally, we're appending the path element to the page's SVG element. 73 | 74 | ```js 75 | document.getElementById('svg').appendChild(path) 76 | ``` 77 | 78 | And in the browser, we see the path: a line that connects all of our input points. 79 | 80 | ```js 81 | import points from './sample.json' 82 | 83 | const pathData = ['M', points[0], 'L', points.slice(1)].join(' ') 84 | 85 | const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') 86 | 87 | path.setAttribute('d', pathData) 88 | path.setAttribute('fill', 'none') 89 | path.setAttribute('stroke', 'black') 90 | path.setAttribute('stroke-width', '4') 91 | 92 | document.getElementById('svg').appendChild(path) 93 | ``` 94 | 95 | Not bad! 96 | 97 | Well, maybe a little bad. 98 | 99 | ### Standard Improvements 100 | 101 | If we wanted to improve this, we would normally do something like this. First, we'd simplify the points. 102 | 103 | ```js 104 | import { simplify } from './helpers' 105 | 106 | points = simplify(points) 107 | ``` 108 | 109 | Next, we could apply a low pass filter to the points. 110 | 111 | ```js 112 | import { simplify } from './helpers' 113 | 114 | points = lowPass(simplify(points)) 115 | ``` 116 | 117 | And finally, instead of creating path data that connects each point by a line, we'd create path data that connects each point by a curve. 118 | 119 | ```js 120 | const pathData = points 121 | .slice(1) 122 | .reduce( 123 | (acc, point, i, arr) => { 124 | const next = arr[i + 1] 125 | if (!next) return acc 126 | acc.push(point, [(point[0] + next[0]) / 2, (point[1] + next[1]) / 2]) 127 | return acc 128 | }, 129 | ['M', points[0], 'Q'] 130 | ) 131 | .join(' ') 132 | ``` 133 | 134 | Now that's much better than what we had before, but it's still not quite perfect. For one, this kind of "simplify-and-curve" approach can only be applied _after_ a line is completed. 135 | 136 | Let's add perfect-freehand and see what it can do. 137 | 138 | ## Adding Perfect-Freehand 139 | 140 | We'll start by installing the `perfect-freehand` library as a dependency. 141 | 142 | In a regular project, you'd do this with `npm install perfect-freehand` or `yarn add perfect-freehand`. 143 | 144 | Next, we'll import get the library's default export, `getStroke`. 145 | 146 | And let's create a stroke by passing our `currentPoints` array into the `getStroke` function. 147 | 148 | ```js 149 | const stroke = getStroke(sample.points) 150 | ``` 151 | 152 | And let's also base our path data off of this stroke instead. 153 | 154 | ```js 155 | const stroke = getStroke(currentPoints) 156 | 157 | const [first, ...rest] = stroke 158 | ``` 159 | 160 | Well look at that: our line has changed from a line to a polygon. 161 | 162 | The `getStroke` function takes in an array of points, usually points that describe a line, and returns a new array of points. These new points describe a polygon that surrounds the original line. 163 | 164 | > Tip: This is exactly how the `stroke` attribute works in SVG, but there's one big difference: our polygon can have a variable width, adjusting its size based on pressure. 165 | 166 | Now that we have a polygon, we can make a few adjustments to our code. 167 | 168 | First, let's close the path by adding a "Z" to the path data. 169 | 170 | ```js 171 | const pathData = ['M', first, 'L', rest, 'Z'].join(' ') 172 | ``` 173 | 174 | And then let's get rid of our stroke, and give the line a fill instead. 175 | 176 | ```js 177 | path.setAttribute('d', pathData) 178 | path.setAttribute('fill', 'black') 179 | ``` 180 | 181 | ### Size 182 | 183 | Now that we have the stroke turned off, our line is looking a little thin. To increase the line's thickness, we can pass an options object as the second parameter to `getStroke`... 184 | 185 | And here we can define our line's thickness under the property `size`. 186 | 187 | ```js 188 | const stroke = getStroke(sample.points, { 189 | size: 16, 190 | }) 191 | ``` 192 | 193 | That looks better! 194 | 195 | ### Thinning 196 | 197 | You might notice that the polygon isn't even: some parts are thinner and some parts are thicker. This is the effect of pressure. 198 | 199 | More pressure will cause the line to become thicker and less pressure will cause the line to become thinner. 200 | 201 | We can adjust the rate of this thinning in the stroke's options. 202 | 203 | ```js 204 | const stroke = getStroke(sample.points, { 205 | size: 16, 206 | thinning: 0.5, 207 | }) 208 | ``` 209 | 210 | The `thinning` option takes a number between -1 and 1. At 0, pressure will have no effect on the width of the line. When positive, pressure will have a positive effect on the width of the line; and when negative, pressure will have a negative effect on the width of the line. 211 | 212 | ```js 213 | const stroke = getStroke(sample.points, { 214 | size: 16, 215 | thinning: 0.9, 216 | }) 217 | ``` 218 | 219 | ```js 220 | const stroke = getStroke(sample.points, { 221 | size: 16, 222 | thinning: -0.9, 223 | }) 224 | ``` 225 | 226 | ### Easing 227 | 228 | For even finer control over the effect of thinning, we can pass an easing function that will adjust the pressure along a curve. For a list of easing functions, check the links in the video description. 229 | 230 | ```js 231 | const stroke = getStroke(sample.points, { 232 | size: 16, 233 | thinning: -0.9, 234 | easing: (t) => 1 - Math.cos((t * Math.PI) / 2), 235 | }) 236 | ``` 237 | 238 | ### Streamline 239 | 240 | Often the input points recorded for a line are 'noisy', or full of irregularities. To fix this, the perfect-freehand algorithm applies a "low pass" filter that moves the points closer to a perfect curve. We can control the strength of this filter through the `streamline` option. 241 | 242 | At zero, the stroke will use the actual input points. As the number goes up, the line will become more evened out. 243 | 244 | ```js 245 | const stroke = getStroke(sample.points, { 246 | size: 16, 247 | thinning: 0.5, 248 | streamline: 0, 249 | }) 250 | ``` 251 | 252 | ```js 253 | const stroke = getStroke(sample.points, { 254 | size: 16, 255 | thinning: 0.5, 256 | streamline: 1, 257 | }) 258 | ``` 259 | 260 | ### Smoothing 261 | 262 | Likewise, we can also control the density of points along the edges of our polygon using the `smoothing` option. At zero, the polygon will contain many points, and may appear jagged or bumpy. At higher values, the polygon will contain fewer points and lose definition. 263 | 264 | ```js 265 | const stroke = getStroke(sample.points, { 266 | size: 16, 267 | thinning: 0.5, 268 | streamline: 0.5, 269 | smoothing: 0, 270 | }) 271 | ``` 272 | 273 | ```js 274 | const stroke = getStroke(sample.points, { 275 | size: 16, 276 | thinning: 0.5, 277 | streamline: 0.5, 278 | smoothing: 1, 279 | }) 280 | ``` 281 | 282 | In our demo, a smoother line looks more geometric. But there are lots of reasons why you might want to keep smoothing as high as possible, especially if you're storing points in some sort of state. To fix the low-poly look, we can update our SVG path. 283 | 284 | Curves can be a little verbose, so let's bring in a snippet to help us out. 285 | 286 | ```js 287 | const pathData = stroke 288 | .reduce( 289 | (acc, [x0, y0], i, arr) => { 290 | if (i === arr.length - 1) return acc 291 | const [x1, y1] = arr[i + 1] 292 | return acc.concat(` ${x0},${y0} ${(x0 + x1) / 2},${(y0 + y1) / 2}`) 293 | }, 294 | ['M ', `${stroke[0][0]},${stroke[0][1]}`, ' Q'] 295 | ) 296 | .concat('Z') 297 | .join('') 298 | ``` 299 | 300 | Wow! That looks much better. 301 | 302 | And the higher smoothing actually makes it look better, too. 303 | 304 | 305 | 306 | ## Setup 307 | 308 | For this tutorial, we're going to start from a little drawing app where a user can draw lines on the page. If you want to follow along, you can find a link to this project in the video description. 309 | 310 | Before we begin, let's take a quick tour of the project. 311 | 312 | In the project's HTML, we just have an `svg` element. 313 | 314 | ...and in our CSS we're styling this element so that it takes up the whole window. 315 | 316 | In our javascript file... 317 | 318 | And we have two variables, `currentPath` and `currentPoints`. 319 | 320 | We're getting a reference to the `svg` element... 321 | 322 | ...and we're setting three event listeners: one for when the user starts pointing on the svg element, one for when the user moves their pointer over the svg element, and one for when the user stops pointing. 323 | 324 | When the user starts pointing, we get the event's point and save that to the `currentPoints` array. Next, we create a new path element and set its properties, using our point for its path data. Then we append the path element to the svg element, and finally we capture the pointer id. 325 | 326 | When the user moves their pointer over the SVG, we first check if we've already captured the event's pointer id. If that's true, then we again get the event's `point` and push it to our `currentPoints` array, and then use the `currentPoints` array to set the `currentPath`'s path data. 327 | 328 | Finally, when we the user stops pointing, we release the pointer capture. 329 | 330 | And that's all it takes to make a drawing app! 331 | --------------------------------------------------------------------------------