├── .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 |
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 |
--------------------------------------------------------------------------------