├── .babelrc ├── .github └── workflows │ └── node.js-test.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── demos └── static-demo.html ├── package-lock.json ├── package.json ├── post-process-js ├── src ├── MathExtras.js ├── PathExtras.js └── index.js └── tests └── index.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "usage", 7 | "corejs": "3", 8 | "targets": { 9 | "browsers": [ 10 | "last 5 versions", 11 | "ie >= 8" 12 | ] 13 | } 14 | } 15 | ] 16 | ] 17 | } -------------------------------------------------------------------------------- /.github/workflows/node.js-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [10.x, 12.x, 14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | coverage/ 4 | **/.DS_Store 5 | **/.cache 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tests 3 | webpack.config.js 4 | demos 5 | .github -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Kevin Desousa 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svg-pen-sketch 2 | An easy-to-use JavaScript library aimed at making it easier to draw on SVG elements when using a digital pen (such as the Surface Pen). 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ## How to use 13 | (Importing as a node module) 14 | ```javascript 15 | import svgSketch from "svg-pen-sketch"; 16 | 17 | // Prep the svg element to be drawn on (custom path styles can be passed in optionally) 18 | 19 | const strokeStyle = {"stroke": "red", "stroke-width": "10px"}; 20 | const canvas = new svgSketch(document.querySelector("svg"), strokeStyle); 21 | 22 | // The svg element that is being used can be returned with getElement() 23 | canvas.getElement(); 24 | 25 | // The styling of the paths can be updated by updating the strokeStyles object 26 | // NOTE: This will only affect new strokes drawn 27 | canvas.strokeStyles = {"stroke": "black", "stroke-width": "1px"}; 28 | 29 | // Callbacks can be set for various events 30 | canvas.penDownCallback = (path, event) => {}; 31 | canvas.penUpCallback = (path, event) => {}; 32 | 33 | // Same can be done for the eraser end of a pen (if it has one) 34 | canvas.eraserDownCallback = (editedPaths, event) => {}; 35 | canvas.eraserUpCallback = (event) => {}; 36 | 37 | // Toggles the use of the eraser 38 | // Useful for when certain pens dont support the eraser 39 | canvas.toggleForcedEraser(); 40 | ``` 41 | 42 | (Including the source in your project) 43 | 44 | ```html 45 | 46 | 47 | 73 | 74 | ``` 75 | 76 | 77 | ## Parameters: 78 | ### Stroke Styles: 79 | - Any CSS style can be applied by adding the style name, and value, in the `strokeStyles` object 80 | ### Stroke Parameters: 81 | - `lineFunc`: A function that converts screen coordinates to an SVG Path - can be overwritten to introduce functionality such as the use of splines (various other D3 curve functions can be found here) 82 | - `minDist`: The minimum distance that is allowed between strokes (smaller values preferred for pixel-eraser functionality - but can be slow) 83 | - `maxTimeDelta`: The maximum time allowed between samples (done to keep a stable sample rate somewhat). Keep in mind this is a ___maximum___, and quicker events can still occur. 84 | ### Eraser Parameters 85 | - `eraserMode`: Which eraser mode to use when erasing. Currently supports `"object"` and `"pixel"` for the object and pixel erasers, respectively 86 | - `eraserSize`: The size of the eraser handle. Note that small eraser sizes (i.e. 1) can cause skipping issues - it will be addressed in later versions) 87 | 88 | ## Build Instructions 89 | 1) Clone the repository and run `npm install` 90 | 2) Run `npm run build` to generate a development build 91 | 3) Run `npm run test` to generate and test a build (uses the tests located in `tests/`) 92 | 93 | #### _Demos can be found in the `demos/` folder - make sure you build the project at least once before running them_ #### 94 | 95 | ## Todo 96 | - More tests need to be made 97 | - Fix stroke recognition issues for the eraser (some portions of strokes are being missed) 98 | - Try to fix the issue with strokes being cut off if the screen is resized 99 | - ~~Add some error checking for the element passed in the constructor~~ 100 | - ~~Add some options to change stroke styles~~ -------------------------------------------------------------------------------- /demos/static-demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SVG PEN SKETCH DEMO 6 | # of strokes on screen: 0 7 | 8 | 9 | 10 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svg-pen-sketch", 3 | "version": "1.2.2", 4 | "description": "An easy-to-use JavaScript library aimed at making it easier to draw on SVG elements when using a digital pen (such as the Surface Pen).", 5 | "repository": "desousak/svg-pen-sketch", 6 | "main": "dist/svg-pen-sketch.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "private": false, 11 | "scripts": { 12 | "start": "parcel watch src/index.js -o svg-pen-sketch --global SvgPenSketch --out-dir dist/ --public-url .", 13 | "build": "parcel build src/index.js -o svg-pen-sketch --global SvgPenSketch --out-dir dist/ --public-url .", 14 | "prepare": "parcel build src/index.js -o svg-pen-sketch --global SvgPenSketch --out-dir dist/ --public-url . --no-cache", 15 | "test": "./post-process-js && jest" 16 | }, 17 | "keywords": [ 18 | "draw", 19 | "svg", 20 | "pen" 21 | ], 22 | "author": "desousak", 23 | "license": "BSD-3-Clause", 24 | "devDependencies": { 25 | "@babel/core": "^7.11.6", 26 | "@babel/preset-env": "^7.11.5", 27 | "babel-jest": "^26.3.0", 28 | "cssnano": "^4.1.10", 29 | "jest": "^26.4.2", 30 | "parcel": "^1.12.4" 31 | }, 32 | "dependencies": { 33 | "core-js": "^3.6.5", 34 | "d3": "~5.16.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /post-process-js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // Taken from: https://github.com/parcel-bundler/parcel/issues/2896 3 | // Modifies the bundled code so that it works with JEST 4 | const fs = require('fs') 5 | 6 | // Path to bundle 7 | const BUNDLE_PATH = 'dist/svg-pen-sketch.js' 8 | 9 | // Read bundle 10 | const bundle = fs.readFileSync(BUNDLE_PATH, 'utf-8') 11 | 12 | // Replace global refs with an isomorphic version 13 | const processed = bundle.replace( 14 | /([^.])parcelRequire/g, 15 | '$1(typeof window === \'undefined\' ? global : window).parcelRequire' 16 | ) 17 | 18 | // Write bundle 19 | fs.writeFileSync(BUNDLE_PATH, processed) -------------------------------------------------------------------------------- /src/MathExtras.js: -------------------------------------------------------------------------------- 1 | function getDist(x1, y1, x2, y2) { 2 | // Return the distance of point 2 (x2,y2) from point 1 (x1, y1) 3 | return Math.sqrt(Math.pow(x2-x1, 2) + Math.pow(y2-y1, 2)); 4 | } 5 | 6 | function lerp (val1, val2, amnt) { 7 | amnt = amnt < 0 ? 0 : amnt; 8 | amnt = amnt > 1 ? 1 : amnt; 9 | return (1-amnt) * val1 + amnt * val2; 10 | } 11 | 12 | const MathExtras = { 13 | getDist: getDist, 14 | lerp: lerp 15 | } 16 | 17 | Object.freeze(MathExtras); 18 | export default MathExtras; -------------------------------------------------------------------------------- /src/PathExtras.js: -------------------------------------------------------------------------------- 1 | function coordsToPath(points) { 2 | let pathStr = ""; 3 | 4 | for (let point of points) { 5 | if (pathStr == "") { 6 | pathStr += "M"; 7 | } else { 8 | pathStr += "L"; 9 | } 10 | pathStr += `${point[0]} ${point[1]} `; 11 | } 12 | 13 | return pathStr.trim(); 14 | } 15 | 16 | function pathToCoords(pathStr) { 17 | let commands = pathStr.split(/(?=[LMC])/); 18 | let points = commands.map(function (point) { 19 | if (point !== " ") { 20 | // If the string doesn't have a space at the end, add it 21 | // Usefule for the last coords 22 | if (point[point.length - 1] != " ") { 23 | point += " "; 24 | } 25 | 26 | // Trim the path string and convert it 27 | let coords = point.slice(1, -1).split(" "); 28 | 29 | // Convert the coords to a float 30 | coords[0] = parseFloat(coords[0]); 31 | coords[1] = parseFloat(coords[1]); 32 | return coords; 33 | } 34 | }); 35 | return points; 36 | } 37 | 38 | function getCachedPathBBox(path) { 39 | if (!path._boundingClientRect) { 40 | path._boundingClientRect = path.getBBox(); 41 | } 42 | return path._boundingClientRect; 43 | } 44 | 45 | function pathCoordHitTest(pathCoords, x, y, range = 1) { 46 | // The bounds 47 | let xLowerBounds = x - range, 48 | xUpperBounds = x + range, 49 | yLowerBounds = y - range, 50 | yUpperBounds = y + range; 51 | // The indicies of the path coord array that the eraser is over 52 | let hitIndicies = []; 53 | 54 | for (let i = 0; i < pathCoords.length; i++) { 55 | let xCoord = pathCoords[i][0], 56 | yCoord = pathCoords[i][1]; 57 | 58 | // If the particular point on the line is within the erasing area 59 | // Eraser area = eraser point +- eraserSize in the X and Y directions 60 | if ( 61 | xLowerBounds <= xCoord && 62 | xCoord <= xUpperBounds && 63 | yLowerBounds <= yCoord && 64 | yCoord <= yUpperBounds 65 | ) { 66 | // If we need to erase this point just create a seperation between the last two points 67 | // The seperation is done by creating two new paths 68 | hitIndicies.push(i); 69 | } 70 | } 71 | 72 | return hitIndicies; 73 | } 74 | 75 | const PathExtras = { 76 | coordsToPath: coordsToPath, 77 | pathToCoords: pathToCoords, 78 | getCachedPathBBox: getCachedPathBBox, 79 | pathCoordHitTest: pathCoordHitTest, 80 | }; 81 | 82 | Object.freeze(PathExtras); 83 | export default PathExtras; 84 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import { path } from "d3"; 3 | import MathExtas from "./MathExtras.js"; 4 | import PathExtras from "./PathExtras.js"; 5 | 6 | // Default settings 7 | const defStrokeParam = { 8 | // Line function for drawing (must convert coordinates to a valid path string) 9 | lineFunc: PathExtras.coordsToPath, 10 | // Minimum distance between points that is allowed (longer will be interpolated) 11 | minDist: 2, 12 | // Max time between events (done to somewhat keep a stable sample rate) 13 | maxTimeDelta: 5, 14 | }; 15 | 16 | const defEraserParam = { 17 | eraserMode: "object", // Can use "object" or "pixel" 18 | eraserSize: 20, // NOTE: Small eraser sizes will cause skipping isses - will need to be fixed 19 | }; 20 | 21 | const defStrokeStyles = { 22 | stroke: "black", 23 | "stroke-width": "1px", 24 | }; 25 | 26 | const defEraserStyles = { 27 | "pointer-events": "none", 28 | "z-index": 999, 29 | fill: "rgba(0,0,0, 0.5)", 30 | }; 31 | 32 | export default class SvgPenSketch { 33 | constructor( 34 | element = null, 35 | strokeStyles = {}, 36 | strokeParam = {}, 37 | eraserParam = {}, 38 | eraserStyles = {} 39 | ) { 40 | // If the element is a valid 41 | if (element != null && typeof element === "object" && element.nodeType) { 42 | // Private variables 43 | // The root SVG element 44 | this._element = d3.select(element); 45 | // Variable for if the pointer event is a pen 46 | this._isPen = false; 47 | // Resize the canvas viewbox on window resize 48 | // TODO: Need to implement a proper fix to allow paths to scale 49 | // window.onresize = _ => { 50 | // this.resizeCanvas(); 51 | // }; 52 | // Prep the canvas for drawing 53 | this._element.on("pointerdown", (_) => this._handlePointer()); 54 | // Stop touch scrolling 55 | this._element.on("touchstart", (_) => { 56 | if (this._isPen) d3.event.preventDefault(); 57 | }); 58 | // Stop the context menu from appearing 59 | this._element.on("contextmenu", (_) => { 60 | d3.event.preventDefault(); 61 | d3.event.stopPropagation(); 62 | }); 63 | 64 | // Public variables 65 | // Handles scaling of parent components 66 | this.parentScale = 1; 67 | // Forces the use of the eraser - even if the pen isn't tilted over 68 | this.forceEraser = false; 69 | // Stroke parameters 70 | this.strokeParam = { ...defStrokeParam, ...strokeParam }; 71 | // Styles for the stroke 72 | this.strokeStyles = { ...defStrokeStyles, ...strokeStyles, fill: "none" }; 73 | // Eraser paraneters 74 | this.eraserParam = { ...defEraserParam, ...eraserParam }; 75 | // Styles for the Eraser 76 | this.eraserStyles = { ...defEraserStyles, ...eraserStyles }; 77 | // Pen Callbacks 78 | this.penDownCallback = (_) => {}; 79 | this.penUpCallback = (_) => {}; 80 | // Eraser Callbacks 81 | this.eraserDownCallback = (_) => {}; 82 | this.eraserUpCallback = (_) => {}; 83 | } else { 84 | throw new Error( 85 | "svg-pen-sketch needs a svg element in the constructor to work" 86 | ); 87 | } 88 | } 89 | 90 | // Public functions 91 | getElement() { 92 | return this._element.node(); 93 | } 94 | toggleForcedEraser() { 95 | this.forceEraser = !this.forceEraser; 96 | } 97 | 98 | // Not being used at the moment 99 | resizeCanvas() { 100 | let bbox = this._element.node().getBoundingClientRect(); 101 | this._element.attr("viewBox", "0 0 " + bbox.width + " " + bbox.height); 102 | } 103 | 104 | // Gets the path elements in a specified range 105 | // Uses their bounding boxes, so we can't tell if we're actually hitting the stroke with this 106 | // Just to determine if a stroke is close 107 | getPathsinRange(x, y, range = 1) { 108 | // The eraser bounds 109 | let x1 = x - range, 110 | x2 = x + range, 111 | y1 = y - range, 112 | y2 = y + range; 113 | let paths = []; 114 | 115 | for (let path of this._element.node().querySelectorAll("path")) { 116 | // Get the bounding boxes for all elements on page 117 | let bbox = PathExtras.getCachedPathBBox(path); 118 | 119 | // If the eraser and the bounding box for the path overlap 120 | // and we havent included it already 121 | if ( 122 | !( 123 | bbox.x > x2 || 124 | bbox.y > y2 || 125 | x1 > bbox.x + bbox.width || 126 | y1 > bbox.y + bbox.height 127 | ) && 128 | !paths.includes(path) 129 | ) { 130 | paths.push(path); 131 | } 132 | } 133 | return paths; 134 | } 135 | 136 | // Remove a stroke if it's within range and the mouse is over it 137 | removePaths(x, y, eraserSize = 1) { 138 | // Prep variables 139 | let removedPathIDs = []; 140 | 141 | // Get paths in the eraser's range 142 | let paths = this.getPathsinRange(x, y, eraserSize); 143 | 144 | // For each path found, remove it 145 | for (let path of paths) { 146 | let pathCoords = PathExtras.pathToCoords(path.getAttribute("d")); 147 | if ( 148 | PathExtras.pathCoordHitTest(pathCoords, x, y, eraserSize).length > 0 149 | ) { 150 | let pathToRemove = d3.select(path); 151 | removedPathIDs.push(pathToRemove.attr("id")); 152 | pathToRemove.remove(); 153 | } 154 | } 155 | return removedPathIDs; 156 | } 157 | 158 | // Edit (erase) a portion of a stroke 159 | erasePaths(x, y, eraserSize = 1) { 160 | // The paths within the bounds 161 | let paths = this.getPathsinRange(x, y, eraserSize); 162 | 163 | // The resultant edited paths 164 | let pathElements = []; 165 | 166 | for (let originalPath of paths) { 167 | let pathCoords = PathExtras.pathToCoords(originalPath.getAttribute("d")); 168 | 169 | let newPaths = []; // The series of stroke coordinates to add 170 | let indicies = PathExtras.pathCoordHitTest(pathCoords, x, y, eraserSize); 171 | 172 | if (indicies.length > 0) { 173 | // Add the path before the eraser 174 | newPaths.push(pathCoords.slice(0, indicies[0])); 175 | // Add the in-between parts of the edited path 176 | for (let i = 0; i < indicies.length - 1; i++) { 177 | if (indicies[i + 1] - indicies[i] > 1) { 178 | newPaths.push(pathCoords.slice(indicies[i], indicies[i + 1])); 179 | } 180 | } 181 | // Add the path after the eraser 182 | newPaths.push( 183 | pathCoords.slice(indicies[indicies.length - 1] + 1, pathCoords.length) 184 | ); 185 | 186 | // Remove paths of only 1 coordinate 187 | newPaths = newPaths.filter((p) => (p.length > 2 ? true : false)); 188 | 189 | // Add the new paths if they have two or more sets of coordinates 190 | // Prevents empty paths from being added 191 | for (let newPath of newPaths) { 192 | let strokePath = this._createPath(); 193 | 194 | // Copy the styles of the original stroke 195 | strokePath.attr("d", this.strokeParam.lineFunc(newPath)); 196 | strokePath.attr("style", originalPath.getAttribute("style")); 197 | strokePath.attr("class", originalPath.getAttribute("class")); 198 | pathElements.push(strokePath.node()); 199 | } 200 | 201 | // Remove the original path 202 | originalPath.remove(); 203 | } 204 | } 205 | 206 | return pathElements; 207 | } 208 | 209 | // Private functions 210 | _createEraserHandle(x, y) { 211 | // Prep the eraser hover element 212 | this._eraserHandle = this._element.append("rect"); 213 | this._eraserHandle.attr("class", "eraserHandle"); 214 | this._eraserHandle.attr("width", this.eraserParam.eraserSize); 215 | this._eraserHandle.attr("height", this.eraserParam.eraserSize); 216 | this._eraserHandle.attr("x", x - this.eraserParam.eraserSize / 2); 217 | this._eraserHandle.attr("y", y - this.eraserParam.eraserSize / 2); 218 | 219 | // Hide the mouse cursor 220 | this._element.style("cursor", "none"); 221 | 222 | // Apply all user-desired styles 223 | for (let styleName in this.eraserStyles) { 224 | this._eraserHandle.style(styleName, this.eraserStyles[styleName]); 225 | } 226 | } 227 | 228 | _moveEraserHandle(x, y) { 229 | if (this._eraserHandle) { 230 | this._eraserHandle.attr("x", x - this.eraserParam.eraserSize / 2); 231 | this._eraserHandle.attr("y", y - this.eraserParam.eraserSize / 2); 232 | } 233 | } 234 | 235 | _removeEraserHandle() { 236 | if (this._eraserHandle) { 237 | this._eraserHandle.remove(); 238 | this._eraserHandle = null; 239 | this._element.style("cursor", null); 240 | } 241 | } 242 | 243 | // Handles the different pointers 244 | // Also allows for pens to be used on modern browsers 245 | _handlePointer() { 246 | // If the pointer is a pen - prevent the touch event and run pointer handling code 247 | if (d3.event.pointerType == "touch") { 248 | this._isPen = false; 249 | } else { 250 | this._isPen = true; 251 | 252 | let pointerButton = d3.event.button; 253 | if (this.forceEraser) pointerButton = 5; 254 | 255 | // Determine if the pen tip or eraser is being used 256 | // ID 0 *should be* the pen tip, with anything else firing the eraser 257 | switch (pointerButton) { 258 | // Pen 259 | case 0: 260 | // Create the path/coordinate arrays and set event handlers 261 | let penCoords = []; 262 | let strokePath = this._createPath(); 263 | 264 | // Create the drawing event handlers 265 | this._element.on("pointermove", (_) => 266 | this._handleDownEvent((_) => this._onDraw(strokePath, penCoords)) 267 | ); 268 | this._element.on("pointerup", (_) => 269 | this._handleUpEvent((_) => this._stopDraw(strokePath, penCoords)) 270 | ); 271 | this._element.on("pointerleave", (_) => 272 | this._handleUpEvent((_) => this._stopDraw(strokePath, penCoords)) 273 | ); 274 | break; 275 | 276 | // Eraser 277 | default: 278 | case 5: 279 | // Create the location arrays 280 | let [x, y] = this._getMousePos(d3.event); 281 | let eraserCoords = [[x, y]]; 282 | 283 | // Create the eraser handle 284 | this._createEraserHandle(x, y); 285 | 286 | // Call the eraser event once for the initial on-click 287 | this._handleDownEvent((_) => this._onErase(eraserCoords)); 288 | 289 | // Create the erase event handlers 290 | this._element.on("pointermove", (_) => { 291 | this._handleDownEvent((_) => this._onErase(eraserCoords)); 292 | }); 293 | this._element.on("pointerup", (_) => 294 | this._handleUpEvent((_) => this._stopErase()) 295 | ); 296 | this._element.on("pointerleave", (_) => 297 | this._handleUpEvent((_) => this._stopErase()) 298 | ); 299 | break; 300 | } 301 | } 302 | } 303 | 304 | // Creates a new pointer event that can be modified 305 | _createEvent() { 306 | let newEvent = {}; 307 | let features = [ 308 | "screenX", 309 | "screenY", 310 | "clientX", 311 | "clientY", 312 | "offsetX", 313 | "offsetY", 314 | "pageX", 315 | "pageY", 316 | "pointerType", 317 | "pressure", 318 | "movementX", 319 | "movementY", 320 | "tiltX", 321 | "tiltY", 322 | "twistX", 323 | "twistY", 324 | "timeStamp", 325 | ]; 326 | 327 | for (let feat of features) { 328 | newEvent[feat] = d3.event[feat]; 329 | } 330 | return newEvent; 331 | } 332 | 333 | // Handles the creation of this._currPointerEvent and this._prevPointerEvent 334 | // Also interpolates between events if needed to keep a particular sample rate 335 | _handleDownEvent(callback) { 336 | if (this._prevPointerEvent) { 337 | let timeDelta = d3.event.timeStamp - this._prevPointerEvent.timeStamp; 338 | 339 | if (timeDelta > this.strokeParam.maxTimeDelta * 2) { 340 | // Calculate how many interpolated samples we need 341 | let numSteps = 342 | Math.floor(timeDelta / this.strokeParam.maxTimeDelta) + 1; 343 | let step = timeDelta / numSteps / timeDelta; 344 | 345 | // For each step 346 | for (let i = step; i < 1; i += step) { 347 | // Make a new event based on the current event 348 | let newEvent = this._createEvent(); 349 | for (let feat in newEvent) { 350 | // For every feature (that is a number) 351 | if (!isNaN(parseFloat(newEvent[feat]))) { 352 | // Linearly interpolate it 353 | newEvent[feat] = MathExtas.lerp( 354 | this._prevPointerEvent[feat], 355 | newEvent[feat], 356 | i 357 | ); 358 | } 359 | } 360 | // Set it and call the callback 361 | this._currPointerEvent = newEvent; 362 | callback(); 363 | } 364 | } 365 | } 366 | 367 | // Call the proper callback with the "real" event 368 | this._currPointerEvent = this._createEvent(); 369 | callback(); 370 | this._prevPointerEvent = this._currPointerEvent; 371 | } 372 | 373 | // Handles the removal of this._currPointerEvent and this._prevPointerEvent 374 | _handleUpEvent(callback) { 375 | // Run the up callback 376 | this._currPointerEvent = this._createEvent(); 377 | callback(); 378 | 379 | // Cleanup the previous pointer events 380 | this._prevPointerEvent = null; 381 | this._currPointerEvent = null; 382 | } 383 | 384 | // Creates a new path on the screen 385 | _createPath() { 386 | let strokePath = this._element.append("path"); 387 | 388 | // Generate a random ID for the stroke 389 | let strokeID = Math.random().toString(32).substr(2, 9); 390 | strokePath.attr("id", strokeID); 391 | 392 | // Apply all user-desired styles 393 | for (let styleName in this.strokeStyles) { 394 | strokePath.style(styleName, this.strokeStyles[styleName]); 395 | } 396 | 397 | return strokePath; 398 | } 399 | 400 | // Gets the mouse position on the canvas 401 | _getMousePos(event) { 402 | let canvasContainer = this.getElement().getBoundingClientRect(); 403 | 404 | // Calculate the offset using the page location and the canvas' offset (also taking scroll into account) 405 | let x = 406 | (event.pageX - canvasContainer.x) / this.parentScale - document.scrollingElement.scrollLeft, 407 | y = (event.pageY - canvasContainer.y) / this.parentScale - document.scrollingElement.scrollTop; 408 | 409 | return [x, y]; 410 | } 411 | 412 | // Handle the drawing 413 | _onDraw(strokePath, penCoords) { 414 | if (this._currPointerEvent.pointerType != "touch") { 415 | let [x, y] = this._getMousePos(this._currPointerEvent); 416 | 417 | // Add the points to the path 418 | penCoords.push([x, y]); 419 | strokePath.attr("d", this.strokeParam.lineFunc(penCoords)); 420 | 421 | // Call the callback 422 | if (this.penDownCallback != undefined) { 423 | this.penDownCallback(strokePath.node(), this._currPointerEvent); 424 | } 425 | } 426 | } 427 | 428 | // Interpolate coordinates in the paths in order to keep a min distance 429 | _interpolateStroke(strokePath, penCoords) { 430 | // Fill in the path if there are missing nodes 431 | let newPath = []; 432 | for (let i = 0; i <= penCoords.length - 2; i++) { 433 | // Get the current and next coordinates 434 | let currCoords = penCoords[i]; 435 | let nextCoords = penCoords[i + 1]; 436 | newPath.push(currCoords); 437 | 438 | // If the distance to the next coord is too large, interpolate between 439 | let dist = MathExtas.getDist( 440 | currCoords[0], 441 | currCoords[1], 442 | nextCoords[0], 443 | nextCoords[1] 444 | ); 445 | if (dist > this.strokeParam.minDist * 2) { 446 | // Calculate how many interpolated samples we need 447 | let step = Math.floor((dist / this.strokeParam.minDist) * 2) + 1; 448 | // Loop through the interpolated samples needed - adding new coordinates 449 | for (let j = dist / step / dist; j < 1; j += dist / step / dist) { 450 | newPath.push([ 451 | MathExtas.lerp(currCoords[0], nextCoords[0], j), 452 | MathExtas.lerp(currCoords[1], nextCoords[1], j), 453 | ]); 454 | } 455 | } 456 | 457 | // Add the final path 458 | if (i == penCoords.length - 2) { 459 | newPath.push(nextCoords); 460 | } 461 | } 462 | 463 | // Update the stroke 464 | strokePath.attr("d", this.strokeParam.lineFunc(newPath)); 465 | } 466 | 467 | // Stop the drawing 468 | _stopDraw(strokePath, penCoords) { 469 | // Remove the event handlers 470 | this._element.on("pointermove", null); 471 | this._element.on("pointerup", null); 472 | this._element.on("pointerleave", null); 473 | 474 | // Interpolate the path if needed 475 | this._interpolateStroke(strokePath, penCoords); 476 | 477 | // Call the callback 478 | if (this.penUpCallback != undefined) { 479 | this.penUpCallback(strokePath.node(), this._currPointerEvent); 480 | } 481 | } 482 | 483 | // Handle the erasing 484 | _onErase(eraserCoords) { 485 | if (this._currPointerEvent.pointerType != "touch") { 486 | let [x, y] = this._getMousePos(this._currPointerEvent); 487 | let affectedPaths = null; 488 | 489 | // Move the eraser cursor 490 | this._moveEraserHandle(x, y); 491 | 492 | // Add the points 493 | eraserCoords.push([x, y]); 494 | 495 | switch (this.eraserParam.eraserMode) { 496 | case "object": 497 | // Remove any paths in the way 498 | affectedPaths = this.removePaths( 499 | x, 500 | y, 501 | this.eraserParam.eraserSize / 2 502 | ); 503 | break; 504 | case "pixel": 505 | affectedPaths = this.erasePaths( 506 | x, 507 | y, 508 | this.eraserParam.eraserSize / 2 509 | ); 510 | break; 511 | default: 512 | console.error("ERROR: INVALID ERASER MODE"); 513 | break; 514 | } 515 | 516 | if (this.eraserDownCallback != undefined) { 517 | this.eraserDownCallback(affectedPaths, this._currPointerEvent); 518 | } 519 | } 520 | } 521 | 522 | // Stop the erasing 523 | _stopErase() { 524 | // Remove the eraser icon and add the cursor 525 | this._removeEraserHandle(); 526 | 527 | // Remove the event handlers 528 | this._element.on("pointermove", null); 529 | this._element.on("pointerup", null); 530 | this._element.on("pointerleave", null); 531 | 532 | // Call the callback 533 | if (this.eraserUpCallback != undefined) { 534 | this.eraserUpCallback(this._currPointerEvent); 535 | } 536 | } 537 | } 538 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | let SvgPenSketch = require("../dist/svg-pen-sketch.js").default; 2 | 3 | // Set up our document body 4 | document.body.innerHTML = ` 5 |
6 | 7 |
8 | `; 9 | 10 | test("Initialize the class with a DOM element", () => { 11 | expect(typeof new SvgPenSketch(document.querySelector("svg"))).toBe("object"); 12 | }); 13 | 14 | test("Catch improper class initialization", () => { 15 | try { 16 | new SvgPenSketch("test"); 17 | // Fail the test if the above code doesn't throw a error 18 | expect(true).toBe(false); 19 | } catch (e) { 20 | expect(e.message).toBe( 21 | "svg-pen-sketch needs a svg element in the constructor to work" 22 | ); 23 | } 24 | }); 25 | 26 | test("Get the DOM element from the class", () => { 27 | let tmp = new SvgPenSketch(document.querySelector("svg")); 28 | expect(tmp.getElement().nodeType).toBe(1); 29 | }); 30 | 31 | test("Try getting a path at (x,y) from the svg canvas", () => { 32 | let tmp = new SvgPenSketch(document.querySelector("svg")); 33 | // We have to fake the querySelectorAll function since this isn't a browser 34 | tmp._element.node().querySelectorAll = (_) => { 35 | return [ 36 | { 37 | _boundingClientRect: { 38 | height: 100, 39 | width: 100, 40 | x: 0, 41 | y: 0, 42 | }, 43 | }, 44 | ]; 45 | }; 46 | expect(tmp.getPathsinRange(0, 0).length).toBe(1); 47 | }); 48 | 49 | test("Try getting a non-existent path at (x,y) from the svg canvas", () => { 50 | let tmp = new SvgPenSketch(document.querySelector("svg")); 51 | // We have to fake the querySelectorAll function since this isn't a browser 52 | tmp._element.node().querySelectorAll = (_) => { 53 | return [ 54 | { 55 | _boundingClientRect: { 56 | height: 1000, 57 | width: 1000, 58 | x: 1000, 59 | y: 1000, 60 | }, 61 | }, 62 | ]; 63 | }; 64 | expect(tmp.getPathsinRange(0, 0).length).toBe(0); 65 | }); 66 | 67 | test("Try removing a path from the svg canvas", () => { 68 | let svg = document.querySelector("svg"); 69 | let tmp = new SvgPenSketch(svg); 70 | svg.innerHTML = ``; 71 | // We have to fake the querySelectorAll function since this isn't a browser 72 | tmp._element.node().querySelectorAll = (_) => { 73 | return [ 74 | { 75 | _boundingClientRect: { 76 | height: 20, 77 | width: 20, 78 | x: 0, 79 | y: 0, 80 | }, 81 | getAttribute: _ => { 82 | return "M0 0 M10 10 L 20 20"; 83 | } 84 | }, 85 | ]; 86 | }; 87 | expect(tmp.removePaths(10, 10).length).toBe(1); 88 | }); 89 | 90 | test("Try removing a non-existent path from the svg canvas", () => { 91 | let svg = document.querySelector("svg"); 92 | let tmp = new SvgPenSketch(svg); 93 | svg.innerHTML = ``; 94 | // We have to fake the querySelectorAll function since this isn't a browser 95 | tmp._element.node().querySelectorAll = (_) => { 96 | return [ 97 | { 98 | _boundingClientRect: { 99 | height: 20, 100 | width: 20, 101 | x: 0, 102 | y: 0, 103 | }, 104 | getAttribute: _ => { 105 | return "M0 0 M10 10 L 20 20"; 106 | } 107 | }, 108 | ]; 109 | }; 110 | expect(tmp.removePaths(100, 100).length).toBe(0); 111 | }); 112 | --------------------------------------------------------------------------------