diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index 06cc47d..0000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "root": true,
- "ignorePatterns": ["**/*"],
- "plugins": ["@nrwl/nx"],
- "overrides": [
- {
- "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
- "rules": {
- "@nrwl/nx/enforce-module-boundaries": [
- "error",
- {
- "enforceBuildableLibDependency": true,
- "allow": [],
- "depConstraints": [
- {
- "sourceTag": "*",
- "onlyDependOnLibsWithTags": ["*"]
- }
- ]
- }
- ]
- }
- },
- {
- "files": ["*.ts", "*.tsx"],
- "extends": ["plugin:@nrwl/nx/typescript"],
- "rules": {}
- },
- {
- "files": ["*.js", "*.jsx"],
- "extends": ["plugin:@nrwl/nx/javascript"],
- "rules": {}
- }
- ]
-}
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..181b419
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,40 @@
+name: CI
+on: [push]
+jobs:
+ build:
+ name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }}
+
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ node: ['12.x', '14.x']
+ os: [ubuntu-latest]
+
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v2
+
+ - name: Use Node ${{ matrix.node }}
+ uses: actions/setup-node@v2
+ with:
+ node-version: ${{ matrix.node }}
+
+ - name: Install Yarn
+ run: npm install -g yarn
+
+ - name: Install deps and build (with cache)
+ run: yarn install --frozen-lockfile --silent
+
+ - name: Lint
+ run: yarn lint
+
+ - name: Test
+ run: yarn test
+
+ - name: Upload coverage
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+ run: npx nyc report --reporter=text-lcov | npx codecov --disable=gcov --pipe
+
+ - name: Build
+ run: yarn build:prod
diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml
deleted file mode 100644
index e3d884a..0000000
--- a/.github/workflows/node.js.yml
+++ /dev/null
@@ -1,37 +0,0 @@
-name: Node.js CI
-on:
- push:
- branches:
- - master
-
-jobs:
- build:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- node-version:
- - 14.x
- steps:
- - uses: actions/checkout@v2
-
- - name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v2
- with:
- node-version: ${{ matrix.node-version }}
- - run: |
- yarn install --frozen-lockfile --silent
- yarn nx run react-sketch-canvas:build
- yarn nx run react-sketch-canvas:build-storybook:ci
-
- - name: Deploy Storybook 🚀
- uses: JamesIves/github-pages-deploy-action@4.1.4
- with:
- branch: gh-pages
- folder: dist/storybook/react-sketch-canvas
- clean: true
-
- - uses: JS-DevTools/npm-publish@v1
- with:
- token: ${{ secrets.NPM_TOKEN }}
- check-version: true
- package: dist/libs/react-sketch-canvas/package.json
diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml
new file mode 100644
index 0000000..ba124b3
--- /dev/null
+++ b/.github/workflows/size.yml
@@ -0,0 +1,13 @@
+name: size
+on: [pull_request]
+jobs:
+ size:
+ runs-on: ubuntu-latest
+ env:
+ CI_JOB_NUMBER: 1
+ steps:
+ - uses: actions/checkout@v1
+ - uses: andresz1/size-limit-action@v1
+ with:
+ build_script: build:prod
+ github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index ee5c9d8..a38ecb1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,39 +1,13 @@
-# See http://help.github.com/ignore-files/ for more about ignoring files.
-
-# compiled output
-/dist
-/tmp
-/out-tsc
-
-# dependencies
-/node_modules
-
-# IDEs and editors
-/.idea
-.project
-.classpath
-.c9/
-*.launch
-.settings/
-*.sublime-workspace
-
-# IDE - VSCode
-.vscode/*
-!.vscode/settings.json
-!.vscode/tasks.json
-!.vscode/launch.json
-!.vscode/extensions.json
-
-# misc
-/.sass-cache
-/connect.lock
-/coverage
-/libpeerconnection.log
-npm-debug.log
-yarn-error.log
-testem.log
-/typings
-
-# System Files
+*.log
.DS_Store
-Thumbs.db
+node_modules
+.cache
+dist
+# Cypress
+cypress/screenshots/
+cypress/videos/
+.nyc_output/
+coverage/
+jest-coverage/
+cypress-coverage/
+reports/
diff --git a/.npmignore b/.npmignore
new file mode 100644
index 0000000..aa59801
--- /dev/null
+++ b/.npmignore
@@ -0,0 +1,17 @@
+.DS_Store
+.cache
+.vscode
+.dependabot
+node_modules
+example/
+cypress/
+.env
+tsconfig.json
+src/
+.github
+.nyc_output/
+coverage/
+jest-coverage/
+cypress-coverage/
+reports/
+cypress.json
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..ba90516
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+registry = "https://registry.npmjs.com/"
+legacy-peer-deps = true
diff --git a/.prettierignore b/.prettierignore
deleted file mode 100644
index d0b804d..0000000
--- a/.prettierignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# Add files here to ignore them from prettier formatting
-
-/dist
-/coverage
diff --git a/.prettierrc b/.prettierrc
deleted file mode 100644
index 92cde39..0000000
--- a/.prettierrc
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "singleQuote": true
-}
\ No newline at end of file
diff --git a/.release-it.json b/.release-it.json
new file mode 100644
index 0000000..64e2ec8
--- /dev/null
+++ b/.release-it.json
@@ -0,0 +1,18 @@
+{
+ "git": {
+ "commitMessage": "chore: release v${version}"
+ },
+ "github": {
+ "release": true,
+ "web": true,
+ "assets": ["dist/*"]
+ },
+ "hooks": {
+ "after:bump": "yarn build:prod",
+ "after:release": "echo Successfully released ${name} v${version} to ${repo.repository}."
+ },
+ "npm": {
+ "release": true,
+ "skipChecks": true
+ }
+}
diff --git a/.storybook/main.js b/.storybook/main.js
deleted file mode 100644
index 1492ac6..0000000
--- a/.storybook/main.js
+++ /dev/null
@@ -1,11 +0,0 @@
-module.exports = {
- stories: [],
- addons: ['@storybook/addon-essentials'],
- // uncomment the property below if you want to apply some webpack config globally
- // webpackFinal: async (config, { configType }) => {
- // // Make whatever fine-grained changes you need that should apply to all storybook configs
-
- // // Return the altered config
- // return config;
- // },
-};
diff --git a/.storybook/tsconfig.json b/.storybook/tsconfig.json
deleted file mode 100644
index 4b11015..0000000
--- a/.storybook/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "extends": "../tsconfig.base.json",
- "exclude": [
- "../**/*.spec.js",
- "../**/*.spec.ts",
- "../**/*.spec.tsx",
- "../**/*.spec.jsx"
- ],
- "include": ["../**/*"]
-}
diff --git a/.vscode/launch.json b/.vscode/launch.json
deleted file mode 100644
index 63270a5..0000000
--- a/.vscode/launch.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- // Use IntelliSense to learn about possible attributes.
- // Hover to view descriptions of existing attributes.
- // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
- "version": "0.2.0",
- "configurations": [
- {
- "type": "pwa-chrome",
- "request": "launch",
- "name": "Debug storybook",
- "url": "http://localhost:4400",
- "webRoot": "${workspaceFolder}",
- "preLaunchTask": "Storybook",
- "sourceMaps": true,
- "sourceMapPathOverrides": {
- "webpack:///*": "${webRoot}/*",
- "webpack:///./*": "${webRoot}/*",
- "webpack:///src/*": "${webRoot}/*",
- "webpack:///./~/*": "${webRoot}/node_modules/*"
- }
- }
- ]
-}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
deleted file mode 100644
index 5aac4f7..0000000
--- a/.vscode/tasks.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- // See https://go.microsoft.com/fwlink/?LinkId=733558
- // for the documentation about the tasks.json format
- "version": "2.0.0",
- "tasks": [
- {
- "label": "Storybook",
- "type": "shell",
- "command": "nx run react-sketch-canvas:storybook",
- "isBackground": true
- }
- ]
-}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e730eb..2833ddf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,33 @@
-## Changelog
+# Changelog
-## [6.0.0]
+## [6.0.2]
-## Added
+### Added
+
+- Add cypress tests for all props and events
+- Added `onStroke` prop to get last stroke on pointer up
+- Adds a point on click (without moving) #45
+
+### Changed
+
+- Upgraded all dependencies
+- Moved to DTS (tsdx fork) instead of nx
+- Switched codebase to hook based implementation (support react >= 16.8)
+- Removed immer dependency
+
+### Fixed
+
+- Changed React import to \* from React #40
+- Export image fails when the background is not an image [beta] #46
+- Fix partial transparent erase (eraser stroke color changed to black for masking, add maskUnits) #44
+
+### Breaking changes
+
+- Renamed `onUpdate` to `onChange`
+
+## [6.0.1-beta]
+
+### Added
- Upgraded all dependencies
- Updated directory structure
@@ -12,37 +37,36 @@
- Updated erase option to use mask instead of canvas color
- Add github action for deployment of storybook and package
-## Breaking changes
+### Breaking changes
- Removed background option to set background image using CSS-in-JS (instead check feature-filled backgroundImage prop)
-
## [5.3.4]
-## Added
+### Added
- Switched to Nx
- Updated documentation
-## Changed
+### Changed
- Removed pepjs. Can be polyfilled by the web app directly instead
## [5.3.3]
-## Fixed
+### Fixed
- add support any version above react 16.4
-
+
## [5.3.2]
-## Fixed
+### Fixed
- Bump dependency versions
-
+
## [5.3.1]
-## Fixed
+### Fixed
- Set default value of `allowOnlyPointerType` as `'all'` again
diff --git a/README.md b/README.md
index 433f828..cc9d88b 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,22 @@
-
-
- Freehand vector drawing tool for React using SVG as canvas 🖌
+ Freehand vector drawing component for React using SVG as canvas 🖌
  
- 
+ 
+[](https://codecov.io/gh/vinothpandian/react-sketch-canvas)
-This project was generated using [Nx](https://nx.dev).
+This project was generated using [DTS](https://github.com/weiran-zsd/dts-cli).
-
-
## Overview
### Features
@@ -30,7 +27,6 @@ This project was generated using [Nx](https://nx.dev).
### Requirements
- ****Requires React >= 16.4****
-- **Depends on [Immer]**
### Wanna test React Sketch Canvas before using it?
@@ -132,12 +128,13 @@ const Canvas = class extends React.Component {
| strokeColor | PropTypes.string | black | Pen color |
| canvasColor | PropTypes.string | white | canvas color (HTML colors) |
| backgroundImage | PropTypes.string | '' | Set SVG background with image URL |
-| exportWithBackgroundImage | PropTypes.bool | true | Keep background image on image/SVG export (on false, canvasColor will be set as background) |
+| exportWithBackgroundImage | PropTypes.bool | false | Keep background image on image/SVG export (on false, canvasColor will be set as background) |
| preserveBackgroundImageAspectRatio | PropTypes.string | none | Set aspect ratio of the background image. For possible values check [MDN docs][preserveaspectratio] |
| strokeWidth | PropTypes.number | 4 | Pen stroke size |
| eraserWidth | PropTypes.number | 8 | Erase size |
| allowOnlyPointerType | PropTypes.string | all | allow pointer type ("all"/"mouse"/"pen"/"touch") |
-| onUpdate | PropTypes.func | all | Returns the current sketch path in `CanvasPath` type on every update |
+| onChange | PropTypes.func | | Returns the current sketch path in `CanvasPath` type on every path change |
+| onStroke | PropTypes.func | | Returns the the last stroke path and whether it is an eraser stroke on every pointer up event |
| style | PropTypes.object | false | Add CSS styling as CSS-in-JS object |
| withTimestamp | PropTypes.bool | false | Add timestamp to individual strokes for measuring sketching time |
@@ -192,12 +189,10 @@ interface CanvasPath {
- Philipp Spiess' [tutorial][based-on]
- Draws smooth curves, thanks to François Romain's [tutorial][smooth-curve-tutorial]
-- Immer [link][immer]
---
[based-on]: https://pspdfkit.com/blog/2017/how-to-build-free-hand-drawing-using-react/
[smooth-curve-tutorial]: https://medium.com/@francoisromain/smooth-a-svg-path-with-cubic-bezier-curves-e37b49d46c74
[css-bg]: https://developer.mozilla.org/en-US/docs/Web/CSS/background
-[immer]: https://immerjs.github.io/immer/docs/introduction
-[preserveaspectratio]: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio
\ No newline at end of file
+[preserveaspectratio]: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio
diff --git a/apps/.gitkeep b/apps/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 0000000..d85911a
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,9 @@
+module.exports = function (api) {
+ if (api.env('production')) {
+ return {};
+ }
+
+ return {
+ plugins: ['transform-class-properties', 'istanbul'],
+ };
+};
diff --git a/babel.config.json b/babel.config.json
deleted file mode 100644
index 065aee7..0000000
--- a/babel.config.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "babelrcRoots": ["*"]
-}
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000..b55a0c3
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,4 @@
+coverage:
+ range: 70..100
+ round: down
+ precision: 2
diff --git a/cypress.json b/cypress.json
new file mode 100644
index 0000000..5714a95
--- /dev/null
+++ b/cypress.json
@@ -0,0 +1,4 @@
+{
+ "baseUrl": "http://localhost:3000",
+ "videos": false
+}
diff --git a/cypress/fixtures/canvasPath/onlyPen.json b/cypress/fixtures/canvasPath/onlyPen.json
new file mode 100644
index 0000000..8f3d3d0
--- /dev/null
+++ b/cypress/fixtures/canvasPath/onlyPen.json
@@ -0,0 +1,31 @@
+[
+ {
+ "drawMode": true,
+ "strokeColor": "#000000",
+ "strokeWidth": 4,
+ "paths": [
+ {
+ "x": 100,
+ "y": 50
+ },
+ {
+ "x": 100,
+ "y": 150
+ },
+ {
+ "x": 200,
+ "y": 150
+ },
+ {
+ "x": 200,
+ "y": 50
+ },
+ {
+ "x": 100,
+ "y": 50
+ }
+ ],
+ "startTimestamp": 1634850314350,
+ "endTimestamp": 1634850314405
+ }
+]
diff --git a/cypress/fixtures/canvasPath/withEraser.json b/cypress/fixtures/canvasPath/withEraser.json
new file mode 100644
index 0000000..606484f
--- /dev/null
+++ b/cypress/fixtures/canvasPath/withEraser.json
@@ -0,0 +1,60 @@
+[
+ {
+ "drawMode": true,
+ "strokeColor": "#000000",
+ "strokeWidth": 4,
+ "paths": [
+ {
+ "x": 100,
+ "y": 50
+ },
+ {
+ "x": 100,
+ "y": 150
+ },
+ {
+ "x": 200,
+ "y": 150
+ },
+ {
+ "x": 200,
+ "y": 50
+ },
+ {
+ "x": 100,
+ "y": 50
+ }
+ ],
+ "startTimestamp": 1634850316661,
+ "endTimestamp": 1634850316712
+ },
+ {
+ "drawMode": false,
+ "strokeColor": "#000000",
+ "strokeWidth": 5,
+ "paths": [
+ {
+ "x": 150,
+ "y": 50
+ },
+ {
+ "x": 150,
+ "y": 150
+ },
+ {
+ "x": 250,
+ "y": 150
+ },
+ {
+ "x": 250,
+ "y": 50
+ },
+ {
+ "x": 150,
+ "y": 50
+ }
+ ],
+ "startTimestamp": 1634850316827,
+ "endTimestamp": 1634850316885
+ }
+]
diff --git a/cypress/fixtures/props.json b/cypress/fixtures/props.json
new file mode 100644
index 0000000..55a6b11
--- /dev/null
+++ b/cypress/fixtures/props.json
@@ -0,0 +1,15 @@
+{
+ "className": "react-sketch-canvas",
+ "width": "100%",
+ "height": "500px",
+ "backgroundImage": "https://upload.wikimedia.org/wikipedia/commons/7/70/Graph_paper_scan_1600x1000_%286509259561%29.jpg",
+ "preserveBackgroundImageAspectRatio": "none",
+ "strokeWidth": 4,
+ "eraserWidth": 5,
+ "strokeColor": "#000000",
+ "canvasColor": "#FFFFFF",
+ "style": { "borderRight": "1px solid #CCC" },
+ "exportWithBackgroundImage": true,
+ "withTimestamp": true,
+ "allowOnlyPointerType": "all"
+}
diff --git a/cypress/integration/canvas.spec.ts b/cypress/integration/canvas.spec.ts
new file mode 100644
index 0000000..185e632
--- /dev/null
+++ b/cypress/integration/canvas.spec.ts
@@ -0,0 +1,14 @@
+beforeEach(() => {
+ cy.visit('/');
+});
+
+it('should contain the canvas with svg', () => {
+ const side = 100;
+ const strokeCount = 4;
+
+ Cypress._.range(strokeCount).forEach((_, i) => {
+ cy.drawSquare(side);
+ });
+
+ cy.get('svg').find('path').should('have.length', strokeCount);
+});
diff --git a/cypress/integration/event.spec.ts b/cypress/integration/event.spec.ts
new file mode 100644
index 0000000..6818aa1
--- /dev/null
+++ b/cypress/integration/event.spec.ts
@@ -0,0 +1,538 @@
+import { CanvasPath, ReactSketchCanvasProps } from 'react-sketch-canvas';
+
+let defaultProps: Partial;
+let canvasPathWithEraser: CanvasPath;
+let canvasPathWithOnlyPen: CanvasPath;
+
+before(() => {
+ cy.fixture('props.json').then((props) => (defaultProps = props));
+ cy.fixture('canvasPath/onlyPen.json').then(
+ (onlyPen) => (canvasPathWithOnlyPen = onlyPen)
+ );
+ cy.fixture('canvasPath/withEraser.json').then(
+ (withEraser) => (canvasPathWithEraser = withEraser)
+ );
+});
+
+beforeEach(() => {
+ cy.visit('/');
+});
+
+it('should trigger erase mode and add a mask for erasing previous strokes', () => {
+ cy.drawSquare(100, 100, 50, 'pen');
+
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.get('#eraser-stroke-group').find('path').should('have.length', 1);
+ cy.get('mask#eraser-mask-0').find('use').should('have.length', 2); // background + one mask path
+
+ cy.get('#stroke-group-0')
+ .should('have.attr', 'mask', 'url(#eraser-mask-0)')
+ .find('path')
+ .should('have.length', 1);
+
+ cy.findByRole('button', { name: /pen/i }).click();
+ cy.drawSquare(105, 105, 55, 'pen');
+
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.get('#eraser-stroke-group').find('path').should('have.length', 2);
+ cy.get('mask#eraser-mask-0').find('use').should('have.length', 3); // background + two mask paths
+ cy.get('mask#eraser-mask-1').find('use').should('have.length', 2); // background + one mask path
+});
+
+describe('undo', () => {
+ it('should undo a stroke', () => {
+ cy.getCanvas().find('path').should('have.length', 0);
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.getCanvas().find('path').should('have.length', 1);
+
+ cy.findByRole('button', { name: /undo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+ });
+
+ it('should undo an eraser stroke', () => {
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.getCanvas().find('path').should('have.length', 2);
+ cy.get('#eraser-stroke-group').find('path').should('have.length', 1);
+ cy.get('mask#eraser-mask-0').find('use').should('have.length', 2); // background + one mask path
+
+ cy.findByRole('button', { name: /undo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 1);
+ cy.get('#eraser-stroke-group').find('path').should('have.length', 0);
+ });
+});
+
+describe('redo', () => {
+ it('should redo a stroke', () => {
+ cy.getCanvas().find('path').should('have.length', 0);
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.getCanvas().find('path').should('have.length', 1);
+
+ cy.findByRole('button', { name: /undo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+
+ cy.findByRole('button', { name: /redo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 1);
+ });
+
+ it('should redo an eraser stroke', () => {
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.getCanvas().find('path').should('have.length', 2);
+ cy.get('#eraser-stroke-group').find('path').should('have.length', 1);
+ cy.get('mask#eraser-mask-0').find('use').should('have.length', 2); // background + one mask path
+
+ cy.findByRole('button', { name: /undo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 1);
+ cy.get('#eraser-stroke-group').find('path').should('have.length', 0);
+ cy.get('mask#eraser-mask-0').should('not.exist');
+
+ cy.findByRole('button', { name: /redo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 2);
+ cy.get('#eraser-stroke-group').find('path').should('have.length', 1);
+ cy.get('mask#eraser-mask-0').find('use').should('have.length', 2); // background + one mask path
+ });
+});
+
+describe('clearCanvas', () => {
+ it('should clearCanvas but still keep the stack', () => {
+ cy.getCanvas().find('path').should('have.length', 0);
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.getCanvas().find('path').should('have.length', 1);
+
+ cy.findByRole('button', { name: /clear all/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+
+ cy.findByRole('button', { name: /redo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+
+ cy.findByRole('button', { name: /undo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 1);
+ });
+
+ it('should clearCanvas with an eraser stroke but still keep the stack', () => {
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.getCanvas().find('path').should('have.length', 2);
+ cy.get('#eraser-stroke-group').find('path').should('have.length', 1);
+ cy.get('mask#eraser-mask-0').find('use').should('have.length', 2); // background + one mask path
+
+ cy.findByRole('button', { name: /clear all/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+ cy.get('mask#eraser-mask-0').should('not.exist');
+
+ cy.findByRole('button', { name: /redo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+ cy.get('mask#eraser-mask-0').should('not.exist');
+
+ cy.findByRole('button', { name: /undo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 2);
+ cy.get('#eraser-stroke-group').find('path').should('have.length', 1);
+ cy.get('mask#eraser-mask-0').find('use').should('have.length', 2); // background + one mask path
+ });
+});
+
+describe('resetCanvas', () => {
+ it('should resetCanvas and remove the stack', () => {
+ cy.getCanvas().find('path').should('have.length', 0);
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.getCanvas().find('path').should('have.length', 1);
+
+ cy.findByRole('button', { name: /reset all/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+
+ cy.findByRole('button', { name: /redo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+
+ cy.findByRole('button', { name: /undo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+ });
+
+ it('should resetCanvas with an eraser stroke and remove the stack', () => {
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.getCanvas().find('path').should('have.length', 2);
+ cy.get('#eraser-stroke-group').find('path').should('have.length', 1);
+ cy.get('mask#eraser-mask-0').find('use').should('have.length', 2); // background + one mask path
+
+ cy.findByRole('button', { name: /reset all/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+ cy.get('mask#eraser-mask-0').should('not.exist');
+
+ cy.findByRole('button', { name: /redo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+ cy.get('mask#eraser-mask-0').should('not.exist');
+
+ cy.findByRole('button', { name: /undo/i }).click();
+ cy.getCanvas().find('path').should('have.length', 0);
+ cy.get('mask#eraser-mask-0').should('not.exist');
+ });
+});
+
+describe('exportImage - png', () => {
+ beforeEach(() => {
+ cy.findByRole('radio', { name: /png/i }).click();
+ cy.findByRole('textbox', { name: 'backgroundImage', exact: true }).clear();
+ cy.findByRole('switch', {
+ name: 'exportWithBackgroundImage',
+ exact: true,
+ }).click();
+ });
+
+ it('should export png with stroke', () => {
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/png;base64/i)
+ .convertDataURIToKiloBytes()
+ .as('fileSizeWithoutStroke');
+
+ cy.drawSquare(100, 100, 50, 'pen');
+
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/png;base64/i)
+ .convertDataURIToKiloBytes()
+ .then((fileSizeWithStroke) => {
+ cy.get('@fileSizeWithoutStroke').should(
+ 'be.lessThan',
+ fileSizeWithStroke
+ );
+ });
+ });
+
+ it('should export png with stroke and eraser', () => {
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/png;base64/i)
+ .convertDataURIToKiloBytes()
+ .as('fileSizeWithoutStrokeAndEraser');
+
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/png;base64/i)
+ .convertDataURIToKiloBytes()
+ .then((fileSizeWithStrokeAndEraser) => {
+ cy.get('@fileSizeWithoutStrokeAndEraser').should(
+ 'be.lessThan',
+ fileSizeWithStrokeAndEraser
+ );
+ });
+ });
+
+ it('should export png with stroke while exportWithBackgroundImage is set', () => {
+ cy.findByRole('switch', {
+ name: 'exportWithBackgroundImage',
+ exact: true,
+ }).click();
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/png;base64/i)
+ .convertDataURIToKiloBytes()
+ .as('fileSizeWithoutStroke');
+
+ cy.drawSquare(100, 100, 50, 'pen');
+
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/png;base64/i)
+ .convertDataURIToKiloBytes()
+ .then((fileSizeWithStroke) => {
+ cy.get('@fileSizeWithoutStroke').should(
+ 'be.lessThan',
+ fileSizeWithStroke
+ );
+ });
+ });
+
+ it('should export png with stroke and eraser while exportWithBackgroundImage is set', () => {
+ cy.findByRole('switch', {
+ name: 'exportWithBackgroundImage',
+ exact: true,
+ }).click();
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/png;base64/i)
+ .convertDataURIToKiloBytes()
+ .as('fileSizeWithoutStrokeAndEraser');
+
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/png;base64/i)
+ .convertDataURIToKiloBytes()
+ .then((fileSizeWithStrokeAndEraser) => {
+ cy.get('@fileSizeWithoutStrokeAndEraser').should(
+ 'be.lessThan',
+ fileSizeWithStrokeAndEraser
+ );
+ });
+ });
+});
+
+describe('exportImage - jpeg', () => {
+ beforeEach(() => {
+ cy.findByRole('radio', { name: /jpeg/i }).click();
+ cy.findByRole('textbox', { name: 'backgroundImage', exact: true }).clear();
+
+ cy.findByRole('switch', {
+ name: 'exportWithBackgroundImage',
+ exact: true,
+ }).click();
+ });
+
+ it('should export jpeg with stroke', () => {
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/jpeg;base64/i)
+ .convertDataURIToKiloBytes()
+ .as('fileSizeWithoutStroke');
+
+ cy.drawSquare(100, 100, 50, 'pen');
+
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/jpeg;base64/i)
+ .convertDataURIToKiloBytes()
+ .then((fileSizeWithStroke) => {
+ cy.get('@fileSizeWithoutStroke').should(
+ 'be.lessThan',
+ fileSizeWithStroke
+ );
+ });
+ });
+
+ it('should export jpeg with stroke and eraser', () => {
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/jpeg;base64/i)
+ .convertDataURIToKiloBytes()
+ .as('fileSizeWithoutStrokeAndEraser');
+
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/jpeg;base64/i)
+ .convertDataURIToKiloBytes()
+ .then((fileSizeWithStrokeAndEraser) => {
+ cy.get('@fileSizeWithoutStrokeAndEraser').should(
+ 'be.lessThan',
+ fileSizeWithStrokeAndEraser
+ );
+ });
+ });
+
+ it('should export jpeg with stroke while exportWithBackgroundImage is set', () => {
+ cy.findByRole('switch', {
+ name: 'exportWithBackgroundImage',
+ exact: true,
+ }).click();
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/jpeg;base64/i)
+ .convertDataURIToKiloBytes()
+ .as('fileSizeWithoutStroke');
+
+ cy.drawSquare(100, 100, 50, 'pen');
+
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/jpeg;base64/i)
+ .convertDataURIToKiloBytes()
+ .then((fileSizeWithStroke) => {
+ cy.get('@fileSizeWithoutStroke').should(
+ 'be.lessThan',
+ fileSizeWithStroke
+ );
+ });
+ });
+
+ it('should export jpeg with stroke and eraser while exportWithBackgroundImage is set', () => {
+ cy.findByRole('switch', {
+ name: 'exportWithBackgroundImage',
+ exact: true,
+ }).click();
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/jpeg;base64/i)
+ .convertDataURIToKiloBytes()
+ .as('fileSizeWithoutStrokeAndEraser');
+
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/jpeg;base64/i)
+ .convertDataURIToKiloBytes()
+ .then((fileSizeWithStrokeAndEraser) => {
+ cy.get('@fileSizeWithoutStrokeAndEraser').should(
+ 'be.lessThan',
+ fileSizeWithStrokeAndEraser
+ );
+ });
+ });
+});
+
+describe('exportImage - svg', () => {
+ beforeEach(() => {
+ cy.findByRole('textbox', { name: 'backgroundImage', exact: true }).clear();
+
+ cy.findByRole('switch', {
+ name: 'exportWithBackgroundImage',
+ exact: true,
+ }).click();
+ });
+
+ it('should export jpeg with stroke', () => {
+ cy.drawSquare(100, 100, 50, 'pen');
+
+ cy.findByRole('button', { name: /export svg/i }).click();
+ cy.get('#exported-svg').find('path').should('have.length', 1);
+ });
+
+ it('should export jpeg with stroke and eraser', () => {
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.findByRole('button', { name: /export svg/i }).click();
+
+ cy.get('#exported-svg').find('path').should('have.length', 2);
+ cy.get('#exported-svg #eraser-stroke-group')
+ .find('path')
+ .should('have.length', 1);
+ cy.get('#exported-svg mask#eraser-mask-0')
+ .find('use')
+ .should('have.length', 2); // background + one mask path
+ });
+
+ it('should export jpeg with stroke while exportWithBackgroundImage is set', () => {
+ cy.findByRole('switch', {
+ name: 'exportWithBackgroundImage',
+ exact: true,
+ }).click();
+
+ cy.drawSquare(100, 100, 50, 'pen');
+
+ cy.findByRole('button', { name: /export svg/i }).click();
+
+ cy.get('#exported-svg').find('path').should('have.length', 1);
+ cy.get('#exported-svg #canvas-background').should(
+ 'have.attr',
+ 'fill',
+ defaultProps.canvasColor
+ );
+ });
+
+ it('should export jpeg with stroke and eraser while exportWithBackgroundImage is set', () => {
+ cy.findByRole('switch', {
+ name: 'exportWithBackgroundImage',
+ exact: true,
+ }).click();
+ cy.drawSquare(100, 100, 50, 'pen');
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawSquare(100, 150, 50, 'pen');
+
+ cy.findByRole('button', { name: /export svg/i }).click();
+ cy.get('#exported-svg').find('path').should('have.length', 2);
+ cy.get('#exported-svg #eraser-stroke-group')
+ .find('path')
+ .should('have.length', 1);
+ cy.get('#exported-svg mask#eraser-mask-0')
+ .find('use')
+ .should('have.length', 2); // background + one mask path
+
+ cy.get('#exported-svg #canvas-background').should(
+ 'have.attr',
+ 'fill',
+ defaultProps.canvasColor
+ );
+ });
+});
+
+describe('loadPaths', () => {
+ it('should load path with only pen', () => {
+ cy.getCanvas().find('path').should('not.exist');
+
+ cy.findByRole('textbox', { name: /paths to load/i })
+ .clear()
+ .type(JSON.stringify(canvasPathWithOnlyPen), {
+ parseSpecialCharSequences: false,
+ delay: 0,
+ });
+
+ cy.findByRole('button', { name: /load paths/i }).click();
+ cy.getCanvas().find('path').should('have.length', 1);
+ });
+
+ it('should load path with pen and eraser', () => {
+ cy.getCanvas().find('path').should('not.exist');
+
+ cy.findByRole('textbox', { name: /paths to load/i })
+ .clear()
+ .type(JSON.stringify(canvasPathWithEraser), {
+ parseSpecialCharSequences: false,
+ delay: 0,
+ });
+
+ cy.findByRole('button', { name: /load paths/i }).click();
+ cy.getCanvas().find('path').should('have.length', 2);
+ cy.get('#eraser-stroke-group').find('path').should('have.length', 1);
+ cy.get('mask#eraser-mask-0').find('use').should('have.length', 2); // background + one mask path
+ });
+});
diff --git a/cypress/integration/props.spec.ts b/cypress/integration/props.spec.ts
new file mode 100644
index 0000000..716c852
--- /dev/null
+++ b/cypress/integration/props.spec.ts
@@ -0,0 +1,455 @@
+import { ReactSketchCanvasProps } from 'react-sketch-canvas';
+
+let defaultProps: Partial;
+
+before(() => {
+ cy.fixture('props.json').then((props) => (defaultProps = props));
+});
+
+beforeEach(() => {
+ cy.visit('/');
+});
+
+it('should update width on props change', () => {
+ const updatedWidth = '100px';
+
+ cy.getCanvas()
+ .should('have.attr', 'style')
+ .and('include', `width: ${defaultProps.width}`);
+
+ cy.findByRole('textbox', { name: /width/i }).clear().type(updatedWidth);
+
+ cy.getCanvas()
+ .should('have.attr', 'style')
+ .and('include', `width: ${updatedWidth}`);
+});
+
+it('should update height on props change', () => {
+ const updatedHeight = '200px';
+
+ cy.getCanvas()
+ .should('have.attr', 'style')
+ .and('include', `height: ${defaultProps.height}`);
+
+ cy.findByRole('textbox', { name: /height/i })
+ .clear()
+ .type(updatedHeight);
+
+ cy.getCanvas()
+ .should('have.attr', 'style')
+ .and('include', `height: ${updatedHeight}`);
+});
+
+it('should update className on props change', () => {
+ const updatedClassName = 'svg-canvas';
+
+ cy.getCanvas().should('have.class', defaultProps.className);
+
+ cy.findByRole('textbox', { name: /className/i })
+ .clear()
+ .type(updatedClassName);
+
+ cy.getCanvas().should('have.class', updatedClassName);
+});
+
+it('should update backgroundImage on props change', () => {
+ const updatedBackgroundImage = 'https://i.imgur.com/jx47T07.jpeg';
+
+ cy.get('#canvas-background').should('have.attr', 'fill', 'url(#background)');
+
+ cy.get('pattern#background image')
+ .as('backgroundImage')
+ .should('have.attr', 'xlink:href', defaultProps.backgroundImage);
+
+ cy.findByRole('textbox', { name: 'backgroundImage', exact: true })
+ .clear()
+ .type(updatedBackgroundImage);
+
+ cy.get('#canvas-background').should('have.attr', 'fill', 'url(#background)');
+ cy.get('@backgroundImage').should(
+ 'have.attr',
+ 'xlink:href',
+ updatedBackgroundImage
+ );
+});
+
+it('should update preserveAspectRatio of the background image', () => {
+ const updatedPreserveAspectRatio = 'xMidYMid meet';
+
+ cy.get('pattern#background image')
+ .as('backgroundImage')
+ .should(
+ 'have.attr',
+ 'preserveAspectRatio',
+ defaultProps.preserveBackgroundImageAspectRatio
+ );
+
+ cy.findByRole('textbox', {
+ name: /preserveBackgroundImageAspectRatio/i,
+ })
+ .clear()
+ .type(updatedPreserveAspectRatio);
+
+ cy.get('@backgroundImage').should(
+ 'have.attr',
+ 'preserveAspectRatio',
+ updatedPreserveAspectRatio
+ );
+});
+
+it('should change stroke width', () => {
+ const updatedStrokeWidth = '8';
+
+ cy.drawLine(100, 100, 100);
+ cy.get('#stroke-group-0')
+ .find('path')
+ .first()
+ .should('have.attr', 'stroke-width', defaultProps.strokeWidth.toString());
+
+ cy.findByRole('spinbutton', { name: /strokeWidth/i })
+ .clear()
+ .type(updatedStrokeWidth);
+
+ cy.drawLine(50, 50, 100);
+ cy.get('#stroke-group-0')
+ .find('path')
+ .last()
+ .should('have.attr', 'stroke-width', updatedStrokeWidth);
+});
+
+it('should change eraser width', () => {
+ const updatedEraserWidth = '8';
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawLine(100, 100, 100);
+ cy.get('#eraser-stroke-group')
+ .find('path')
+ .first()
+ .should('have.attr', 'stroke-width', defaultProps.eraserWidth.toString());
+
+ cy.findByRole('spinbutton', { name: /eraserWidth/i })
+ .clear()
+ .type(updatedEraserWidth);
+
+ cy.drawLine(50, 50, 100);
+ cy.get('#eraser-stroke-group')
+ .find('path')
+ .last()
+ .should('have.attr', 'stroke-width', updatedEraserWidth);
+});
+
+it('should change stroke color', () => {
+ const updatedStrokeColor = '#FF0000';
+ cy.drawLine(100, 100, 100);
+ cy.get('#stroke-group-0')
+ .find('path')
+ .first()
+ .should('have.attr', 'stroke', defaultProps.strokeColor);
+
+ cy.findByLabelText(/strokeColor/i)
+ .focus()
+ .invoke('val', updatedStrokeColor)
+ .trigger('change');
+
+ cy.drawLine(50, 50, 100);
+ cy.get('#stroke-group-0')
+ .find('path')
+ .last()
+ .should('have.attr', 'stroke', updatedStrokeColor.toLowerCase());
+});
+
+it('should change canvas color', () => {
+ cy.get('#canvas-background').should('have.attr', 'fill', 'url(#background)');
+
+ const updatedCanvasColor = '#FF0000';
+ cy.findByLabelText(/canvasColor/i)
+ .focus()
+ .invoke('val', updatedCanvasColor)
+ .trigger('change');
+
+ cy.get('#canvas-background').should(
+ 'have.attr',
+ 'fill',
+ updatedCanvasColor.toLowerCase()
+ );
+});
+
+describe('exportWithBackgroundImage', () => {
+ it('should export svg with background when enabled and canvas color background when disabled', () => {
+ cy.findByRole('button', { name: /export svg/i }).click();
+ cy.get('#exported-svg #canvas-background')
+ .as('exportedCanvasBackground')
+ .should('have.attr', 'fill', 'url(#background)');
+
+ cy.get('#exported-svg pattern#background image').should(
+ 'have.attr',
+ 'xlink:href',
+ defaultProps.backgroundImage
+ );
+
+ cy.findByRole('switch', { name: /exportWithBackgroundImage/i }).click();
+
+ cy.findByRole('button', { name: /export svg/i }).click();
+ cy.get('@exportedCanvasBackground').should(
+ 'have.attr',
+ 'fill',
+ defaultProps.canvasColor
+ );
+ });
+
+ it('should export png with background when enabled and canvas color background when disabled', () => {
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/png;base64/i)
+ .convertDataURIToKiloBytes()
+ .as('fileSizeWithExportedImage');
+
+ cy.findByRole('switch', { name: /exportWithBackgroundImage/i }).click();
+
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/png;base64/i)
+ .convertDataURIToKiloBytes()
+ .then((fileSizeWithoutExportedImage) => {
+ cy.get('@fileSizeWithExportedImage').should(
+ 'not.be.lessThan',
+ fileSizeWithoutExportedImage
+ );
+ });
+ });
+
+ it('should export jpeg with background when enabled and canvas color background when disabled', () => {
+ cy.findByRole('radio', { name: /jpeg/i }).click();
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/jpeg;base64/i)
+ .convertDataURIToKiloBytes()
+ .as('fileSizeWithExportedImage');
+
+ cy.findByRole('switch', { name: /exportWithBackgroundImage/i }).click();
+
+ cy.findByRole('button', { name: /export image/i }).click();
+
+ cy.get('#exported-image')
+ .should('have.attr', 'src')
+ .and('match', /^data:image\/jpeg;base64/i)
+ .convertDataURIToKiloBytes()
+ .then((fileSizeWithoutExportedImage) => {
+ cy.get('@fileSizeWithExportedImage').should(
+ 'not.be.lessThan',
+ fileSizeWithoutExportedImage
+ );
+ });
+ });
+});
+
+it('should throw exception when attempted to get sketching time when withTimestamp is disabled', () => {
+ const getSketchingTimeInString = (sketchingTime: number): string =>
+ `${(sketchingTime / 1000).toFixed(3)} sec`;
+
+ const initialTime = 0;
+ cy.get('#sketchingTime')
+ .as('sketchingTimeContainer')
+ .should('contain.text', getSketchingTimeInString(initialTime));
+
+ cy.drawSquare(100);
+ cy.findByRole('button', { name: /get sketching time/i }).click();
+
+ cy.get('@sketchingTimeContainer').then(($sketchingTimeContainer) => {
+ const sketchingTime = Number($sketchingTimeContainer.text().slice(0, 5));
+ expect(sketchingTime).to.be.greaterThan(0);
+ });
+
+ cy.findByRole('switch', { name: /withTimestamp/i }).click();
+
+ cy.drawSquare(100, 200, 200);
+ cy.findByRole('button', { name: /get sketching time/i }).click();
+ cy.get('@sketchingTimeContainer').should(
+ 'contain.text',
+ getSketchingTimeInString(initialTime)
+ );
+});
+
+describe('allowOnlyPointerType', () => {
+ it('should allow sketching with mouse, touch, and stylus when allowOnlyPointerType is set as all', () => {
+ cy.drawLine(50, 0, 10, 'mouse');
+ cy.drawLine(100, 50, 10, 'touch');
+ cy.drawLine(200, 100, 10, 'pen');
+
+ cy.get('#stroke-group-0').find('path').should('have.length', 3);
+ });
+
+ it('should allow sketching only with mouse when allowOnlyPointerType is set as mouse', () => {
+ cy.findByRole('radio', { name: /mouse/i }).click();
+
+ cy.drawLine(50, 0, 10, 'mouse');
+ cy.get('#stroke-group-0').find('path').should('have.length', 1);
+ cy.drawLine(100, 50, 10, 'touch');
+ cy.drawLine(200, 100, 10, 'pen');
+
+ cy.get('#stroke-group-0').find('path').should('have.length', 1);
+ });
+
+ it('should allow sketching only with touch when allowOnlyPointerType is set as touch', () => {
+ cy.findByRole('radio', { name: /touch/i }).click();
+
+ cy.drawLine(50, 0, 10, 'touch');
+ cy.get('#stroke-group-0').find('path').should('have.length', 1);
+ cy.drawLine(100, 50, 10, 'mouse');
+ cy.drawLine(200, 100, 10, 'pen');
+
+ cy.get('#stroke-group-0').find('path').should('have.length', 1);
+ });
+
+ it('should allow sketching only with pen when allowOnlyPointerType is set as pen', () => {
+ cy.findByRole('radio', { name: /pen/i }).click();
+
+ cy.drawLine(50, 0, 10, 'pen');
+ cy.get('#stroke-group-0').find('path').should('have.length', 1);
+ cy.drawLine(100, 50, 10, 'mouse');
+ cy.drawLine(200, 100, 10, 'touch');
+
+ cy.get('#stroke-group-0').find('path').should('have.length', 1);
+ });
+});
+
+it('should call onChange when a new stroke or eraser is added', () => {
+ cy.get('#paths')
+ .as('pathsContainer')
+ .should('have.text', 'Sketch to get paths');
+
+ cy.drawLine(50, 0, 10, 'pen');
+ cy.get('@pathsContainer').then(($pathsContainer) => {
+ const paths = JSON.parse($pathsContainer.text());
+ expect(paths).to.have.length(1);
+ expect(paths.pop()).to.have.property('drawMode', true);
+ });
+
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawLine(50, 0, 10, 'pen');
+ cy.get('@pathsContainer').then(($pathsContainer) => {
+ const paths = JSON.parse($pathsContainer.text());
+ expect(paths).to.have.length(2);
+ expect(paths.pop()).to.have.property('drawMode', false);
+ });
+});
+
+it('should update style', () => {
+ const updatedStyle = {
+ border: '10px solid red',
+ outline: '10px solid green',
+ };
+
+ cy.getCanvas()
+ .should('have.attr', 'style')
+ .CssStyleToObject()
+ .and(
+ 'have.any.key',
+ Object.keys(defaultProps.style).map(Cypress._.kebabCase)
+ );
+
+ cy.findByRole('textbox', { name: /style/i })
+ .clear()
+ .type(JSON.stringify(updatedStyle), {
+ parseSpecialCharSequences: false,
+ delay: 0,
+ });
+
+ cy.getCanvas()
+ .should('have.attr', 'style')
+ .CssStyleToObject()
+ .and(
+ 'not.have.any.keys',
+ Object.keys(defaultProps.style).map(Cypress._.kebabCase)
+ )
+ .and('have.any.keys', Object.keys(updatedStyle).map(Cypress._.kebabCase));
+});
+
+describe('onStroke', () => {
+ it('should return the last stroke', () => {
+ cy.drawLine(200, 10, 20, 'pen');
+
+ cy.findByRole('textbox', { name: /last stroke:pen/i })
+ .StringToObject()
+ .then((canvaspath) => {
+ expect(canvaspath).has.property('drawMode', true);
+ expect(canvaspath).has.deep.property('paths', [
+ { x: 10, y: 20 },
+ { x: 210, y: 220 },
+ ]);
+ });
+
+ cy.drawLine(100, 0, 0, 'pen');
+
+ cy.findByRole('textbox', { name: /last stroke:pen/i })
+ .StringToObject()
+ .then((canvaspath) => {
+ expect(canvaspath).has.property('drawMode', true);
+ expect(canvaspath).has.deep.property('paths', [
+ { x: 0, y: 0 },
+ { x: 100, y: 100 },
+ ]);
+ });
+ });
+
+ it('should return the last eraser stroke', () => {
+ cy.drawLine(200, 10, 20, 'pen');
+
+ cy.findByRole('textbox', { name: /last stroke:pen/i })
+ .StringToObject()
+ .then((canvaspath) => {
+ expect(canvaspath).has.property('drawMode', true);
+ expect(canvaspath).has.deep.property('paths', [
+ { x: 10, y: 20 },
+ { x: 210, y: 220 },
+ ]);
+ });
+
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawLine(100, 0, 0, 'pen');
+
+ cy.findByRole('textbox', { name: /last stroke:eraser/i })
+ .StringToObject()
+ .then((canvaspath) => {
+ expect(canvaspath).has.property('drawMode', false);
+ expect(canvaspath).has.deep.property('paths', [
+ { x: 0, y: 0 },
+ { x: 100, y: 100 },
+ ]);
+ });
+ });
+});
+
+describe('point', () => {
+ it('should create a point with circle on single point stroke', () => {
+ const x = 10;
+ const y = 20;
+ cy.drawPoint(x, y);
+
+ cy.getCanvas()
+ .find('circle#0')
+ .should('have.attr', 'r', defaultProps.strokeWidth / 2)
+ .should('have.attr', 'cx', x)
+ .should('have.attr', 'cy', y);
+ });
+
+ it('should create a eraser point with circle on single point erase', () => {
+ const x = 10;
+ const y = 20;
+
+ cy.drawPoint(x, y);
+ cy.findByRole('button', { name: /eraser/i }).click();
+ cy.drawPoint(x, y);
+
+ cy.getCanvas()
+ .find('circle#eraser-0')
+ .should('have.attr', 'r', defaultProps.eraserWidth / 2)
+ .should('have.attr', 'cx', x)
+ .should('have.attr', 'cy', y);
+ });
+});
diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts
new file mode 100644
index 0000000..8e5acdc
--- /dev/null
+++ b/cypress/plugins/index.ts
@@ -0,0 +1,24 @@
+///
+// ***********************************************************
+// This example plugins/index.js can be used to load plugins
+//
+// You can change the location of this file or turn off loading
+// the plugins file with the 'pluginsFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/plugins-guide
+// ***********************************************************
+
+// This function is called when a project is opened or re-opened (e.g. due to
+// the project's config changing)
+
+/**
+ * @type {Cypress.PluginConfig}
+ */
+// eslint-disable-next-line no-unused-vars
+module.exports = (on, config) => {
+ require('@cypress/code-coverage/task')(on, config);
+ // `on` is used to hook into various events Cypress emits
+ // `config` is the resolved Cypress config
+ return config;
+};
diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts
new file mode 100644
index 0000000..b7f0e1d
--- /dev/null
+++ b/cypress/support/commands.ts
@@ -0,0 +1,170 @@
+import '@testing-library/cypress/add-commands';
+
+Cypress.Commands.add('getCanvas', function () {
+ return cy.findByRole('presentation', { name: /react\-sketch\-canvas/i });
+});
+
+Cypress.Commands.add(
+ 'drawSquare',
+ function (
+ side: number,
+ originX: number = 0,
+ originY: number = 0,
+ pointerType: Cypress.PointerEventType = 'pen'
+ ) {
+ cy.findByRole('presentation', { name: /react\-sketch\-canvas/i }).then(
+ ($canvas) => {
+ const x = $canvas.offset().left + originX;
+ const y = $canvas.offset().top + originY;
+
+ cy.wrap($canvas)
+ .trigger('pointerdown', {
+ pointerType: pointerType,
+ force: true,
+ button: 0,
+ pageX: x,
+ pageY: y,
+ })
+ .trigger('pointermove', {
+ pointerType: pointerType,
+ force: true,
+ pageX: x,
+ pageY: y + side,
+ })
+ .trigger('pointermove', {
+ pointerType: pointerType,
+ force: true,
+ button: 0,
+ pageX: x + side,
+ pageY: y + side,
+ })
+ .trigger('pointermove', {
+ pointerType: pointerType,
+ force: true,
+ button: 0,
+ pageX: x + side,
+ pageY: y,
+ })
+ .trigger('pointermove', {
+ pointerType: pointerType,
+ force: true,
+ button: 0,
+ pageX: x,
+ pageY: y,
+ })
+ .trigger('pointerup', {
+ pointerType: pointerType,
+ force: true,
+ button: 0,
+ });
+ }
+ );
+ }
+);
+
+Cypress.Commands.add(
+ 'drawLine',
+ function (
+ length: number,
+ originX: number = 0,
+ originY: number = 0,
+ pointerType: Cypress.PointerEventType = 'pen'
+ ) {
+ cy.findByRole('presentation', { name: /react\-sketch\-canvas/i }).then(
+ ($canvas) => {
+ const x = $canvas.offset().left + originX;
+ const y = $canvas.offset().top + originY;
+
+ cy.wrap($canvas)
+ .trigger('pointerdown', {
+ pointerType: pointerType,
+ force: true,
+ button: 0,
+ pageX: x,
+ pageY: y,
+ })
+ .trigger('pointermove', {
+ pointerType: pointerType,
+ force: true,
+ button: 0,
+ pageX: x + length,
+ pageY: y + length,
+ })
+ .trigger('pointerup', {
+ pointerType: pointerType,
+ button: 0,
+ force: true,
+ });
+ }
+ );
+ }
+);
+
+Cypress.Commands.add(
+ 'drawPoint',
+ function (
+ originX: number = 0,
+ originY: number = 0,
+ pointerType: Cypress.PointerEventType = 'pen'
+ ) {
+ cy.findByRole('presentation', { name: /react\-sketch\-canvas/i }).then(
+ ($canvas) => {
+ const x = $canvas.offset().left + originX;
+ const y = $canvas.offset().top + originY;
+
+ cy.wrap($canvas)
+ .trigger('pointerdown', {
+ pointerType: pointerType,
+ force: true,
+ button: 0,
+ pageX: x,
+ pageY: y,
+ })
+ .trigger('pointerup', {
+ pointerType: pointerType,
+ button: 0,
+ force: true,
+ });
+ }
+ );
+ }
+);
+
+Cypress.Commands.add(
+ 'convertDataURIToKiloBytes',
+ { prevSubject: true },
+ function (subject) {
+ const base64str = subject.split('base64,')[1];
+ const decoded = atob(base64str);
+ const fileSizeInKB = Math.floor(decoded.length / 1024);
+ return cy.wrap(fileSizeInKB);
+ }
+);
+
+Cypress.Commands.add(
+ 'CssStyleToObject',
+ { prevSubject: true },
+ function (subject) {
+ const regex = /([\w-]*)\s*:\s*([^;]*)/g;
+ let match = null;
+ let properties = {};
+ while ((match = regex.exec(subject))) {
+ properties[match[1]] = match[2].trim();
+ }
+
+ return cy.wrap(properties);
+ }
+);
+
+Cypress.Commands.add(
+ 'StringToObject',
+ { prevSubject: true },
+ function (subject) {
+ try {
+ const value = JSON.parse(subject.text());
+ return cy.wrap(value);
+ } catch (error) {
+ return cy.wrap({});
+ }
+ }
+);
diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts
new file mode 100644
index 0000000..231e2ef
--- /dev/null
+++ b/cypress/support/index.d.ts
@@ -0,0 +1,29 @@
+///
+
+declare namespace Cypress {
+ type PointerEventType = 'pen' | 'touch' | 'mouse';
+
+ interface Chainable {
+ drawSquare(
+ side: number,
+ originX?: number,
+ originY?: number,
+ eventType?: PointerEventType
+ ): Chainable;
+ drawLine(
+ length: number,
+ originX?: number,
+ originY?: number,
+ eventType?: PointerEventType
+ ): Chainable;
+ drawPoint(
+ originX?: number,
+ originY?: number,
+ eventType?: PointerEventType
+ ): Chainable;
+ getCanvas(): Chainable;
+ convertDataURIToKiloBytes(): Chainable;
+ CssStyleToObject(): Chainable