├── .eslintrc.cjs ├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── demo ├── color-picker.js ├── demo.js ├── img │ ├── bg.jpg │ └── muchotravka.png ├── index.html ├── logger.js ├── recording.js └── styles.css ├── dist ├── cjs │ ├── fill.js │ └── index.js └── esm │ ├── fill.js │ └── index.js ├── package-lock.json ├── package.json ├── rollup.config.mjs └── src ├── constants.js ├── events.js ├── fill ├── flood.js ├── index.js └── worker.js ├── index.js ├── mouse.js ├── pixels.js └── pointer-events.js /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignorePatterns: ['dist'], 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | }, 7 | extends: 'airbnb-base', 8 | parserOptions: { 9 | ecmaVersion: 'latest', 10 | sourceType: 'module', 11 | }, 12 | rules: { 13 | 'import/extensions': ['off'], 14 | 'import/prefer-default-export': ['off'], 15 | 'no-plusplus': ['off'], 16 | 'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], 17 | }, 18 | overrides: [ 19 | { 20 | files: ['src/*.js'], 21 | excludedFiles: 'demo/*.js', 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jakubfiala 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm 3 | uglifyjs 4 | dev-demo 5 | .DS_STORE 6 | .idea 7 | test.html 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /demo 2 | /src 3 | /.github 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "jsxBracketSameLine": false, 5 | "jsxSingleQuote": false, 6 | "semi": true, 7 | "singleQuote": true, 8 | "tabWidth": 2, 9 | "trailingComma": "all" 10 | } 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'node' 4 | install: npm install 5 | script: npm run build 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v4.0.0 2 | 3 | ## Breaking API changes 4 | 5 | - Atrament now only supports evergreen browsers (Firefox, Chrome and Chromium-based browsers) 6 | and Safari 15 or above. If your application must support older browsers, please use version 3. 7 | - the `Atrament` class is now a default export 8 | - the `mode` setter now only accepts symbols exported from the library (e.g. `MODE_DRAW`). If anything else is passed, an error is thrown. 9 | - the stroke object now stores an array of `segments`, instead of `points`. Each segment then contains a `point`. This should clarify the data model and help avoid repetitive code such as `stroke.points.forEach((point) => point.point)`. 10 | - because of the above, the `pointdrawn` event has been renamed to `segmentdrawn` 11 | - the `toImage()` method has been removed - please use `canvas.toDataURL()` to achieve the same effect 12 | - the `isDirty()` method has been replaced by the `dirty` getter, making it more consistent with the rest of the API 13 | - the `Atrament` class now uses private fields and methods. A number of undocumented fields+methods are now not accessible from the outside. 14 | 15 | ## Drawing experience changes 16 | 17 | - if `adaptiveStroke` is enabled (default), Atrament now responds to the pointer's pressure by changing the stroke thickness. This is useful when using pressure-sensitive input methods such as the Apple Pencil. 18 | - stroke segments are now drawn as individual paths. This means strokes tend to start thin, then thicken and get thinner again towards the end, which is closer to the behaviour of an ink pen. 19 | - strokes are always at least as thick as the `weight` setting in pixels, leading to a more consistent drawing feel especially when drawing finer details. 20 | 21 | ## Other changes 22 | 23 | - Atrament is now built with Rollup and the code is not transpiled (other than separate ES Module and CommonJS bundles). 24 | - Atrament now uses [Pointer Events](https://w3c.github.io/pointerevents/) instead of the specific mouse+touch event handlers. This allows us to increase drawing precision, solve a number of bugs and reduce code complexity. 25 | - Fill mode is now implemented with a Web Worker bundled together with the library. This stops the fill algorithm from blocking the main thread. 26 | - Error messages are now prefixed with `atrament: ` 27 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at studio@fiala.space. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 2024 Jakub Fiala 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Atrament 2 | 3 | **A small JS library for beautiful drawing and handwriting on the HTML Canvas** 4 | 5 | --- 6 | 7 | ![](demo/img/muchotravka.png) 8 | 9 | Atrament is a library for drawing and handwriting on the HTML canvas. 10 | Its goal is for drawing to feel natural and comfortable, and the result to be smooth and pleasing. 11 | Atrament does not store the stroke paths itself - instead, it draws directly onto the canvas bitmap, 12 | just like an ink pen onto a piece of paper ("atrament" means ink in Slovak and Polish). 13 | This makes it suitable for certain applications, and not quite ideal for others - see Alternatives. 14 | 15 | ⚠️ **Note:** From version 4, Atrament supports evergeen browsers (Firefox, Chrome and Chromium-based browsers) 16 | and Safari 15 or above. If your application must support older browsers, please use version 3. You can view the v3 documentation [here](https://github.com/jakubfiala/atrament/blob/ded0a8289c7b1ff7a79dbad36893986da09f37fc/README.md). 17 | 18 | **Features:** 19 | 20 | - Draw/Fill/Erase modes 21 | - Adjustable adaptive smoothing 22 | - Events tracking the drawing - this allows the app to "replay" or reconstruct the drawing, e.g. for undo functionality 23 | - Adjustable line thickness and colour 24 | 25 | [Here's a basic demo.](https://fiala.space/atrament/demo/) 26 | 27 | Enjoy! 28 | 29 | - [Atrament](#atrament) 30 | - [Installation](#installation) 31 | - [Usage](#usage) 32 | - [Options \& config](#options--config) 33 | - [Fill mode](#fill-mode) 34 | - [Data model](#data-model) 35 | - [High DPI screens](#high-dpi-screens) 36 | - [Events](#events) 37 | - [Dirty/clean](#dirtyclean) 38 | - [Stroke start/end](#stroke-startend) 39 | - [Fill start/end](#fill-startend) 40 | - [Stroke recording](#stroke-recording) 41 | - [Programmatic drawing](#programmatic-drawing) 42 | - [Implementing Undo/Redo](#implementing-undoredo) 43 | - [Development](#development) 44 | - [Running the demo locally](#running-the-demo-locally) 45 | 46 | ## Installation 47 | 48 | If you're using a tool like `rollup` or `webpack` to bundle your code, you can install it using npm. 49 | 50 | - install atrament as a dependency using `npm install --save atrament`. 51 | - You can access the Atrament class using `import { Atrament } from 'atrament';` 52 | 53 | ## Usage 54 | 55 | - create a `` tag, e.g.: 56 | 57 | ```html 58 | 59 | ``` 60 | 61 | - in your JavaScript, create an `Atrament` instance passing it your canvas object: 62 | 63 | ```js 64 | import Atrament from 'atrament'; 65 | 66 | const canvas = document.querySelector('#sketchpad'); 67 | const sketchpad = new Atrament(canvas); 68 | ``` 69 | 70 | - you can also pass the width, height, resolution and default colour to the constructor (see [note on high DPI screens](#high-dpi-screens)) 71 | 72 | ```js 73 | const sketchpad = new Atrament(canvas, { 74 | width: 500, 75 | height: 500, 76 | resolution: 2, // the intrinsic canvas size will be 2*500 x 2*500 77 | color: 'orange', 78 | }); 79 | ``` 80 | 81 | - that's it, happy drawing! 82 | 83 | ## Options & config 84 | 85 | - clear the canvas: 86 | 87 | ```js 88 | sketchpad.clear(); 89 | ``` 90 | 91 | - change the line thickness: 92 | 93 | ```js 94 | sketchpad.weight = 20; //in pixels 95 | ``` 96 | 97 | - change the color: 98 | 99 | ```js 100 | sketchpad.color = '#ff485e'; //just like CSS 101 | ``` 102 | 103 | - toggle between modes (**Note:** for Fill mode, you must also set the `fillWorker` config option in the constructor. See [next section](#fill-mode)) 104 | 105 | ```js 106 | import { MODE_DRAW, MODE_ERASE, MODE_FILL, MODE_DISABLED } from 'atrament'; 107 | 108 | sketchpad.mode = MODE_DRAW; // default 109 | sketchpad.mode = MODE_ERASE; // eraser tool 110 | sketchpad.mode = MODE_FILL; // click to fill area (see next section for more info) 111 | sketchpad.mode = MODE_DISABLED; // no modification to the canvas (will still fire stroke events) 112 | ``` 113 | 114 | - tweak smoothing - higher values make the drawings look smoother, lower values make drawing feel a bit more responsive. Set to `0.85` by default. 115 | 116 | ```js 117 | sketchpad.smoothing = 1.3; 118 | ``` 119 | 120 | - toggle adaptive stroke, i.e. line width changing based on drawing speed and stroke progress. This simulates the variation in ink discharge of a physical pen. `true` by default. 121 | 122 | ```js 123 | sketchpad.adaptiveStroke = false; 124 | ``` 125 | 126 | - record stroke data (enables the `strokerecorded` event). `false` by default. 127 | 128 | ```js 129 | sketchpad.recordStrokes = true; 130 | ``` 131 | 132 | ## Fill mode 133 | 134 | From version 5.0.0, Atrament does not bundle the fill Worker within the main bundle. This is so applications that don't require fill mode 135 | benefit from an approx. 60% smaller import size. The fill module can be imported separately and injected into Atrament via the constructor: 136 | 137 | ```js 138 | import Atrament from 'atrament'; 139 | import fill from 'atrament/fill'; 140 | 141 | const sketchpad = new Atrament({ fill }); 142 | ``` 143 | 144 | ## Data model 145 | 146 | - Atrament models its output as a set of independent _strokes_. Only one stroke can be drawn at a time. 147 | - Each stroke consists of a list of _segments_, which correspond to all the pointer positions recorded during drawing. 148 | - Each segment consists of a _point_ which contains `x` and `y` coordinates, and a `time` which is the number of milliseconds since the stroke began, until the segment was drawn. 149 | - Each stroke also contains information about the drawing settings at the time of drawing (see Events > Stroke recording). 150 | 151 | 152 | ## High DPI screens 153 | 154 | To make drawings look sharp on high DPI screens, Atrament scales its drawing context by `window.devicePixelRatio` since v4.0.0. This means when you set a custom `width` or `height`, you should also multiply the CSS pixel values by `devicePixelRatio`. The values accepted by `draw()` and included in stroke events are always in CSS pixels. 155 | 156 | As of Atrament v4.5.0, the `resolution` config option allows overriding the DPR scaling - this is useful if, for instance, you'd like to export the image at a higher resolution than displayed. 157 | 158 | ## Events 159 | 160 | ### Dirty/clean 161 | 162 | These events fire when the canvas is first drawn on, and when it's cleared. 163 | The state is stored in the `dirty` property. 164 | 165 | ```js 166 | sketchpad.addEventListener('dirty', () => console.info(sketchpad.dirty)); 167 | sketchpad.addEventListener('clean', () => console.info(sketchpad.dirty)); 168 | ``` 169 | 170 | ### Stroke start/end 171 | 172 | These events don't provide any data - they just inform that a stroke has started/finished. 173 | 174 | ```js 175 | sketchpad.addEventListener('strokestart', () => console.info('strokestart')); 176 | sketchpad.addEventListener('strokeend', () => console.info('strokeend')); 177 | ``` 178 | 179 | ### Fill start/end 180 | 181 | These only fire in fill mode. The `fillstart` event also contains `x` and `y` properties 182 | denoting the starting point of the fill operation (where the user has clicked). 183 | 184 | ```js 185 | sketchpad.addEventListener('fillstart', ({ x, y }) => 186 | console.info(`fillstart ${x} ${y}`), 187 | ); 188 | sketchpad.addEventListener('fillend', () => console.info('fillend')); 189 | ``` 190 | 191 | ### Stroke recording 192 | 193 | The following events only fire if the `recordStrokes` property is set to true. 194 | 195 | `strokerecorded` fires at the same time as `strokeend` and contains data necessary for reconstructing the stroke. 196 | `segmentdrawn` fires during stroke recording every time the `draw` method is called. It contains the same data as `strokerecorded`. 197 | 198 | ```js 199 | sketchpad.addEventListener('strokerecorded', ({ stroke }) => 200 | console.info(stroke), 201 | ); 202 | /* 203 | { 204 | segments: [ 205 | { 206 | point: { x, y }, 207 | time, 208 | } 209 | ], 210 | color, 211 | weight, 212 | smoothing, 213 | adaptiveStroke, 214 | } 215 | */ 216 | sketchpad.addEventListener('segmentdrawn', ({ stroke }) => 217 | console.info(stroke), 218 | ); 219 | ``` 220 | 221 | ## Programmatic drawing 222 | 223 | To enable functionality such as undo/redo, stroke post-processing, and SVG export in apps using Atrament, the library 224 | can be configured to record and programmatically draw the strokes. 225 | 226 | The first step is to enable `recordStrokes`, and add a listener for the `strokerecorded` event: 227 | 228 | ```js 229 | atrament.recordStrokes = true; 230 | atrament.addEventListener('strokerecorded', ({ stroke }) => { 231 | // store `stroke` somewhere 232 | }); 233 | ``` 234 | 235 | The stroke can then be reconstructed using methods of the `Atrament` class: 236 | 237 | ```js 238 | // set drawing options 239 | atrament.mode = stroke.mode; 240 | atrament.weight = stroke.weight; 241 | atrament.smoothing = stroke.smoothing; 242 | atrament.color = stroke.color; 243 | atrament.adaptiveStroke = stroke.adaptiveStroke; 244 | 245 | // don't want to modify original data 246 | const segments = stroke.segments.slice(); 247 | 248 | const firstPoint = segments.shift().point; 249 | // beginStroke moves the "pen" to the given position and starts the path 250 | atrament.beginStroke(firstPoint.x, firstPoint.y); 251 | 252 | let prevPoint = firstPoint; 253 | while (segments.length > 0) { 254 | const point = segments.shift().point; 255 | 256 | // the `draw` method accepts the current real coordinates 257 | // (i. e. actual cursor position), and the previous processed (filtered) 258 | // position. It returns an object with the current processed position. 259 | const { x, y } = atrament.draw(point.x, point.y, prevPoint.x, prevPoint.y); 260 | 261 | // the processed position is the one where the line is actually drawn to 262 | // so we have to store it and pass it to `draw` in the next step 263 | prevPoint = { x, y }; 264 | } 265 | 266 | // endStroke closes the path 267 | atrament.endStroke(prevPoint.x, prevPoint.y); 268 | ``` 269 | 270 | ### Implementing Undo/Redo 271 | 272 | Atrament does not provide its own undo/redo functionality to keep the scope as small as possible. However, using stroke recording and programmatic drawing, 273 | it is possible to implement undo/redo with a relatively small amount of code. See @nidoro and @feored's example [here](https://github.com/jakubfiala/atrament/issues/71#issuecomment-1214261577). 274 | 275 | ## Development 276 | 277 | To obtain the dependencies, `cd` into the atrament directory and run `npm install`. 278 | You should be able to then build atrament by simply running `npm run build` and rebuild continuously with `npm run watch`. 279 | 280 | ### Running the demo locally 281 | 282 | The demo app is useful for development, and it's set up to use the compiled files in `/dist`. It's a plain HTML website which can be served with any local server. 283 | A good way to develop using the demo is to run `python -m http.server` (with Python 3) in the `/demo` directory. The demo will be served on `localhost:8000`. 284 | 285 | ## Alternatives 286 | 287 | Atrament's philosophy is to provide a **simple** and **small** tool that takes care of everything from pointer events to drawing pixels on screen. 288 | Atrament uses the native Canvas API to draw strokes, instead of computing custom curves. This means it's very lightweight (5.9kB gzipped with fill mode, 2.4kB without) 289 | and pretty much as fast as the browser allows. 290 | 291 | This does mean Atrament's rendering quality is limited by the Canvas API. If your application requires higher drawing quality, there are libraries such as 292 | [perfect-freehand](https://github.com/steveruizok/perfect-freehand) which compute their own curves and achieve somewhat more pleasing, higher-fidelity results. 293 | This comes at the expense of size (`perfect-freehand` is almost 2kB gzipped to generate the curve shape, but you need to take care of rendering it, handling pointer interactions, etc.). 294 | 295 | For a more fully-featured solution including drawing shapes, graphs, text, built-in Undo/Redo and many other features, 296 | you might want to consider a larger tool such as [excalidraw](https://github.com/excalidraw/excalidraw). 297 | -------------------------------------------------------------------------------- /demo/color-picker.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | export default Pickr.create({ 3 | el: '#color-picker', 4 | theme: 'classic', 5 | default: 'rgb(0,0,0)', 6 | swatches: [ 7 | 'rgb(244, 67, 54)', 8 | 'rgb(233, 30, 99)', 9 | 'rgb(156, 39, 176)', 10 | 'rgb(103, 58, 183)', 11 | 'rgb(63, 81, 181)', 12 | 'rgb(33, 150, 243)', 13 | 'rgb(3, 169, 244)', 14 | 'rgb(0, 188, 212)', 15 | 'rgb(0, 150, 136)', 16 | 'rgb(76, 175, 80)', 17 | 'rgb(139, 195, 74)', 18 | 'rgb(205, 220, 57)', 19 | 'rgb(255, 235, 59)', 20 | 'rgb(255, 193, 7)', 21 | ], 22 | components: { 23 | // Main components 24 | preview: true, 25 | opacity: true, 26 | hue: true, 27 | // Input / output Options 28 | interaction: { 29 | hex: true, 30 | rgb: true, 31 | hsla: true, 32 | hsva: true, 33 | cmyk: true, 34 | input: true, 35 | save: true, 36 | }, 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /demo/demo.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import Atrament, { 3 | MODE_DRAW, MODE_FILL, MODE_ERASE, MODE_DISABLED, 4 | } from '../dist/esm/index.js'; 5 | import fill from '../dist/esm/fill.js'; 6 | 7 | import { setRecorded, playRecorded } from './recording.js'; 8 | import colorPicker from './color-picker.js'; 9 | import log from './logger.js'; 10 | 11 | // first, we need to set up the canvas 12 | const canvas = document.getElementById('sketcher'); 13 | const toolbarToggle = document.getElementById('toolbar-toggle'); 14 | const toolbar = document.getElementsByClassName('toolbar')[0]; 15 | const clearButton = document.getElementById('clear'); 16 | const recordButton = document.getElementById('recordButton'); 17 | const playButton = document.getElementById('playButton'); 18 | const weightInput = document.getElementById('weight'); 19 | const smoothingInput = document.getElementById('smoothing'); 20 | const adaptiveInput = document.getElementById('adaptive'); 21 | const modeInput = document.getElementById('mode'); 22 | 23 | const modes = { 24 | draw: MODE_DRAW, 25 | fill: MODE_FILL, 26 | erase: MODE_ERASE, 27 | disabled: MODE_DISABLED, 28 | }; 29 | 30 | // instantiate Atrament 31 | const atrament = new Atrament(canvas, { 32 | width: canvas.offsetWidth, 33 | height: canvas.offsetHeight, 34 | fill, 35 | }); 36 | 37 | toolbarToggle.addEventListener('click', () => { 38 | toolbar.classList.toggle('toolbar-visible'); 39 | }); 40 | 41 | clearButton.addEventListener('click', () => atrament.clear()); 42 | 43 | recordButton.addEventListener('click', () => { 44 | atrament.recordStrokes = true; 45 | document.querySelector('#recordButton').value = 'Recording...'; 46 | }); 47 | 48 | playButton.addEventListener('click', () => { 49 | atrament.clear(); 50 | playRecorded(atrament); 51 | }); 52 | 53 | weightInput.addEventListener('input', ({ target: { value } }) => { 54 | atrament.weight = parseFloat(value); 55 | }); 56 | 57 | smoothingInput.addEventListener('change', ({ target: { value } }) => { 58 | atrament.smoothing = parseFloat(value); 59 | }); 60 | 61 | adaptiveInput.addEventListener('change', ({ target: { checked } }) => { 62 | atrament.adaptiveStroke = checked; 63 | }); 64 | 65 | modeInput.addEventListener('change', ({ target: { value } }) => { 66 | atrament.mode = modes[value]; 67 | }); 68 | 69 | colorPicker.on('save', (color) => { 70 | atrament.color = color.toRGBA().toString(); 71 | colorPicker.hide(); 72 | }); 73 | 74 | atrament.addEventListener('dirty', () => { 75 | log('event: dirty'); 76 | clearButton.hidden = false; 77 | }); 78 | 79 | atrament.addEventListener('clean', () => { 80 | log('event: clean'); 81 | }); 82 | 83 | atrament.addEventListener('fillstart', ({ x, y }) => { 84 | log(`event: fillstart x: ${x} y: ${y}`); 85 | }); 86 | 87 | atrament.addEventListener('fillend', () => { 88 | log('event: fillend'); 89 | }); 90 | 91 | atrament.addEventListener('strokestart', () => log('event: strokestart')); 92 | atrament.addEventListener('strokeend', () => log('event: strokeend')); 93 | 94 | atrament.addEventListener('strokerecorded', ({ stroke }) => { 95 | log(`event: strokerecorded - ${stroke.segments.length} segments`); 96 | setRecorded(stroke); 97 | 98 | atrament.recordStrokes = false; 99 | document.querySelector('#recordButton').value = 'Record a stroke'; 100 | document.querySelector('#playButton').hidden = false; 101 | }); 102 | 103 | atrament.addEventListener('segmentdrawn', () => log('event: segmentdrawn')); 104 | -------------------------------------------------------------------------------- /demo/img/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakubfiala/atrament/4ee5c032916fcb6a5ac6e8232313d04e7795c176/demo/img/bg.jpg -------------------------------------------------------------------------------- /demo/img/muchotravka.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakubfiala/atrament/4ee5c032916fcb6a5ac6e8232313d04e7795c176/demo/img/muchotravka.png -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | atrament ~ demo 7 | 11 | 12 | 16 | 17 | 18 | 19 | 20 |
21 |

22 | Atrament 23 | 26 | 33 | 47 | 48 |

49 | 50 |
51 |
52 | 53 |
54 | 55 | 61 |
62 |
63 |
64 | 73 |
74 |
75 |
76 | 85 |
86 |
87 | 88 | 89 |
90 |
91 | 92 | 98 |
99 |
100 | 101 |
102 |
103 | 104 |
105 |
106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /demo/logger.js: -------------------------------------------------------------------------------- 1 | // a little helper tool for logging events 2 | const eventsLog = []; 3 | const logElement = document.getElementById('events'); 4 | 5 | export default (...messages) => { 6 | if (eventsLog.push(messages.map((m) => JSON.stringify(m)).join()) > 5) { 7 | eventsLog.shift(); 8 | } 9 | 10 | logElement.innerText = eventsLog.join('\n'); 11 | // eslint-disable-next-line no-console 12 | console.log(...messages); 13 | }; 14 | -------------------------------------------------------------------------------- /demo/recording.js: -------------------------------------------------------------------------------- 1 | const recordedStroke = {}; 2 | 3 | const waitUntil = (reference, time) => { 4 | const timeElapsed = performance.now() - reference; 5 | const timeToWait = time - timeElapsed; 6 | 7 | return new Promise((resolve) => { 8 | setTimeout(resolve, timeToWait); 9 | }); 10 | }; 11 | 12 | export const setRecorded = (stroke) => Object.assign(recordedStroke, stroke); 13 | 14 | export const playRecorded = async (atrament) => { 15 | // set drawing options 16 | /* eslint-disable no-param-reassign */ 17 | atrament.weight = recordedStroke.weight; 18 | atrament.mode = recordedStroke.mode; 19 | atrament.smoothing = recordedStroke.smoothing; 20 | atrament.color = recordedStroke.color; 21 | atrament.adaptiveStroke = recordedStroke.adaptiveStroke; 22 | /* eslint-enable no-param-reassign */ 23 | 24 | // add a time reference 25 | const reference = performance.now(); 26 | 27 | // wait for the first point 28 | await waitUntil(reference, recordedStroke.segments[0].time); 29 | 30 | let prevPoint = recordedStroke.segments[0].point; 31 | atrament.beginStroke(prevPoint.x, prevPoint.y); 32 | 33 | // eslint-disable-next-line no-restricted-syntax 34 | for (const segment of recordedStroke.segments) { 35 | // waiting for time from reference 36 | // eslint-disable-next-line no-await-in-loop 37 | await waitUntil(reference, segment.time); 38 | 39 | // the `draw` method accepts the current real coordinates 40 | // (i. e. actual cursor position), and the previous processed (filtered) 41 | // position. It returns an object with the current processed position. 42 | prevPoint = atrament.draw( 43 | segment.point.x, 44 | segment.point.y, 45 | prevPoint.x, 46 | prevPoint.y, 47 | ); 48 | } 49 | 50 | atrament.endStroke(prevPoint.x, prevPoint.y); 51 | }; 52 | -------------------------------------------------------------------------------- /demo/styles.css: -------------------------------------------------------------------------------- 1 | canvas { 2 | cursor: crosshair; 3 | width: 100vw; 4 | height: 100vh; 5 | position: fixed; 6 | top: 0; 7 | left: 0; 8 | z-index: 2; 9 | } 10 | 11 | form { 12 | z-index: 3; 13 | position: fixed; 14 | background: rgba(0, 0, 0, 0.7); 15 | backdrop-filter: blur(5px); 16 | padding: 1em; 17 | color: white; 18 | box-sizing: border-box; 19 | width: 100vw; 20 | } 21 | 22 | @media screen and (min-width: 768px) { 23 | form { 24 | width: 16em; 25 | } 26 | } 27 | 28 | button, 29 | input, 30 | label, 31 | select { 32 | font: inherit; 33 | } 34 | 35 | input[type='checkbox'] { 36 | margin-right: 0.5rem; 37 | display: inline-block; 38 | } 39 | 40 | body { 41 | --grid-size: 20px; 42 | background-size: var(--grid-size) var(--grid-size); 43 | background-color: rgba(0, 0, 0, 0.05); 44 | background-image: radial-gradient( 45 | circle, 46 | rgba(0, 0, 0, 0.2) 1px, 47 | rgba(0, 0, 0, 0) 1px 48 | ); 49 | font-family: sans-serif; 50 | font-size: 18px; 51 | padding: 0; 52 | margin: 0; 53 | } 54 | 55 | h1 { 56 | font-size: 1.8em; 57 | margin: 0; 58 | display: flex; 59 | align-items: center; 60 | } 61 | 62 | .pickr { 63 | display: inline-block; 64 | height: 1.75em; 65 | overflow: hidden; 66 | border: solid 2px; 67 | border-radius: 5px; 68 | margin-bottom: 0; 69 | vertical-align: middle; 70 | margin-left: 5px; 71 | } 72 | 73 | #toolbar-toggle { 74 | display: inline-block; 75 | height: 36px; 76 | padding: 0; 77 | line-height: 0; 78 | margin-left: 0.5em; 79 | } 80 | 81 | #github { 82 | display: flex; 83 | margin-left: auto; 84 | margin-right: 0; 85 | } 86 | 87 | .toolbar { 88 | display: none; 89 | padding-top: 0.5em; 90 | } 91 | 92 | .toolbar-visible { 93 | display: block; 94 | } 95 | 96 | .toolbar > div { 97 | margin-bottom: 1em; 98 | } 99 | 100 | @media screen and (min-width: 768px) { 101 | #toolbar-toggle { 102 | display: none; 103 | } 104 | 105 | .toolbar { 106 | display: block; 107 | } 108 | } 109 | 110 | #events { 111 | font-family: monospace; 112 | font-size: 0.7em; 113 | } 114 | -------------------------------------------------------------------------------- /dist/cjs/fill.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { value: true }); 4 | 5 | function decodeBase64(base64, enableUnicode) { 6 | var binaryString = atob(base64); 7 | return binaryString; 8 | } 9 | 10 | function createURL(base64, sourcemapArg, enableUnicodeArg) { 11 | var source = decodeBase64(base64); 12 | var start = source.indexOf('\n', 10) + 1; 13 | var body = source.substring(start) + (''); 14 | var blob = new Blob([body], { type: 'application/javascript' }); 15 | return URL.createObjectURL(blob); 16 | } 17 | 18 | function createBase64WorkerFactory(base64, sourcemapArg, enableUnicodeArg) { 19 | var url; 20 | return function WorkerFactory(options) { 21 | url = url || createURL(base64); 22 | return new Worker(url, options); 23 | }; 24 | } 25 | 26 | var WorkerFactory = createBase64WorkerFactory('Lyogcm9sbHVwLXBsdWdpbi13ZWItd29ya2VyLWxvYWRlciAqLwooZnVuY3Rpb24gKCkgewogICd1c2Ugc3RyaWN0JzsKCiAgLy8gY29sb3VyIGluZGljZXMgcGVyIHBpeGVsCiAgY29uc3QgUiA9IDA7CiAgY29uc3QgRyA9IDE7CiAgY29uc3QgQiA9IDI7CiAgY29uc3QgQSA9IDM7CgogIGNvbnN0IFBJWEVMID0gNDsKICBjb25zdCBUUkFOU1BBUkVOVCA9IDA7CiAgY29uc3QgT1BBUVVFID0gMjU1OwoKICBjb25zdCBoZXhUb1JnYiA9IChoZXhDb2xvcikgPT4gewogICAgLy8gU2luY2UgaW5wdXQgdHlwZSBjb2xvciBwcm92aWRlcyBoZXggYW5kIEltYWdlRGF0YSBhY2NlcHRzIFJHQiBuZWVkIHRvIHRyYW5zZm9ybQogICAgY29uc3QgbSA9IGhleENvbG9yLm1hdGNoKC9eIz8oW1xkYS1mXXsyfSkoW1xkYS1mXXsyfSkoW1xkYS1mXXsyfSkkL2kpOwogICAgcmV0dXJuIFsKICAgICAgcGFyc2VJbnQobVsxXSwgMTYpLAogICAgICBwYXJzZUludChtWzJdLCAxNiksCiAgICAgIHBhcnNlSW50KG1bM10sIDE2KSwKICAgIF07CiAgfTsKCiAgLy8gUGl4ZWwgY29sb3IgZXF1YWxzIGNvbXAgY29sb3I/CiAgY29uc3QgY29sb3JNYXRjaGVyID0gKGRhdGEsIGNvbXBSLCBjb21wRywgY29tcEIsIGNvbXBBKSA9PiAocGl4ZWxQb3MpID0+ICgKICAgIGRhdGFbcGl4ZWxQb3MgKyBSXSA9PT0gY29tcFIKICAgICYmIGRhdGFbcGl4ZWxQb3MgKyBHXSA9PT0gY29tcEcKICAgICYmIGRhdGFbcGl4ZWxQb3MgKyBCXSA9PT0gY29tcEIKICAgICYmIGRhdGFbcGl4ZWxQb3MgKyBBXSA9PT0gY29tcEEKICApOwoKICBjb25zdCBjb2xvck1hdGNoZXJJZ25vcmVBbHBoYSA9IChkYXRhLCAuLi5hcmdzKSA9PiB7CiAgICBjb25zdCBtYXRjaCA9IGNvbG9yTWF0Y2hlcihkYXRhLCAuLi5hcmdzKTsKCiAgICByZXR1cm4gKHBpeGVsUG9zKSA9PiB7CiAgICAgIGNvbnN0IGFscGhhID0gZGF0YVtwaXhlbFBvcyArIEFdOwogICAgICBpZiAoYWxwaGEgIT09IFRSQU5TUEFSRU5UICYmIGFscGhhICE9PSBPUEFRVUUpIHsKICAgICAgICByZXR1cm4gdHJ1ZTsKICAgICAgfQoKICAgICAgcmV0dXJuIG1hdGNoKHBpeGVsUG9zKTsKICAgIH07CiAgfTsKCiAgLyogZXNsaW50LWRpc2FibGUgbm8tcGFyYW0tcmVhc3NpZ24gKi8KICBjb25zdCBwaXhlbFBhaW50ZXIgPSAoZGF0YSwgZmlsbFIsIGZpbGxHLCBmaWxsQiwgZmlsbEEpID0+IChwaXhlbFBvcykgPT4gewogICAgZGF0YVtwaXhlbFBvcyArIFJdID0gZmlsbFI7CiAgICBkYXRhW3BpeGVsUG9zICsgR10gPSBmaWxsRzsKICAgIGRhdGFbcGl4ZWxQb3MgKyBCXSA9IGZpbGxCOwogICAgZGF0YVtwaXhlbFBvcyArIEFdID0gZmlsbEE7CiAgfTsKCiAgY29uc3QgcGl4ZWxQYWludGVyTWl4QWxwaGEgPSAoZGF0YSwgZmlsbFIsIGZpbGxHLCBmaWxsQiwgZmlsbEEpID0+IChwaXhlbFBvcykgPT4gewogICAgY29uc3Qgb2xkQWxwaGEgPSBkYXRhW3BpeGVsUG9zICsgQV07CiAgICAvLyBjYWxjdWxhdGUgcmF0aW8gb2Ygb2xkIHZzLiBuZXcgY29sb3VyIHRvIGJlIGFscGhhLW1peGVkCiAgICBjb25zdCBtaXhBbHBoYU9sZCA9IG9sZEFscGhhID09PSBPUEFRVUUKICAgICAgPyBUUkFOU1BBUkVOVAogICAgICA6IG9sZEFscGhhIC8gT1BBUVVFOwogICAgY29uc3QgbWl4QWxwaGFOZXcgPSAxIC0gbWl4QWxwaGFPbGQ7CgogICAgY29uc3QgcGFpbnQgPSBwaXhlbFBhaW50ZXIoCiAgICAgIGRhdGEsCiAgICAgIE1hdGguZmxvb3IobWl4QWxwaGFPbGQgKiBkYXRhW3BpeGVsUG9zICsgUl0gKyBtaXhBbHBoYU5ldyAqIGZpbGxSKSwKICAgICAgTWF0aC5mbG9vcihtaXhBbHBoYU9sZCAqIGRhdGFbcGl4ZWxQb3MgKyBHXSArIG1peEFscGhhTmV3ICogZmlsbEcpLAogICAgICBNYXRoLmZsb29yKG1peEFscGhhT2xkICogZGF0YVtwaXhlbFBvcyArIEJdICsgbWl4QWxwaGFOZXcgKiBmaWxsQiksCiAgICAgIGZpbGxBLAogICAgKTsKCiAgICByZXR1cm4gcGFpbnQocGl4ZWxQb3MpOwogIH07CiAgLyogZXNsaW50LWVuYWJsZSBuby1wYXJhbS1yZWFzc2lnbiAqLwoKICAvKioKICAgKiBTdGFjay0gYW5kIHNwYW4tYmFzZWQgZmxvb2QgZmlsbCBhbGdvcml0aG0KICAgKiBzZWUgaHR0cHM6Ly9lbi53aWtpcGVkaWEub3JnL3dpa2kvRmxvb2RfZmlsbCNTcGFuX2ZpbGxpbmcKICAgKgogICAqIEBwYXJhbSB7T2JqZWN0fSBvcHRpb25zIG9wdGlvbnMgb2JqZWN0CiAgICogQHJldHVybnMge1VJbnQ4Q2xhbXBlZEFycmF5fSB0aGUgbW9kaWZpZWQgcGl4ZWxzCiAgICovCiAgY29uc3QgZmxvb2RGaWxsID0gKHsKICAgIGltYWdlLAogICAgd2lkdGgsCiAgICBoZWlnaHQsCiAgICBjb2xvciwKICAgIGdsb2JhbEFscGhhLAogICAgc3RhcnRYLAogICAgc3RhcnRZLAogICAgc3RhcnRDb2xvciwKICB9KSA9PiB7CiAgICBjb25zdCByb3cgPSB3aWR0aCAqIFBJWEVMOwogICAgLy8gbWFrZSBzdXJlIHN0YXJ0IGNvb3JkaW5hdGVzIGFyZSBpbnRlZ2VycwogICAgY29uc3Qgc3RhcnRYQ29vcmQgPSBNYXRoLmZsb29yKHN0YXJ0WCk7CiAgICBjb25zdCBzdGFydFlDb29yZCA9IE1hdGguZmxvb3Ioc3RhcnRZKTsKICAgIC8vIGhleCBuZWVkcyB0byBiZSB0cmFzZm9ybWVkIHRvIHJnYiBzaW5jZSBJbWFnZURhdGEgdXNlcyBSR0IKICAgIGNvbnN0IGZpbGxDb2xvciA9IGhleFRvUmdiKGNvbG9yKTsKICAgIC8vIGVuc3VyZSBhbHBoYSBpcyBhbiBpbnRlZ2VyIGluIHRoZSByYW5nZSBvZiAwLTI1NQogICAgY29uc3QgZmlsbEFscGhhID0gTWF0aC5mbG9vcihNYXRoLm1heCgwLCBNYXRoLm1pbihnbG9iYWxBbHBoYSAqIE9QQVFVRSwgT1BBUVVFKSkpOwogICAgLy8gd2UgbmVlZCBkaWZmZXJlbnQgYmVoYXZpb3VyIGluIGNhc2Ugd2UncmUgZmlsbGluZyBhIG5vbi1vcGFxdWUgYXJlYQogICAgY29uc3QgZmlsbGluZ05vbk9wYXF1ZSA9IHN0YXJ0Q29sb3JbQV0gIT09IE9QQVFVRTsKICAgIC8vIG91ciBwaXhlbCBwYWludGVyIHNob3VsZCBvbmx5IG1peCBhbHBoYSBpZiB3ZSdyZSBzdGFydGluZyBpbiBhIG5vbi1vcGFxdWUgYXJlYQogICAgY29uc3QgcGl4ZWxQYWludGVyT2ZDaG9pY2UgPSBmaWxsaW5nTm9uT3BhcXVlID8gcGl4ZWxQYWludGVyTWl4QWxwaGEgOiBwaXhlbFBhaW50ZXI7CiAgICBjb25zdCBwYWludFBpeGVsID0gcGl4ZWxQYWludGVyT2ZDaG9pY2UoaW1hZ2UsIC4uLmZpbGxDb2xvciwgZmlsbEFscGhhKTsKICAgIC8vIHdoZW4gbG9va2luZyBmb3IgdGhlIHNwYW4gc3RhcnQsIHdlIGlnbm9yZSB0aGUgYWxwaGEgdmFsdWUgaWYgZmlsbGluZyBhIG5vbi1vcGFxdWUgYXJlYQogICAgLy8gdGhpcyBlbnN1cmVzIHRoYXQgd2UnbGwgbWl4IHRoZSBmaWxsIGludG8gYW50aWFsaWFzZWQgZWRnZXMKICAgIGNvbnN0IGNvbG9yTWF0Y2hlclNwYW5TdGFydCA9IGZpbGxpbmdOb25PcGFxdWUgPyBjb2xvck1hdGNoZXJJZ25vcmVBbHBoYSA6IGNvbG9yTWF0Y2hlcjsKICAgIGNvbnN0IG1hdGNoU3BhblN0YXJ0Q29sb3IgPSBjb2xvck1hdGNoZXJTcGFuU3RhcnQoaW1hZ2UsIC4uLnN0YXJ0Q29sb3IpOwogICAgLy8gZm9yIGFsbCBvdGhlciBjYXNlcywgd2UgbG9vayBmb3IgdGhlIHN0YXJ0IGNvbG91ciBleGFjdGx5CiAgICBjb25zdCBtYXRjaFN0YXJ0Q29sb3IgPSBjb2xvck1hdGNoZXJJZ25vcmVBbHBoYShpbWFnZSwgLi4uc3RhcnRDb2xvcik7CgogICAgLy8gY2hlY2sgaWYgd2UncmUgdHJ5aW5nIHRvIGZpbGwgd2l0aCB0aGUgc2FtZSBjb2xvdXIsIGlmIHNvLCBzdG9wCiAgICBjb25zdCBtYXRjaEZpbGxDb2xvciA9IGNvbG9yTWF0Y2hlcihpbWFnZSwgLi4uWy4uLmZpbGxDb2xvciwgT1BBUVVFXSk7CiAgICBpZiAobWF0Y2hGaWxsQ29sb3IoKHN0YXJ0WUNvb3JkICogd2lkdGggKyBzdGFydFhDb29yZCkgKiBQSVhFTCkpIHsKICAgICAgcmV0dXJuIGltYWdlOwogICAgfQogICAgLy8gYmVnaW4gd2l0aCBvdXIgc3RhcnQgcGl4ZWwKICAgIGNvbnN0IHBpeGVsU3RhY2sgPSBbW3N0YXJ0WENvb3JkLCBzdGFydFlDb29yZF1dOwogICAgd2hpbGUgKHBpeGVsU3RhY2subGVuZ3RoKSB7CiAgICAgIGNvbnN0IFt4LCB5XSA9IHBpeGVsU3RhY2sucG9wKCk7CiAgICAgIC8vIGNvbHVtbiBwb3NpdGlvbiBpcyBpbiBjYXJ0ZXNpYW4gc3BhY2UgKHgseSkKICAgICAgbGV0IGNvbHVtblBvc2l0aW9uID0geTsKICAgICAgLy8gcGl4ZWwgcG9zaXRpb24gaXMgaW4gMUQgc3BhY2UgKHRoZSByYXcgaW1hZ2UgZGF0YSBVSW50OENsYW1wZWRBcnJheSkKICAgICAgbGV0IHBpeGVsUG9zID0gKGNvbHVtblBvc2l0aW9uICogd2lkdGggKyB4KSAqIFBJWEVMOwogICAgICAvLyBzdGFydCBtb3ZpbmcgZGlyZWN0bHkgdXAgZnJvbSBvdXIgc3RhcnQgcG9zaXRpb24KICAgICAgLy8gdW50aWwgd2UgZmluZCBhIGRpZmZlcmVudCBjb2xvdXIgdG8gdGhlIHN0YXJ0IGNvbG91cgogICAgICAvLyB0aGlzIGlzIHRoZSBiZWdpbm5pbmcgb2Ygb3VyIHNwYW4KICAgICAgd2hpbGUgKGNvbHVtblBvc2l0aW9uLS0gPj0gMCAmJiBtYXRjaFNwYW5TdGFydENvbG9yKHBpeGVsUG9zKSkgewogICAgICAgIHBpeGVsUG9zIC09IHJvdzsKICAgICAgfQogICAgICAvLyBtb3ZlIG9uZSByb3cgZG93biAodG9wbW9zdCBwaXhlbCBvZiBmaWxsYWJsZSBhcmVhKQogICAgICBwaXhlbFBvcyArPSByb3c7CgogICAgICBsZXQgcmVhY2hMZWZ0ID0gZmFsc2U7CiAgICAgIGxldCByZWFjaFJpZ2h0ID0gZmFsc2U7CiAgICAgIC8vIGZvciBlYWNoIHJvdywgY2hlY2sgaWYgdGhlIGZpcnN0IHBpeGVsIHN0aWxsIGhhcyB0aGUgc3RhcnQgY29sb3VyCiAgICAgIC8vIGlmIGl0IGRvZXMsIHBhaW50IGl0IGFuZCBwdXNoIHN1cnJvdW5kaW5nIHBpeGVscyB0byB0aGUgc3RhY2sgb2YgcGl4ZWxzIHRvIGNoZWNrCiAgICAgIHdoaWxlICgrK2NvbHVtblBvc2l0aW9uIDwgaGVpZ2h0IC0gMSAmJiBtYXRjaFN0YXJ0Q29sb3IocGl4ZWxQb3MpKSB7CiAgICAgICAgcGFpbnRQaXhlbChwaXhlbFBvcyk7CiAgICAgICAgLy8gY2hlY2sgdGhlIHBpeGVsIHRvIHRoZSBsZWZ0CiAgICAgICAgaWYgKHggPiAwKSB7CiAgICAgICAgICBpZiAobWF0Y2hTdGFydENvbG9yKHBpeGVsUG9zIC0gUElYRUwpKSB7CiAgICAgICAgICAgIGlmICghcmVhY2hMZWZ0KSB7CiAgICAgICAgICAgICAgcGl4ZWxTdGFjay5wdXNoKFt4IC0gMSwgY29sdW1uUG9zaXRpb25dKTsKICAgICAgICAgICAgICByZWFjaExlZnQgPSB0cnVlOwogICAgICAgICAgICB9CiAgICAgICAgICB9IGVsc2UgaWYgKHJlYWNoTGVmdCkgewogICAgICAgICAgICByZWFjaExlZnQgPSBmYWxzZTsKICAgICAgICAgIH0KICAgICAgICB9CiAgICAgICAgLy8gY2hlY2sgdGhlIHBpeGVsIHRvIHRoZSByaWdodAogICAgICAgIGlmICh4IDwgd2lkdGggLSAxKSB7CiAgICAgICAgICBpZiAobWF0Y2hTdGFydENvbG9yKHBpeGVsUG9zICsgUElYRUwpKSB7CiAgICAgICAgICAgIGlmICghcmVhY2hSaWdodCkgewogICAgICAgICAgICAgIHBpeGVsU3RhY2sucHVzaChbeCArIDEsIGNvbHVtblBvc2l0aW9uXSk7CiAgICAgICAgICAgICAgcmVhY2hSaWdodCA9IHRydWU7CiAgICAgICAgICAgIH0KICAgICAgICAgIH0gZWxzZSBpZiAocmVhY2hSaWdodCkgewogICAgICAgICAgICByZWFjaFJpZ2h0ID0gZmFsc2U7CiAgICAgICAgICB9CiAgICAgICAgfQogICAgICAgIC8vIG1vdmUgdG8gdGhlIG5leHQgcm93CiAgICAgICAgcGl4ZWxQb3MgKz0gcm93OwogICAgICB9CiAgICB9CgogICAgcmV0dXJuIGltYWdlOwogIH07CgogIGdsb2JhbFRoaXMuYWRkRXZlbnRMaXN0ZW5lcignbWVzc2FnZScsICh7IGRhdGEgfSkgPT4gewogICAgY29uc3QgcmVzdWx0ID0gZmxvb2RGaWxsKGRhdGEpOwoKICAgIGdsb2JhbFRoaXMucG9zdE1lc3NhZ2UoeyB0eXBlOiAnZmlsbC1yZXN1bHQnLCByZXN1bHQgfSwgW3Jlc3VsdC5idWZmZXJdKTsKICB9KTsKCn0pKCk7Ci8vIyBzb3VyY2VNYXBwaW5nVVJMPXdvcmtlci5qcy5tYXAKCg=='); 27 | /* eslint-enable */ 28 | 29 | // eslint-disable-next-line import/no-unresolved 30 | 31 | exports.default = WorkerFactory; 32 | -------------------------------------------------------------------------------- /dist/cjs/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { value: true }); 4 | 5 | /* eslint-disable max-classes-per-file */ 6 | // make a class for Point 7 | class Point { 8 | constructor(x, y) { 9 | this.x = x; 10 | this.y = y; 11 | } 12 | 13 | set(x, y) { 14 | this.x = x; 15 | this.y = y; 16 | } 17 | } 18 | 19 | // make a class for the mouse data 20 | class Mouse extends Point { 21 | constructor() { 22 | super(0, 0); 23 | this.down = false; 24 | this.previous = new Point(0, 0); 25 | } 26 | } 27 | 28 | class AtramentEventTarget { 29 | constructor() { 30 | this.eventListeners = new Map(); 31 | } 32 | 33 | addEventListener(eventName, handler) { 34 | const handlers = this.eventListeners.get(eventName) || new Set(); 35 | handlers.add(handler); 36 | this.eventListeners.set(eventName, handlers); 37 | } 38 | 39 | removeEventListener(eventName, handler) { 40 | const handlers = this.eventListeners.get(eventName); 41 | if (!handlers) return; 42 | handlers.delete(handler); 43 | } 44 | 45 | dispatchEvent(eventName, data) { 46 | const handlers = this.eventListeners.get(eventName); 47 | if (!handlers) return; 48 | [...handlers].forEach((handler) => handler(data)); 49 | } 50 | } 51 | 52 | // colour indices per pixel 53 | 54 | const lineDistance = (x1, y1, x2, y2) => { 55 | // calculate euclidean distance between (x1, y1) and (x2, y2) 56 | const xs = (x2 - x1) ** 2; 57 | const ys = (y2 - y1) ** 2; 58 | return Math.sqrt(xs + ys); 59 | }; 60 | /* eslint-enable no-param-reassign */ 61 | 62 | const pointerEventHandler = (handler) => (event) => { 63 | // Ignore pointers such as additional touches on a multi-touch screen, 64 | // as well as all mouse buttons other than the left button. 65 | // `PointerEvent.button` is -1 if no button is pressed, but also for `pointermove` events, 66 | // and this value is relevant to us. See https://w3c.github.io/pointerevents/#the-button-property 67 | if (!event.isPrimary || event.button > 0) { 68 | return; 69 | } 70 | 71 | if (event.cancelable) { 72 | event.preventDefault(); 73 | } 74 | 75 | handler(event); 76 | }; 77 | 78 | const setupPointerEvents = ({ 79 | canvas, 80 | move, 81 | down, 82 | up, 83 | }) => { 84 | const moveListener = pointerEventHandler(move); 85 | const downListener = pointerEventHandler(down); 86 | const upListener = pointerEventHandler(up); 87 | 88 | canvas.addEventListener('pointermove', moveListener); 89 | canvas.addEventListener('pointerdown', downListener); 90 | document.addEventListener('pointerup', upListener); 91 | document.addEventListener('pointerout', upListener); 92 | 93 | return () => { 94 | canvas.removeEventListener('pointermove', moveListener); 95 | canvas.removeEventListener('pointerdown', downListener); 96 | document.removeEventListener('pointerup', upListener); 97 | document.removeEventListener('pointerout', upListener); 98 | }; 99 | }; 100 | 101 | const MAX_LINE_THICKNESS = 100; 102 | 103 | const MIN_LINE_THICKNESS = 2; 104 | const LINE_THICKNESS_RANGE = MAX_LINE_THICKNESS - MIN_LINE_THICKNESS; 105 | const THICKNESS_INCREMENT = 0.25; 106 | const MIN_SMOOTHING_FACTOR = 0.87; 107 | const INITIAL_SMOOTHING_FACTOR = 0.85; 108 | const WEIGHT_SPREAD = 30; 109 | const INITIAL_THICKNESS = 2; 110 | const DEFAULT_PRESSURE = 0.5; 111 | 112 | const MODE_DRAW = Symbol('atrament mode - draw'); 113 | const MODE_ERASE = Symbol('atrament mode - erase'); 114 | const MODE_FILL = Symbol('atrament mode - fill'); 115 | const MODE_DISABLED = Symbol('atrament mode - disabled'); 116 | 117 | const pathDrawingModes = [MODE_DRAW, MODE_ERASE]; 118 | const configKeys = ['weight', 'smoothing', 'adaptiveStroke', 'mode']; 119 | 120 | class Atrament extends AtramentEventTarget { 121 | adaptiveStroke = true; 122 | canvas; 123 | recordStrokes = false; 124 | resolution = window.devicePixelRatio; 125 | smoothing = INITIAL_SMOOTHING_FACTOR; 126 | thickness = INITIAL_THICKNESS; 127 | 128 | #context; 129 | #dirty = false; 130 | #filling = false; 131 | #fillStack = []; 132 | #fillWorker = null; 133 | #mode = MODE_DRAW; 134 | #mouse = new Mouse(); 135 | #pressure = DEFAULT_PRESSURE; 136 | #removePointerEventListeners; 137 | #strokeMemory = []; 138 | #thickness = INITIAL_THICKNESS; 139 | #weight = INITIAL_THICKNESS; 140 | 141 | constructor(selector, config = {}) { 142 | if (typeof window === 'undefined') { 143 | throw new Error('atrament: looks like we\'re not running in a browser'); 144 | } 145 | 146 | super(); 147 | 148 | this.canvas = Atrament.#setupCanvas(selector, config); 149 | this.#context = Atrament.#setupContext(this.canvas, config); 150 | this.#setupFill({ FillWorker: config.fill }); 151 | 152 | this.#removePointerEventListeners = setupPointerEvents({ 153 | canvas: this.canvas, 154 | move: this.#pointerMove.bind(this), 155 | down: this.#pointerDown.bind(this), 156 | up: this.#pointerUp.bind(this), 157 | }); 158 | 159 | configKeys.forEach((key) => { 160 | if (config[key] !== undefined) { 161 | this[key] = config[key]; 162 | } 163 | }); 164 | } 165 | 166 | /** 167 | * Begins a stroke at a given position 168 | * 169 | * @param {number} x 170 | * @param {number} y 171 | */ 172 | beginStroke(x, y) { 173 | this.#context.moveTo(x, y); 174 | this.#thickness = this.#weight; 175 | 176 | if (this.recordStrokes) { 177 | this.strokeTimestamp = performance.now(); 178 | } 179 | 180 | this.dispatchEvent('strokestart', { x, y }); 181 | } 182 | 183 | /** 184 | * Ends a stroke at a given position 185 | * 186 | * @param {number} x 187 | * @param {number} y 188 | */ 189 | endStroke(x, y) { 190 | this.dispatchEvent('strokeend', { x, y }); 191 | 192 | if (this.recordStrokes) { 193 | this.dispatchEvent('strokerecorded', { stroke: this.currentStroke }); 194 | } 195 | this.#strokeMemory = []; 196 | delete (this.strokeTimestamp); 197 | } 198 | 199 | /** 200 | * Draws the next stroke segment as a smooth quadratic curve 201 | * with adaptive stroke thickness between two points. 202 | * 203 | * @param {number} x current X coordinate 204 | * @param {number} y current Y coordinate 205 | * @param {number} previousX previous X coordinate 206 | * @param {number} previousY previous Y coordinate 207 | */ 208 | draw(x, y, previousX, previousY) { 209 | // If the user clicks (or double clicks) without moving the mouse, 210 | // previousX/Y will be 0. In this case, we don't want to draw a line from (0,0) to (x,y), 211 | // but a "point" from (x,y) to (x,y). 212 | const prevX = previousX || x; 213 | const prevY = previousY || y; 214 | // get distance from the previous point 215 | // and use it to calculate the smoothed coordinates 216 | const smoothingFactor = this.getSmoothingFactor(lineDistance(x, y, prevX, prevY)); 217 | const procX = x - (x - prevX) * smoothingFactor; 218 | const procY = y - (y - prevY) * smoothingFactor; 219 | 220 | // recalculate distance from previous point, this time relative to the smoothed coords 221 | const dist = lineDistance(procX, procY, prevX, prevY); 222 | 223 | // Adaptive stroke allows an effect where thickness changes 224 | // over the course of the stroke. This simulates the variation in 225 | // ink discharge of a physical pen. 226 | if (this.adaptiveStroke) { 227 | // Thickness range is inversely proportional to pressure, 228 | // because with higher pressure, the effect of distance 229 | // on the thickness ratio should be greater. 230 | const range = LINE_THICKNESS_RANGE * (1 - this.#pressure); 231 | const ratio = (dist - MIN_LINE_THICKNESS) / range; 232 | const targetThickness = ratio * (this.#maxWeight - this.#weight) + this.#weight; 233 | // approach the target gradually 234 | if (this.#thickness > targetThickness) { 235 | this.#thickness -= THICKNESS_INCREMENT; 236 | } else if (this.#thickness < targetThickness) { 237 | this.#thickness += THICKNESS_INCREMENT; 238 | } 239 | } else { 240 | this.#thickness = this.#weight; 241 | } 242 | 243 | this.#context.lineWidth = this.#thickness; 244 | 245 | // Draw the segment using quad interpolation. 246 | this.#context.beginPath(); 247 | this.#context.moveTo(prevX, prevY); 248 | this.#context.quadraticCurveTo(prevX, prevY, procX, procY); 249 | this.#context.closePath(); 250 | this.#context.stroke(); 251 | 252 | if (this.recordStrokes) { 253 | this.#strokeMemory.push({ 254 | point: new Point(x, y), 255 | time: performance.now() - this.strokeTimestamp, 256 | }); 257 | 258 | this.dispatchEvent('segmentdrawn', { stroke: this.currentStroke }); 259 | } 260 | 261 | // At this point, we can be certain the canvas has some drawing on it, 262 | // so we can toggle the "dirty" state. Checking it here ensures that 263 | // the state is also updated during programmatic drawing. 264 | if (!this.#dirty && this.#mode === MODE_DRAW) { 265 | this.#dirty = true; 266 | this.dispatchEvent('dirty'); 267 | } 268 | 269 | return { x: procX, y: procY }; 270 | } 271 | 272 | clear() { 273 | this.#dirty = false; 274 | this.dispatchEvent('clean'); 275 | 276 | // make sure we're in the right compositing mode, and erase everything 277 | const eraseMode = this.mode === MODE_ERASE; 278 | if (eraseMode) { 279 | this.mode = MODE_DRAW; 280 | } 281 | 282 | // clear the canvas without the transform 283 | // code taken from https://stackoverflow.com/a/6722031 284 | this.#context.save(); 285 | this.#context.setTransform(1, 0, 0, 1, 0, 0); 286 | this.#context.clearRect(0, 0, this.canvas.width, this.canvas.height); 287 | this.#context.restore(); 288 | 289 | if (eraseMode) { 290 | this.mode = MODE_ERASE; 291 | } 292 | } 293 | 294 | destroy() { 295 | this.clear(); 296 | this.#removePointerEventListeners?.(); 297 | } 298 | 299 | get color() { 300 | return this.#context.strokeStyle; 301 | } 302 | 303 | set color(c) { 304 | if (typeof c !== 'string') throw new Error('atrament: wrong argument type setting color'); 305 | this.#context.strokeStyle = c; 306 | } 307 | 308 | get weight() { 309 | return this.#weight; 310 | } 311 | 312 | set weight(w) { 313 | if (typeof w !== 'number') throw new Error('atrament: wrong argument type setting weight'); 314 | this.#thickness = w; 315 | this.#weight = w; 316 | } 317 | 318 | // For small weights, this allows for a lot of spread, 319 | // while for larger weights, the effect is less prominent. 320 | // This means at small weights, Atrament behaves more like an ink pen, 321 | // and at larger weights more like a marker. 322 | get #maxWeight() { 323 | return this.#weight + WEIGHT_SPREAD; 324 | } 325 | 326 | // Here we scale the initial smoothing factor by the raw distance 327 | // - this means that when the mouse moves fast, there is more smoothing, 328 | // and when we're drawing small detailed stuff, we have more control. 329 | getSmoothingFactor(dist) { 330 | return Math.min( 331 | MIN_SMOOTHING_FACTOR, 332 | this.smoothing + (dist - 60) / 3000, 333 | ); 334 | } 335 | 336 | get mode() { 337 | return this.#mode; 338 | } 339 | 340 | set mode(m) { 341 | switch (m) { 342 | case MODE_ERASE: 343 | this.#mode = MODE_ERASE; 344 | this.#context.globalCompositeOperation = 'destination-out'; 345 | break; 346 | case MODE_FILL: 347 | this.#mode = MODE_FILL; 348 | this.#context.globalCompositeOperation = 'source-over'; 349 | break; 350 | case MODE_DISABLED: 351 | this.#mode = MODE_DISABLED; 352 | break; 353 | case MODE_DRAW: 354 | this.#mode = MODE_DRAW; 355 | this.#context.globalCompositeOperation = 'source-over'; 356 | break; 357 | default: 358 | throw new Error('atrament: mode is not one of the allowed modes.'); 359 | } 360 | } 361 | 362 | get currentStroke() { 363 | return { 364 | segments: this.#strokeMemory.slice(), 365 | mode: this.mode, 366 | weight: this.weight, 367 | smoothing: this.smoothing, 368 | color: this.color, 369 | adaptiveStroke: this.adaptiveStroke, 370 | }; 371 | } 372 | 373 | get dirty() { 374 | return this.#dirty; 375 | } 376 | 377 | static #setupCanvas(selector, config) { 378 | let canvas; 379 | // get canvas element 380 | if (selector instanceof window.Node && selector.tagName === 'CANVAS') canvas = selector; 381 | else if (typeof selector === 'string') canvas = document.querySelector(selector); 382 | else throw new Error(`atrament: can't look for canvas based on '${selector}'`); 383 | if (!canvas) throw new Error('atrament: canvas not found'); 384 | // since this method is static, we have to add a fallback to the resolution here 385 | // TODO: see if these methods really have to be static. 386 | const scale = config.resolution || window.devicePixelRatio; 387 | canvas.width = (config.width || canvas.width) * scale; 388 | canvas.height = (config.height || canvas.height) * scale; 389 | canvas.style.touchAction = 'none'; 390 | 391 | return canvas; 392 | } 393 | 394 | static #setupContext(canvas, config) { 395 | const context = canvas.getContext('2d'); 396 | // since this method is static, we have to add a fallback to the resolution here 397 | // TODO: see if these methods really have to be static. 398 | const scale = config.resolution || window.devicePixelRatio; 399 | context.scale(scale, scale); 400 | context.globalCompositeOperation = 'source-over'; 401 | context.globalAlpha = 1; 402 | context.strokeStyle = config.color || 'rgba(0,0,0,1)'; 403 | context.lineCap = 'round'; 404 | context.lineJoin = 'round'; 405 | 406 | return context; 407 | } 408 | 409 | #pointerMove(event) { 410 | const positions = event.getCoalescedEvents?.() || [event]; 411 | positions.forEach((position) => { 412 | const x = position.offsetX; 413 | const y = position.offsetY; 414 | 415 | // draw if we should draw 416 | if (this.#mouse.down && pathDrawingModes.includes(this.#mode)) { 417 | const { x: newX, y: newY } = this.draw( 418 | x, 419 | y, 420 | this.#mouse.previous.x, 421 | this.#mouse.previous.y, 422 | ); 423 | 424 | this.#mouse.set(x, y); 425 | this.#mouse.previous.set(newX, newY); 426 | // Android Chrome sets pressure to constant 1 by default, 427 | // which would break the algorithm. 428 | // We also handle the case when pressure is 0. 429 | this.#pressure = position.pressure === 1 430 | ? DEFAULT_PRESSURE 431 | : position.pressure || DEFAULT_PRESSURE; 432 | } else { 433 | this.#mouse.set(x, y); 434 | this.#mouse.previous.set(x, y); 435 | } 436 | }); 437 | } 438 | 439 | #pointerDown(event) { 440 | // if we are filling - fill and return 441 | if (this.mode === MODE_FILL) { 442 | this.#fill(); 443 | return; 444 | } 445 | 446 | this.#mouse.down = true; 447 | // update position just in case 448 | this.#pointerMove(event); 449 | 450 | this.beginStroke(this.#mouse.previous.x, this.#mouse.previous.y); 451 | } 452 | 453 | #pointerUp(event) { 454 | if (this.#mode === MODE_FILL) { 455 | return; 456 | } 457 | 458 | if (!this.#mouse.down) { 459 | return; 460 | } 461 | 462 | this.#mouse.down = false; 463 | 464 | if (this.#mouse.x === event.offsetX 465 | && this.#mouse.y === event.offsetY && pathDrawingModes.includes(this.mode)) { 466 | this.draw( 467 | this.#mouse.x, 468 | this.#mouse.y, 469 | this.#mouse.previous.x, 470 | this.#mouse.previous.y, 471 | ); 472 | } 473 | 474 | this.#mouse.previous.set(0, 0); 475 | 476 | this.endStroke(this.#mouse.x, this.#mouse.y); 477 | } 478 | 479 | #setupFill({ FillWorker }) { 480 | if (!FillWorker) { 481 | return; 482 | } 483 | 484 | this.#fillWorker = new FillWorker(); 485 | this.#fillWorker.addEventListener('message', ({ data }) => { 486 | if (data.type === 'fill-result') { 487 | this.#filling = false; 488 | this.dispatchEvent('fillend', {}); 489 | 490 | const imageData = new ImageData(data.result, this.canvas.width, this.canvas.height); 491 | this.#context.putImageData(imageData, 0, 0); 492 | 493 | if (this.#fillStack.length > 0) { 494 | this.#postToFillWorker(this.#fillStack.shift()); 495 | } 496 | } 497 | }); 498 | } 499 | 500 | #fill() { 501 | if (!this.#fillWorker) { 502 | throw new Error('atrament: fill mode only works if the fillWorker option is passed to the Atrament constructor'); 503 | } 504 | 505 | const { x, y } = this.#mouse; 506 | this.dispatchEvent('fillstart', { x, y }); 507 | 508 | const startColor = Array.from(this.#context.getImageData(x, y, 1, 1).data); 509 | const fillData = { 510 | color: this.color, 511 | globalAlpha: this.#context.globalAlpha, 512 | width: this.canvas.width, 513 | height: this.canvas.height, 514 | startColor, 515 | startX: x * this.resolution, 516 | startY: y * this.resolution, 517 | }; 518 | 519 | if (!this.#filling) { 520 | this.#filling = true; 521 | this.#postToFillWorker(fillData); 522 | } else { 523 | this.#fillStack.push(fillData); 524 | } 525 | } 526 | 527 | #postToFillWorker(fillData) { 528 | const image = this.#context.getImageData(0, 0, this.canvas.width, this.canvas.height).data; 529 | this.#fillWorker?.postMessage({ image, ...fillData }, [image.buffer]); 530 | } 531 | } 532 | 533 | exports.MODE_DISABLED = MODE_DISABLED; 534 | exports.MODE_DRAW = MODE_DRAW; 535 | exports.MODE_ERASE = MODE_ERASE; 536 | exports.MODE_FILL = MODE_FILL; 537 | exports.default = Atrament; 538 | -------------------------------------------------------------------------------- /dist/esm/fill.js: -------------------------------------------------------------------------------- 1 | function g(g,I,C){var G=function(g,I){return atob(g)}(g),A=G.indexOf("\n",10)+1,b=G.substring(A)+"",B=new Blob([b],{type:"application/javascript"});return URL.createObjectURL(B)}var I,C,G=(I="Lyogcm9sbHVwLXBsdWdpbi13ZWItd29ya2VyLWxvYWRlciAqLwooZnVuY3Rpb24gKCkgewogICd1c2Ugc3RyaWN0JzsKCiAgLy8gY29sb3VyIGluZGljZXMgcGVyIHBpeGVsCiAgY29uc3QgUiA9IDA7CiAgY29uc3QgRyA9IDE7CiAgY29uc3QgQiA9IDI7CiAgY29uc3QgQSA9IDM7CgogIGNvbnN0IFBJWEVMID0gNDsKICBjb25zdCBUUkFOU1BBUkVOVCA9IDA7CiAgY29uc3QgT1BBUVVFID0gMjU1OwoKICBjb25zdCBoZXhUb1JnYiA9IChoZXhDb2xvcikgPT4gewogICAgLy8gU2luY2UgaW5wdXQgdHlwZSBjb2xvciBwcm92aWRlcyBoZXggYW5kIEltYWdlRGF0YSBhY2NlcHRzIFJHQiBuZWVkIHRvIHRyYW5zZm9ybQogICAgY29uc3QgbSA9IGhleENvbG9yLm1hdGNoKC9eIz8oW1xkYS1mXXsyfSkoW1xkYS1mXXsyfSkoW1xkYS1mXXsyfSkkL2kpOwogICAgcmV0dXJuIFsKICAgICAgcGFyc2VJbnQobVsxXSwgMTYpLAogICAgICBwYXJzZUludChtWzJdLCAxNiksCiAgICAgIHBhcnNlSW50KG1bM10sIDE2KSwKICAgIF07CiAgfTsKCiAgLy8gUGl4ZWwgY29sb3IgZXF1YWxzIGNvbXAgY29sb3I/CiAgY29uc3QgY29sb3JNYXRjaGVyID0gKGRhdGEsIGNvbXBSLCBjb21wRywgY29tcEIsIGNvbXBBKSA9PiAocGl4ZWxQb3MpID0+ICgKICAgIGRhdGFbcGl4ZWxQb3MgKyBSXSA9PT0gY29tcFIKICAgICYmIGRhdGFbcGl4ZWxQb3MgKyBHXSA9PT0gY29tcEcKICAgICYmIGRhdGFbcGl4ZWxQb3MgKyBCXSA9PT0gY29tcEIKICAgICYmIGRhdGFbcGl4ZWxQb3MgKyBBXSA9PT0gY29tcEEKICApOwoKICBjb25zdCBjb2xvck1hdGNoZXJJZ25vcmVBbHBoYSA9IChkYXRhLCAuLi5hcmdzKSA9PiB7CiAgICBjb25zdCBtYXRjaCA9IGNvbG9yTWF0Y2hlcihkYXRhLCAuLi5hcmdzKTsKCiAgICByZXR1cm4gKHBpeGVsUG9zKSA9PiB7CiAgICAgIGNvbnN0IGFscGhhID0gZGF0YVtwaXhlbFBvcyArIEFdOwogICAgICBpZiAoYWxwaGEgIT09IFRSQU5TUEFSRU5UICYmIGFscGhhICE9PSBPUEFRVUUpIHsKICAgICAgICByZXR1cm4gdHJ1ZTsKICAgICAgfQoKICAgICAgcmV0dXJuIG1hdGNoKHBpeGVsUG9zKTsKICAgIH07CiAgfTsKCiAgLyogZXNsaW50LWRpc2FibGUgbm8tcGFyYW0tcmVhc3NpZ24gKi8KICBjb25zdCBwaXhlbFBhaW50ZXIgPSAoZGF0YSwgZmlsbFIsIGZpbGxHLCBmaWxsQiwgZmlsbEEpID0+IChwaXhlbFBvcykgPT4gewogICAgZGF0YVtwaXhlbFBvcyArIFJdID0gZmlsbFI7CiAgICBkYXRhW3BpeGVsUG9zICsgR10gPSBmaWxsRzsKICAgIGRhdGFbcGl4ZWxQb3MgKyBCXSA9IGZpbGxCOwogICAgZGF0YVtwaXhlbFBvcyArIEFdID0gZmlsbEE7CiAgfTsKCiAgY29uc3QgcGl4ZWxQYWludGVyTWl4QWxwaGEgPSAoZGF0YSwgZmlsbFIsIGZpbGxHLCBmaWxsQiwgZmlsbEEpID0+IChwaXhlbFBvcykgPT4gewogICAgY29uc3Qgb2xkQWxwaGEgPSBkYXRhW3BpeGVsUG9zICsgQV07CiAgICAvLyBjYWxjdWxhdGUgcmF0aW8gb2Ygb2xkIHZzLiBuZXcgY29sb3VyIHRvIGJlIGFscGhhLW1peGVkCiAgICBjb25zdCBtaXhBbHBoYU9sZCA9IG9sZEFscGhhID09PSBPUEFRVUUKICAgICAgPyBUUkFOU1BBUkVOVAogICAgICA6IG9sZEFscGhhIC8gT1BBUVVFOwogICAgY29uc3QgbWl4QWxwaGFOZXcgPSAxIC0gbWl4QWxwaGFPbGQ7CgogICAgY29uc3QgcGFpbnQgPSBwaXhlbFBhaW50ZXIoCiAgICAgIGRhdGEsCiAgICAgIE1hdGguZmxvb3IobWl4QWxwaGFPbGQgKiBkYXRhW3BpeGVsUG9zICsgUl0gKyBtaXhBbHBoYU5ldyAqIGZpbGxSKSwKICAgICAgTWF0aC5mbG9vcihtaXhBbHBoYU9sZCAqIGRhdGFbcGl4ZWxQb3MgKyBHXSArIG1peEFscGhhTmV3ICogZmlsbEcpLAogICAgICBNYXRoLmZsb29yKG1peEFscGhhT2xkICogZGF0YVtwaXhlbFBvcyArIEJdICsgbWl4QWxwaGFOZXcgKiBmaWxsQiksCiAgICAgIGZpbGxBLAogICAgKTsKCiAgICByZXR1cm4gcGFpbnQocGl4ZWxQb3MpOwogIH07CiAgLyogZXNsaW50LWVuYWJsZSBuby1wYXJhbS1yZWFzc2lnbiAqLwoKICAvKioKICAgKiBTdGFjay0gYW5kIHNwYW4tYmFzZWQgZmxvb2QgZmlsbCBhbGdvcml0aG0KICAgKiBzZWUgaHR0cHM6Ly9lbi53aWtpcGVkaWEub3JnL3dpa2kvRmxvb2RfZmlsbCNTcGFuX2ZpbGxpbmcKICAgKgogICAqIEBwYXJhbSB7T2JqZWN0fSBvcHRpb25zIG9wdGlvbnMgb2JqZWN0CiAgICogQHJldHVybnMge1VJbnQ4Q2xhbXBlZEFycmF5fSB0aGUgbW9kaWZpZWQgcGl4ZWxzCiAgICovCiAgY29uc3QgZmxvb2RGaWxsID0gKHsKICAgIGltYWdlLAogICAgd2lkdGgsCiAgICBoZWlnaHQsCiAgICBjb2xvciwKICAgIGdsb2JhbEFscGhhLAogICAgc3RhcnRYLAogICAgc3RhcnRZLAogICAgc3RhcnRDb2xvciwKICB9KSA9PiB7CiAgICBjb25zdCByb3cgPSB3aWR0aCAqIFBJWEVMOwogICAgLy8gbWFrZSBzdXJlIHN0YXJ0IGNvb3JkaW5hdGVzIGFyZSBpbnRlZ2VycwogICAgY29uc3Qgc3RhcnRYQ29vcmQgPSBNYXRoLmZsb29yKHN0YXJ0WCk7CiAgICBjb25zdCBzdGFydFlDb29yZCA9IE1hdGguZmxvb3Ioc3RhcnRZKTsKICAgIC8vIGhleCBuZWVkcyB0byBiZSB0cmFzZm9ybWVkIHRvIHJnYiBzaW5jZSBJbWFnZURhdGEgdXNlcyBSR0IKICAgIGNvbnN0IGZpbGxDb2xvciA9IGhleFRvUmdiKGNvbG9yKTsKICAgIC8vIGVuc3VyZSBhbHBoYSBpcyBhbiBpbnRlZ2VyIGluIHRoZSByYW5nZSBvZiAwLTI1NQogICAgY29uc3QgZmlsbEFscGhhID0gTWF0aC5mbG9vcihNYXRoLm1heCgwLCBNYXRoLm1pbihnbG9iYWxBbHBoYSAqIE9QQVFVRSwgT1BBUVVFKSkpOwogICAgLy8gd2UgbmVlZCBkaWZmZXJlbnQgYmVoYXZpb3VyIGluIGNhc2Ugd2UncmUgZmlsbGluZyBhIG5vbi1vcGFxdWUgYXJlYQogICAgY29uc3QgZmlsbGluZ05vbk9wYXF1ZSA9IHN0YXJ0Q29sb3JbQV0gIT09IE9QQVFVRTsKICAgIC8vIG91ciBwaXhlbCBwYWludGVyIHNob3VsZCBvbmx5IG1peCBhbHBoYSBpZiB3ZSdyZSBzdGFydGluZyBpbiBhIG5vbi1vcGFxdWUgYXJlYQogICAgY29uc3QgcGl4ZWxQYWludGVyT2ZDaG9pY2UgPSBmaWxsaW5nTm9uT3BhcXVlID8gcGl4ZWxQYWludGVyTWl4QWxwaGEgOiBwaXhlbFBhaW50ZXI7CiAgICBjb25zdCBwYWludFBpeGVsID0gcGl4ZWxQYWludGVyT2ZDaG9pY2UoaW1hZ2UsIC4uLmZpbGxDb2xvciwgZmlsbEFscGhhKTsKICAgIC8vIHdoZW4gbG9va2luZyBmb3IgdGhlIHNwYW4gc3RhcnQsIHdlIGlnbm9yZSB0aGUgYWxwaGEgdmFsdWUgaWYgZmlsbGluZyBhIG5vbi1vcGFxdWUgYXJlYQogICAgLy8gdGhpcyBlbnN1cmVzIHRoYXQgd2UnbGwgbWl4IHRoZSBmaWxsIGludG8gYW50aWFsaWFzZWQgZWRnZXMKICAgIGNvbnN0IGNvbG9yTWF0Y2hlclNwYW5TdGFydCA9IGZpbGxpbmdOb25PcGFxdWUgPyBjb2xvck1hdGNoZXJJZ25vcmVBbHBoYSA6IGNvbG9yTWF0Y2hlcjsKICAgIGNvbnN0IG1hdGNoU3BhblN0YXJ0Q29sb3IgPSBjb2xvck1hdGNoZXJTcGFuU3RhcnQoaW1hZ2UsIC4uLnN0YXJ0Q29sb3IpOwogICAgLy8gZm9yIGFsbCBvdGhlciBjYXNlcywgd2UgbG9vayBmb3IgdGhlIHN0YXJ0IGNvbG91ciBleGFjdGx5CiAgICBjb25zdCBtYXRjaFN0YXJ0Q29sb3IgPSBjb2xvck1hdGNoZXJJZ25vcmVBbHBoYShpbWFnZSwgLi4uc3RhcnRDb2xvcik7CgogICAgLy8gY2hlY2sgaWYgd2UncmUgdHJ5aW5nIHRvIGZpbGwgd2l0aCB0aGUgc2FtZSBjb2xvdXIsIGlmIHNvLCBzdG9wCiAgICBjb25zdCBtYXRjaEZpbGxDb2xvciA9IGNvbG9yTWF0Y2hlcihpbWFnZSwgLi4uWy4uLmZpbGxDb2xvciwgT1BBUVVFXSk7CiAgICBpZiAobWF0Y2hGaWxsQ29sb3IoKHN0YXJ0WUNvb3JkICogd2lkdGggKyBzdGFydFhDb29yZCkgKiBQSVhFTCkpIHsKICAgICAgcmV0dXJuIGltYWdlOwogICAgfQogICAgLy8gYmVnaW4gd2l0aCBvdXIgc3RhcnQgcGl4ZWwKICAgIGNvbnN0IHBpeGVsU3RhY2sgPSBbW3N0YXJ0WENvb3JkLCBzdGFydFlDb29yZF1dOwogICAgd2hpbGUgKHBpeGVsU3RhY2subGVuZ3RoKSB7CiAgICAgIGNvbnN0IFt4LCB5XSA9IHBpeGVsU3RhY2sucG9wKCk7CiAgICAgIC8vIGNvbHVtbiBwb3NpdGlvbiBpcyBpbiBjYXJ0ZXNpYW4gc3BhY2UgKHgseSkKICAgICAgbGV0IGNvbHVtblBvc2l0aW9uID0geTsKICAgICAgLy8gcGl4ZWwgcG9zaXRpb24gaXMgaW4gMUQgc3BhY2UgKHRoZSByYXcgaW1hZ2UgZGF0YSBVSW50OENsYW1wZWRBcnJheSkKICAgICAgbGV0IHBpeGVsUG9zID0gKGNvbHVtblBvc2l0aW9uICogd2lkdGggKyB4KSAqIFBJWEVMOwogICAgICAvLyBzdGFydCBtb3ZpbmcgZGlyZWN0bHkgdXAgZnJvbSBvdXIgc3RhcnQgcG9zaXRpb24KICAgICAgLy8gdW50aWwgd2UgZmluZCBhIGRpZmZlcmVudCBjb2xvdXIgdG8gdGhlIHN0YXJ0IGNvbG91cgogICAgICAvLyB0aGlzIGlzIHRoZSBiZWdpbm5pbmcgb2Ygb3VyIHNwYW4KICAgICAgd2hpbGUgKGNvbHVtblBvc2l0aW9uLS0gPj0gMCAmJiBtYXRjaFNwYW5TdGFydENvbG9yKHBpeGVsUG9zKSkgewogICAgICAgIHBpeGVsUG9zIC09IHJvdzsKICAgICAgfQogICAgICAvLyBtb3ZlIG9uZSByb3cgZG93biAodG9wbW9zdCBwaXhlbCBvZiBmaWxsYWJsZSBhcmVhKQogICAgICBwaXhlbFBvcyArPSByb3c7CgogICAgICBsZXQgcmVhY2hMZWZ0ID0gZmFsc2U7CiAgICAgIGxldCByZWFjaFJpZ2h0ID0gZmFsc2U7CiAgICAgIC8vIGZvciBlYWNoIHJvdywgY2hlY2sgaWYgdGhlIGZpcnN0IHBpeGVsIHN0aWxsIGhhcyB0aGUgc3RhcnQgY29sb3VyCiAgICAgIC8vIGlmIGl0IGRvZXMsIHBhaW50IGl0IGFuZCBwdXNoIHN1cnJvdW5kaW5nIHBpeGVscyB0byB0aGUgc3RhY2sgb2YgcGl4ZWxzIHRvIGNoZWNrCiAgICAgIHdoaWxlICgrK2NvbHVtblBvc2l0aW9uIDwgaGVpZ2h0IC0gMSAmJiBtYXRjaFN0YXJ0Q29sb3IocGl4ZWxQb3MpKSB7CiAgICAgICAgcGFpbnRQaXhlbChwaXhlbFBvcyk7CiAgICAgICAgLy8gY2hlY2sgdGhlIHBpeGVsIHRvIHRoZSBsZWZ0CiAgICAgICAgaWYgKHggPiAwKSB7CiAgICAgICAgICBpZiAobWF0Y2hTdGFydENvbG9yKHBpeGVsUG9zIC0gUElYRUwpKSB7CiAgICAgICAgICAgIGlmICghcmVhY2hMZWZ0KSB7CiAgICAgICAgICAgICAgcGl4ZWxTdGFjay5wdXNoKFt4IC0gMSwgY29sdW1uUG9zaXRpb25dKTsKICAgICAgICAgICAgICByZWFjaExlZnQgPSB0cnVlOwogICAgICAgICAgICB9CiAgICAgICAgICB9IGVsc2UgaWYgKHJlYWNoTGVmdCkgewogICAgICAgICAgICByZWFjaExlZnQgPSBmYWxzZTsKICAgICAgICAgIH0KICAgICAgICB9CiAgICAgICAgLy8gY2hlY2sgdGhlIHBpeGVsIHRvIHRoZSByaWdodAogICAgICAgIGlmICh4IDwgd2lkdGggLSAxKSB7CiAgICAgICAgICBpZiAobWF0Y2hTdGFydENvbG9yKHBpeGVsUG9zICsgUElYRUwpKSB7CiAgICAgICAgICAgIGlmICghcmVhY2hSaWdodCkgewogICAgICAgICAgICAgIHBpeGVsU3RhY2sucHVzaChbeCArIDEsIGNvbHVtblBvc2l0aW9uXSk7CiAgICAgICAgICAgICAgcmVhY2hSaWdodCA9IHRydWU7CiAgICAgICAgICAgIH0KICAgICAgICAgIH0gZWxzZSBpZiAocmVhY2hSaWdodCkgewogICAgICAgICAgICByZWFjaFJpZ2h0ID0gZmFsc2U7CiAgICAgICAgICB9CiAgICAgICAgfQogICAgICAgIC8vIG1vdmUgdG8gdGhlIG5leHQgcm93CiAgICAgICAgcGl4ZWxQb3MgKz0gcm93OwogICAgICB9CiAgICB9CgogICAgcmV0dXJuIGltYWdlOwogIH07CgogIGdsb2JhbFRoaXMuYWRkRXZlbnRMaXN0ZW5lcignbWVzc2FnZScsICh7IGRhdGEgfSkgPT4gewogICAgY29uc3QgcmVzdWx0ID0gZmxvb2RGaWxsKGRhdGEpOwoKICAgIGdsb2JhbFRoaXMucG9zdE1lc3NhZ2UoeyB0eXBlOiAnZmlsbC1yZXN1bHQnLCByZXN1bHQgfSwgW3Jlc3VsdC5idWZmZXJdKTsKICB9KTsKCn0pKCk7Ci8vIyBzb3VyY2VNYXBwaW5nVVJMPXdvcmtlci5qcy5tYXAKCg==",function(G){return C=C||g(I),new Worker(C,G)});export{G as default}; 2 | -------------------------------------------------------------------------------- /dist/esm/index.js: -------------------------------------------------------------------------------- 1 | class t{constructor(t,e){this.x=t,this.y=e}set(t,e){this.x=t,this.y=e}}class e extends t{constructor(){super(0,0),this.down=!1,this.previous=new t(0,0)}}class s{constructor(){this.eventListeners=new Map}addEventListener(t,e){const s=this.eventListeners.get(t)||new Set;s.add(e),this.eventListeners.set(t,s)}removeEventListener(t,e){const s=this.eventListeners.get(t);s&&s.delete(e)}dispatchEvent(t,e){const s=this.eventListeners.get(t);s&&[...s].forEach((t=>t(e)))}}const i=(t,e,s,i)=>{const o=(s-t)**2,r=(i-e)**2;return Math.sqrt(o+r)},o=t=>e=>{!e.isPrimary||e.button>0||(e.cancelable&&e.preventDefault(),t(e))},r=Symbol("atrament mode - draw"),n=Symbol("atrament mode - erase"),h=Symbol("atrament mode - fill"),a=Symbol("atrament mode - disabled"),c=[r,n],l=["weight","smoothing","adaptiveStroke","mode"];class d extends s{adaptiveStroke=!0;canvas;recordStrokes=!1;resolution=window.devicePixelRatio;smoothing=.85;thickness=2;#t;#e=!1;#s=!1;#i=[];#o=null;#r=r;#n=new e;#h=.5;#a;#c=[];#l=2;#d=2;constructor(t,e={}){if("undefined"==typeof window)throw new Error("atrament: looks like we're not running in a browser");super(),this.canvas=d.#m(t,e),this.#t=d.#u(this.canvas,e),this.#p({FillWorker:e.fill}),this.#a=(({canvas:t,move:e,down:s,up:i})=>{const r=o(e),n=o(s),h=o(i);return t.addEventListener("pointermove",r),t.addEventListener("pointerdown",n),document.addEventListener("pointerup",h),document.addEventListener("pointerout",h),()=>{t.removeEventListener("pointermove",r),t.removeEventListener("pointerdown",n),document.removeEventListener("pointerup",h),document.removeEventListener("pointerout",h)}})({canvas:this.canvas,move:this.#v.bind(this),down:this.#g.bind(this),up:this.#w.bind(this)}),l.forEach((t=>{void 0!==e[t]&&(this[t]=e[t])}))}beginStroke(t,e){this.#t.moveTo(t,e),this.#l=this.#d,this.recordStrokes&&(this.strokeTimestamp=performance.now()),this.dispatchEvent("strokestart",{x:t,y:e})}endStroke(t,e){this.dispatchEvent("strokeend",{x:t,y:e}),this.recordStrokes&&this.dispatchEvent("strokerecorded",{stroke:this.currentStroke}),this.#c=[],delete this.strokeTimestamp}draw(e,s,o,n){const h=o||e,a=n||s,c=this.getSmoothingFactor(i(e,s,h,a)),l=e-(e-h)*c,d=s-(s-a)*c,m=i(l,d,h,a);if(this.adaptiveStroke){const t=(m-2)/(98*(1-this.#h))*(this.#k-this.#d)+this.#d;this.#l>t?this.#l-=.25:this.#l{const e=t.offsetX,s=t.offsetY;if(this.#n.down&&c.includes(this.#r)){const{x:i,y:o}=this.draw(e,s,this.#n.previous.x,this.#n.previous.y);this.#n.set(e,s),this.#n.previous.set(i,o),this.#h=1===t.pressure?.5:t.pressure||.5}else this.#n.set(e,s),this.#n.previous.set(e,s)}))}#g(t){this.mode!==h?(this.#n.down=!0,this.#v(t),this.beginStroke(this.#n.previous.x,this.#n.previous.y)):this.#f()}#w(t){this.#r!==h&&this.#n.down&&(this.#n.down=!1,this.#n.x===t.offsetX&&this.#n.y===t.offsetY&&c.includes(this.mode)&&this.draw(this.#n.x,this.#n.y,this.#n.previous.x,this.#n.previous.y),this.#n.previous.set(0,0),this.endStroke(this.#n.x,this.#n.y))}#p({FillWorker:t}){t&&(this.#o=new t,this.#o.addEventListener("message",(({data:t})=>{if("fill-result"===t.type){this.#s=!1,this.dispatchEvent("fillend",{});const e=new ImageData(t.result,this.canvas.width,this.canvas.height);this.#t.putImageData(e,0,0),this.#i.length>0&&this.#x(this.#i.shift())}})))}#f(){if(!this.#o)throw new Error("atrament: fill mode only works if the fillWorker option is passed to the Atrament constructor");const{x:t,y:e}=this.#n;this.dispatchEvent("fillstart",{x:t,y:e});const s=Array.from(this.#t.getImageData(t,e,1,1).data),i={color:this.color,globalAlpha:this.#t.globalAlpha,width:this.canvas.width,height:this.canvas.height,startColor:s,startX:t*this.resolution,startY:e*this.resolution};this.#s?this.#i.push(i):(this.#s=!0,this.#x(i))}#x(t){const e=this.#t.getImageData(0,0,this.canvas.width,this.canvas.height).data;this.#o?.postMessage({image:e,...t},[e.buffer])}}export{a as MODE_DISABLED,r as MODE_DRAW,n as MODE_ERASE,h as MODE_FILL,d as default}; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atrament", 3 | "version": "4.6.0", 4 | "description": "Tiny JS library for beautiful drawing and handwriting on the HTML Canvas", 5 | "main": "./dist/cjs/index.js", 6 | "module": "./dist/esm/index.js", 7 | "scripts": { 8 | "build": "rollup --config", 9 | "watch": "rollup --config --watch", 10 | "lint": "./node_modules/eslint/bin/eslint.js ./src" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/jakubfiala/atrament.git" 15 | }, 16 | "keywords": [ 17 | "canvas", 18 | "drawing", 19 | "handwriting", 20 | "graphics" 21 | ], 22 | "author": "Jakub Fiala", 23 | "license": "SEE LICENSE IN LICENSE.md", 24 | "bugs": { 25 | "url": "https://github.com/jakubfiala/atrament/issues" 26 | }, 27 | "homepage": "https://github.com/jakubfiala/atrament#readme", 28 | "exports": { 29 | ".": { 30 | "import": "./dist/esm/index.js", 31 | "require": "./dist/cjs/index.js" 32 | }, 33 | "./fill": { 34 | "import": "./dist/esm/fill.js", 35 | "require": "./dist/cjs/fill.js" 36 | } 37 | }, 38 | "dependencies": { 39 | "@rollup/plugin-terser": "^0.4.4", 40 | "eslint": "^8.56.0", 41 | "eslint-config-airbnb-base": "^15.0.0", 42 | "eslint-plugin-import": "^2.26.0", 43 | "rollup": "^4.9.4", 44 | "rollup-plugin-web-worker-loader": "^1.6.1" 45 | }, 46 | "overrides": { 47 | "rollup-plugin-web-worker-loader": { 48 | "rollup": "$rollup" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import terser from '@rollup/plugin-terser'; 2 | import webWorkerLoader from 'rollup-plugin-web-worker-loader'; 3 | 4 | export default { 5 | input: { 6 | index: 'src/index.js', 7 | fill: 'src/fill/index.js', 8 | }, 9 | output: [ 10 | { 11 | dir: 'dist/esm', 12 | plugins: [ 13 | terser(), 14 | ], 15 | }, 16 | { 17 | dir: 'dist/cjs', 18 | format: 'cjs', 19 | exports: 'named', 20 | }, 21 | ], 22 | plugins: [ 23 | webWorkerLoader({ 24 | targetPlatform: 'browser', 25 | }), 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | const MAX_LINE_THICKNESS = 100; 2 | 3 | export const MIN_LINE_THICKNESS = 2; 4 | export const LINE_THICKNESS_RANGE = MAX_LINE_THICKNESS - MIN_LINE_THICKNESS; 5 | export const THICKNESS_INCREMENT = 0.25; 6 | export const MIN_SMOOTHING_FACTOR = 0.87; 7 | export const INITIAL_SMOOTHING_FACTOR = 0.85; 8 | export const WEIGHT_SPREAD = 30; 9 | export const INITIAL_THICKNESS = 2; 10 | export const DEFAULT_PRESSURE = 0.5; 11 | -------------------------------------------------------------------------------- /src/events.js: -------------------------------------------------------------------------------- 1 | export default class AtramentEventTarget { 2 | constructor() { 3 | this.eventListeners = new Map(); 4 | } 5 | 6 | addEventListener(eventName, handler) { 7 | const handlers = this.eventListeners.get(eventName) || new Set(); 8 | handlers.add(handler); 9 | this.eventListeners.set(eventName, handlers); 10 | } 11 | 12 | removeEventListener(eventName, handler) { 13 | const handlers = this.eventListeners.get(eventName); 14 | if (!handlers) return; 15 | handlers.delete(handler); 16 | } 17 | 18 | dispatchEvent(eventName, data) { 19 | const handlers = this.eventListeners.get(eventName); 20 | if (!handlers) return; 21 | [...handlers].forEach((handler) => handler(data)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/fill/flood.js: -------------------------------------------------------------------------------- 1 | import { 2 | PIXEL, A, OPAQUE, 3 | hexToRgb, 4 | pixelPainter, 5 | pixelPainterMixAlpha, 6 | colorMatcher, 7 | colorMatcherIgnoreAlpha, 8 | } from '../pixels.js'; 9 | 10 | /** 11 | * Stack- and span-based flood fill algorithm 12 | * see https://en.wikipedia.org/wiki/Flood_fill#Span_filling 13 | * 14 | * @param {Object} options options object 15 | * @returns {UInt8ClampedArray} the modified pixels 16 | */ 17 | const floodFill = ({ 18 | image, 19 | width, 20 | height, 21 | color, 22 | globalAlpha, 23 | startX, 24 | startY, 25 | startColor, 26 | }) => { 27 | const row = width * PIXEL; 28 | // make sure start coordinates are integers 29 | const startXCoord = Math.floor(startX); 30 | const startYCoord = Math.floor(startY); 31 | // hex needs to be trasformed to rgb since ImageData uses RGB 32 | const fillColor = hexToRgb(color); 33 | // ensure alpha is an integer in the range of 0-255 34 | const fillAlpha = Math.floor(Math.max(0, Math.min(globalAlpha * OPAQUE, OPAQUE))); 35 | // we need different behaviour in case we're filling a non-opaque area 36 | const fillingNonOpaque = startColor[A] !== OPAQUE; 37 | // our pixel painter should only mix alpha if we're starting in a non-opaque area 38 | const pixelPainterOfChoice = fillingNonOpaque ? pixelPainterMixAlpha : pixelPainter; 39 | const paintPixel = pixelPainterOfChoice(image, ...fillColor, fillAlpha); 40 | // when looking for the span start, we ignore the alpha value if filling a non-opaque area 41 | // this ensures that we'll mix the fill into antialiased edges 42 | const colorMatcherSpanStart = fillingNonOpaque ? colorMatcherIgnoreAlpha : colorMatcher; 43 | const matchSpanStartColor = colorMatcherSpanStart(image, ...startColor); 44 | // for all other cases, we look for the start colour exactly 45 | const matchStartColor = colorMatcherIgnoreAlpha(image, ...startColor); 46 | 47 | // check if we're trying to fill with the same colour, if so, stop 48 | const matchFillColor = colorMatcher(image, ...[...fillColor, OPAQUE]); 49 | if (matchFillColor((startYCoord * width + startXCoord) * PIXEL)) { 50 | return image; 51 | } 52 | // begin with our start pixel 53 | const pixelStack = [[startXCoord, startYCoord]]; 54 | while (pixelStack.length) { 55 | const [x, y] = pixelStack.pop(); 56 | // column position is in cartesian space (x,y) 57 | let columnPosition = y; 58 | // pixel position is in 1D space (the raw image data UInt8ClampedArray) 59 | let pixelPos = (columnPosition * width + x) * PIXEL; 60 | // start moving directly up from our start position 61 | // until we find a different colour to the start colour 62 | // this is the beginning of our span 63 | while (columnPosition-- >= 0 && matchSpanStartColor(pixelPos)) { 64 | pixelPos -= row; 65 | } 66 | // move one row down (topmost pixel of fillable area) 67 | pixelPos += row; 68 | 69 | let reachLeft = false; 70 | let reachRight = false; 71 | // for each row, check if the first pixel still has the start colour 72 | // if it does, paint it and push surrounding pixels to the stack of pixels to check 73 | while (++columnPosition < height - 1 && matchStartColor(pixelPos)) { 74 | paintPixel(pixelPos); 75 | // check the pixel to the left 76 | if (x > 0) { 77 | if (matchStartColor(pixelPos - PIXEL)) { 78 | if (!reachLeft) { 79 | pixelStack.push([x - 1, columnPosition]); 80 | reachLeft = true; 81 | } 82 | } else if (reachLeft) { 83 | reachLeft = false; 84 | } 85 | } 86 | // check the pixel to the right 87 | if (x < width - 1) { 88 | if (matchStartColor(pixelPos + PIXEL)) { 89 | if (!reachRight) { 90 | pixelStack.push([x + 1, columnPosition]); 91 | reachRight = true; 92 | } 93 | } else if (reachRight) { 94 | reachRight = false; 95 | } 96 | } 97 | // move to the next row 98 | pixelPos += row; 99 | } 100 | } 101 | 102 | return image; 103 | }; 104 | 105 | export default floodFill; 106 | -------------------------------------------------------------------------------- /src/fill/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import FillWorker from 'web-worker:./worker'; 3 | 4 | export default FillWorker; 5 | -------------------------------------------------------------------------------- /src/fill/worker.js: -------------------------------------------------------------------------------- 1 | import floodFill from './flood'; 2 | 3 | globalThis.addEventListener('message', ({ data }) => { 4 | const result = floodFill(data); 5 | 6 | globalThis.postMessage({ type: 'fill-result', result }, [result.buffer]); 7 | }); 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { Mouse, Point } from './mouse.js'; 2 | import AtramentEventTarget from './events.js'; 3 | import { lineDistance } from './pixels.js'; 4 | import { setupPointerEvents } from './pointer-events.js'; 5 | 6 | import { 7 | MIN_LINE_THICKNESS, 8 | LINE_THICKNESS_RANGE, 9 | THICKNESS_INCREMENT, 10 | MIN_SMOOTHING_FACTOR, 11 | INITIAL_SMOOTHING_FACTOR, 12 | WEIGHT_SPREAD, 13 | INITIAL_THICKNESS, 14 | DEFAULT_PRESSURE, 15 | } from './constants.js'; 16 | 17 | export const MODE_DRAW = Symbol('atrament mode - draw'); 18 | export const MODE_ERASE = Symbol('atrament mode - erase'); 19 | export const MODE_FILL = Symbol('atrament mode - fill'); 20 | export const MODE_DISABLED = Symbol('atrament mode - disabled'); 21 | 22 | const pathDrawingModes = [MODE_DRAW, MODE_ERASE]; 23 | const configKeys = ['weight', 'smoothing', 'adaptiveStroke', 'mode']; 24 | 25 | export default class Atrament extends AtramentEventTarget { 26 | adaptiveStroke = true; 27 | canvas; 28 | recordStrokes = false; 29 | resolution = window.devicePixelRatio; 30 | smoothing = INITIAL_SMOOTHING_FACTOR; 31 | thickness = INITIAL_THICKNESS; 32 | 33 | #context; 34 | #dirty = false; 35 | #filling = false; 36 | #fillStack = []; 37 | #fillWorker = null; 38 | #mode = MODE_DRAW; 39 | #mouse = new Mouse(); 40 | #pressure = DEFAULT_PRESSURE; 41 | #removePointerEventListeners; 42 | #strokeMemory = []; 43 | #thickness = INITIAL_THICKNESS; 44 | #weight = INITIAL_THICKNESS; 45 | 46 | constructor(selector, config = {}) { 47 | if (typeof window === 'undefined') { 48 | throw new Error('atrament: looks like we\'re not running in a browser'); 49 | } 50 | 51 | super(); 52 | 53 | this.canvas = Atrament.#setupCanvas(selector, config); 54 | this.#context = Atrament.#setupContext(this.canvas, config); 55 | this.#setupFill({ FillWorker: config.fill }); 56 | 57 | this.#removePointerEventListeners = setupPointerEvents({ 58 | canvas: this.canvas, 59 | move: this.#pointerMove.bind(this), 60 | down: this.#pointerDown.bind(this), 61 | up: this.#pointerUp.bind(this), 62 | }); 63 | 64 | configKeys.forEach((key) => { 65 | if (config[key] !== undefined) { 66 | this[key] = config[key]; 67 | } 68 | }); 69 | } 70 | 71 | /** 72 | * Begins a stroke at a given position 73 | * 74 | * @param {number} x 75 | * @param {number} y 76 | */ 77 | beginStroke(x, y) { 78 | this.#context.moveTo(x, y); 79 | this.#thickness = this.#weight; 80 | 81 | if (this.recordStrokes) { 82 | this.strokeTimestamp = performance.now(); 83 | } 84 | 85 | this.dispatchEvent('strokestart', { x, y }); 86 | } 87 | 88 | /** 89 | * Ends a stroke at a given position 90 | * 91 | * @param {number} x 92 | * @param {number} y 93 | */ 94 | endStroke(x, y) { 95 | this.dispatchEvent('strokeend', { x, y }); 96 | 97 | if (this.recordStrokes) { 98 | this.dispatchEvent('strokerecorded', { stroke: this.currentStroke }); 99 | } 100 | this.#strokeMemory = []; 101 | delete (this.strokeTimestamp); 102 | } 103 | 104 | /** 105 | * Draws the next stroke segment as a smooth quadratic curve 106 | * with adaptive stroke thickness between two points. 107 | * 108 | * @param {number} x current X coordinate 109 | * @param {number} y current Y coordinate 110 | * @param {number} previousX previous X coordinate 111 | * @param {number} previousY previous Y coordinate 112 | */ 113 | draw(x, y, previousX, previousY) { 114 | // If the user clicks (or double clicks) without moving the mouse, 115 | // previousX/Y will be 0. In this case, we don't want to draw a line from (0,0) to (x,y), 116 | // but a "point" from (x,y) to (x,y). 117 | const prevX = previousX || x; 118 | const prevY = previousY || y; 119 | // get distance from the previous point 120 | // and use it to calculate the smoothed coordinates 121 | const smoothingFactor = this.getSmoothingFactor(lineDistance(x, y, prevX, prevY)); 122 | const procX = x - (x - prevX) * smoothingFactor; 123 | const procY = y - (y - prevY) * smoothingFactor; 124 | 125 | // recalculate distance from previous point, this time relative to the smoothed coords 126 | const dist = lineDistance(procX, procY, prevX, prevY); 127 | 128 | // Adaptive stroke allows an effect where thickness changes 129 | // over the course of the stroke. This simulates the variation in 130 | // ink discharge of a physical pen. 131 | if (this.adaptiveStroke) { 132 | // Thickness range is inversely proportional to pressure, 133 | // because with higher pressure, the effect of distance 134 | // on the thickness ratio should be greater. 135 | const range = LINE_THICKNESS_RANGE * (1 - this.#pressure); 136 | const ratio = (dist - MIN_LINE_THICKNESS) / range; 137 | const targetThickness = ratio * (this.#maxWeight - this.#weight) + this.#weight; 138 | // approach the target gradually 139 | if (this.#thickness > targetThickness) { 140 | this.#thickness -= THICKNESS_INCREMENT; 141 | } else if (this.#thickness < targetThickness) { 142 | this.#thickness += THICKNESS_INCREMENT; 143 | } 144 | } else { 145 | this.#thickness = this.#weight; 146 | } 147 | 148 | this.#context.lineWidth = this.#thickness; 149 | 150 | // Draw the segment using quad interpolation. 151 | this.#context.beginPath(); 152 | this.#context.moveTo(prevX, prevY); 153 | this.#context.quadraticCurveTo(prevX, prevY, procX, procY); 154 | this.#context.closePath(); 155 | this.#context.stroke(); 156 | 157 | if (this.recordStrokes) { 158 | this.#strokeMemory.push({ 159 | point: new Point(x, y), 160 | time: performance.now() - this.strokeTimestamp, 161 | }); 162 | 163 | this.dispatchEvent('segmentdrawn', { stroke: this.currentStroke }); 164 | } 165 | 166 | // At this point, we can be certain the canvas has some drawing on it, 167 | // so we can toggle the "dirty" state. Checking it here ensures that 168 | // the state is also updated during programmatic drawing. 169 | if (!this.#dirty && this.#mode === MODE_DRAW) { 170 | this.#dirty = true; 171 | this.dispatchEvent('dirty'); 172 | } 173 | 174 | return { x: procX, y: procY }; 175 | } 176 | 177 | clear() { 178 | this.#dirty = false; 179 | this.dispatchEvent('clean'); 180 | 181 | // make sure we're in the right compositing mode, and erase everything 182 | const eraseMode = this.mode === MODE_ERASE; 183 | if (eraseMode) { 184 | this.mode = MODE_DRAW; 185 | } 186 | 187 | // clear the canvas without the transform 188 | // code taken from https://stackoverflow.com/a/6722031 189 | this.#context.save(); 190 | this.#context.setTransform(1, 0, 0, 1, 0, 0); 191 | this.#context.clearRect(0, 0, this.canvas.width, this.canvas.height); 192 | this.#context.restore(); 193 | 194 | if (eraseMode) { 195 | this.mode = MODE_ERASE; 196 | } 197 | } 198 | 199 | destroy() { 200 | this.clear(); 201 | this.#removePointerEventListeners?.(); 202 | } 203 | 204 | get color() { 205 | return this.#context.strokeStyle; 206 | } 207 | 208 | set color(c) { 209 | if (typeof c !== 'string') throw new Error('atrament: wrong argument type setting color'); 210 | this.#context.strokeStyle = c; 211 | } 212 | 213 | get weight() { 214 | return this.#weight; 215 | } 216 | 217 | set weight(w) { 218 | if (typeof w !== 'number') throw new Error('atrament: wrong argument type setting weight'); 219 | this.#thickness = w; 220 | this.#weight = w; 221 | } 222 | 223 | // For small weights, this allows for a lot of spread, 224 | // while for larger weights, the effect is less prominent. 225 | // This means at small weights, Atrament behaves more like an ink pen, 226 | // and at larger weights more like a marker. 227 | get #maxWeight() { 228 | return this.#weight + WEIGHT_SPREAD; 229 | } 230 | 231 | // Here we scale the initial smoothing factor by the raw distance 232 | // - this means that when the mouse moves fast, there is more smoothing, 233 | // and when we're drawing small detailed stuff, we have more control. 234 | getSmoothingFactor(dist) { 235 | return Math.min( 236 | MIN_SMOOTHING_FACTOR, 237 | this.smoothing + (dist - 60) / 3000, 238 | ); 239 | } 240 | 241 | get mode() { 242 | return this.#mode; 243 | } 244 | 245 | set mode(m) { 246 | switch (m) { 247 | case MODE_ERASE: 248 | this.#mode = MODE_ERASE; 249 | this.#context.globalCompositeOperation = 'destination-out'; 250 | break; 251 | case MODE_FILL: 252 | this.#mode = MODE_FILL; 253 | this.#context.globalCompositeOperation = 'source-over'; 254 | break; 255 | case MODE_DISABLED: 256 | this.#mode = MODE_DISABLED; 257 | break; 258 | case MODE_DRAW: 259 | this.#mode = MODE_DRAW; 260 | this.#context.globalCompositeOperation = 'source-over'; 261 | break; 262 | default: 263 | throw new Error('atrament: mode is not one of the allowed modes.'); 264 | } 265 | } 266 | 267 | get currentStroke() { 268 | return { 269 | segments: this.#strokeMemory.slice(), 270 | mode: this.mode, 271 | weight: this.weight, 272 | smoothing: this.smoothing, 273 | color: this.color, 274 | adaptiveStroke: this.adaptiveStroke, 275 | }; 276 | } 277 | 278 | get dirty() { 279 | return this.#dirty; 280 | } 281 | 282 | static #setupCanvas(selector, config) { 283 | let canvas; 284 | // get canvas element 285 | if (selector instanceof window.Node && selector.tagName === 'CANVAS') canvas = selector; 286 | else if (typeof selector === 'string') canvas = document.querySelector(selector); 287 | else throw new Error(`atrament: can't look for canvas based on '${selector}'`); 288 | if (!canvas) throw new Error('atrament: canvas not found'); 289 | // since this method is static, we have to add a fallback to the resolution here 290 | // TODO: see if these methods really have to be static. 291 | const scale = config.resolution || window.devicePixelRatio; 292 | canvas.width = (config.width || canvas.width) * scale; 293 | canvas.height = (config.height || canvas.height) * scale; 294 | canvas.style.touchAction = 'none'; 295 | 296 | return canvas; 297 | } 298 | 299 | static #setupContext(canvas, config) { 300 | const context = canvas.getContext('2d'); 301 | // since this method is static, we have to add a fallback to the resolution here 302 | // TODO: see if these methods really have to be static. 303 | const scale = config.resolution || window.devicePixelRatio; 304 | context.scale(scale, scale); 305 | context.globalCompositeOperation = 'source-over'; 306 | context.globalAlpha = 1; 307 | context.strokeStyle = config.color || 'rgba(0,0,0,1)'; 308 | context.lineCap = 'round'; 309 | context.lineJoin = 'round'; 310 | 311 | return context; 312 | } 313 | 314 | #pointerMove(event) { 315 | const positions = event.getCoalescedEvents?.() || [event]; 316 | positions.forEach((position) => { 317 | const x = position.offsetX; 318 | const y = position.offsetY; 319 | 320 | // draw if we should draw 321 | if (this.#mouse.down && pathDrawingModes.includes(this.#mode)) { 322 | const { x: newX, y: newY } = this.draw( 323 | x, 324 | y, 325 | this.#mouse.previous.x, 326 | this.#mouse.previous.y, 327 | ); 328 | 329 | this.#mouse.set(x, y); 330 | this.#mouse.previous.set(newX, newY); 331 | // Android Chrome sets pressure to constant 1 by default, 332 | // which would break the algorithm. 333 | // We also handle the case when pressure is 0. 334 | this.#pressure = position.pressure === 1 335 | ? DEFAULT_PRESSURE 336 | : position.pressure || DEFAULT_PRESSURE; 337 | } else { 338 | this.#mouse.set(x, y); 339 | this.#mouse.previous.set(x, y); 340 | } 341 | }); 342 | } 343 | 344 | #pointerDown(event) { 345 | // if we are filling - fill and return 346 | if (this.mode === MODE_FILL) { 347 | this.#fill(); 348 | return; 349 | } 350 | 351 | this.#mouse.down = true; 352 | // update position just in case 353 | this.#pointerMove(event); 354 | 355 | this.beginStroke(this.#mouse.previous.x, this.#mouse.previous.y); 356 | } 357 | 358 | #pointerUp(event) { 359 | if (this.#mode === MODE_FILL) { 360 | return; 361 | } 362 | 363 | if (!this.#mouse.down) { 364 | return; 365 | } 366 | 367 | this.#mouse.down = false; 368 | 369 | if (this.#mouse.x === event.offsetX 370 | && this.#mouse.y === event.offsetY && pathDrawingModes.includes(this.mode)) { 371 | this.draw( 372 | this.#mouse.x, 373 | this.#mouse.y, 374 | this.#mouse.previous.x, 375 | this.#mouse.previous.y, 376 | ); 377 | } 378 | 379 | this.#mouse.previous.set(0, 0); 380 | 381 | this.endStroke(this.#mouse.x, this.#mouse.y); 382 | } 383 | 384 | #setupFill({ FillWorker }) { 385 | if (!FillWorker) { 386 | return; 387 | } 388 | 389 | this.#fillWorker = new FillWorker(); 390 | this.#fillWorker.addEventListener('message', ({ data }) => { 391 | if (data.type === 'fill-result') { 392 | this.#filling = false; 393 | this.dispatchEvent('fillend', {}); 394 | 395 | const imageData = new ImageData(data.result, this.canvas.width, this.canvas.height); 396 | this.#context.putImageData(imageData, 0, 0); 397 | 398 | if (this.#fillStack.length > 0) { 399 | this.#postToFillWorker(this.#fillStack.shift()); 400 | } 401 | } 402 | }); 403 | } 404 | 405 | #fill() { 406 | if (!this.#fillWorker) { 407 | throw new Error('atrament: fill mode only works if the fillWorker option is passed to the Atrament constructor'); 408 | } 409 | 410 | const { x, y } = this.#mouse; 411 | this.dispatchEvent('fillstart', { x, y }); 412 | 413 | const startColor = Array.from(this.#context.getImageData(x, y, 1, 1).data); 414 | const fillData = { 415 | color: this.color, 416 | globalAlpha: this.#context.globalAlpha, 417 | width: this.canvas.width, 418 | height: this.canvas.height, 419 | startColor, 420 | startX: x * this.resolution, 421 | startY: y * this.resolution, 422 | }; 423 | 424 | if (!this.#filling) { 425 | this.#filling = true; 426 | this.#postToFillWorker(fillData); 427 | } else { 428 | this.#fillStack.push(fillData); 429 | } 430 | } 431 | 432 | #postToFillWorker(fillData) { 433 | const image = this.#context.getImageData(0, 0, this.canvas.width, this.canvas.height).data; 434 | this.#fillWorker?.postMessage({ image, ...fillData }, [image.buffer]); 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /src/mouse.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | // make a class for Point 3 | export class Point { 4 | constructor(x, y) { 5 | this.x = x; 6 | this.y = y; 7 | } 8 | 9 | set(x, y) { 10 | this.x = x; 11 | this.y = y; 12 | } 13 | } 14 | 15 | // make a class for the mouse data 16 | export class Mouse extends Point { 17 | constructor() { 18 | super(0, 0); 19 | this.down = false; 20 | this.previous = new Point(0, 0); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/pixels.js: -------------------------------------------------------------------------------- 1 | // colour indices per pixel 2 | const R = 0; 3 | const G = 1; 4 | const B = 2; 5 | export const A = 3; 6 | 7 | export const PIXEL = 4; 8 | export const TRANSPARENT = 0; 9 | export const OPAQUE = 255; 10 | 11 | export const lineDistance = (x1, y1, x2, y2) => { 12 | // calculate euclidean distance between (x1, y1) and (x2, y2) 13 | const xs = (x2 - x1) ** 2; 14 | const ys = (y2 - y1) ** 2; 15 | return Math.sqrt(xs + ys); 16 | }; 17 | 18 | export const hexToRgb = (hexColor) => { 19 | // Since input type color provides hex and ImageData accepts RGB need to transform 20 | const m = hexColor.match(/^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i); 21 | return [ 22 | parseInt(m[1], 16), 23 | parseInt(m[2], 16), 24 | parseInt(m[3], 16), 25 | ]; 26 | }; 27 | 28 | // Pixel color equals comp color? 29 | export const colorMatcher = (data, compR, compG, compB, compA) => (pixelPos) => ( 30 | data[pixelPos + R] === compR 31 | && data[pixelPos + G] === compG 32 | && data[pixelPos + B] === compB 33 | && data[pixelPos + A] === compA 34 | ); 35 | 36 | export const colorMatcherIgnoreAlpha = (data, ...args) => { 37 | const match = colorMatcher(data, ...args); 38 | 39 | return (pixelPos) => { 40 | const alpha = data[pixelPos + A]; 41 | if (alpha !== TRANSPARENT && alpha !== OPAQUE) { 42 | return true; 43 | } 44 | 45 | return match(pixelPos); 46 | }; 47 | }; 48 | 49 | /* eslint-disable no-param-reassign */ 50 | export const pixelPainter = (data, fillR, fillG, fillB, fillA) => (pixelPos) => { 51 | data[pixelPos + R] = fillR; 52 | data[pixelPos + G] = fillG; 53 | data[pixelPos + B] = fillB; 54 | data[pixelPos + A] = fillA; 55 | }; 56 | 57 | export const pixelPainterMixAlpha = (data, fillR, fillG, fillB, fillA) => (pixelPos) => { 58 | const oldAlpha = data[pixelPos + A]; 59 | // calculate ratio of old vs. new colour to be alpha-mixed 60 | const mixAlphaOld = oldAlpha === OPAQUE 61 | ? TRANSPARENT 62 | : oldAlpha / OPAQUE; 63 | const mixAlphaNew = 1 - mixAlphaOld; 64 | 65 | const paint = pixelPainter( 66 | data, 67 | Math.floor(mixAlphaOld * data[pixelPos + R] + mixAlphaNew * fillR), 68 | Math.floor(mixAlphaOld * data[pixelPos + G] + mixAlphaNew * fillG), 69 | Math.floor(mixAlphaOld * data[pixelPos + B] + mixAlphaNew * fillB), 70 | fillA, 71 | ); 72 | 73 | return paint(pixelPos); 74 | }; 75 | /* eslint-enable no-param-reassign */ 76 | -------------------------------------------------------------------------------- /src/pointer-events.js: -------------------------------------------------------------------------------- 1 | const pointerEventHandler = (handler) => (event) => { 2 | // Ignore pointers such as additional touches on a multi-touch screen, 3 | // as well as all mouse buttons other than the left button. 4 | // `PointerEvent.button` is -1 if no button is pressed, but also for `pointermove` events, 5 | // and this value is relevant to us. See https://w3c.github.io/pointerevents/#the-button-property 6 | if (!event.isPrimary || event.button > 0) { 7 | return; 8 | } 9 | 10 | if (event.cancelable) { 11 | event.preventDefault(); 12 | } 13 | 14 | handler(event); 15 | }; 16 | 17 | export const setupPointerEvents = ({ 18 | canvas, 19 | move, 20 | down, 21 | up, 22 | }) => { 23 | const moveListener = pointerEventHandler(move); 24 | const downListener = pointerEventHandler(down); 25 | const upListener = pointerEventHandler(up); 26 | 27 | canvas.addEventListener('pointermove', moveListener); 28 | canvas.addEventListener('pointerdown', downListener); 29 | document.addEventListener('pointerup', upListener); 30 | document.addEventListener('pointerout', upListener); 31 | 32 | return () => { 33 | canvas.removeEventListener('pointermove', moveListener); 34 | canvas.removeEventListener('pointerdown', downListener); 35 | document.removeEventListener('pointerup', upListener); 36 | document.removeEventListener('pointerout', upListener); 37 | }; 38 | }; 39 | --------------------------------------------------------------------------------