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 @@ - -

React Sketch Canvas


- Freehand vector drawing tool for React using SVG as canvas 🖌 + Freehand vector drawing component for React using SVG as canvas 🖌



![npm](https://img.shields.io/npm/v/react-sketch-canvas?style=flat-square)    ![NPM](https://img.shields.io/npm/l/react-sketch-canvas?style=flat-square)    ![npm](https://img.shields.io/npm/dm/react-sketch-canvas?style=flat-square)
-![npm bundle size](https://img.shields.io/bundlephobia/min/react-sketch-canvas?style=flat-square)    ![npm bundle size](https://img.shields.io/bundlephobia/minzip/react-sketch-canvas?style=flat-square) +![npm bundle size](https://img.shields.io/bundlephobia/min/react-sketch-canvas?style=flat-square)    ![npm bundle size](https://img.shields.io/bundlephobia/minzip/react-sketch-canvas?style=flat-square)
+[![codecov](https://codecov.io/gh/vinothpandian/react-sketch-canvas/branch/master/graph/badge.svg?token=NJcqGRgbSa)](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; + StringToObject(): Chainable; + } +} diff --git a/cypress/support/index.ts b/cypress/support/index.ts new file mode 100644 index 0000000..5ae0f8a --- /dev/null +++ b/cypress/support/index.ts @@ -0,0 +1,4 @@ +/// + +import '@cypress/code-coverage/support'; +import './commands'; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 0000000..a0acf22 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "@testing-library/cypress"] + }, + "include": ["**/*.ts"] +} diff --git a/example/.env b/example/.env new file mode 100644 index 0000000..6f809cc --- /dev/null +++ b/example/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..4d29575 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..ff4775c --- /dev/null +++ b/example/README.md @@ -0,0 +1 @@ +# ReactSketchCanvas demo app diff --git a/example/craco.config.js b/example/craco.config.js new file mode 100644 index 0000000..e10d2af --- /dev/null +++ b/example/craco.config.js @@ -0,0 +1,10 @@ +const path = require('path'); + +module.exports = { + webpack: { + alias: { + react: path.resolve(__dirname, './node_modules/react'), + 'react-dom': path.resolve(__dirname, './node_modules/react-dom'), + }, + }, +}; diff --git a/example/package.json b/example/package.json new file mode 100644 index 0000000..4ce0455 --- /dev/null +++ b/example/package.json @@ -0,0 +1,46 @@ +{ + "name": "example", + "version": "0.1.0", + "private": true, + "dependencies": { + "@craco/craco": "^6.3.0", + "@testing-library/jest-dom": "^5.11.4", + "@testing-library/react": "^11.1.0", + "@testing-library/user-event": "^12.1.10", + "@types/jest": "^26.0.15", + "@types/node": "^12.0.0", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-scripts": "4.0.3", + "typescript": "^4.1.2", + "typescript-is": "^0.18.3", + "web-vitals": "^1.0.1" + }, + "scripts": { + "start": "craco start", + "build": "craco build", + "test": "craco test", + "eject": "react-scripts eject", + "lint": "eslint --ext js,ts,tsx src" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/example/public/favicon.ico b/example/public/favicon.ico new file mode 100644 index 0000000..a11777c Binary files /dev/null and b/example/public/favicon.ico differ diff --git a/example/public/index.html b/example/public/index.html new file mode 100644 index 0000000..bd444d7 --- /dev/null +++ b/example/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + ReactSketchCanvas: Demo + + + +
+ + + diff --git a/example/public/logo192.png b/example/public/logo192.png new file mode 100644 index 0000000..fc44b0a Binary files /dev/null and b/example/public/logo192.png differ diff --git a/example/public/logo512.png b/example/public/logo512.png new file mode 100644 index 0000000..a4e47a6 Binary files /dev/null and b/example/public/logo512.png differ diff --git a/example/public/manifest.json b/example/public/manifest.json new file mode 100644 index 0000000..080d6c7 --- /dev/null +++ b/example/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/example/public/robots.txt b/example/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/example/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/example/src/App.tsx b/example/src/App.tsx new file mode 100644 index 0000000..90bd096 --- /dev/null +++ b/example/src/App.tsx @@ -0,0 +1,664 @@ +import React from 'react'; +import { + CanvasPath, + ExportImageType, + ReactSketchCanvas, + ReactSketchCanvasProps, + ReactSketchCanvasRef, +} from 'react-sketch-canvas'; + +type Handlers = [string, () => void, string][]; + +interface InputFieldProps { + fieldName: keyof ReactSketchCanvasProps; + type?: string; + canvasProps: Partial; + setCanvasProps: React.Dispatch< + React.SetStateAction> + >; +} + +function InputField({ + fieldName, + type = 'text', + canvasProps, + setCanvasProps, +}: InputFieldProps) { + const handleChange = ({ + target, + }: React.ChangeEvent): void => { + setCanvasProps((prevCanvasProps: Partial) => ({ + ...prevCanvasProps, + [fieldName]: target.value, + })); + }; + + const id = 'validation' + fieldName; + + return ( +
+ + +
+ ); +} + +function App() { + const [canvasProps, setCanvasProps] = React.useState< + Partial + >({ + 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', + }); + + const inputProps: Array<[keyof ReactSketchCanvasProps, 'text' | 'number']> = [ + ['className', 'text'], + ['width', 'text'], + ['height', 'text'], + ['backgroundImage', 'text'], + ['preserveBackgroundImageAspectRatio', 'text'], + ['strokeWidth', 'number'], + ['eraserWidth', 'number'], + ]; + + const canvasRef = React.createRef(); + + const [dataURI, setDataURI] = React.useState(''); + const [svg, setSVG] = React.useState(''); + const [paths, setPaths] = React.useState([]); + const [lastStroke, setLastStroke] = React.useState<{ + stroke: CanvasPath | null; + isEraser: boolean | null; + }>({ stroke: null, isEraser: null }); + const [pathsToLoad, setPathsToLoad] = React.useState(''); + const [sketchingTime, setSketchingTime] = React.useState(0); + const [exportImageType, setexportImageType] = + React.useState('png'); + + const imageExportHandler = async () => { + const exportImage = canvasRef.current?.exportImage; + + if (exportImage) { + const exportedDataURI = await exportImage(exportImageType); + setDataURI(exportedDataURI); + } + }; + + const svgExportHandler = async () => { + const exportSvg = canvasRef.current?.exportSvg; + + if (exportSvg) { + const exportedDataURI = await exportSvg(); + setSVG(exportedDataURI); + } + }; + + const getSketchingTimeHandler = async () => { + const getSketchingTime = canvasRef.current?.getSketchingTime; + + try { + if (getSketchingTime) { + const currentSketchingTime = await getSketchingTime(); + setSketchingTime(currentSketchingTime); + } + } catch { + setSketchingTime(0); + console.error('With timestamp is disabled'); + } + }; + + const penHandler = () => { + const eraseMode = canvasRef.current?.eraseMode; + + if (eraseMode) { + eraseMode(false); + } + }; + + const eraserHandler = () => { + const eraseMode = canvasRef.current?.eraseMode; + + if (eraseMode) { + eraseMode(true); + } + }; + + const undoHandler = () => { + const undo = canvasRef.current?.undo; + + if (undo) { + undo(); + } + }; + + const redoHandler = () => { + const redo = canvasRef.current?.redo; + + if (redo) { + redo(); + } + }; + + const clearHandler = () => { + const clearCanvas = canvasRef.current?.clearCanvas; + + if (clearCanvas) { + clearCanvas(); + } + }; + + const resetCanvasHandler = () => { + const resetCanvas = canvasRef.current?.resetCanvas; + + if (resetCanvas) { + resetCanvas(); + } + }; + + const createButton = ( + label: string, + handler: () => void, + themeColor: string + ) => ( + + ); + + const buttonsWithHandlers: Handlers = [ + ['Undo', undoHandler, 'primary'], + ['Redo', redoHandler, 'primary'], + ['Clear All', clearHandler, 'primary'], + ['Reset All', resetCanvasHandler, 'primary'], + ['Pen', penHandler, 'secondary'], + ['Eraser', eraserHandler, 'secondary'], + ['Export Image', imageExportHandler, 'success'], + ['Export SVG', svgExportHandler, 'success'], + ['Get Sketching time', getSketchingTimeHandler, 'success'], + ]; + + const onChange = (updatedPaths: CanvasPath[]): void => { + setPaths(updatedPaths); + }; + + return ( +
+
+ React Sketch Canvas +

ReactSketchCanvas

+
+
+