├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── config.yml └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── example ├── public │ └── index.html └── src │ ├── index.js │ └── styles.module.css ├── jest.config.ts ├── package-lock.json ├── package.json ├── rollup.config.ts ├── src └── index.tsx ├── test ├── fixtures.ts └── index.spec.tsx ├── tsconfig.build.json ├── tsconfig.json └── types └── trim-canvas.d.ts /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Reproducible Bug Report 2 | description: If you found a bug within `react-signature-canvas` itself and have a minimal reproduction of it. For support requests, use StackOverflow. 3 | body: 4 | # larger description of what this template's intended usage is 5 | - type: markdown 6 | attributes: 7 | value: | 8 | This template is to report a reproducible bug within `react-signature-canvas` itself. 9 | 10 | Issues [should _not_](https://docs.github.com/en/get-started/using-github/communicating-on-github) be used for support requests -- use [StackOverflow](https://stackoverflow.com/search?q=react-signature-canvas) for that instead. 11 | 12 | This should _not_ be used for issues with the underlying `signature_pad` -- use [`signature_pad`'s issues](https://github.com/szimek/signature_pad/issues) for that instead. 13 | 14 | Before opening a new issue, please do a [search of existing issues](https://github.com/agilgur5/react-signature-canvas/issues?q=is%3Aissue). 15 | If a relevant open issue exists, you should :+1: upvote it instead. 16 | If a relevant closed issue exists, please follow the directions of the closing comments. 17 | Do not open duplicates of existing issues. 18 | 19 | # require that users have searched existing issues 20 | - type: checkboxes 21 | attributes: 22 | label: Have you searched the existing issues? 23 | description: Please search to see if an issue already exists for the problem you encountered 24 | options: 25 | - label: I have searched the existing issues and cannot find my problem 26 | required: true 27 | 28 | # require that users provide a minimal reproduction 29 | - type: input 30 | attributes: 31 | label: Provide a link to code that _minimally_ reproduces this bug 32 | description: | 33 | Link to a [minimal reproduction](https://stackoverflow.com/help/minimal-reproducible-example) via a public [CodeSandbox](https://codesandbox.io/s/github/agilgur5/react-signature-canvas/tree/codesandbox-example), StackBlitz project, or GitHub repository. 34 | 35 | _Skipping this or providing an invalid link may result in your issue being summarily closed._ 36 | placeholder: 'https://codesandbox.io/p/sandbox/my-minimal-react-signature-canvas-bug-reproduction' 37 | validations: 38 | required: true 39 | 40 | # require that users provide their environment details 41 | - type: textarea 42 | attributes: 43 | label: Provide version numbers for your environment by running the below command 44 | description: npx envinfo --npmPackages react-signature-canvas,react,react-dom,typescript --npmGlobalPackages typescript --binaries --browsers --system os 45 | render: text # render as a ```text code block 46 | # example output to clue in user about what it should look like 47 | placeholder: | 48 | System: 49 | OS: macOS 14.5 50 | Binaries: 51 | Node: 22.14.0 - ~/.local/share/mise/installs/node/22.14.0/bin/node 52 | Yarn: 1.22.19 - /usr/local/bin/yarn 53 | npm: 10.9.2 - ~/.local/share/mise/installs/node/22.14.0/bin/npm 54 | Browsers: 55 | Chrome: 134.0.6998.166 56 | Safari: 17.5 57 | npmPackages: 58 | react: ^19.0.0 => 19.0.0 59 | react-dom: ^19.0.0 => 19.0.0 60 | react-signature-canvas: ^1.0.7 => 1.0.7 61 | typescript: ^4.6.3 => 4.6.4 62 | validations: 63 | required: true 64 | 65 | # describe the problem 66 | - type: textarea 67 | attributes: 68 | label: Describe the problem, how to reproduce it, and why you believe the behavior is a bug in this library 69 | description: What is the current behavior vs. the expected behavior? 70 | render: markdown # render directly as markdown 71 | # example output to clue in user about what it should look like 72 | placeholder: | 73 | In the provided reproduction, run `npm run typecheck`. This results in a TypeScript error: `Could not find a declaration file for module 'react-signature-canvas'`. 74 | As this library is natively written in TypeScript, I assumed that type declarations should be provided and that a TS build would succeed. 75 | validations: 76 | required: true 77 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Search on StackOverflow 3 | url: https://stackoverflow.com/search?q=react-signature-canvas 4 | about: Use StackOverflow for support questions. Issues are for reproducible bug reports and feature requests. 5 | - name: Upstream `signature_pad`'s issues 6 | url: https://github.com/szimek/signature_pad/issues 7 | about: This library is a wrapper around `signature_pad`. If you have an with `signature_pad` itself (as opposed to this wrapper), please see its issue tracker. 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | ci: 9 | name: CI - Node ${{ matrix.node-version }}, ${{ matrix.os }} 10 | 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | node-version: [18.x, 20.x, 22.x] # LTS Node: https://nodejs.org/en/about/releases/ 15 | os: [ubuntu-latest] 16 | 17 | steps: 18 | - name: Checkout repo 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | - name: Setup Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | - name: Install 26 | run: npm ci 27 | 28 | - name: Typecheck 29 | run: npm run tsc 30 | - name: Lint 31 | run: npm run lint 32 | - name: Build 33 | run: npm run build 34 | 35 | - name: Test w/ coverage report 36 | run: npm run test:coverage 37 | - name: Upload coverage report to Codecov 38 | uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### custom ### 2 | 3 | # parcel cache 4 | .parcel-cache/ 5 | # build output 6 | dist/ 7 | # test coverage output 8 | coverage/ 9 | 10 | ### Node ### 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | 17 | # Dependency directories 18 | node_modules/ 19 | 20 | # Optional npm cache directory 21 | .npm 22 | 23 | # Optional REPL history 24 | .node_repl_history 25 | 26 | # Output of 'npm pack' 27 | *.tgz 28 | 29 | # dotenv environment variables file 30 | .env 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The changelog is currently hosted on [the GitHub Releases page](https://github.com/agilgur5/react-signature-canvas/releases).
4 | It is currently mostly a summary and list of commits made before any tag. 5 | The commits in this library mostly follow a convention and tend to be quite detailed. 6 | 7 | This project adheres to [Semantic Versioning](http://semver.org/). 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Anton Gilgur 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 |

5 | 6 | # react-signature-canvas 7 | 8 | 9 | [![package-json](https://img.shields.io/github/package-json/v/agilgur5/react-signature-canvas.svg)](https://npmjs.org/package/react-signature-canvas) 10 | [![releases](https://img.shields.io/github/tag-pre/agilgur5/react-signature-canvas.svg)](https://github.com/agilgur5/react-signature-canvas/releases) 11 | [![commits](https://img.shields.io/github/commits-since/agilgur5/react-signature-canvas/latest.svg)](https://github.com/agilgur5/react-signature-canvas/commits/main) 12 |
13 | [![dt](https://img.shields.io/npm/dt/react-signature-canvas.svg)](https://npmjs.org/package/react-signature-canvas) 14 | [![dy](https://img.shields.io/npm/dy/react-signature-canvas.svg)](https://npmjs.org/package/react-signature-canvas) 15 | [![dm](https://img.shields.io/npm/dm/react-signature-canvas.svg)](https://npmjs.org/package/react-signature-canvas) 16 | [![dw](https://img.shields.io/npm/dw/react-signature-canvas.svg)](https://npmjs.org/package/react-signature-canvas) 17 |
18 | [![typings](https://img.shields.io/npm/types/react-signature-canvas.svg)](src/index.tsx) 19 | [![build status](https://img.shields.io/github/actions/workflow/status/agilgur5/react-signature-canvas/ci.yml?branch=main)](https://github.com/agilgur5/react-signature-canvas/actions/workflows/ci.yml?query=branch%3Amain) 20 | [![code coverage](https://img.shields.io/codecov/c/gh/agilgur5/react-signature-canvas/main.svg)](https://codecov.io/gh/agilgur5/react-signature-canvas) 21 | 22 | A React wrapper component around [signature_pad](https://github.com/szimek/signature_pad). 23 | 24 | Originally, this was just an _unopinionated_ fork of [react-signature-pad](https://github.com/blackjk3/react-signature-pad) that did not impose any styling or wrap any other unwanted elements around your canvas -- it's just a wrapper around a single canvas element! 25 | Hence the naming difference. 26 | Nowadays, this repo / library has significantly evolved, introducing new features, fixing various bugs, and now wrapping the upstream `signature_pad` to have its updates and bugfixes baked in. 27 | 28 | This fork also allows you to directly pass [props](#props) to the underlying canvas element, has new, documented [API methods](#api) you can use, has new, documented [props](#props) you can pass to it, has a [live demo](https://agilgur5.github.io/react-signature-canvas/), has a [CodeSandbox playground](https://codesandbox.io/s/github/agilgur5/react-signature-canvas/tree/codesandbox-example), has [100% test coverage](https://codecov.io/gh/agilgur5/react-signature-canvas), and is [written in TypeScript](src/index.tsx). 29 | 30 | ## Installation 31 | 32 | ```sh 33 | npm i -S react-signature-canvas 34 | ``` 35 | 36 | ## Usage 37 | 38 | ```jsx 39 | import React from 'react' 40 | import { createRoot } from 'react-dom/client' 41 | import SignatureCanvas from 'react-signature-canvas' 42 | 43 | createRoot( 44 | document.getElementById('my-react-container') 45 | ).render( 46 | , 48 | ) 49 | ``` 50 | 51 | ### Props 52 | 53 | The props of SignatureCanvas mainly control the properties of the pen stroke used in drawing. 54 | All props are **optional**. 55 | 56 | - `velocityFilterWeight` : `number`, default: `0.7` 57 | - `minWidth` : `number`, default: `0.5` 58 | - `maxWidth` : `number`, default: `2.5` 59 | - `minDistance`: `number`, default: `5` 60 | - `dotSize` : `number` or `function`, 61 | default: `() => (this.minWidth + this.maxWidth) / 2` 62 | - `penColor` : `string`, default: `'black'` 63 | - `throttle`: `number`, default: `16` 64 | 65 | There are also two callbacks that will be called when a stroke ends and one begins, respectively. 66 | 67 | - `onEnd` : `function` 68 | - `onBegin` : `function` 69 | 70 | Additional props are used to control the canvas element. 71 | 72 | - `canvasProps`: `object` 73 | - directly passed to the underlying `` element 74 | - `backgroundColor` : `string`, default: `'rgba(0,0,0,0)'` 75 | - used in the [API's](#api) `clear` convenience method (which itself is called internally during resizes) 76 | - `clearOnResize`: `bool`, default: `true` 77 | - whether or not the canvas should be cleared when the window resizes 78 | 79 | Of these props, all, except for `canvasProps` and `clearOnResize`, are passed through to `signature_pad` as its [options](https://github.com/szimek/signature_pad#options). 80 | `signature_pad`'s internal state is automatically kept in sync with prop updates for you (via a `componentDidUpdate` hook). 81 | 82 | ### API 83 | 84 | All API methods require [a ref](https://react.dev/learn/manipulating-the-dom-with-refs) to the SignatureCanvas in order to use and are instance methods of the ref. 85 | 86 | ```jsx 87 | import React, { useRef } from 'react' 88 | import SignatureCanvas from 'react-signature-canvas' 89 | 90 | function MyApp() { 91 | const sigCanvas = useRef(null); 92 | 93 | return 94 | } 95 | ``` 96 | 97 | - `isEmpty()` : `boolean`, self-explanatory 98 | - `clear()` : `void`, clears the canvas using the `backgroundColor` prop 99 | - `fromDataURL(base64String, options)` : `void`, writes a base64 image to canvas 100 | - `toDataURL(mimetype, encoderOptions)`: `base64string`, returns the signature image as a data URL 101 | - `fromData(pointGroupArray)`: `void`, draws signature image from an array of point groups 102 | - `toData()`: `pointGroupArray`, returns signature image as an array of point groups 103 | - `off()`: `void`, unbinds all event handlers 104 | - `on()`: `void`, rebinds all event handlers 105 | - `getCanvas()`: `canvas`, returns the underlying canvas ref. 106 | Allows you to modify the canvas however you want or call methods such as `toDataURL()` 107 | - `getTrimmedCanvas()`: `canvas`, creates a copy of the canvas and returns a [trimmed version](https://github.com/agilgur5/trim-canvas) of it, with all whitespace removed. 108 | - `getSignaturePad()`: `SignaturePad`, returns the underlying SignaturePad reference. 109 | 110 | The API methods are _mostly_ just wrappers around [`signature_pad`'s API](https://github.com/szimek/signature_pad#api). 111 | `on()` and `off()` will, in addition, bind/unbind the window resize event handler. 112 | `getCanvas()`, `getTrimmedCanvas()`, and `getSignaturePad()` are new. 113 | 114 | ## Example 115 | 116 | You can interact with the example in a few different ways: 117 | 118 | 1. Run `npm start` and navigate to [http://localhost:1234/](http://localhost:1234/).
119 | Hosted locally via the [`example/`](example/) directory 120 | 121 | 1. [View the live demo here](https://agilgur5.github.io/react-signature-canvas/).
122 | Hosted via the [`gh-pages` branch](https://github.com/agilgur5/react-signature-canvas/tree/gh-pages), a standalone version of the code in [`example/`](example/) 123 | 124 | 1. [Play with the CodeSandbox here](https://codesandbox.io/s/github/agilgur5/react-signature-canvas/tree/codesandbox-example).
125 | Hosted via the [`codesandbox-example` branch](https://github.com/agilgur5/react-signature-canvas/tree/codesandbox-example), a slightly modified version of the above. 126 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const pkgJson = require('./package.json') 2 | 3 | const runtimeVersion = pkgJson.dependencies['@babel/runtime'] 4 | // eslint-disable-next-line dot-notation -- this conflicts with tsc, possibly due to outdated ESLint 5 | const NODE_ENV = process.env['NODE_ENV'] 6 | 7 | /** @type {import('@babel/core').ConfigFunction} */ 8 | module.exports = api => { 9 | api.cache.using(() => NODE_ENV + '_' + runtimeVersion) // cache based on NODE_ENV and runtimeVersion 10 | 11 | // normally use browserslistrc, but for Jest, use current version of Node 12 | const isTest = api.env('test') 13 | const jestTargets = { targets: { node: 'current' } } 14 | /** @type {[import('@babel/core').PluginTarget, import('@babel/core').PluginOptions]} */ 15 | const presetEnv = ['@babel/preset-env', { bugfixes: true }] 16 | if (isTest) presetEnv[1] = { ...presetEnv[1], ...jestTargets } 17 | 18 | return { 19 | // @ts-expect-error -- @types/babel__core doesn't specify assumptions yet 20 | assumptions: { 21 | // optimizations equivalent to previous Babel 6 "loose" behavior for preset-stage-2 (https://github.com/babel/rfcs/blob/main/rfcs/0003-top-level-assumptions.md#assumptions-list, https://github.com/babel/babel/tree/v7.5.5/packages/babel-preset-stage-2) 22 | setPublicClassFields: true, 23 | constantSuper: true 24 | }, 25 | presets: [ 26 | presetEnv, 27 | '@babel/preset-typescript', 28 | '@babel/preset-react' 29 | ], 30 | plugins: [ 31 | // used with @rollup/plugin-babel 32 | ['@babel/plugin-transform-runtime', { 33 | regenerator: false, // not used, and would prefer babel-polyfills over this anyway 34 | version: runtimeVersion // @babel/runtime's version 35 | }] 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Signature Pad Example 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | 4 | import SignatureCanvas from '../../src/index.tsx' 5 | 6 | import * as styles from './styles.module.css' 7 | 8 | function App () { 9 | const sigCanvas = useRef(null) 10 | const [trimmedDataURL, setTrimmedDataURL] = useState(null) 11 | 12 | function clear () { 13 | sigCanvas.current.clear() 14 | } 15 | 16 | function trim () { 17 | setTrimmedDataURL(sigCanvas.current.getTrimmedCanvas().toDataURL('image/png')) 18 | } 19 | 20 | return ( 21 |
22 |
23 | 24 |
25 |
26 | 27 | 28 |
29 | {trimmedDataURL 30 | ? signature 31 | : null} 32 |
33 | ) 34 | } 35 | 36 | createRoot(document.getElementById('container')).render() 37 | -------------------------------------------------------------------------------- /example/src/styles.module.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: gray; 3 | -moz-user-select: none; 4 | -webkit-user-select: none; 5 | -ms-user-select: none; 6 | } 7 | 8 | .container { 9 | width: 100%; 10 | height: 100%; 11 | top: 10%; 12 | left: 10%; 13 | } 14 | 15 | .sigContainer { 16 | width: 80%; 17 | height: 80%; 18 | margin: 0 auto; 19 | background-color: #fff; 20 | } 21 | 22 | .sigCanvas { 23 | width: 100%; 24 | height: 100%; 25 | } 26 | 27 | .buttons { 28 | width: 100%; 29 | height: 30px; 30 | } 31 | 32 | .sigImage { 33 | background-size: 200px 50px; 34 | width: 200px; 35 | height: 50px; 36 | background-color: white; 37 | } 38 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types' 2 | import { defaults } from 'jest-config' 3 | 4 | const config: Config.InitialOptions = { 5 | injectGlobals: false, // use @jest/globals 6 | testEnvironment: 'jsdom', 7 | setupFilesAfterEnv: [ 8 | // polyfill window.resizeTo 9 | 'window-resizeto/polyfill' 10 | ], 11 | coveragePathIgnorePatterns: [ 12 | ...defaults.coveragePathIgnorePatterns, 13 | '/test/' // ignore any test helper files 14 | ] 15 | } 16 | 17 | export default config 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-signature-canvas", 3 | "version": "1.1.0-alpha.2", 4 | "description": "A React wrapper component around signature_pad. 100% test coverage, types, examples, & more. Unopinionated and heavily updated fork of react-signature-pad", 5 | "source": "./src/index.tsx", 6 | "main": "./dist/index.umd.min.js", 7 | "module": "./dist/index.mjs", 8 | "types": "./dist/index.d.ts", 9 | "exports": { 10 | "types": "./dist/index.d.ts", 11 | "require": "./dist/index.umd.min.js", 12 | "default": "./dist/index.mjs" 13 | }, 14 | "files": [ 15 | "./src/", 16 | "./dist/" 17 | ], 18 | "author": "Anton Gilgur", 19 | "license": "Apache-2.0", 20 | "homepage": "https://github.com/agilgur5/react-signature-canvas", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/agilgur5/react-signature-canvas.git" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/agilgur5/react-signature-canvas/issues" 27 | }, 28 | "funding": { 29 | "url": "https://github.com/sponsors/agilgur5" 30 | }, 31 | "keywords": [ 32 | "react", 33 | "react-component", 34 | "component", 35 | "signature", 36 | "sign", 37 | "e-sign", 38 | "e-signature", 39 | "canvas", 40 | "trim", 41 | "whitespace", 42 | "draw", 43 | "pad", 44 | "wrapper", 45 | "signature-pad", 46 | "react-signature-pad" 47 | ], 48 | "scripts": { 49 | "start": "parcel example/public/index.html --open", 50 | "clean": "rm -rf dist/ && rm -f *.tgz", 51 | "clean:build": "npm run clean && npm run build", 52 | "build": "concurrently -n rollup,tsc \"npm run build:rollup\" \"npm run build:types\"", 53 | "build:rollup": "rollup -c rollup.config.ts --configPlugin rollup-plugin-typescript2", 54 | "build:types": "tsc -p tsconfig.build.json", 55 | "build:watch": "concurrently -n rollup,tsc \"npm run build:rollup -- -w\" \"npm run build:types -- -w\"", 56 | "tsc": "tsc", 57 | "lint": "ts-standard", 58 | "lint:fix": "npm run lint -- --fix", 59 | "test": "jest", 60 | "test:coverage": "jest --coverage", 61 | "test:watch": "jest --watch", 62 | "test:pub": "npm run clean:build && npm pack", 63 | "prepub": "concurrently -n test-pub,test-cov,tsc \"npm run test:pub\" \"npm run test:coverage\" \"npm run tsc\"", 64 | "pub": "npm run clean:build && npm publish" 65 | }, 66 | "peerDependencies": { 67 | "@types/prop-types": "^15.7.3", 68 | "@types/react": "0.14 - 19", 69 | "prop-types": "^15.5.8", 70 | "react": "0.14 - 19", 71 | "react-dom": "0.14 - 19" 72 | }, 73 | "peerDependenciesMeta": { 74 | "@types/prop-types": { 75 | "optional": true 76 | }, 77 | "@types/react": { 78 | "optional": true 79 | } 80 | }, 81 | "dependencies": { 82 | "@babel/runtime": "^7.17.9", 83 | "@types/signature_pad": "^2.3.0", 84 | "signature_pad": "^2.3.2", 85 | "trim-canvas": "^0.1.0" 86 | }, 87 | "devDependencies": { 88 | "@agilgur5/tsconfig": "^0.0.1", 89 | "@babel/core": "^7.17.9", 90 | "@babel/plugin-transform-runtime": "^7.17.0", 91 | "@babel/preset-env": "^7.16.11", 92 | "@babel/preset-react": "^7.16.7", 93 | "@babel/preset-typescript": "^7.16.7", 94 | "@jest/globals": "^29.7.0", 95 | "@jest/types": "^29.6.3", 96 | "@rollup/plugin-babel": "^5.3.1", 97 | "@rollup/plugin-commonjs": "^21.1.0", 98 | "@rollup/plugin-node-resolve": "^13.2.1", 99 | "@testing-library/react": "^16.1.0", 100 | "@types/prop-types": "^15.7.3", 101 | "@types/react": "^19.0.2", 102 | "canvas": "^3.1.0", 103 | "concurrently": "^9.1.2", 104 | "jest": "^29.7.0", 105 | "jest-config": "^29.7.0", 106 | "jest-environment-jsdom": "^29.7.0", 107 | "package-json-type": "^1.0.3", 108 | "parcel": "^2.4.1", 109 | "react": "^19.0.0", 110 | "react-dom": "^19.0.0", 111 | "rollup": "^2.70.2", 112 | "rollup-plugin-node-externals": "^4.0.0", 113 | "rollup-plugin-terser": "^7.0.2", 114 | "rollup-plugin-typescript2": "^0.32.1", 115 | "ts-node": "^10.7.0", 116 | "ts-standard": "^11.0.0", 117 | "typescript": "^4.6.3", 118 | "window-resizeto": "^0.0.2" 119 | }, 120 | "overrides": { 121 | "jest-environment-jsdom": { 122 | "canvas": "$canvas" 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import type { IPackageJson } from 'package-json-type' 2 | import type { RollupOptions, OutputOptions } from 'rollup' 3 | import { externals } from 'rollup-plugin-node-externals' 4 | import { nodeResolve } from '@rollup/plugin-node-resolve' 5 | import commonjs from '@rollup/plugin-commonjs' 6 | import { babel } from '@rollup/plugin-babel' 7 | import { DEFAULT_EXTENSIONS as BABEL_DEFAULT_EXTENSIONS } from '@babel/core' 8 | import { terser } from 'rollup-plugin-terser' 9 | 10 | import packageJson from './package.json' 11 | 12 | const pkgJson = packageJson as IPackageJson // coerce to the right type 13 | 14 | const outputDefaults: OutputOptions = { 15 | // always provide a sourcemap for better debugging for consumers 16 | sourcemap: true, 17 | // don't duplicate source code in the sourcemap as we already provide the 18 | // source code with the package. also makes the sourcemap _much_ smaller 19 | sourcemapExcludeSources: true 20 | } 21 | 22 | const configs: RollupOptions[] = [{ 23 | // use package.json conventions supported by existing tools like microbundle (https://github.com/developit/microbundle) 24 | input: pkgJson['source'], 25 | output: [{ 26 | // ESM for current/maintained environments 27 | file: pkgJson['module'], 28 | format: 'esm', 29 | ...outputDefaults 30 | }, { 31 | // UMD for all older envs 32 | file: pkgJson.main, 33 | format: 'umd', 34 | name: 'SignatureCanvas', // backward-compat with old build's name 35 | plugins: [terser({ 36 | ecma: 5, 37 | // https://github.com/babel/preset-modules#important-minification 38 | safari10: true 39 | })], 40 | ...outputDefaults 41 | }], 42 | plugins: [ 43 | externals(), // https://github.com/Septh/rollup-plugin-node-externals#3-order-matters 44 | nodeResolve(), 45 | commonjs(), 46 | babel({ 47 | // don't transpile externals 48 | exclude: 'node_modules/**', 49 | // suport TS 50 | extensions: [...BABEL_DEFAULT_EXTENSIONS, 'ts', 'tsx'], 51 | // use @babel/runtime since we're building a library 52 | babelHelpers: 'runtime' 53 | }) 54 | ] 55 | }] 56 | 57 | export default configs 58 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React, { Component } from 'react' 3 | import SignaturePad from 'signature_pad' 4 | import trimCanvas from 'trim-canvas' 5 | 6 | export interface SignatureCanvasProps extends SignaturePad.SignaturePadOptions { 7 | canvasProps?: React.CanvasHTMLAttributes 8 | clearOnResize?: boolean 9 | } 10 | 11 | export class SignatureCanvas extends Component { 12 | static override propTypes = { 13 | // signature_pad's props 14 | velocityFilterWeight: PropTypes.number, 15 | minWidth: PropTypes.number, 16 | maxWidth: PropTypes.number, 17 | minDistance: PropTypes.number, 18 | dotSize: PropTypes.oneOfType([PropTypes.number, PropTypes.func]), 19 | penColor: PropTypes.string, 20 | throttle: PropTypes.number, 21 | onEnd: PropTypes.func, 22 | onBegin: PropTypes.func, 23 | // props specific to the React wrapper 24 | canvasProps: PropTypes.object, 25 | clearOnResize: PropTypes.bool 26 | } 27 | 28 | static defaultProps: Pick = { 29 | clearOnResize: true 30 | } 31 | 32 | static refNullError = new Error('react-signature-canvas is currently ' + 33 | 'mounting or unmounting: React refs are null during this phase.') 34 | 35 | // shortcut reference (https://stackoverflow.com/a/29244254/3431180) 36 | private readonly staticThis = this.constructor as typeof SignatureCanvas 37 | 38 | _sigPad: SignaturePad | null = null 39 | _canvas: HTMLCanvasElement | null = null 40 | 41 | private readonly setRef = (ref: HTMLCanvasElement | null): void => { 42 | this._canvas = ref 43 | // if component is unmounted, set internal references to null 44 | if (this._canvas === null) { 45 | this._sigPad = null 46 | } 47 | } 48 | 49 | _excludeOurProps = (): SignaturePad.SignaturePadOptions => { 50 | const { canvasProps, clearOnResize, ...sigPadProps } = this.props 51 | return sigPadProps 52 | } 53 | 54 | override componentDidMount: Component['componentDidMount'] = () => { 55 | const canvas = this.getCanvas() 56 | this._sigPad = new SignaturePad(canvas, this._excludeOurProps()) 57 | this._resizeCanvas() 58 | this.on() 59 | } 60 | 61 | override componentWillUnmount: Component['componentWillUnmount'] = () => { 62 | this.off() 63 | } 64 | 65 | // propagate prop updates to SignaturePad 66 | override componentDidUpdate: Component['componentDidUpdate'] = () => { 67 | Object.assign(this._sigPad, this._excludeOurProps()) 68 | } 69 | 70 | // return the canvas ref for operations like toDataURL 71 | getCanvas = (): HTMLCanvasElement => { 72 | if (this._canvas === null) { 73 | throw this.staticThis.refNullError 74 | } 75 | return this._canvas 76 | } 77 | 78 | // return a trimmed copy of the canvas 79 | getTrimmedCanvas = (): HTMLCanvasElement => { 80 | // copy the canvas 81 | const canvas = this.getCanvas() 82 | const copy = document.createElement('canvas') 83 | copy.width = canvas.width 84 | copy.height = canvas.height 85 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 86 | copy.getContext('2d')!.drawImage(canvas, 0, 0) 87 | // then trim it 88 | return trimCanvas(copy) 89 | } 90 | 91 | // return the internal SignaturePad reference 92 | getSignaturePad = (): SignaturePad => { 93 | if (this._sigPad === null) { 94 | throw this.staticThis.refNullError 95 | } 96 | return this._sigPad 97 | } 98 | 99 | _checkClearOnResize = (): void => { 100 | if (!this.props.clearOnResize) { // eslint-disable-line @typescript-eslint/strict-boolean-expressions -- this is backward compatible with the previous behavior, where null was treated as falsey 101 | return 102 | } 103 | this._resizeCanvas() 104 | } 105 | 106 | _resizeCanvas = (): void => { 107 | const canvasProps = this.props.canvasProps ?? {} 108 | const { width, height } = canvasProps 109 | // don't resize if the canvas has fixed width and height 110 | if (typeof width !== 'undefined' && typeof height !== 'undefined') { 111 | return 112 | } 113 | 114 | const canvas = this.getCanvas() 115 | /* When zoomed out to less than 100%, for some very strange reason, 116 | some browsers report devicePixelRatio as less than 1 117 | and only part of the canvas is cleared then. */ 118 | const ratio = Math.max(window.devicePixelRatio ?? 1, 1) 119 | 120 | if (typeof width === 'undefined') { 121 | canvas.width = canvas.offsetWidth * ratio 122 | } 123 | if (typeof height === 'undefined') { 124 | canvas.height = canvas.offsetHeight * ratio 125 | } 126 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 127 | canvas.getContext('2d')!.scale(ratio, ratio) 128 | this.clear() 129 | } 130 | 131 | override render: Component['render'] = () => { 132 | const { canvasProps } = this.props 133 | return 134 | } 135 | 136 | // all wrapper functions below render 137 | // 138 | on: SignaturePad['on'] = () => { 139 | window.addEventListener('resize', this._checkClearOnResize) 140 | return this.getSignaturePad().on() 141 | } 142 | 143 | off: SignaturePad['off'] = () => { 144 | window.removeEventListener('resize', this._checkClearOnResize) 145 | return this.getSignaturePad().off() 146 | } 147 | 148 | clear: SignaturePad['clear'] = () => { 149 | return this.getSignaturePad().clear() 150 | } 151 | 152 | isEmpty: SignaturePad['isEmpty'] = () => { 153 | return this.getSignaturePad().isEmpty() 154 | } 155 | 156 | fromDataURL: SignaturePad['fromDataURL'] = (dataURL, options) => { 157 | return this.getSignaturePad().fromDataURL(dataURL, options) 158 | } 159 | 160 | toDataURL: SignaturePad['toDataURL'] = (type, encoderOptions) => { 161 | return this.getSignaturePad().toDataURL(type, encoderOptions) 162 | } 163 | 164 | fromData: SignaturePad['fromData'] = (pointGroups) => { 165 | return this.getSignaturePad().fromData(pointGroups) 166 | } 167 | 168 | toData: SignaturePad['toData'] = () => { 169 | return this.getSignaturePad().toData() 170 | } 171 | } 172 | 173 | export default SignatureCanvas 174 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | import type SignaturePad from 'signature_pad' 2 | 3 | // signature_pad options 4 | const sigPadOptions = { 5 | velocityFilterWeight: 0.8, 6 | minWidth: 0.6, 7 | maxWidth: 2.6, 8 | minDistance: 4, 9 | dotSize: 2, 10 | penColor: 'green', 11 | throttle: 17, 12 | onEnd: () => { return 'onEnd' }, 13 | onBegin: () => { return 'onBegin' } 14 | } 15 | // props specific to React wrapper 16 | const rSCProps = { 17 | canvasProps: { width: 500, height: 500 }, 18 | clearOnResize: false 19 | } 20 | // should all be different from the defaults 21 | const props = { ...sigPadOptions, ...rSCProps } 22 | export const propsF = { sigPadOptions, all: props } 23 | 24 | const dotData = [ 25 | [{ x: 466.59375, y: 189, time: 1564339579755, color: 'black' }] 26 | ] as SignaturePad.Point[][] 27 | const canvasProps = { width: 1011, height: 326 } 28 | const trimmedSize = { width: 4, height: 4 } 29 | export const dotF = { data: dotData, canvasProps, trimmedSize } 30 | -------------------------------------------------------------------------------- /test/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { jest, describe, it, test, expect } from '@jest/globals' 2 | import { render, RenderResult } from '@testing-library/react' 3 | import React from 'react' 4 | 5 | import { SignatureCanvas, SignatureCanvasProps } from '../src/index' 6 | import { propsF, dotF } from './fixtures' 7 | 8 | function renderSCWithRef (props?: SignatureCanvasProps): { wrapper: RenderResult, instance: SignatureCanvas, ref: React.RefObject } { 9 | const ref = React.createRef() 10 | const wrapper = render() 11 | const instance = ref.current! // eslint-disable-line @typescript-eslint/no-non-null-assertion -- this simplifies the code; it does exist immediately after render. it won't exist after unmount, but we literally test for that separately 12 | return { wrapper, instance, ref } 13 | } 14 | 15 | test('mounts canvas and instance properly', () => { 16 | const { wrapper: { container }, instance } = renderSCWithRef() 17 | expect(container.querySelector('canvas')).toBeTruthy() 18 | expect(instance.isEmpty()).toBe(true) 19 | }) 20 | 21 | describe('setting and updating props', () => { 22 | it('should set default props', () => { 23 | const { instance } = renderSCWithRef() 24 | expect(instance.props).toStrictEqual(SignatureCanvas.defaultProps) 25 | }) 26 | 27 | it('should set initial mount props and SigPad options', () => { 28 | const { instance } = renderSCWithRef(propsF.all) 29 | const sigPad = instance.getSignaturePad() 30 | 31 | expect(instance.props).toMatchObject(propsF.all) 32 | expect(sigPad).toMatchObject(propsF.sigPadOptions) 33 | }) 34 | 35 | it('should update props and SigPad options', () => { 36 | const { wrapper, instance, ref } = renderSCWithRef() 37 | const sigPad = instance.getSignaturePad() 38 | 39 | // default props and options should not match new ones 40 | expect(instance.props).not.toMatchObject(propsF.all) 41 | expect(sigPad).not.toMatchObject(propsF.sigPadOptions) 42 | 43 | // should match when updated 44 | wrapper.rerender() 45 | expect(instance.props).toMatchObject(propsF.all) 46 | expect(sigPad).toMatchObject(propsF.sigPadOptions) 47 | }) 48 | }) 49 | 50 | describe('SigCanvas wrapper methods return equivalent to SigPad', () => { 51 | const { instance } = renderSCWithRef() 52 | const rSigPad = instance 53 | const sigPad = rSigPad.getSignaturePad() 54 | 55 | test('toData should be equivalent', () => { 56 | const rData = rSigPad.toData() 57 | expect(rData).toStrictEqual([]) 58 | expect(rData).toBe(sigPad.toData()) 59 | }) 60 | 61 | test('fromData should be equivalent', () => { 62 | rSigPad.fromData(dotF.data) 63 | const rData = rSigPad.toData() 64 | expect(rData).toBe(dotF.data) 65 | expect(rData).toBe(sigPad.toData()) 66 | 67 | // test reverse as both froms should be equivalent 68 | sigPad.fromData(dotF.data) 69 | const data = sigPad.toData() 70 | expect(rData).toBe(data) 71 | expect(rSigPad.toData()).toBe(data) 72 | }) 73 | 74 | test('toDataURL should be equivalent', () => { 75 | rSigPad.fromData(dotF.data) 76 | expect(rSigPad.toDataURL()).toBe(sigPad.toDataURL()) 77 | expect(rSigPad.toDataURL('image/jpg')).toBe(sigPad.toDataURL('image/jpg')) 78 | expect(rSigPad.toDataURL('image/jpg', 0.7)).toBe(sigPad.toDataURL('image/jpg', 0.7)) 79 | expect(rSigPad.toDataURL('image/svg+xml')).toBe(sigPad.toDataURL('image/svg+xml')) 80 | }) 81 | 82 | test('fromDataURL should be equivalent', () => { 83 | // convert data fixture to dataURL 84 | rSigPad.fromData(dotF.data) 85 | const dotFDataURL = rSigPad.toDataURL() 86 | 87 | rSigPad.fromDataURL(dotFDataURL) 88 | const rDataURL = rSigPad.toDataURL() 89 | expect(rDataURL).toBe(dotFDataURL) 90 | expect(rDataURL).toBe(sigPad.toDataURL()) 91 | 92 | // test reverse as both froms should be equivalent 93 | sigPad.fromDataURL(dotFDataURL) 94 | const dataURL = sigPad.toDataURL() 95 | expect(rDataURL).toBe(dataURL) 96 | expect(rSigPad.toDataURL()).toBe(dataURL) 97 | }) 98 | 99 | test('isEmpty & clear should be equivalent', () => { 100 | rSigPad.fromData(dotF.data) 101 | let isEmpty = rSigPad.isEmpty() 102 | expect(isEmpty).toBe(false) 103 | expect(isEmpty).toBe(sigPad.isEmpty()) 104 | 105 | // both empty after clear 106 | rSigPad.clear() 107 | isEmpty = rSigPad.isEmpty() 108 | expect(isEmpty).toBe(true) 109 | expect(isEmpty).toBe(sigPad.isEmpty()) 110 | 111 | // test reverse 112 | sigPad.fromData(dotF.data) 113 | isEmpty = rSigPad.isEmpty() 114 | expect(isEmpty).toBe(false) 115 | expect(isEmpty).toBe(sigPad.isEmpty()) 116 | 117 | // both empty after internal sigPad clear 118 | sigPad.clear() 119 | isEmpty = rSigPad.isEmpty() 120 | expect(isEmpty).toBe(true) 121 | expect(isEmpty).toBe(sigPad.isEmpty()) 122 | }) 123 | }) 124 | 125 | // comes after props and wrapper methods as it uses both 126 | describe('get methods', () => { 127 | const { instance } = renderSCWithRef({ canvasProps: dotF.canvasProps }) 128 | instance.fromData(dotF.data) 129 | 130 | test('getCanvas should return the same underlying canvas', () => { 131 | const canvas = instance.getCanvas() 132 | expect(instance.toDataURL()).toBe(canvas.toDataURL()) 133 | }) 134 | 135 | test('getTrimmedCanvas should return a trimmed canvas', () => { 136 | const trimmed = instance.getTrimmedCanvas() 137 | expect(trimmed.width).toBe(dotF.trimmedSize.width) 138 | expect(trimmed.height).toBe(dotF.trimmedSize.height) 139 | }) 140 | }) 141 | 142 | // comes after props, wrappers, and gets as it uses them all 143 | describe('canvas resizing', () => { 144 | const { wrapper, instance, ref } = renderSCWithRef() 145 | const canvas = instance.getCanvas() 146 | 147 | it('should clear on resize', () => { 148 | instance.fromData(dotF.data) 149 | expect(instance.isEmpty()).toBe(false) 150 | 151 | window.resizeTo(500, 500) 152 | expect(instance.isEmpty()).toBe(true) 153 | }) 154 | 155 | it('should not clear when clearOnResize is false', () => { 156 | wrapper.rerender() 157 | 158 | instance.fromData(dotF.data) 159 | expect(instance.isEmpty()).toBe(false) 160 | 161 | window.resizeTo(500, 500) 162 | expect(instance.isEmpty()).toBe(false) 163 | }) 164 | 165 | const size = { width: 100, height: 100 } 166 | it('should not change size if fixed width & height', () => { 167 | // reset clearOnResize back to true after previous test 168 | wrapper.rerender() 169 | window.resizeTo(500, 500) 170 | 171 | expect(canvas.width).toBe(size.width) 172 | expect(canvas.height).toBe(size.height) 173 | }) 174 | 175 | it('should change size if no width or height', () => { 176 | wrapper.rerender() 177 | window.resizeTo(500, 500) 178 | 179 | expect(canvas.width).not.toBe(size.width) 180 | expect(canvas.height).not.toBe(size.height) 181 | }) 182 | 183 | it('should partially change size if one of width or height', () => { 184 | wrapper.rerender() 185 | window.resizeTo(500, 500) 186 | 187 | expect(canvas.width).toBe(size.width) 188 | expect(canvas.height).not.toBe(size.height) 189 | 190 | // now do height instead 191 | wrapper.rerender() 192 | window.resizeTo(500, 500) 193 | 194 | expect(canvas.width).not.toBe(size.width) 195 | expect(canvas.height).toBe(size.height) 196 | }) 197 | }) 198 | 199 | // comes after wrappers and resizing as it uses both 200 | describe('on & off methods', () => { 201 | const { wrapper, instance } = renderSCWithRef() 202 | 203 | it('should not clear when off, should clear when back on', () => { 204 | instance.fromData(dotF.data) 205 | expect(instance.isEmpty()).toBe(false) 206 | 207 | instance.off() 208 | window.resizeTo(500, 500) 209 | expect(instance.isEmpty()).toBe(false) 210 | 211 | instance.on() 212 | window.resizeTo(500, 500) 213 | expect(instance.isEmpty()).toBe(true) 214 | }) 215 | 216 | it('should no longer fire after unmount', () => { 217 | // monkey-patch on with a mock to tell if it were called, as there's no way 218 | // to check what event listeners are attached to window 219 | const origOn = instance.on 220 | instance.on = jest.fn(origOn) 221 | 222 | wrapper.unmount() 223 | window.resizeTo(500, 500) 224 | expect(instance.on).not.toBeCalled() 225 | }) 226 | }) 227 | 228 | // unmounting comes last 229 | describe('unmounting', () => { 230 | const { wrapper, instance } = renderSCWithRef() 231 | 232 | it('should error when retrieving instance variables', () => { 233 | wrapper.unmount() 234 | expect(() => { 235 | instance.getCanvas() 236 | }).toThrowError(SignatureCanvas.refNullError) 237 | expect(() => { 238 | instance.getSignaturePad() 239 | }).toThrowError(SignatureCanvas.refNullError) 240 | }) 241 | }) 242 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | // tsconfig.json is used for type-checking _all_ files, tsconfig.build.json is just used for the build 3 | "extends": "./tsconfig.json", 4 | // allowlist of files to build 5 | "files": ["src/index.tsx", "types/trim-canvas.d.ts"], 6 | "compilerOptions": { 7 | // override the base 8 | "noEmit": false, 9 | // don't output JS files, only declarations (Rollup outputs the JS) 10 | "emitDeclarationOnly": true, 11 | }, 12 | // read this file as a tsconfig even though it's named slightly differently 13 | "$schema": "https://json.schemastore.org/tsconfig", 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://github.com/agilgur5/tsconfig 3 | "extends": "@agilgur5/tsconfig/src/tsconfig.library.json", 4 | // exclude node_modules (the default), dist dir, coverage dir, and example for now 5 | "exclude": ["node_modules/", "dist/", "coverage/", "example/"], 6 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 7 | "compilerOptions": { 8 | // output to dist/ dir 9 | "outDir": "dist/", 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /types/trim-canvas.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'trim-canvas' { 2 | export default function trimCanvas (canvas: HTMLCanvasElement): HTMLCanvasElement 3 | } 4 | --------------------------------------------------------------------------------