├── .gitignore ├── .prettierrc.cjs ├── LICENSE ├── README.md ├── demo ├── App.vue ├── components │ ├── Copyright.vue │ ├── Scene.vue │ ├── Sidebar.vue │ ├── Slider.vue │ └── Toggle.vue ├── index.ts └── styles │ └── main.css ├── examples ├── basic.html └── friction.html ├── index.html ├── package.json ├── postcss.config.cjs ├── public ├── _redirects ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── icon.png └── og.png ├── src ├── LazyBrush.ts ├── LazyPoint.ts ├── main.ts └── vite-env.d.ts ├── tailwind.config.cjs ├── test ├── LazyBrush.spec.ts └── LazyPoint.spec.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config-demo.ts ├── vite.config.ts └── vitest.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | lib/ 3 | dist/ 4 | node_modules/ 5 | coverage 6 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "none", 3 | tabWidth: 2, 4 | semi: false, 5 | singleQuote: true 6 | }; 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Jan Hug 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 5 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 6 | persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 9 | Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 12 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 13 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 14 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lazy-brush - smooth drawing with a mouse, finger or any pointing device 2 | 3 | [![lazy-brush banner](public/og.png?raw=true "Lazy Brush in action")](https://lazybrush.dulnan.net) 4 | 5 | **[Demo](https://lazybrush.dulnan.net)** - **[NPM](https://npmjs.com/package/lazy-brush)** - **[CodePen Examples](https://codepen.io/collection/wayKwv)** 6 | 7 | __The demo app also uses 8 | [catenary-curve](https://github.com/dulnan/catenary-curve) to draw the little 9 | "rope" between mouse and brush.__ 10 | 11 | This library provides the math required to implement a "lazy brush". 12 | It takes a radius and the {x,y} coordinates of a mouse/pointer and calculates 13 | the position of the brush. 14 | 15 | The brush will only move when the pointer is outside the "lazy area" of the 16 | brush. With this technique it's possible to freely draw smooth lines and curves 17 | with just a mouse or finger. 18 | 19 | ## How it works 20 | 21 | When the position of the pointer is updated, the distance to the brush is 22 | calculated. If this distance is larger than the defined radius, the brush will 23 | be moved by `distance - radius` pixels in the direction where the pointer is. 24 | 25 | ## Usage 26 | 27 | lazy-brush is on npm so you can install it with your favorite package manager. 28 | 29 | ```bash 30 | npm install --save lazy-brush 31 | ``` 32 | 33 | lazy-brush can be easily added in any canvas drawing scenario. It acts like a 34 | "proxy" between user input and drawing. 35 | 36 | It exports a `LazyBrush` class. Create a single instance of the class: 37 | 38 | ```javascript 39 | const lazy = new LazyBrush({ 40 | radius: 30, 41 | enabled: true, 42 | initialPoint: { x: 0, y: 0 } 43 | }) 44 | ``` 45 | 46 | You can now use the `update()` method whenever the position of the mouse (or 47 | touch) changes: 48 | 49 | ```javascript 50 | // Move mouse 20 pixels to the right. 51 | lazy.update({ x: 20, y: 0 }) 52 | // Brush is not moved, because 20 is less than the radius (30). 53 | console.log(lazy.getBrushCoordinates()) // { x: 0, y: 0 } 54 | 55 | // Move mouse 40 pixels to the right. 56 | lazy.update({ x: 40, y: 0 }) 57 | // Brush is now moved by 10 pixels because 40 (mouse X) - 30 (radius) = 10. 58 | console.log(lazy.getBrushCoordinates()) // { x: 10, y: 0 } 59 | ``` 60 | 61 | The function returns a boolean to indicate whether any of the values (brush or 62 | pointer) have changed. This can be used to prevent unneccessary canvas 63 | redrawing. 64 | 65 | If you need to know if the position of the brush was changed, you can get that 66 | boolean via `LazyBrush.brushHasMoved()`. Use this information to decide if you 67 | need to redraw the brush on the canvas. 68 | 69 | To get the current brush coordinates, use `LazyBrush.getBrushCoordinates()`. 70 | For the pointer coordinates use `LazyBrush.getPointerCoordinates()`. This will 71 | return a `Point` object with x and y properties. 72 | 73 | The functions `getBrush()` and `getPointer()` will return a `LazyPoint`, which 74 | has some additional functions like `getDistanceTo`, `getAngleTo` or `equalsTo`. 75 | 76 | ### With Friction 77 | 78 | You can also pass a friction value (number between 0 and 1) when calling `update()`: 79 | 80 | ```javascript 81 | lazy.update({ x: 40, y: 0 }, { friction: 0.5 }) 82 | ``` 83 | 84 | This will reduce the speed at which the brush moves towards the pointer. A 85 | value of 0 means "no friction", which is the same as not passing a value. 1 86 | means "inifinte friction", the brush won't move at all. 87 | 88 | You can define a constant value or make it dynamic, for example using a pressur 89 | value from a touch event. 90 | 91 | ### Updating both values 92 | 93 | You can also update the pointer and the brush coordinates at the same time: 94 | 95 | ```javascript 96 | lazy.update({ x: 40, y: 0 }, { both: true }) 97 | ``` 98 | 99 | This can be used when supporting touch events: On touch start you would update 100 | both the pointer and the brush so that the pointer can be "moved" away from the 101 | brush until the lazy radius is reached. This is how it's implemented in the 102 | demo page. 103 | 104 | ## Examples 105 | 106 | Check out the [basic example](examples/basic.html) for a simple starting point 107 | on how to use this library. 108 | -------------------------------------------------------------------------------- /demo/App.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 78 | -------------------------------------------------------------------------------- /demo/components/Copyright.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /demo/components/Scene.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 377 | -------------------------------------------------------------------------------- /demo/components/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 44 | -------------------------------------------------------------------------------- /demo/components/Slider.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /demo/components/Toggle.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 33 | -------------------------------------------------------------------------------- /demo/index.ts: -------------------------------------------------------------------------------- 1 | import './styles/main.css' 2 | 3 | import { ViteSSG } from 'vite-ssg/single-page' 4 | import App from './App.vue' 5 | 6 | export const createApp = ViteSSG(App) 7 | -------------------------------------------------------------------------------- /demo/styles/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .h-canvas { 7 | height: calc(100vh - (13 * 24px)); 8 | } 9 | .image-rendering-pixelated { 10 | image-rendering: pixelated; 11 | } 12 | } 13 | 14 | @layer components { 15 | .canvas { 16 | @apply absolute top-0 left-0 w-full h-full; 17 | } 18 | .slider { 19 | @apply range block w-full h-6 bg-white border px-1.5 rounded-full border-stone-300 appearance-none cursor-pointer dark:bg-stone-700; 20 | } 21 | 22 | .range { 23 | background: transparent !important; 24 | box-shadow: inset 0px 0px 0px 20px white; 25 | &::-webkit-slider-runnable-track { 26 | @apply bg-white; 27 | } 28 | &::-webkit-slider-thumb { 29 | -webkit-appearance: none; 30 | @apply w-3 h-3 rounded-full bg-orange-600 shadow-none; 31 | } 32 | &.disabled { 33 | &::-webkit-slider-thumb { 34 | @apply bg-stone-300; 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | lazy-brush Basic Example 5 | 14 | 15 | 16 |
17 | 18 |
19 | 20 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /examples/friction.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Friction Example 5 | 14 | 15 | 16 |
17 | 18 |
19 | 20 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | Lazy Brush - Smooth Canvas Drawing 43 | 44 | 45 |
46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lazy-brush", 3 | "version": "2.0.2", 4 | "description": "Lazy brush - smooth drawing using mouse or finger", 5 | "type": "module", 6 | "files": [ 7 | "lib" 8 | ], 9 | "main": "./lib/lazy-brush.umd.cjs", 10 | "module": "./lib/lazy-brush.js", 11 | "types": "./lib/main.d.ts", 12 | "exports": { 13 | ".": { 14 | "import": { 15 | "default": "./lib/lazy-brush.js", 16 | "types": "./lib/main.d.ts" 17 | }, 18 | "require": { 19 | "default": "./lib/lazy-brush.umd.cjs", 20 | "types": "./lib/main.d.ts" 21 | } 22 | } 23 | }, 24 | "keywords": [ 25 | "brush", 26 | "mouse", 27 | "lazy", 28 | "canvas", 29 | "drawing", 30 | "smooth", 31 | "pencil", 32 | "string", 33 | "radius" 34 | ], 35 | "repository": "https://github.com/dulnan/lazy-brush", 36 | "directories": { 37 | "lib": "lib" 38 | }, 39 | "scripts": { 40 | "dev": "vite -c ./vite.config-demo.ts --host", 41 | "build": "tsc && vite build", 42 | "build:demo": "vite-ssg -c ./vite.config-demo.ts build", 43 | "preview": "vite -c ./vite.config-demo.ts preview", 44 | "test": "vitest", 45 | "test:ci": "vitest run", 46 | "test:coverage": "vitest run --coverage" 47 | }, 48 | "author": "Jan Hug ", 49 | "license": "MIT", 50 | "devDependencies": { 51 | "@types/node": "^18.11.18", 52 | "@vitejs/plugin-vue": "^4.0.0", 53 | "@vitest/coverage-c8": "^0.26.3", 54 | "autoprefixer": "^10.4.13", 55 | "catenary-curve": "^2.0.0", 56 | "postcss": "^8.4.20", 57 | "prettier": "^2.8.1", 58 | "tailwindcss": "^3.2.4", 59 | "typescript": "^4.9.3", 60 | "vite": "^4.0.0", 61 | "vite-plugin-dts": "^1.7.1", 62 | "vite-ssg": "^0.22.1", 63 | "vitest": "^0.26.3", 64 | "vue-tsc": "^1.0.22" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'tailwindcss/nesting': {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | https://lazy-brush.netlify.app https://lazybrush.dulnan.net 2 | https://lazy-brush.netlify.app/ https://lazybrush.dulnan.net/ 3 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dulnan/lazy-brush/9d95eceffb4bf003e548a37e6f4e7b09ad662c20/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dulnan/lazy-brush/9d95eceffb4bf003e548a37e6f4e7b09ad662c20/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dulnan/lazy-brush/9d95eceffb4bf003e548a37e6f4e7b09ad662c20/public/favicon.ico -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dulnan/lazy-brush/9d95eceffb4bf003e548a37e6f4e7b09ad662c20/public/icon.png -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dulnan/lazy-brush/9d95eceffb4bf003e548a37e6f4e7b09ad662c20/public/og.png -------------------------------------------------------------------------------- /src/LazyBrush.ts: -------------------------------------------------------------------------------- 1 | import { LazyPoint, Point } from './LazyPoint' 2 | 3 | const RADIUS_DEFAULT = 30 4 | 5 | interface LazyBrushOptions { 6 | radius?: number 7 | enabled?: boolean 8 | initialPoint?: Point 9 | } 10 | 11 | interface LazyBrushUpdateOptions { 12 | /** 13 | * Update both pointer and brush at the same time. 14 | * 15 | * This can be used when supporting touch events: On touch start you would 16 | * update both the pointer and the brush so that the pointer can be "moved" 17 | * away from the brush until the lazy radius is reached. 18 | */ 19 | both?: boolean 20 | 21 | /** 22 | * Define the friction (value between 0 and 1) for the brush. 23 | * 24 | * A value of 0 means "no friction" (default when not set). A value of "1" 25 | * means infinite friction (the brush won't move at all). 26 | */ 27 | friction?: number 28 | } 29 | 30 | class LazyBrush { 31 | /** 32 | * If the lazy brush should be enabled. 33 | */ 34 | _isEnabled: boolean 35 | 36 | /** 37 | * Indicates if the brush has moved in the last update cycle. 38 | */ 39 | _hasMoved: boolean 40 | 41 | /** 42 | * The lazy radius. 43 | */ 44 | radius: number 45 | 46 | /** 47 | * Coordinates of the pointer. 48 | */ 49 | pointer: LazyPoint 50 | 51 | /** 52 | * Coordinates of the brush. 53 | */ 54 | brush: LazyPoint 55 | 56 | /** 57 | * The angle between pointer and brush in the last update cycle. 58 | */ 59 | angle: number 60 | 61 | /** 62 | * The distance between pointer and brush in the last update cycle. 63 | */ 64 | distance: number 65 | 66 | /** 67 | * Constructs a new LazyBrush. 68 | */ 69 | constructor(options: LazyBrushOptions = {}) { 70 | const initialPoint = options.initialPoint || { x: 0, y: 0 } 71 | this.radius = options.radius || RADIUS_DEFAULT 72 | this._isEnabled = options.enabled === false ? false : true 73 | 74 | this.pointer = new LazyPoint(initialPoint.x, initialPoint.y) 75 | this.brush = new LazyPoint(initialPoint.x, initialPoint.y) 76 | 77 | this.angle = 0 78 | this.distance = 0 79 | this._hasMoved = false 80 | } 81 | 82 | /** 83 | * Enable lazy brush calculations. 84 | * 85 | */ 86 | enable(): void { 87 | this._isEnabled = true 88 | } 89 | 90 | /** 91 | * Disable lazy brush calculations. 92 | * 93 | */ 94 | disable(): void { 95 | this._isEnabled = false 96 | } 97 | 98 | /** 99 | * @returns {boolean} 100 | */ 101 | isEnabled(): boolean { 102 | return this._isEnabled 103 | } 104 | 105 | /** 106 | * Update the radius 107 | * 108 | * @param {number} radius 109 | */ 110 | setRadius(radius: number): void { 111 | this.radius = radius 112 | } 113 | 114 | /** 115 | * Return the current radius 116 | * 117 | * @returns {number} 118 | */ 119 | getRadius(): number { 120 | return this.radius 121 | } 122 | 123 | /** 124 | * Return the brush coordinates as a simple object 125 | * 126 | * @returns {object} 127 | */ 128 | getBrushCoordinates(): Point { 129 | return this.brush.toObject() 130 | } 131 | 132 | /** 133 | * Return the pointer coordinates as a simple object 134 | * 135 | * @returns {object} 136 | */ 137 | getPointerCoordinates(): Point { 138 | return this.pointer.toObject() 139 | } 140 | 141 | /** 142 | * Return the brush as a LazyPoint 143 | * 144 | * @returns {LazyPoint} 145 | */ 146 | getBrush(): LazyPoint { 147 | return this.brush 148 | } 149 | 150 | /** 151 | * Return the pointer as a LazyPoint 152 | * 153 | * @returns {LazyPoint} 154 | */ 155 | getPointer(): LazyPoint { 156 | return this.pointer 157 | } 158 | 159 | /** 160 | * Return the angle between pointer and brush 161 | * 162 | * @returns {number} Angle in radians 163 | */ 164 | getAngle(): number { 165 | return this.angle 166 | } 167 | 168 | /** 169 | * Return the distance between pointer and brush 170 | * 171 | * @returns {number} Distance in pixels 172 | */ 173 | getDistance(): number { 174 | return this.distance 175 | } 176 | 177 | /** 178 | * Return if the previous update has moved the brush. 179 | * 180 | * @returns {boolean} Whether the brush moved previously. 181 | */ 182 | brushHasMoved(): boolean { 183 | return this._hasMoved 184 | } 185 | 186 | /** 187 | * Updates the pointer point and calculates the new brush point. 188 | */ 189 | update( 190 | newPointerPoint: Point, 191 | options: LazyBrushUpdateOptions = {} 192 | ): boolean { 193 | this._hasMoved = false 194 | if ( 195 | this.pointer.equalsTo(newPointerPoint) && 196 | !options.both && 197 | !options.friction 198 | ) { 199 | return false 200 | } 201 | 202 | this.pointer.update(newPointerPoint) 203 | 204 | if (options.both) { 205 | this._hasMoved = true 206 | this.brush.update(newPointerPoint) 207 | return true 208 | } 209 | 210 | if (this._isEnabled) { 211 | this.distance = this.pointer.getDistanceTo(this.brush) 212 | this.angle = this.pointer.getAngleTo(this.brush) 213 | 214 | const isOutside = Math.round((this.distance - this.radius) * 10) / 10 > 0 215 | const friction = 216 | options.friction && options.friction < 1 && options.friction > 0 217 | ? options.friction 218 | : undefined 219 | 220 | if (isOutside) { 221 | this.brush.moveByAngle( 222 | this.angle, 223 | this.distance - this.radius, 224 | friction 225 | ) 226 | this._hasMoved = true 227 | } 228 | } else { 229 | this.distance = 0 230 | this.angle = 0 231 | this.brush.update(newPointerPoint) 232 | this._hasMoved = true 233 | } 234 | 235 | return true 236 | } 237 | } 238 | 239 | export default LazyBrush 240 | -------------------------------------------------------------------------------- /src/LazyPoint.ts: -------------------------------------------------------------------------------- 1 | export interface Point { 2 | x: number 3 | y: number 4 | } 5 | 6 | function ease(x: number): number { 7 | return 1 - Math.sqrt(1 - Math.pow(x, 2)) 8 | } 9 | 10 | export class LazyPoint implements Point { 11 | x: number 12 | y: number 13 | 14 | constructor(x: number, y: number) { 15 | this.x = x 16 | this.y = y 17 | } 18 | 19 | /** 20 | * Update the x and y values 21 | */ 22 | update(point: Point): LazyPoint { 23 | this.x = point.x 24 | this.y = point.y 25 | return this 26 | } 27 | 28 | /** 29 | * Move the point to another position using an angle and distance 30 | */ 31 | moveByAngle( 32 | // The angle in radians 33 | angle: number, 34 | // How much the point should be moved 35 | distance: number, 36 | // How much of the required distance the coordinates are moved. A value of 37 | // 1 means the full distance is moved. A lower value reduces the distance 38 | // and makes the brush more sluggish. 39 | friction?: number 40 | ): LazyPoint { 41 | // Rotate the angle based on the browser coordinate system ([0,0] in the top left) 42 | const angleRotated = angle + Math.PI / 2 43 | 44 | if (friction) { 45 | this.x = this.x + Math.sin(angleRotated) * distance * ease(1 - friction) 46 | this.y = this.y - Math.cos(angleRotated) * distance * ease(1 - friction) 47 | } else { 48 | this.x = this.x + Math.sin(angleRotated) * distance 49 | this.y = this.y - Math.cos(angleRotated) * distance 50 | } 51 | 52 | return this 53 | } 54 | 55 | /** 56 | * Check if this point is the same as another point 57 | */ 58 | equalsTo(point: Point): boolean { 59 | return this.x === point.x && this.y === point.y 60 | } 61 | 62 | /** 63 | * Get the difference for x and y axis to another point 64 | */ 65 | getDifferenceTo(point: Point): LazyPoint { 66 | return new LazyPoint(this.x - point.x, this.y - point.y) 67 | } 68 | 69 | /** 70 | * Calculate distance to another point 71 | */ 72 | getDistanceTo(point: Point): number { 73 | const diff = this.getDifferenceTo(point) 74 | 75 | return Math.sqrt(Math.pow(diff.x, 2) + Math.pow(diff.y, 2)) 76 | } 77 | 78 | /** 79 | * Calculate the angle to another point 80 | */ 81 | getAngleTo(point: Point): number { 82 | const diff = this.getDifferenceTo(point) 83 | 84 | return Math.atan2(diff.y, diff.x) 85 | } 86 | 87 | /** 88 | * Return a simple object with x and y properties 89 | */ 90 | toObject(): Point { 91 | return { 92 | x: this.x, 93 | y: this.y 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import LazyBrush from './LazyBrush' 2 | import { LazyPoint } from './LazyPoint' 3 | import type { Point } from './LazyPoint' 4 | 5 | export { LazyBrush, Point, LazyPoint } 6 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./index.html", 5 | "./demo/**/*.{vue,js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: { 9 | fontFamily: { 10 | sans: ['Space Mono', 'Monaco', 'monospace'] 11 | } 12 | }, 13 | }, 14 | plugins: [], 15 | } 16 | -------------------------------------------------------------------------------- /test/LazyBrush.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import LazyBrush from '../src/LazyBrush' 3 | import { LazyPoint } from '../src/LazyPoint' 4 | 5 | describe('LazyBrush', () => { 6 | it('Should be instantiatable with a radius and options', () => { 7 | const b = new LazyBrush({ radius: 100, enabled: false }) 8 | 9 | expect(b.pointer.x).equal(0) 10 | expect(b.pointer.y).equal(0) 11 | expect(b.brush.x).equal(0) 12 | expect(b.brush.y).equal(0) 13 | 14 | expect(b.radius).equal(100) 15 | expect(b.isEnabled()).equal(false) 16 | }) 17 | 18 | it('Should be instantiatable without radius and options', () => { 19 | const b = new LazyBrush() 20 | expect(b.radius).equal(30) 21 | expect(b.isEnabled()).equal(true) 22 | }) 23 | 24 | it('Should enable lazy brush', () => { 25 | const b = new LazyBrush({ enabled: false }) 26 | b.enable() 27 | expect(b.isEnabled()).equal(true) 28 | }) 29 | 30 | it('Should disable lazy brush', () => { 31 | const b = new LazyBrush() 32 | b.disable() 33 | expect(b.isEnabled()).equal(false) 34 | }) 35 | 36 | it('Should set radius correctly', () => { 37 | const b = new LazyBrush() 38 | 39 | expect(b.radius).equal(30) 40 | b.setRadius(156) 41 | expect(b.radius).equal(156) 42 | expect(b.getRadius()).equal(156) 43 | }) 44 | 45 | it('Should update pointer points corrently', () => { 46 | const b = new LazyBrush({ radius: 100 }) 47 | b.update({ x: 500, y: 1000 }) 48 | expect(b.pointer.x).equal(500) 49 | expect(b.pointer.y).equal(1000) 50 | }) 51 | 52 | it('Should return a simple coordinates object for the brush', () => { 53 | const b = new LazyBrush({ radius: 100 }) 54 | b.update({ x: 500, y: 1000 }) 55 | const brush = b.getBrushCoordinates() 56 | expect(brush).toHaveProperty('x') 57 | expect(brush).toHaveProperty('y') 58 | expect(Object.keys(brush).length).toEqual(2) 59 | }) 60 | 61 | it('Should return a simple coordinates object for the pointer', () => { 62 | const b = new LazyBrush({ radius: 100 }) 63 | b.update({ x: 500, y: 1000 }) 64 | const pointer = b.getPointerCoordinates() 65 | expect(pointer).toHaveProperty('x') 66 | expect(pointer).toHaveProperty('y') 67 | expect(Object.keys(pointer).length).toEqual(2) 68 | }) 69 | 70 | it('Should return the brush object.', () => { 71 | const b = new LazyBrush({ radius: 100, initialPoint: { x: 500, y: 500 } }) 72 | const brush = b.getBrush() 73 | expect(brush).toBeInstanceOf(LazyPoint) 74 | }) 75 | 76 | it('Should return the pointer object.', () => { 77 | const b = new LazyBrush({ radius: 100, initialPoint: { x: 500, y: 500 } }) 78 | const pointer = b.getPointer() 79 | expect(pointer).toBeInstanceOf(LazyPoint) 80 | }) 81 | 82 | it('Should return the current angle between pointer and brush', () => { 83 | const b = new LazyBrush({ radius: 100, initialPoint: { x: 100, y: 100 } }) 84 | b.update({ x: 100, y: 500 }) 85 | expect(b.getAngle()).toBeCloseTo(1.5, 0) 86 | }) 87 | 88 | it('Should return the current distance between pointer and brush', () => { 89 | const b = new LazyBrush({ radius: 100, initialPoint: { x: 100, y: 100 } }) 90 | b.update({ x: 100, y: 500 }) 91 | expect(b.getDistance()).toBeCloseTo(400, 0) 92 | }) 93 | 94 | it('Should return the correct state if the brush has moved', () => { 95 | const b = new LazyBrush({ radius: 100, initialPoint: { x: 100, y: 100 } }) 96 | b.update({ x: 100, y: 500 }) 97 | expect(b.brushHasMoved()).toBe(true) 98 | b.update({ x: 100, y: 450 }) 99 | expect(b.brushHasMoved()).toBe(false) 100 | b.update({ x: 100, y: 450 }, { both: true }) 101 | expect(b.brushHasMoved()).toBe(true) 102 | }) 103 | 104 | it('Should set state to defaults if lazy is disabled', () => { 105 | const b = new LazyBrush({ 106 | radius: 100, 107 | initialPoint: { x: 100, y: 100 }, 108 | enabled: false 109 | }) 110 | b.update({ x: 100, y: 500 }) 111 | expect(b.getDistance()).toBeCloseTo(0, 0) 112 | expect(b.getAngle()).toBeCloseTo(0, 0) 113 | }) 114 | 115 | it('Should detect changes corectly', () => { 116 | const b = new LazyBrush({ radius: 100 }) 117 | 118 | const hasChanged1 = b.update({ x: 10, y: 10 }) 119 | expect(hasChanged1).equal(true) 120 | 121 | const hasChanged2 = b.update({ x: 10, y: 10 }) 122 | expect(hasChanged2).equal(false) 123 | }) 124 | 125 | it('Should not move brush when pointer is inside radius', () => { 126 | const b = new LazyBrush({ radius: 100 }) 127 | 128 | b.update({ x: 10, y: 10 }) 129 | 130 | expect(b.brush.x).equal(0) 131 | expect(b.brush.y).equal(0) 132 | }) 133 | 134 | it('Should move brush when pointer is outside radius on the right', () => { 135 | const b = new LazyBrush({ radius: 100 }) 136 | 137 | b.update({ x: 100, y: 0 }) 138 | b.update({ x: 300, y: 0 }) 139 | 140 | expect(b.brush.x).toBeCloseTo(200) 141 | expect(b.brush.y).toBeCloseTo(0) 142 | }) 143 | 144 | it('Should move brush when pointer is outside radius on the left', () => { 145 | const b = new LazyBrush({ radius: 100 }) 146 | 147 | b.update({ x: 500, y: 0 }) 148 | expect(b.brush.x).equal(400) 149 | 150 | b.update({ x: 400, y: 0 }) 151 | expect(b.brush.x).equal(400) 152 | 153 | b.update({ x: 300, y: 0 }) 154 | expect(b.brush.x).equal(400) 155 | 156 | b.update({ x: 100, y: 0 }) 157 | expect(b.brush.x).equal(200) 158 | }) 159 | 160 | it('Should move brush when pointer is outside radius the bottom right', () => { 161 | const b = new LazyBrush({ radius: 100 }) 162 | 163 | b.update({ x: 1071, y: 1071 }) 164 | expect(b.brush.x).toBeCloseTo(1000, 0) 165 | expect(b.brush.y).toBeCloseTo(1000, 0) 166 | }) 167 | 168 | it('Should use the provided friction value to move the brush', () => { 169 | const b = new LazyBrush({ radius: 100, initialPoint: { x: 300, y: 300 } }) 170 | 171 | b.update({ x: 900, y: 900 }, { friction: 0.5 }) 172 | 173 | expect(b.brush.x).toBeCloseTo(370.91, 0) 174 | expect(b.brush.y).toBeCloseTo(370.91, 0) 175 | 176 | b.update({ x: 900, y: 900 }, { friction: 0.5 }) 177 | 178 | expect(b.brush.x).toBeCloseTo(432.32, 0) 179 | expect(b.brush.y).toBeCloseTo(432.32, 0) 180 | 181 | b.update({ x: 1200, y: 1200 }) 182 | 183 | expect(b.brush.x).toBeCloseTo(1129.28, 0) 184 | expect(b.brush.y).toBeCloseTo(1129.28, 0) 185 | }) 186 | }) 187 | -------------------------------------------------------------------------------- /test/LazyPoint.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import chai from 'chai' 3 | import { LazyPoint } from '../src/main' 4 | 5 | chai.should() 6 | 7 | describe('LazyPoint', () => { 8 | it('Should be instantiatable with two coordinates', () => { 9 | const p = new LazyPoint(100, 50) 10 | 11 | expect(p.x).be.a('number') 12 | expect(p.x).toBeCloseTo(100) 13 | 14 | expect(p.y).be.a('number') 15 | expect(p.y).toBeCloseTo(50) 16 | }) 17 | 18 | it('Should update coordinates correctly', () => { 19 | const p = new LazyPoint(10, 20) 20 | 21 | const pNew = new LazyPoint(500, 300) 22 | 23 | p.update(pNew) 24 | 25 | expect(p.x).toBeCloseTo(500) 26 | expect(p.y).toBeCloseTo(300) 27 | }) 28 | 29 | it('Should move point by angle correctly', () => { 30 | const p = new LazyPoint(100, 100) 31 | 32 | // This equals to 90° in radians 33 | const angle = Math.PI / 2 34 | p.moveByAngle(angle, 100) 35 | 36 | expect(p.x).toBeCloseTo(100) 37 | expect(p.y).toBeCloseTo(200) 38 | }) 39 | 40 | it('Should compare equality to another point correctly', () => { 41 | const p = new LazyPoint(300, 300) 42 | 43 | const p1 = new LazyPoint(300, 300) 44 | const p2 = new LazyPoint(299, 300) 45 | const p3 = new LazyPoint(301, 300) 46 | const p4 = new LazyPoint(301, 299) 47 | const p5 = new LazyPoint(300, 300.000000000001) 48 | 49 | const r1 = p.equalsTo(p1) 50 | const r2 = p.equalsTo(p2) 51 | const r3 = p.equalsTo(p3) 52 | const r4 = p.equalsTo(p4) 53 | const r5 = p.equalsTo(p5) 54 | 55 | expect(r1).equal(true) 56 | expect(r2).equal(false) 57 | expect(r3).equal(false) 58 | expect(r4).equal(false) 59 | expect(r5).equal(false) 60 | }) 61 | 62 | it('Should calculate the difference between another point correctly', () => { 63 | const p1 = new LazyPoint(300, 300) 64 | const p2 = new LazyPoint(300, 600) 65 | 66 | const r = p1.getDifferenceTo(p2) 67 | 68 | expect(r.x).equal(0) 69 | expect(r.y).equal(-300) 70 | }) 71 | 72 | it('Should calculate the distance to another point correctly', () => { 73 | const p1 = new LazyPoint(300, 300) 74 | const p2 = new LazyPoint(300, 600) 75 | 76 | const r = p1.getDistanceTo(p2) 77 | 78 | expect(r).equal(300) 79 | }) 80 | 81 | it('Should calculate the angle to another point correctly', () => { 82 | const p1 = new LazyPoint(500, 500) 83 | const p2 = new LazyPoint(1000, 500) 84 | 85 | const r = p1.getAngleTo(p2) 86 | 87 | expect(r).equal(Math.PI) 88 | }) 89 | 90 | it('Should return a coordinates object correctly', () => { 91 | const p = new LazyPoint(511.5932, 159.999994) 92 | 93 | const r = p.toObject() 94 | 95 | expect(r).be.a('object') 96 | expect(r.x).equal(511.5932) 97 | expect(r.y).equal(159.999994) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": true, 12 | "noEmit": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "skipLibCheck": true 17 | }, 18 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 19 | "references": [{ "path": "./tsconfig.node.json" }] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config-demo.ts: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue' 2 | import { defineConfig } from 'vite' 3 | 4 | export default defineConfig({ 5 | plugins: [ 6 | vue(), 7 | ] 8 | }) 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import dts from 'vite-plugin-dts' 3 | import { defineConfig } from 'vite' 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | dts({ 8 | insertTypesEntry: true 9 | }) 10 | ], 11 | publicDir: false, 12 | build: { 13 | outDir: 'lib', 14 | lib: { 15 | // Could also be a dictionary or array of multiple entry points 16 | entry: resolve(__dirname, 'src/main.ts'), 17 | name: 'lazy-brush', 18 | // the proper extensions will be added 19 | fileName: 'lazy-brush' 20 | }, 21 | rollupOptions: {} 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | all: true, 7 | provider: 'c8', 8 | reporter: ['text', 'json', 'html'], 9 | include: ['src/**/*.*'] 10 | } 11 | } 12 | }) 13 | --------------------------------------------------------------------------------