diff --git a/.babelrc b/.babelrc deleted file mode 100644 index b611bbc8..00000000 --- a/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "presets": ["@babel/env", "@babel/react", "@babel/preset-typescript"], - "plugins": ["@babel/plugin-proposal-class-properties"] -} diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 9bfe2888..00000000 --- a/.eslintrc +++ /dev/null @@ -1,27 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2020, - "sourceType": "module", - "ecmaFeatures": { - "jsx": true - } - }, - "plugins": ["@typescript-eslint", "react-hooks", "prettier"], - "extends": ["plugin:react/recommended", "plugin:@typescript-eslint/recommended"], - "rules": { - "@typescript-eslint/no-var-requires": 0, - "@typescript-eslint/no-explicit-any": 0, - "@typescript-eslint/ban-types": 0, - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn", - "prettier/prettier": "error", - "react/prop-types": "off" - }, - "settings": { - "react": { - "pragma": "React", - "version": "detect" - } - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..0e73e321 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + ci: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [22, 24] + env: + HUSKY: "0" + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: yarn + - run: yarn install --frozen-lockfile + - run: yarn format:check + - run: yarn lint + - run: yarn typecheck + - run: yarn build + - run: yarn test diff --git a/.gitignore b/.gitignore index 66f1efd7..7e95ed3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ node_modules -lib -lib-esm -_bundles +*.tsbuildinfo ### Linux template *~ @@ -161,7 +159,6 @@ build/Release # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git node_modules -lib/ build/ dist/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..37236231 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +yarn lint-staged diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 2ec3b23e..00000000 --- a/.npmignore +++ /dev/null @@ -1,201 +0,0 @@ -.* -**/tsconfig.json -**/webpack.config.js -node_modules -src - - -examples/ -test/ -src/ -docs/ -.travis.yml -.babelrc -.eslint -.gitignore -.karma.config.js -webpack.* -yarn.lock -package.lock - - -### Linux template -*~ - -# KDE directory preferences -.directory - -# Linux trash folder which might appear on any partition or disk -.Trash-* - - -### JetBrains template -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio - -*.iml - -## Directory-based project format: -.idea/ -# if you remove the above rule, at least ignore the following: - -# User-specific stuff: -# .idea/workspace.xml -# .idea/tasks.xml -# .idea/dictionaries - -# Sensitive or high-churn files: -# .idea/dataSources.ids -# .idea/dataSources.xml -# .idea/sqlDataSources.xml -# .idea/dynamic.xml -# .idea/uiDesigner.xml - -# Gradle: -# .idea/gradle.xml -# .idea/libraries - -# Mongo Explorer plugin: -# .idea/mongoSettings.xml - -## File-based project format: -*.ipr -*.iws - -## Plugin-specific files: - -# IntelliJ -/out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties - - -### OSX template -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - - -### Eclipse template -*.pydevproject -.metadata -.gradle -bin/ -tmp/ -*.tmp -*.bak -*.swp -*~.nib -local.properties -.settings/ -.loadpath - -# Eclipse Core -.project - -# External tool builders -.externalToolBuilders/ - -# Locally stored "Eclipse launch configurations" -*.launch - -# CDT-specific -.cproject - -# JDT-specific (Eclipse Java Development Tools) -.classpath - -# Java annotation processor (APT) -.factorypath - -# PDT-specific -.buildpath - -# sbteclipse plugin -.target - -# TeXlipse plugin -.texlipse - - -### Node template -# Logs -logs -*.log -npm-debug.log* - -# Runtime data -pids -*.pid -*.seed - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directory -# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git -node_modules - -build/ - -### Windows template -# Windows image file caches -Thumbs.db -ehthumbs.db - -# Folder config file -Desktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msm -*.msp - -# Windows shortcuts -*.lnk - diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.prettierignore b/.prettierignore index 8cf73b26..c8164d77 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,14 +3,5 @@ coverage/ dist/ docs/ node_modules/ -*.md -.prettierignore yarn.lock .idea -LICENSE -*.jpg -.DS_Store -.npmignore -.gitignore -*.log -*.ico \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 72c28815..3907f1ea 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,5 +6,6 @@ "useTabs": false, "singleQuote": false, "bracketSpacing": true, - "jsxBracketSameLine": true + "bracketSameLine": true, + "trailingComma": "es5" } diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 41098692..00000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -language: node_js -node_js: - - "10" - - "9" - - "8" -cache: - yarn: true - directories: - - node_modules -env: - - CXX=g++-4.8 -addons: - apt: - sources: - - ubuntu-toolchain-r-test - packages: - - g++-4.8 -before_install: - - sudo apt-get update -qq - - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.9.4 - - sudo apt-get install -y -qq libgif-dev libpng-dev libjpeg8-dev libpango1.0-dev libcairo2-dev diff --git a/CHANGELOG.md b/CHANGELOG.md index 248bde47..76128fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,46 +1,219 @@ -## Changelog - -* **0.4.0** New Features and fixes - - Fixes - -- Upgraded to latest packages (thanks @fjogeleit) - -- Fix for undo/redo (thanks @pomelyu) - -- Issue #9 fixed - -- Issue #10 and #15 you can no longer give dataUrl as value only as background image and/or by adding the image as an object - - Breaking Changes - -- `data` and `dataType` properties have been replaced with `value` and `defaultValue` - to be able to use the component as controlled component - -- node support of 6,7 and 8 - - New Features - -- ability to add an image as object -* **0.3.0** New Features and fixes - - HOT Deploy fixed - - Added selection of background Color - - Added selection of a background image - - Fixing canUndo button -* **0.2.13** Maintenance release - - Upgrading packages - - performing required changes for webpack v2.x -* **0.2.7** New Functionality and bug fixes - - Zoom In/Out functionality of drawing - - Upgrading to latest package versions -* **0.2.6** Bug fixes and performance improvements -* **0.2.5** Upgrading to latest libraries and bug fixes - - Major - - React 15.1.0 - - Fabric 1.6.2 - - Bug fixes and performance improvements - - exposing disable/enable Scroll for tablets and touch devices -* **0.2.4** Maintenance Release -* **0.2.3** Upgrading to latest libraries - - Major - - React 15.0.1 - - Fabric 1.6.0 - - Other - - babel - - react-color - - etc. -* **0.2.2** Minor bug fixes and improvements release -* **0.2.1** Bundling fabric to the library, to avoid complexity by adding it as external dependency -* **0.2.0** Adding FabricJS as the core of the sketch field -* **0.1.1** Adding support for touch events -* **0.1.0** Initial release \ No newline at end of file +# Changelog + +## 1.0.0 (unreleased) + +Rewrite in TypeScript. The component API of 0.5.x is kept (properties, methods, the named exports +`SketchField` and `Tools`, and the default export). The differences in behavior are listed below. + +### Packaging and tooling + +- The repository is a Yarn 1 workspaces monorepo: `packages/react-sketch` (the library) and + `packages/showcase` (the demo application, private). The legacy JavaScript sources in `src/`, the + `examples/` folder and the `webpack/` configurations of 0.5.x are removed. +- The library is written in TypeScript and ships its type declarations (`dist/index.d.ts`). The + hand-written `types/index.d.ts` of 0.5.x is removed. +- React 17, 18 and 19 are supported. `react` is a peer dependency (`^17.0.0 || ^18.0.0 || ^19.0.0`). + `react-dom` and `prop-types` are no longer dependencies of the library. +- fabric 7.4 (`fabric` `^7.4.0`) is a dependency of the library. 0.5.x bundled fabric into the build + (fabric 3.5 at 0.5.6), now it is installed next to the library and is external in the bundle. fabric + 7.4 fixes the stored XSS advisories of fabric versions before 7.2 and 7.4 that have no patch in fabric + 4 to 6. fabric types are not part of the public declarations of the package. +- Developing requires Node.js 22.22.1 or later (`engines` in the root `package.json`, `.nvmrc` has 24). The + package itself has no Node.js requirement. The `node >= 8` engine of 0.5.x and the Node.js 6, 7 and 8 + notes are removed. +- New build output in `packages/react-sketch/dist`: `index.js`, a UMD bundle (global `ReactSketch`) + with `react`, `react/jsx-runtime` and `fabric` as externals, with a source map, and `index.d.ts`. + Built with webpack 5, ts-loader and `tsc`. `main`, `types` and `files` of the package point to it. +- The showcase replaces `examples/`: React 19, `@mui/material` 9 and MobX 7, with a tools panel, an + actions toolbar and the scenarios `defaultValue`, `value` with `forceValue`, and `addImg`. It runs + with `yarn start` on port 23000 and uses the sources of the library, no build needed first. +- Toolchain: TypeScript 6, ESLint 10 (flat config) with typescript-eslint and @eslint-react, Prettier 3, Jest 30 with + ts-jest and Testing Library (replacing karma, enzyme and the Jest 23 setup of 0.5.x), husky 9 with + lint-staged. GitHub Actions (`.github/workflows/ci.yml`, Node.js 22 and 24) replaces Travis CI. +- Documentation: README rewritten, code of conduct updated to Contributor Covenant 2.1, README files + added for the package and the showcase. + +### Removed + +- Support for Internet Explorer 11: the `Object.assign` polyfill (`src/polyfill.js`) is not ported and + the pointer helpers no longer have IE specific code. + +### Exports + +- Named exports: `SketchField`, `Tools` and `Types`. The default export is the component, which also + carries `SketchField` and `Tools`, so `import Sketch from "react-sketch"` with ``, + `Sketch.SketchField` and `Sketch.Tools.Pencil` work as in 0.5.x. +- `Tools` has the keys and values of 0.5.x: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, + `RectangleLabel`, `Circle`, `Pan`, `DefaultTool` and `Highlighter`. +- `Types` is new: the `Tool` enumeration (the values of `Tools`, including `"rectangle-label"` and + `"default-tool"`), `Tools`, and the types `SketchProperties`, `SketchEvent`, `SketchObject`, + `SketchValue`, `SketchData`, `DataUrlOptions`, `ImageOptions`, `BackgroundOptions` and `TextOptions`. + +### Added + +- The `autoresize` property (default `true`). It switches the resize of the canvas on window resize on + and off. In 0.5.x the canvas always followed the window. +- The `Types` namespace, see Exports. + +### Changed + +- The default `tool` is `"select"`. In 0.5.x it was `null`, which meant no tool: objects could be moved + and nothing was drawn. `null` is still accepted and is treated as `"select"`. An unknown tool name + selects `"pencil"`, as before. +- The Highlighter tool no longer throws for a `lineColor` it cannot convert (for example `rgb(0, 0, 0)` + or `"transparent"`): it uses the default color. +- `imageFormat` is the default format of `toDataURL`, used when the options of the call have no `format` + (it was not used in 0.5.x). +- `fromJSON()` resets the undo history and applies the tool configuration to the loaded objects again, so + for example they cannot be selected while a drawing tool is active. +- `toJSON()` is typed as an object. The declaration file of 0.5.x said string, the value was always an + object. +- Every tool falls back to line width 1, color black and fill `"transparent"` when the properties are + not set. +- Because of fabric 7, the JSON returned by `toJSON()` and `clear()` has the fabric 7 format: the + `type` of each object is PascalCase (`"Rect"`, `"IText"`), there is a `version` field, and numbers are + rounded to 4 decimals. JSON saved by 0.5.x still loads with `fromJSON()`, `value` and `defaultValue` + (the showcase sample data is JSON saved by 0.5.x). JSON saved by 1.0.0 does not load in 0.5.x. The + `type` of live objects, as seen in the event callbacks, is unchanged, except `ActiveSelection`, which + is now `activeselection`. +- Undoing a modification restores the values to a precision of 1e-4. +- The Pencil and Highlighter paths have a center origin, so their `left` and `top` are the center of the + path. They are drawn the same, and a stroke now includes its last point. +- `addImg()` with a URL that cannot be loaded adds nothing and does not call `onObjectAdded`. In 0.5.x it + added an empty image object. +- Objects added by `copy()`, `paste()`, and by loading a value appear a few microtasks after the call or + the 100 ms timer. Tests that use fake timers need to flush promises. + +### Fixed + +- `lineColor`, `lineWidth` and `fillColor` changes apply right away. In 0.5.x they were applied only + after the tool changed. +- Undo, then modify an object, then undo again, restores the right state. +- Redo of `removeSelected()` removes the objects again. +- `removeSelected()` leaves the selection first, so the objects keep their own position. +- `copy()` without a selection and `paste()` without a copied object do nothing. +- `addText()` uses the `left` and `top` options when they are given. The text is centered otherwise. +- `clear()` and `fromJSON()` keep the `backgroundColor` property (clearing a canvas dropped the background). +- The canvas is not resized when its container has no size (hidden, or no layout), which collapsed the + objects. +- Unmounting clears the pending timers (`fromJSON`, `onChange`) and disposes the canvas, so no callback + runs on a disposed canvas. +- `undo()` does nothing when there is nothing to undo. In 0.5.x it threw an error. +- `enableTouchScroll()` and `disableTouchScroll()` also set the `touch-action` style of the canvas, as + fabric reads the flag only when it creates the canvas. In 0.5.x the flag changed and the browser + kept blocking the scroll. +- The Rectangle and RectangleLabel tools follow the pointer when dragging in every direction. +- The Arrow tool no longer adds the previous arrow again on a mouse up that follows a finished arrow. +- The Pencil tool uses the default cursor, not the crosshair of fabric (#181). +- Three wrong entries of the color name table used by the Highlighter are corrected. + +### Known limitations + +These behaviors of 0.5.x are kept. + +- `opacity` is accepted and not applied. It had no effect in 0.5.x either. +- `onChange` is called 10 ms after every mouse up and mouse out, whether or not the drawing changed + (#80). It is also called after `undo()`, `redo()` and when a value is loaded. +- `undoSteps` keeps one step more than its value: `undoSteps + 1` actions can be undone. It is read when + the component mounts. +- Drawing an arrow records undo steps that have no visible effect. +- Undoing the move of a line puts the line at 0,0, and redo leaves it there. Undoing the move of the + rectangle of a RectangleLabel restores a width and a height of 0. Both happen in 0.5.x too. +- `forceValue` loads `value` on every update of the component, so a parent that re-renders often loads + it often. +- Calling a method of the component before it is mounted throws. + +## 0.5.x + +The `0.5.2` to `0.5.6` versions were set in `package.json` on the master branch and have no git tags. +They are not on npm under the name `react-sketch`, whose latest version is 0.5.1. From December 2019 to +November 2020 the package name in `package.json` was `@tarik.djurdjevic/react-sketch`, it is +`react-sketch` again since 2020-11-14. + +- **0.5.2 to 0.5.6** (January 2019 to April 2021, unreleased) + - New features + - TypeScript declarations (`types/index.d.ts`), with the `Tools` types (thanks @karlvr) + - `hasSelection()` and `clearSelection()` (thanks @karlvr) + - `setBackgroundFromDataUrl` options `stretched`, `stretchedX` and `stretchedY`, and a fix for the + stretching of the background image (thanks @maxaggedon) + - Arrow tool (thanks @clavierpaul) + - Highlighter tool + - Rectangle with label tool and default tool, which disables any change of the objects (thanks + @tarik-djurdjevic) + - Callbacks `onObjectAdded`, `onObjectModified`, `onObjectRemoved`, `onObjectMoving`, + `onObjectScaling`, `onObjectRotating`, `onMouseDown`, `onMouseMove`, `onMouseUp` and `onMouseOut` + - The `tool` property accepts `null`, and it is the default + - Images are loaded with CORS support (`crossOrigin`) + - Polyfill for `Object.assign`, for Internet Explorer 11 + - Fixes + - The webpack externals configuration of the library build is removed (#56) + - The default of `widthCorrection` is 0, it was 2 + - Other + - Upgraded to fabric 3.5 + - The tools are organized in one folder per tool, the code is formatted with Prettier + - Code of conduct added +- **0.5.1** (2018-12-13) Fixes + - `NODE_ENV` is set properly for the library build, react-hot-loader is no longer bundled (#49) +- **0.5.0** (2018-11-29) New features and fixes + - New features + - `addText` to add a text to the canvas + - `removeSelected` to remove the selected objects + - `copy` and `paste` of the selected objects + - Fixes + - `backgroundColor` when the initial value was not set + - Other + - Upgraded to React 16.6, fabric 2.4, webpack 4 and Babel 7, Jest for the tests + +## 0.4.x + +- **0.4.4** (2018-02-06) and **0.4.2** (2018-02-02) Maintenance releases + - Upgraded libraries (#26), fix for issue #29 + +- **0.4.0** New Features and fixes + - Fixes + - Upgraded to latest packages (thanks @fjogeleit) + - Fix for undo/redo (thanks @pomelyu) + - Issue #9 fixed + - Issue #10 and #15 you can no longer give dataUrl as value only as background image and/or by adding + the image as an object + - Breaking Changes + - `data` and `dataType` properties have been replaced with `value` and `defaultValue` to be able to + use the component as controlled component + - node support of 6, 7 and 8 + - New Features + - ability to add an image as object + +## Older versions + +- **0.3.0** New Features and fixes + - HOT Deploy fixed + - Added selection of background Color + - Added selection of a background image + - Fixing canUndo button +- **0.2.13** Maintenance release + - Upgrading packages + - performing required changes for webpack v2.x +- **0.2.7** New Functionality and bug fixes + - Zoom In/Out functionality of drawing + - Upgrading to latest package versions +- **0.2.6** Bug fixes and performance improvements +- **0.2.5** Upgrading to latest libraries and bug fixes + - Major + - React 15.1.0 + - Fabric 1.6.2 + - Bug fixes and performance improvements + - exposing disable/enable Scroll for tablets and touch devices +- **0.2.4** Maintenance Release +- **0.2.3** Upgrading to latest libraries + - Major + - React 15.0.1 + - Fabric 1.6.0 + - Other + - babel + - react-color + - etc. +- **0.2.2** Minor bug fixes and improvements release +- **0.2.1** Bundling fabric to the library, to avoid complexity by adding it as external dependency +- **0.2.0** Adding FabricJS as the core of the sketch field +- **0.1.1** Adding support for touch events +- **0.1.0** Initial release diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6b43580a..04d05730 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,75 +2,82 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to creating a positive environment -include: +Examples of behavior that contributes to a positive environment for our community include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at thomas.bolis@gmail.com. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at thomas.bolis@gmail.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. -[homepage]: https://www.contributor-covenant.org +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/README.md b/README.md index 8e1836d7..ade83e10 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,368 @@ # react-sketch -[![GitHub release][github-image]][github-url] +[![CI][ci-image]][ci-url] [![NPM release][npm-image]][npm-url] [![NPM downloads][downloads-image]][downloads-url] -[![Build status][travis-image]][travis-url] - -A Sketch tool for React based applications, backed-up by [FabricJS](http://fabricjs.com/) - -![idea-image] _Please note that this module is still in development! Feel free to send me enhancements and ideas :)_ +A sketch field component for React applications, built on [fabric.js](https://www.fabricjs.com/). +Draw with a pencil, lines, arrows, rectangles and circles, select and move objects, add images and +text, undo and redo, and export the drawing as JSON or as an image. The package includes TypeScript +declarations. ## Installation ```sh -npm install react-sketch --save +yarn add react-sketch ``` -or with yarn +or ```sh -yarn add react-sketch +npm install react-sketch +``` + +`react` (`^17.0.0 || ^18.0.0 || ^19.0.0`) is a peer dependency: install it in your application. +`fabric` is a dependency of `react-sketch` and is installed with it. + +## Usage + +`SketchField` is a class component. The methods of the component (`undo`, `toJSON`, ...) are available +through a `ref`, from the moment the component is mounted until it is unmounted. + +### TypeScript + +```tsx +import { useRef, useState } from "react"; +import { SketchField, Tools } from "react-sketch"; + +export const Drawing = () => { + const sketch = useRef(null); + const [canUndo, setCanUndo] = useState(false); + + return ( + <> + setCanUndo(sketch.current?.canUndo() ?? false)} + /> + + + ); +}; ``` -### Source installation +All types are available in the `Types` namespace, for example `Types.SketchProperties`, +`Types.SketchEvent` and the `Types.Tool` enumeration: -In order to build from source, read the [relevant instructions](http://fabricjs.com/fabric-intro-part-4#node) first. +```tsx +import { SketchField, Types } from "react-sketch"; -Tested with node versions 6,7,8. +const onObjectAdded = (event: Types.SketchEvent) => { + console.log("added a", event.target?.type); +}; -## Usage +export const Highlight = () => ( + +); +``` + +### JavaScript + +```jsx +import { useRef } from "react"; +import { SketchField, Tools } from "react-sketch"; + +export const Drawing = () => { + const sketch = useRef(null); + + return ( +
+ + +
+ ); +}; +``` -Import the relevant SketchField component and use it, you can find more on the examples folder of the project +The default export of 0.5.x keeps working. It is the component, which also carries `SketchField` and +`Tools`: -```javascript -import {SketchField, Tools} from 'react-sketch'; +```jsx +import Sketch from "react-sketch"; + +export const Drawing = () => ; +``` -class SketchFieldDemo extends React.Component { - render() { - return ( - - ) - } -} +### Initial, loaded and saved drawings + +`toJSON()` returns the drawing as an object. `defaultValue` and `value` accept that object, or the same +object as a JSON string. The object has the format of fabric 7 (object types such as `"Rect"` and +`"IText"`). Drawings saved with `react-sketch` 0.5.x load as well, drawings saved with 1.0.0 do not load +in 0.5.x. + +- `defaultValue` is loaded when the component mounts, unless `value` is set. +- `value` is loaded when it changes (a different object, compared by identity). Pass a new object, for + example `{ ...saved }`, to load the same drawing again, or set `forceValue` to load `value` on every + update of the component. +- The drawing is loaded 100 ms later, the undo history is reset, and `onChange` is called when the load + is done. +- `value` is not updated while the user draws. Read the drawing with `toJSON()`. Do not store the result + of `toJSON()` in `value` from `onChange`: loading a value calls `onChange`, so the value would be + loaded again and again. +- Images added with `addImg` and the background image are part of the JSON, so one `value` or + `defaultValue` holds shapes and images. Loading replaces everything, the background image included: + add images after `onChange` reports the load. + +```tsx +import { useRef, useState } from "react"; +import { SketchField, Tools, Types } from "react-sketch"; + +export const SaveAndLoad = ({ initial }: { initial: Types.SketchValue }) => { + const sketch = useRef(null); + const [saved, setSaved] = useState(); + const [value, setValue] = useState(); + + return ( + <> + + + + + ); +}; +``` +### Images, text and export + +```tsx +import { useRef } from "react"; +import { SketchField, Tools } from "react-sketch"; + +export const Editor = ({ imageUrl }: { imageUrl: string }) => { + const sketch = useRef(null); + + const download = () => { + const link = document.createElement("a"); + link.href = sketch.current?.toDataURL({ format: "png", multiplier: 2 }) ?? ""; + link.download = "sketch.png"; + link.click(); + }; + + return ( + <> + + + + + + + ); +}; ``` -Configuration Options - -| Option | Type | Default | Description | -|--- |--- |--- |--- | -| tool | Enumeration (string) | pencil | The tool to use, can be select, pencil, circle, rectangle, pan | -| lineColor | String | black | The color of the line | -| lineWidth | Number | 1 | The width of the line | -| fillColor | String | transparent | The fill color (hex format) of the shape when applicable (e.g. circle) | -| backgroundColor | String | transparent | The the background color of the sketch in hex or rgba | -| undoSteps | Number | 15 | number of undo/redo steps to maintain | -| imageFormat | String | png | image format when calling toDataURL, can be png or jpeg | -| width | Number | No Value(null)| Set/control the canvas width, if left empty the sketch will scale to parent element | -| height | Number | 512 | Set/control the canvas height, if left empty the sketch will take a reasonable default height | -| value | JSON | | Property to utilize and handle the sketch data as controlled component | -| defaultValue | JSON | | Default initial data, to load. If value is set then value will be loaded instead | -| widthCorrection | Number | 2 | Specify some width correction which will be applied on resize of canvas, this will help to correct some possible border on the canvas style | -| heightCorrection | Number | 0 | Specify some height correction which will be applied on resize of canvas, this will help to correct some possible border on the canvas style | - - -Available tools - -| Tool | Description | -|--- |--- | -| Pencil | Free drawing pencil | -| Line | Gives you the ability to draw lines | -| Rectangle | Create rectangles | -| Circle | Create circles | -| Rectangle | Create Rectangles | -| Select | Disables drawing and gives you the ability to modify existing elements in the canvas | -| Pan | Disables drawing and gives you the ability to move the complete canvas at will, useful to adjust the canvas when zooming in or out (thank you [wmaillard](https://github.com/wmaillard)) | - - -## Examples - -The project includes a webpack server for running the examples, just run: + +## Properties + +All properties are optional. + +| Property | Type | Default | Description | +| ------------------ | ------------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `tool` | `Types.Tool` or `string` | `"select"` | The tool to use, see [Tools](#tools). `null` is treated as `"select"`, an unknown name selects `"pencil"`. | +| `lineColor` | `string` | `"black"` | Color of the line. | +| `lineWidth` | `number` | `10` | Width of the line. | +| `fillColor` | `string` | `"transparent"` | Fill color of the shapes, where applicable (rectangle, rectangle-label, circle). | +| `backgroundColor` | `string` | `"transparent"` | Background color of the sketch, in hex or rgba format. | +| `opacity` | `number` | `1.0` | Accepted and not applied: it has no effect. It had no effect in 0.5.x either. | +| `undoSteps` | `number` | `25` | Number of undo/redo steps to keep. Read when the component mounts, a later change has no effect. | +| `imageFormat` | `string` | | Default format of `toDataURL`, `png` or `jpeg`, used when the options of the call have no `format`. Without it, `png`. | +| `value` | `object` or `string` | | Drawing to load, loaded again when it changes. See [Initial, loaded and saved drawings](#initial-loaded-and-saved-drawings). | +| `forceValue` | `boolean` | `false` | Load `value` on every update of the component, even when it did not change. | +| `defaultValue` | `object` or `string` | | Drawing loaded when the component mounts, unless `value` is set. | +| `widthCorrection` | `number` | `0` | Pixels subtracted from the width of the container when the canvas is resized. Compensates a border or padding of the container. | +| `heightCorrection` | `number` | `0` | Pixels subtracted from the height of the container when the canvas is resized. | +| `autoresize` | `boolean` | `true` | Resize the canvas, and scale its objects, when the window is resized. The canvas is always fitted to its container on mount and when `width` or `height` change. | +| `width` | `number` | undefined | Width of the sketch in pixels. When not set, the sketch takes the width of its parent element. | +| `height` | `number` | `512` | Height of the sketch in pixels. | +| `className` | `string` | | Class name of the container element of the canvas. | +| `style` | `CSS.Properties` | | Style of the container element of the canvas. | +| `onChange` | `(event?: Event) => void` | | Called 10 ms after every mouse up and mouse out on the canvas, whether or not the drawing changed (see [Known limitations](#known-limitations)), and after `undo`, `redo` and the load of a value. The argument is the DOM event after a mouse event, nothing otherwise. | +| `onObjectAdded` | `(event: Types.SketchEvent) => void` | no-op | An object was added by the user. Not called when `undo` or `redo` add an object back. | +| `onObjectModified` | `(event: Types.SketchEvent) => void` | no-op | An object was modified by the user (moved, scaled, rotated). | +| `onObjectRemoved` | `(event: Types.SketchEvent) => void` | no-op | An object was removed from the sketch. | +| `onObjectMoving` | `(event: Types.SketchEvent) => void` | no-op | An object is being moved. | +| `onObjectScaling` | `(event: Types.SketchEvent) => void` | no-op | An object is being scaled. | +| `onObjectRotating` | `(event: Types.SketchEvent) => void` | no-op | An object is being rotated. | +| `onMouseDown` | `(event: Types.SketchEvent) => void` | no-op | Mouse down on the canvas. | +| `onMouseMove` | `(event: Types.SketchEvent) => void` | no-op | Mouse move on the canvas. | +| `onMouseUp` | `(event: Types.SketchEvent) => void` | no-op | Mouse up on the canvas. | +| `onMouseOut` | `(event: Types.SketchEvent) => void` | no-op | The mouse left the canvas. | + +`Types.SketchEvent` is `{ e?: Event; target?: Types.SketchObject; [key: string]: unknown }`: `e` is the +DOM event, `target` is the object the event is about (object events only). `Types.SketchObject` is +`{ type?: string; [key: string]: unknown }`, for example with the type `"path"`, `"line"` or `"rect"`. + +## Methods + +The methods are properties of the component instance, access them through a `ref`. They are valid from +the moment the component is mounted until it is unmounted. Calling one before the component is mounted +throws. + +| Method | Returns | Description | +| --------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `undo()` | `void` | Undo the last action. Does nothing when there is nothing to undo. Calls `onChange` when it undid something. | +| `redo()` | `void` | Redo the last undone action. Does nothing when there is nothing to redo. Calls `onChange` when it redid something. | +| `canUndo()` | `boolean` | Whether `undo()` has something to undo. | +| `canRedo()` | `boolean` | Whether `redo()` has something to redo. | +| `toDataURL(options?)` | `string` | The sketch as a data URL. Options: `format` (`"png"` or `"jpeg"`, default `imageFormat`, then `"png"`), `quality` (0 to 1, jpeg only, default 1), `multiplier` (default 1), and the crop area `left`, `top`, `width`, `height`. | +| `toJSON(propertiesToInclude?)` | `Types.SketchData` | The sketch as an object. `propertiesToInclude` lists additional properties of the objects to include. | +| `fromJSON(json?)` | `void` | Load a drawing (object or JSON string). Loads after 100 ms, resets the undo history, applies the tool configuration again and calls `onChange`. Does nothing for an empty argument. | +| `clear(propertiesToInclude?)` | `Types.SketchData` | Remove all objects and reset the undo history. Returns the discarded content as JSON. The `backgroundColor` property is kept. | +| `addImg(dataUrl, options?)` | `void` | Add an image, given as URL or data URL, as an object. It is added when it has loaded. Options: `left`, `top` (default: random position), `scale` (default `0.5`). | +| `addText(text, options?)` | `void` | Add an editable text. It is centered unless `left` or `top` are given. `options` are the options of a fabric text object, for example `fontSize`, `fill`. | +| `setBackgroundFromDataUrl(dataUrl, options?)` | `void` | Use an image as background. Options: `stretched` (fit the canvas), `stretchedX` (fit the width), `stretchedY` (fit the height); any other option is an image option. | +| `zoom(factor)` | `void` | Scale the objects by a factor: 2 doubles their size, 0.5 halves it. | +| `hasSelection()` | `boolean` | Whether an object is selected. | +| `clearSelection()` | `void` | Deselect the selected object or objects. | +| `removeSelected()` | `void` | Remove the selected object or objects. It can be undone. | +| `copy()` | `void` | Copy the selected object. Does nothing without a selection. | +| `paste()` | `void` | Paste the copied object, 10 pixels further down and to the right of the previous one. Does nothing when nothing was copied. | +| `enableTouchScroll()` | `void` | Allow the page to scroll on touch devices when touching the canvas. | +| `disableTouchScroll()` | `void` | Do not scroll the page when touching the canvas. | + +## Tools + +Use the tools by name (`"pencil"`), with `Tools` (`Tools.Pencil`) or with the enumeration +`Types.Tool` (`Types.Tool.PENCIL`). + +| Tool | `Tools` | Value | Description | +| --------------- | ---------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Select | `Tools.Select` | `select` | Disables drawing. Objects can be selected, moved, scaled and rotated. | +| Pencil | `Tools.Pencil` | `pencil` | Free drawing. | +| Line | `Tools.Line` | `line` | Straight lines. | +| Arrow | `Tools.Arrow` | `arrow` | Lines with an arrow head. | +| Rectangle | `Tools.Rectangle` | `rectangle` | Rectangles, filled with `fillColor`. | +| Rectangle label | `Tools.RectangleLabel` | `rectangle-label` | Rectangles with a text box above them. The text box follows the rectangle when it is moved or scaled. | +| Circle | `Tools.Circle` | `circle` | Circles, filled with `fillColor`. The drag from the first to the second point is the diameter. | +| Pan | `Tools.Pan` | `pan` | Disables drawing. Dragging moves the whole canvas, useful after zooming (contributed by [wmaillard](https://github.com/wmaillard)). | +| Highlighter | `Tools.Highlighter` | `highlighter` | Free drawing in a semi-transparent color. `lineColor` must be a hex code or a color name; any other value falls back to the default color. | +| Default tool | `Tools.DefaultTool` | `default-tool` | Disables drawing and selection. Use it to show a drawing that is not edited. | + +## Exports + +| Export | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `SketchField` | The component (named export). Also usable as a type, for the `ref`. | +| `Tools` | The tools by name: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, `RectangleLabel`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`. | +| `Types` | Namespace of the types: `Tool` (enumeration), `Tools`, `SketchProperties`, `SketchEvent`, `SketchObject`, `SketchValue`, `SketchData`, `DataUrlOptions`, `ImageOptions`, `BackgroundOptions`, `TextOptions`. | +| default export | The component, which also carries `SketchField` and `Tools` (as in 0.5.x). | + +## Known limitations + +- `opacity` is accepted and not applied. +- `onChange` is called after every mouse up and mouse out, whether or not the drawing changed (issue [#80](https://github.com/tbolis/react-sketch/issues/80)). +- `undoSteps` keeps one step more than its value: `undoSteps + 1` actions can be undone. +- Drawing an arrow records undo steps which have no visible effect. +- With `forceValue`, every update of the component (for example each re-render of the parent with new properties) loads `value` again. + +## Support + +- React: `^17.0.0 || ^18.0.0 || ^19.0.0` (peer dependency). The test suite runs on React 19. +- Browsers: browsers with HTML5 canvas support. No polyfills are included and Internet Explorer 11 is not supported. +- Server-side rendering has not been tested. Render the component in the browser only. +- Node.js: the package has no runtime requirement for Node.js. Developing in this repository requires Node.js 22.22.1 or later (`.nvmrc` has 24) and Yarn 1. + +## Repository layout + +This is a Yarn 1 workspaces monorepo. + +| Path | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/react-sketch` | The library, published to npm as `react-sketch`. The build output is `dist/index.js` (UMD bundle, `react` and `fabric` are external) and `dist/index.d.ts` (type declarations). | +| `packages/showcase` | Demo application (not published): a tools panel and the `defaultValue`, `value` and `addImg` scenarios. | +| `.github/workflows` | Continuous integration ([ci.yml](.github/workflows/ci.yml)). | + +## Development + +Requirements: Node.js 22.22.1 or later, Yarn 1. ```sh git clone https://github.com/tbolis/react-sketch.git +cd react-sketch yarn install -npm start +yarn start ``` -and navigate to http://localhost:23000 +`yarn start` serves the showcase at . It uses the sources of the library, so the +library does not have to be built first. + +| Command | Description | +| ---------------- | --------------------------------------------------------------------------------------------------- | +| `yarn start` | Serve the showcase on port 23000. | +| `yarn build` | Build the library (`packages/react-sketch/dist`) and the showcase (`packages/showcase/build`). | +| `yarn test` | Run the tests of the library (Jest). `yarn test:watch` and `yarn test:coverage` are also available. | +| `yarn typecheck` | Type-check both packages. | +| `yarn lint` | Lint the TypeScript sources with ESLint. `yarn lint:fix` fixes what it can. | +| `yarn format` | Format the repository with Prettier. `yarn format:check` only checks. | +| `yarn clean` | Remove the build output. | + +## Upgrading from 0.5.x + +1.0.0 is a rewrite in TypeScript which keeps the component API of 0.5.x: the properties, the methods, +the named exports `SketchField` and `Tools`, and the default export. It requires React 17 or later and +does not support Internet Explorer 11. A few behaviors differ deliberately and several bugs are fixed, +the [changelog](CHANGELOG.md) lists them. -You can as well check the live showcase here: http://tbolis.github.io/showcase/react-sketch/ - -## Issues +## Release notes -See https://github.com/tbolis/react-sketch/issues +See [CHANGELOG.md](CHANGELOG.md). -## Changelog +## Contributing -See https://github.com/tbolis/react-sketch/blob/master/CHANGELOG.md +Issues and pull requests are welcome at . Before opening a +pull request run `yarn lint`, `yarn typecheck`, `yarn test` and `yarn format:check`. A pre-commit hook +(husky and lint-staged) runs ESLint and Prettier on the staged files. Participation in this project is +subject to the [Code of Conduct](CODE_OF_CONDUCT.md). ## License -MIT, do remember to add a reference if you find it useful :) +MIT. See [LICENSE](LICENSE). -[warning-image]: /docs/img/warning.png -[idea-image]: /docs/img/idea.png -[github-image]: https://img.shields.io/github/release/tbolis/react-sketch.svg -[github-url]: https://github.com/tbolis/react-sketch/releases +[ci-image]: https://github.com/tbolis/react-sketch/actions/workflows/ci.yml/badge.svg +[ci-url]: https://github.com/tbolis/react-sketch/actions/workflows/ci.yml [npm-image]: https://img.shields.io/npm/v/react-sketch.svg [npm-url]: https://www.npmjs.com/package/react-sketch [downloads-image]: https://img.shields.io/npm/dm/react-sketch.svg [downloads-url]: https://www.npmjs.com/package/react-sketch -[travis-image]: https://img.shields.io/travis/tbolis/react-sketch.svg -[travis-url]: https://travis-ci.org/tbolis/react-sketch diff --git a/docs/img/idea.png b/docs/img/idea.png deleted file mode 100644 index d2c94e54..00000000 Binary files a/docs/img/idea.png and /dev/null differ diff --git a/docs/img/warning.png b/docs/img/warning.png deleted file mode 100644 index 44860ab8..00000000 Binary files a/docs/img/warning.png and /dev/null differ diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..1f014d44 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,33 @@ +import js from "@eslint/js"; +import eslintReact from "@eslint-react/eslint-plugin"; +import { defineConfig, globalIgnores } from "eslint/config"; +import prettier from "eslint-config-prettier/flat"; +import reactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default defineConfig([ + globalIgnores(["**/dist/", "**/coverage/", "**/node_modules/"]), + { + files: ["packages/**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + eslintReact.configs["recommended-typescript"], + reactHooks.configs.flat.recommended, + ], + languageOptions: { globals: globals.browser }, + rules: { + "no-console": ["error", { allow: ["warn", "error"] }], + // The public methods of SketchField (undo, toJSON, ...) are its ref API, + // so they are not used inside the class. + "@eslint-react/no-unused-class-component-members": "off", + }, + }, + { + files: ["packages/**/*.spec.{ts,tsx}"], + languageOptions: { globals: globals.jest }, + }, + // Last: switches off rules that conflict with Prettier. + prettier, +]); diff --git a/examples/base.html b/examples/base.html deleted file mode 100644 index ddebe5d8..00000000 --- a/examples/base.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - <%= htmlWebpackPlugin.options.title %> - - - - - - - -
- - - - - diff --git a/examples/layout/tools.tsx b/examples/layout/tools.tsx deleted file mode 100644 index bab7c7f1..00000000 --- a/examples/layout/tools.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from "react"; -import Paper from "@material-ui/core/Paper"; -import { makeStyles } from "@material-ui/core"; -import ToolSelector from "../tools/ToolSelector"; -import ColorSelector from "../tools/ColorSelector"; -import CanvasControls from "../tools/CanvasControls"; -import Background from "../tools/Background"; -import Text from "../tools/Text"; -import ImageControls from "../tools/ImageControls"; - -const useStyles = makeStyles((theme) => ({ - paper: { - color: theme.palette.text.secondary, - }, -})); - -export default function () { - const classes = useStyles(); - - const [tool, setTool] = React.useState("pencil"); - const [lineColor, setLineColor] = React.useState("black"); - const [fillColor, setFillColor] = React.useState("transparent"); - - const handleSelectTool = (event, tool) => { - tool != null && setTool(tool); - }; - - const handleLineColor = (color, event) => { - color != null && setLineColor(color.hex); - }; - - const handleFillColor = (color, event) => { - color != null && setFillColor(color.hex); - }; - - return ( - - - - - - - - - ); -} diff --git a/examples/main.css b/examples/main.css deleted file mode 100644 index bae1962b..00000000 --- a/examples/main.css +++ /dev/null @@ -1,27 +0,0 @@ -body, -html { - margin: 0; - padding: 0; - min-height: 100%; - height: 100%; - background-color: beige; - font-weight: 300; - font-family: "Roboto", sans-serif; -} - -#container { - display: flex; - flex-direction: column; - overflow-x: hidden; - min-height: 100%; - width: 100%; - height: 100%; - background-color: #607d8b; - color: #000000; -} - -.canvas-area { - margin: 7px 0 0 7px; - border: 1px solid #0d3349; - background-color: #ffffff; -} diff --git a/examples/main.jsx b/examples/main.jsx deleted file mode 100644 index 98083472..00000000 --- a/examples/main.jsx +++ /dev/null @@ -1,767 +0,0 @@ -/*eslint no-unused-vars: 0, no-console: 0*/ - -import React from "react"; -import { CompactPicker } from "react-color"; -import "flexboxgrid"; -import "./main.css"; -import AppBar from "@material-ui/core/AppBar"; -import Card from "@material-ui/core/Card"; -import CardContent from "@material-ui/core/CardContent"; -import ExpandMore from "@material-ui/icons/ExpandMore"; -import CardHeader from "@material-ui/core/CardHeader"; -import GridListTile from "@material-ui/core/GridListTile"; -import IconButton from "@material-ui/core/IconButton"; -import MenuItem from "@material-ui/core/MenuItem"; -import Button from "@material-ui/core/Button"; -import Slider from "@material-ui/lab/Slider"; -import TextField from "@material-ui/core/TextField"; -import Switch from "@material-ui/core/Switch"; -import FormControlLabel from "@material-ui/core/FormControlLabel"; -import Collapse from "@material-ui/core/Collapse"; -import { createMuiTheme, MuiThemeProvider } from "@material-ui/core/styles"; -import color from "@material-ui/core/colors/blueGrey"; - -import UndoIcon from "@material-ui/icons/Undo"; -import RedoIcon from "@material-ui/icons/Redo"; -import DeleteIcon from "@material-ui/icons/Delete"; -import SaveIcon from "@material-ui/icons/Save"; -import ClearIcon from "@material-ui/icons/Clear"; -import AddIcon from "@material-ui/icons/Add"; -import CopyIcon from "@material-ui/icons/FileCopy"; -import RemoveIcon from "@material-ui/icons/Remove"; -import DownloadIcon from "@material-ui/icons/CloudDownload"; -import ZoomInIcon from "@material-ui/icons/ZoomIn"; -import ZoomOutIcon from "@material-ui/icons/ZoomOut"; -import dataJson from "./data.json"; -import dataJsonControlled from "./data.json.controlled"; -import { SketchField, Tools } from "../src"; -import dataUrl from "./data.url"; -import DropZone from "react-dropzone"; -import Toolbar from "@material-ui/core/Toolbar/Toolbar"; -import Typography from "@material-ui/core/Typography/Typography"; - -const styles = { - root: { - padding: "3px", - display: "flex", - flexWrap: "wrap", - margin: "10px 10px 5px 10px", - justifyContent: "space-around", - }, - gridList: { - width: "100%", - overflowY: "auto", - marginBottom: "24px", - }, - gridTile: { - backgroundColor: "#fcfcfc", - }, - appBar: { - backgroundColor: "#333", - }, - radioButton: { - marginTop: "3px", - marginBottom: "3px", - }, - separator: { - height: "42px", - backgroundColor: "white", - }, - iconButton: { - fill: "white", - width: "42px", - height: "42px", - }, - dropArea: { - width: "100%", - height: "64px", - border: "2px dashed rgb(102, 102, 102)", - borderStyle: "dashed", - borderRadius: "5px", - textAlign: "center", - paddingTop: "20px", - }, - activeStyle: { - borderStyle: "solid", - backgroundColor: "#eee", - }, - rejectStyle: { - borderStyle: "solid", - backgroundColor: "#ffdddd", - }, - card: { - margin: "10px 10px 5px 0", - }, -}; - -/** - * Helper function to manually fire an event - * - * @param el the element - * @param etype the event type - */ -function eventFire(el, etype) { - if (el.fireEvent) { - el.fireEvent("on" + etype); - } else { - var evObj = document.createEvent("Events"); - evObj.initEvent(etype, true, false); - el.dispatchEvent(evObj); - } -} - -class SketchFieldDemo extends React.Component { - constructor(props) { - super(props); - - this.state = { - lineWidth: 10, - lineColor: "black", - fillColor: "#68CCCA", - backgroundColor: "transparent", - shadowWidth: 0, - shadowOffset: 0, - tool: Tools.Pencil, - enableRemoveSelected: false, - fillWithColor: false, - fillWithBackgroundColor: false, - drawings: [], - canUndo: false, - canRedo: false, - controlledSize: false, - sketchWidth: 600, - sketchHeight: 600, - stretched: true, - stretchedX: false, - stretchedY: false, - originX: "left", - originY: "top", - imageUrl: "https://files.gamebanana.com/img/ico/sprays/4ea2f4dad8d6f.png", - expandTools: false, - expandControls: false, - expandColors: false, - expandBack: false, - expandImages: false, - expandControlled: false, - text: "a text, cool!", - enableCopyPaste: false, - }; - } - - _selectTool = (event) => { - this.setState({ - tool: event.target.value, - enableRemoveSelected: event.target.value === Tools.Select, - enableCopyPaste: event.target.value === Tools.Select, - }); - }; - - _save = () => { - let drawings = this.state.drawings; - drawings.push(this._sketch.toDataURL()); - this.setState({ drawings: drawings }); - }; - - _download = () => { - console.save(this._sketch.toDataURL(), "toDataURL.txt"); - console.save(JSON.stringify(this._sketch.toJSON()), "toDataJSON.txt"); - - /*eslint-enable no-console*/ - - let { imgDown } = this.refs; - let event = new Event("click", {}); - - imgDown.href = this._sketch.toDataURL(); - imgDown.download = "toPNG.png"; - imgDown.dispatchEvent(event); - }; - - _renderTile = (drawing, index) => { - return ( - this._removeMe(index)}> - - - }> - - - ); - }; - - _removeMe = (index) => { - let drawings = this.state.drawings; - drawings.splice(index, 1); - this.setState({ drawings: drawings }); - }; - - _undo = () => { - this._sketch.undo(); - this.setState({ - canUndo: this._sketch.canUndo(), - canRedo: this._sketch.canRedo(), - }); - }; - - _redo = () => { - this._sketch.redo(); - this.setState({ - canUndo: this._sketch.canUndo(), - canRedo: this._sketch.canRedo(), - }); - }; - - _clear = () => { - this._sketch.clear(); - this._sketch.setBackgroundFromDataUrl(""); - this.setState({ - controlledValue: null, - backgroundColor: "transparent", - fillWithBackgroundColor: false, - canUndo: this._sketch.canUndo(), - canRedo: this._sketch.canRedo(), - }); - }; - - _removeSelected = () => { - this._sketch.removeSelected(); - }; - - _onSketchChange = () => { - let prev = this.state.canUndo; - let now = this._sketch.canUndo(); - if (prev !== now) { - this.setState({ canUndo: now }); - } - }; - - _onBackgroundImageDrop = (accepted /*, rejected*/) => { - if (accepted && accepted.length > 0) { - let sketch = this._sketch; - let reader = new FileReader(); - let { stretched, stretchedX, stretchedY, originX, originY } = this.state; - reader.addEventListener( - "load", - () => - sketch.setBackgroundFromDataUrl(reader.result, { - stretched: stretched, - stretchedX: stretchedX, - stretchedY: stretchedY, - originX: originX, - originY: originY, - }), - false - ); - reader.readAsDataURL(accepted[0]); - } - }; - - _addText = () => this._sketch.addText(this.state.text); - - componentDidMount = () => { - (function (console) { - console.save = function (data, filename) { - if (!data) { - console.error("Console.save: No data"); - return; - } - if (!filename) filename = "console.json"; - if (typeof data === "object") { - data = JSON.stringify(data, undefined, 4); - } - var blob = new Blob([data], { type: "text/json" }), - e = document.createEvent("MouseEvents"), - a = document.createElement("a"); - a.download = filename; - a.href = window.URL.createObjectURL(blob); - a.dataset.downloadurl = ["text/json", a.download, a.href].join(":"); - e.initMouseEvent( - "click", - true, - false, - window, - 0, - 0, - 0, - 0, - 0, - false, - false, - false, - false, - 0, - null - ); - a.dispatchEvent(e); - }; - })(console); - }; - - render = () => { - let { controlledValue } = this.state; - const theme = createMuiTheme({ - typography: { - useNextVariants: true, - }, - palette: { - primary: { main: color[500] }, // Purple and green play nicely together. - secondary: { main: "#11cb5f" }, // This is just green.A700 as hex. - }, - }); - return ( - -
-
- - - - Sketch Tool - - - - - - - - - - - - - - - - - - -
-
-
-
- (this._sketch = c)} - lineColor={this.state.lineColor} - lineWidth={this.state.lineWidth} - fillColor={ - this.state.fillWithColor ? this.state.fillColor : "transparent" - } - backgroundColor={ - this.state.fillWithBackgroundColor - ? this.state.backgroundColor - : "transparent" - } - width={this.state.controlledSize ? this.state.sketchWidth : null} - height={this.state.controlledSize ? this.state.sketchHeight : null} - defaultValue={dataJson} - value={controlledValue} - forceValue - onChange={this._onSketchChange} - tool={this.state.tool} - /> -
-
- - - this.setState({ expandTools: !this.state.expandTools }) - }> - - - } - /> - - -
-
- - - Select - - - Pencil - - - Line - - - Arrow - - - Rectangle - - - Circle - - - Pan - - - Highlighter - - - RectangleLabel - - -
-
-
-
- Line Weight - this.setState({ lineWidth: v })} - /> -
- -
- this._sketch.zoom(1.25)}> - - - this._sketch.zoom(0.8)}> - - -
-
-
- this.setState({ text: e.target.value })} - value={this.state.text} - /> -
-
- - - -
-
-
-
-
- - - this.setState({ - expandControls: !this.state.expandControls, - }) - }> - - - } - /> - - -
-
- - this.setState({ - controlledSize: !this.state.controlledSize, - }) - } - /> - } - label="Control size" - /> -
- Change Canvas Width - this.setState({ sketchWidth: v })} - /> -
- Change Canvas Height - this.setState({ sketchHeight: v })} - /> -
-
-
- -
-
- { - this._sketch.copy(); - this._sketch.paste(); - }}> - - -
-
- - - -
-
-
-
-
- - - this.setState({ expandColors: !this.state.expandColors }) - }> - - - } - /> - - - -
- this.setState({ lineColor: color.hex })} - /> -
-
- - this.setState({ - fillWithColor: !this.state.fillWithColor, - }) - } - /> - } - label="Fill" - /> - this.setState({ fillColor: color.hex })} - /> -
-
-
- - - this.setState({ expandBack: !this.state.expandBack }) - }> - - - } - /> - - - - this.setState({ - fillWithBackgroundColor: !this.state - .fillWithBackgroundColor, - }) - } - /> - } - /> - - this.setState({ backgroundColor: color.hex }) - } - /> -
-
- -
- - this.setState({ stretched: !this.state.stretched }) - } - /> - } - /> - - this.setState({ stretchedX: !this.state.stretchedX }) - } - /> - } - /> - - this.setState({ stretchedY: !this.state.stretchedY }) - } - /> - } - /> -
- - Try dropping an image here, -
- or click -
- to select image as background. -
-
-
-
-
- - - this.setState({ expandImages: !this.state.expandImages }) - }> - - - } - /> - - -
- this.setState({ imageUrl: e.target.value })} - value={this.state.imageUrl} - /> - -
-
- -
-
-
- - - this.setState({ - expandControlled: !this.state.expandControlled, - }) - }> - - - } - /> - - - - - - -
-
-
-
- {/* Sketch area */} - -
-
-
-
- ); - }; -} - -export default SketchFieldDemo; diff --git a/examples/run.jsx b/examples/run.jsx deleted file mode 100644 index 80134c77..00000000 --- a/examples/run.jsx +++ /dev/null @@ -1,11 +0,0 @@ -(function () { - const React = require("react"); - const ReactDOM = require("react-dom"); - - const Demo = require("./main").default; - - // Needed for React Developer Tools - window.React = React; - - ReactDOM.render(, document.getElementById("container")); -})(); diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 03de6683..00000000 --- a/jest.config.js +++ /dev/null @@ -1,20 +0,0 @@ -module.exports = { - roots: ["src"], - - // "automock": false, - // "testEnvironment": "jest-environment-jsdom", - // "testRegex": "./src/.spec.jsx?$", - - transform: { - "^.+\\.(j|t)sx?$": "babel-jest", - "^.+\\.svg$": "jest-svg-transformer", - }, - moduleNameMapper: { - "\\.(css)$": "identity-obj-proxy", - }, - moduleDirectories: ["node_modules"], - setupFilesAfterEnv: ["./jest.setup.js"], - // setupTestFrameworkScriptFile: "./jest.setup.js", - - moduleFileExtensions: ["js", "json", "jsx"], -}; diff --git a/jest.setup.js b/jest.setup.js deleted file mode 100644 index 724ebf69..00000000 --- a/jest.setup.js +++ /dev/null @@ -1,8 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies, no-multi-assign, no-undef */ -import "jest-enzyme"; -import Enzyme from "enzyme"; -import Adapter from "enzyme-adapter-react-16"; - -Enzyme.configure({ - adapter: new Adapter(), -}); diff --git a/package.json b/package.json index 1068ea3a..2771c3cd 100644 --- a/package.json +++ b/package.json @@ -1,69 +1,48 @@ { "name": "react-sketch-project", - "version": "0.5.6", - "description": "Sketch Element for React based applications, backed-up by fabricjs as its core", - "keywords": [ - "react", - "canvas", - "sketch", - "fabricjs", - "fabric.js" - ], - "author": { - "name": "Thomas Bolis", - "email": "thomas.bolis@gmail.com" - }, - "repository": { - "type": "git", - "url": "https://github.com/tbolis/react-sketch.git" - }, - "bugs": { - "url": "https://github.com/tbolis/react-sketch/issues" - }, + "description": "Monorepo for react-sketch, a sketch element for React applications backed by fabric.js", "private": true, "workspaces": [ "packages/*" ], "license": "MIT", + "engines": { + "node": ">=22.22.1" + }, "scripts": { - "start": "wsrun start", - "lint": "eslint --fix ./packages/**/src", - "format": "prettier --write \"./**\"" + "start": "yarn workspace showcase start", + "build": "yarn workspace react-sketch build && yarn workspace showcase build", + "typecheck": "yarn workspace react-sketch typecheck && yarn workspace showcase typecheck", + "clean": "node -e \"for (const d of ['packages/react-sketch/dist', 'packages/showcase/build']) require('fs').rmSync(d, { recursive: true, force: true })\"", + "lint": "eslint \"packages/**/*.{ts,tsx}\"", + "lint:fix": "eslint --fix \"packages/**/*.{ts,tsx}\"", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "yarn workspace react-sketch test", + "test:watch": "yarn workspace react-sketch test --watch", + "test:coverage": "yarn workspace react-sketch test:coverage", + "prepare": "husky" }, "devDependencies": { - "@types/react": "^17.0.3", - "@types/react-dom": "^17.0.3", - "@typescript-eslint/eslint-plugin": "^4.21.0", - "@typescript-eslint/parser": "^4.21.0", - "awesome-typescript-loader": "^5.2.1", - "css-loader": "^5.2.0", - "eslint": "^7.23.0", - "eslint-plugin-prettier": "^3.3.1", - "eslint-plugin-react": "^7.23.1", - "eslint-plugin-react-hooks": "^4.2.0", - "husky": "^6.0.0", - "lint-staged": "^10.5.4", - "prettier": "^2.2.1", - "source-map-loader": "^2.0.1", - "typescript": "^4.2.3", - "webpack": "^5.30.0", - "wsrun": "^5.2.4" - }, - "husky": { - "hooks": { - "pre-commit": "lint-staged" - } + "@eslint-react/eslint-plugin": "^5.26.5", + "@eslint/js": "^10.0.1", + "eslint": "^10.12.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.13.0", + "husky": "^9.1.7", + "lint-staged": "^17.6.0", + "prettier": "^3.9.10", + "typescript": "^6.0.3", + "typescript-eslint": "^8.71.2", + "webpack": "^5.111.1", + "webpack-cli": "^7.2.3" }, "lint-staged": { - "*.{js,jsx}": [ - "prettier --write", + "*.{ts,tsx}": [ "eslint --fix", - "jest --findRelatedTests", - "git add" + "prettier --write" ], - "*.{html,css,less,ejs,json}": [ - "prettier --write", - "git add" - ] + "*.{json,css,md,yml}": "prettier --write" } } diff --git a/packages/react-sketch/LICENSE b/packages/react-sketch/LICENSE new file mode 100644 index 00000000..033dc841 --- /dev/null +++ b/packages/react-sketch/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2016 Thomas Bolis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/react-sketch/README.md b/packages/react-sketch/README.md new file mode 100644 index 00000000..305c4b8f --- /dev/null +++ b/packages/react-sketch/README.md @@ -0,0 +1,61 @@ +# react-sketch + +A sketch field component for React applications, built on [fabric.js](https://www.fabricjs.com/). +Pencil, line, arrow, rectangle, circle, highlighter, pan and select tools, images and text, undo and +redo, JSON and image export. Includes TypeScript declarations. + +## Installation + +```sh +yarn add react-sketch +``` + +or + +```sh +npm install react-sketch +``` + +`react` (`^17.0.0 || ^18.0.0 || ^19.0.0`) is a peer dependency: install it in your application. +`fabric` is installed with `react-sketch`. + +## Usage + +```tsx +import { useRef } from "react"; +import { SketchField, Tools } from "react-sketch"; + +export const Drawing = () => { + const sketch = useRef(null); + + return ( + <> + + + + ); +}; +``` + +Exports: `SketchField` (the component), `Tools` (`Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, +`RectangleLabel`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`), `Types` (namespace with the types and +the `Tool` enumeration) and a default export, which is the component with `SketchField` and `Tools` +attached (as in 0.5.x). + +## Documentation + +The properties, the methods, the tools and the usage with a saved or controlled drawing are documented +in the [README of the repository](https://github.com/tbolis/react-sketch#readme). Release notes are in +the [changelog](https://github.com/tbolis/react-sketch/blob/master/CHANGELOG.md). Report problems at +. + +## License + +MIT diff --git a/packages/react-sketch/jest.config.js b/packages/react-sketch/jest.config.js new file mode 100644 index 00000000..72dc620f --- /dev/null +++ b/packages/react-sketch/jest.config.js @@ -0,0 +1,31 @@ +/** @type {import('jest').Config} */ +module.exports = { + testEnvironment: "jsdom", + roots: ["/src"], + testMatch: ["/src/**/*.spec.ts?(x)"], + moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"], + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + tsconfig: "/tsconfig.spec.json", + // Only spec files are type-checked during the test run. Type errors in + // production sources are reported by tsc, so they do not fail tests. + // Remove the line below to type-check every file imported by a test. + diagnostics: { exclude: ["**", "!**/*.spec.ts", "!**/*.spec.tsx"] }, + }, + ], + }, + setupFilesAfterEnv: ["/jest.setup.ts"], + collectCoverageFrom: [ + "src/**/*.{ts,tsx}", + "!src/**/*.spec.{ts,tsx}", + "!src/**/*.d.ts", + // Re-export barrels only: history/index.ts and tools/index.ts hold code, so + // they stay in the report. + "!src/index.ts", + "!src/components/SketchField/index.ts", + "!src/components/SketchField/tools/*/index.ts", + ], + coverageDirectory: "coverage", +}; diff --git a/packages/react-sketch/jest.setup.ts b/packages/react-sketch/jest.setup.ts new file mode 100644 index 00000000..ec778a21 --- /dev/null +++ b/packages/react-sketch/jest.setup.ts @@ -0,0 +1,4 @@ +// jest matchers for the DOM (toBeInTheDocument, toHaveStyle, ...) +import "@testing-library/jest-dom"; +// jsdom has no 2D canvas implementation; fabric needs getContext("2d") to work +import "jest-canvas-mock"; diff --git a/packages/react-sketch/package.json b/packages/react-sketch/package.json index f0282331..6e503e7e 100644 --- a/packages/react-sketch/package.json +++ b/packages/react-sketch/package.json @@ -1,21 +1,25 @@ { "name": "react-sketch", "version": "1.0.0", - "description": "Sketch Element for React based applications, backed-up by fabricjs as its core", + "description": "React component for drawing on an HTML canvas with pencil, line, arrow, rectangle and circle tools, built on fabric.js, with undo/redo, JSON import/export and TypeScript types", "keywords": [ "react", "canvas", "sketch", + "drawing", + "fabric", "fabricjs", - "fabric.js" + "typescript" ], + "homepage": "https://github.com/tbolis/react-sketch#readme", "author": { "name": "Thomas Bolis", "email": "thomas.bolis@gmail.com" }, "repository": { "type": "git", - "url": "https://github.com/tbolis/react-sketch.git" + "url": "https://github.com/tbolis/react-sketch.git", + "directory": "packages/react-sketch" }, "bugs": { "url": "https://github.com/tbolis/react-sketch/issues" @@ -23,25 +27,38 @@ "license": "MIT", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "files": [ + "dist" + ], "scripts": { - "build:typescript": "tsc && webpack --mode production", - "build": "webpack --mode production", - "start": "webpack --mode development --watch" + "build": "webpack --mode production && tsc -p tsconfig.build.json", + "start": "webpack --mode development --watch", + "typecheck": "tsc --noEmit -p tsconfig.build.json && tsc --noEmit -p tsconfig.spec.json", + "test": "jest", + "test:coverage": "jest --coverage", + "prepublishOnly": "yarn build" }, "dependencies": { - "csstype": "^3.0.7", - "fabric": "^4.4.0", - "prop-types": "^15.6.2", - "react": "^17.0.2", - "react-dom": "^17.0.2" + "csstype": "^3.2.3", + "fabric": "^7.4.0" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "devDependencies": { - "@babel/preset-react": "^7.13.13", - "@babel/preset-typescript": "^7.13.0", - "@types/fabric": "^4.2.5", - "@types/node": "^14.14.37", - "clean-webpack-plugin": "^3.0.0", - "concurrently": "^6.0.1", - "terser-webpack-plugin": "^5.1.1" + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/jest": "^30.0.0", + "@types/node": "^22.20.5", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "jest": "^30.5.2", + "jest-canvas-mock": "^2.5.8", + "jest-environment-jsdom": "^30.5.2", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "ts-jest": "^29.4.14", + "ts-loader": "^9.6.2" } } diff --git a/packages/react-sketch/src/components/SketchField/SketchField.fabric.spec.tsx b/packages/react-sketch/src/components/SketchField/SketchField.fabric.spec.tsx new file mode 100644 index 00000000..330f9d69 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/SketchField.fabric.spec.tsx @@ -0,0 +1,972 @@ +/** + * What the upgrade of fabric 4 to fabric 7 must not change: old data keeps + * loading, what is drawn has the same geometry, undo and redo restore the + * same state, and the methods behave as before. + */ +import { createRef } from "react"; +import { render } from "@testing-library/react"; +import * as fabric from "fabric"; +import controlledValue from "../../../../showcase/src/data/controlledValue"; +import defaultValue from "../../../../showcase/src/data/defaultValue"; +import SketchField from "./SketchField"; +import { SketchProperties, Tool } from "../../types"; + +type Point = [number, number]; + +const mount = (props: SketchProperties = {}) => { + const ref = createRef(); + const view = render(); + const sketch = ref.current as SketchField; + return { ...view, sketch, canvas: sketch["canvas"] as fabric.Canvas }; +}; + +/** Fire a canvas event, as the canvas does when the user interacts with it */ +const fire = (canvas: fabric.Canvas, name: string, event: object): void => + (canvas as unknown as fabric.Observable>).fire(name, event); + +const mouseEvent = (type: string, [x, y]: Point) => ({ + e: new MouseEvent(type, { clientX: x, clientY: y }), +}); + +/** Press the mouse at `from`, move it to `to` and release it */ +const drag = (canvas: fabric.Canvas, from: Point, to: Point): void => { + fire(canvas, "mouse:down", mouseEvent("mousedown", from)); + fire(canvas, "mouse:move", mouseEvent("mousemove", to)); + fire(canvas, "mouse:up", mouseEvent("mouseup", to)); +}; + +/** An event of the browser, as the canvas receives it from the page */ +const domEvent = (type: string, [x, y]: Point, button = 0): MouseEvent => + new MouseEvent(type, { + clientX: x, + clientY: y, + button, + buttons: type === "mouseup" ? 0 : 1 << button, + bubbles: true, + cancelable: true, + }); + +/** Draw a free hand stroke, through the brush as the canvas does */ +const stroke = (canvas: fabric.Canvas, points: Point[]): void => { + const brush = canvas.freeDrawingBrush as fabric.BaseBrush; + const event = (type: string, point: Point) => ({ + ...mouseEvent(type, point), + pointer: new fabric.Point(...point), + }); + const [first, ...rest] = points; + const last = points[points.length - 1]; + brush.onMouseDown(new fabric.Point(...first), event("mousedown", first)); + rest.forEach((point) => + brush.onMouseMove(new fabric.Point(...point), event("mousemove", point)) + ); + brush.onMouseUp(event("mouseup", last)); +}; + +/** Timers (fromJSON, onChange) and the pending promises (loading, cloning) */ +const flush = (): Promise => jest.advanceTimersByTimeAsync(100); + +const round = (value: number, digits = 6): number => { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +}; + +/** Where an object is, and how big. JSON keeps 4 decimals, so 3 digits compare restored objects */ +const geometry = (object: fabric.FabricObject, digits = 6) => ({ + type: object.type, + originX: object.originX, + originY: object.originY, + left: round(object.left, digits), + top: round(object.top, digits), + width: round(object.width, digits), + height: round(object.height, digits), + angle: round(object.angle, digits), +}); + +/** The geometry and the area an object covers on the canvas */ +const shape = (object: fabric.FabricObject, digits = 6) => { + const { left, top, width, height } = object.getBoundingRect(); + return { + ...geometry(object, digits), + bbox: [left, top, width, height].map((n) => round(n, digits)), + }; +}; + +/** The shapes of a canvas, and for a group the geometry of its objects */ +const shapes = (canvas: fabric.Canvas, digits = 6) => + canvas.getObjects().map((object) => + object instanceof fabric.Group + ? { + ...shape(object, digits), + children: object.getObjects().map((o) => geometry(o, digits)), + } + : shape(object, digits) + ); + +/** The end points of a line, as drawn */ +const endPoints = (object: fabric.FabricObject): number[] => { + const { x1, y1, x2, y2 } = object as fabric.Line; + return [x1, y1, x2, y2]; +}; + +const typesOf = (canvas: fabric.Canvas): string[] => + canvas.getObjects().map((object) => object.type); + +beforeEach(() => jest.useFakeTimers()); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +describe("SketchField on fabric 7", () => { + describe("sample data of the showcase (JSON of fabric 3/4)", () => { + const samples = [ + ["defaultValue", defaultValue], + ["controlledValue", controlledValue], + ] as const; + + it.each(samples)( + "loads %s with the same number and types of objects", + async (_name, data) => { + const onChange = jest.fn(); + const { sketch, canvas } = mount({ onChange }); + + sketch.fromJSON(data); + await flush(); + + expect(canvas.getObjects()).toHaveLength(data.objects.length); + expect(typesOf(canvas)).toEqual(data.objects.map((o) => o.type)); + expect(onChange).toHaveBeenCalledTimes(1); + } + ); + + it("has the lines, circles, rectangle and path the data describes", async () => { + const { sketch, canvas } = mount(); + + sketch.fromJSON(controlledValue); + await flush(); + + const count = (type: string) => + typesOf(canvas).filter((t) => t === type).length; + expect([count("line"), count("circle"), count("rect"), count("path")]).toEqual( + [10, 2, 1, 1] + ); + }); + + it.each(samples)( + "keeps the position and the origin of the objects of %s", + async (_name, data) => { + const { sketch, canvas } = mount(); + + sketch.fromJSON(data); + await flush(); + + canvas.getObjects().forEach((object, index) => { + const source = data.objects[index] as unknown as Record< + string, + number | string + >; + expect(object.originX).toEqual(source.originX); + expect(object.originY).toEqual(source.originY); + expect(object.left).toBeCloseTo(source.left as number, 3); + expect(object.top).toBeCloseTo(source.top as number, 3); + }); + } + ); + + it("loads the data given as a string", async () => { + const { sketch, canvas } = mount(); + + sketch.fromJSON(JSON.stringify(defaultValue)); + await flush(); + + expect(typesOf(canvas)).toEqual(defaultValue.objects.map((o) => o.type)); + }); + + it("loads it as defaultValue and as value, once mounted", async () => { + const first = mount({ defaultValue }); + const second = mount({ value: controlledValue }); + await flush(); + + expect(first.canvas.getObjects()).toHaveLength(defaultValue.objects.length); + expect(second.canvas.getObjects()).toHaveLength( + controlledValue.objects.length + ); + }); + + it("can be undone, one object at a time", async () => { + const { sketch, canvas } = mount(); + sketch.fromJSON(defaultValue); + await flush(); + + sketch.undo(); + + expect(canvas.getObjects()).toHaveLength(defaultValue.objects.length - 1); + sketch.redo(); + expect(canvas.getObjects()).toHaveLength(defaultValue.objects.length); + }); + }); + + describe("toJSON", () => { + const drawings: [Tool, Point, Point, string[]][] = [ + [Tool.LINE, [10, 20], [60, 80], ["line"]], + [Tool.RECTANGLE, [10, 20], [110, 70], ["rect"]], + [Tool.CIRCLE, [0, 0], [30, 40], ["circle"]], + [Tool.ARROW, [0, 0], [100, 50], ["group"]], + [Tool.RECTANGLE_LABEL, [100, 100], [180, 160], ["rect", "textbox"]], + ]; + + it.each(drawings)( + "gives an object that fromJSON loads again, for the %s tool", + async (tool, from, to, types) => { + const { sketch, canvas } = mount({ + tool, + lineColor: "red", + lineWidth: 3, + fillColor: "blue", + }); + drag(canvas, from, to); + const drawn = shapes(canvas, 3); + const saved = sketch.toJSON(); + expect(typeof saved).toBe("object"); + + const other = mount(); + other.sketch.fromJSON(JSON.parse(JSON.stringify(saved))); + await flush(); + + expect(typesOf(other.canvas)).toEqual(types); + expect(shapes(other.canvas, 3)).toEqual(drawn); + } + ); + + it("gives an object that fromJSON loads again, for the pencil", async () => { + const { sketch, canvas } = mount({ + tool: Tool.PENCIL, + lineColor: "green", + lineWidth: 4, + }); + stroke(canvas, [ + [10, 10], + [30, 20], + [50, 15], + [70, 40], + ]); + const saved = sketch.toJSON(); + + const other = mount(); + other.sketch.fromJSON(JSON.stringify(saved)); + await flush(); + + expect(typesOf(other.canvas)).toEqual(["path"]); + const [path] = other.canvas.getObjects(); + expect([path.stroke, path.strokeWidth]).toEqual(["green", 4]); + expect(shapes(other.canvas, 3)).toEqual(shapes(canvas, 3)); + }); + + it("restores everything drawn on the same sketch, after clear", async () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + drag(canvas, [10, 20], [110, 70]); + drag(canvas, [5, 5], [25, 45]); + const drawn = shapes(canvas, 3); + + const discarded = sketch.clear(); + expect(canvas.getObjects()).toHaveLength(0); + sketch.fromJSON(discarded); + await flush(); + + expect(shapes(canvas, 3)).toEqual(drawn); + }); + }); + + describe("geometry of what is drawn (the numbers of fabric 4)", () => { + const props = { lineColor: "red", lineWidth: 3, fillColor: "blue" }; + const draw = (tool: Tool, from: Point, to: Point) => { + const { canvas } = mount({ tool, ...props }); + drag(canvas, from, to); + return canvas; + }; + const drawn = (tool: Tool, from: Point, to: Point) => + shapes(draw(tool, from, to)); + + it("Line", () => { + expect(drawn(Tool.LINE, [10, 20], [60, 80])).toEqual([ + { + type: "line", + originX: "center", + originY: "center", + left: 35, + top: 50, + width: 50, + height: 60, + angle: 0, + bbox: [8.5, 18.5, 53, 63], + }, + ]); + expect(endPoints(draw(Tool.LINE, [10, 20], [60, 80]).getObjects()[0])).toEqual( + [10, 20, 60, 80] + ); + expect(drawn(Tool.LINE, [100, 100], [40, 60])).toEqual([ + { + type: "line", + originX: "center", + originY: "center", + left: 70, + top: 80, + width: 60, + height: 40, + angle: 0, + bbox: [38.5, 58.5, 63, 43], + }, + ]); + expect( + endPoints(draw(Tool.LINE, [100, 100], [40, 60]).getObjects()[0]) + ).toEqual([100, 100, 40, 60]); + }); + + it("Rectangle", () => { + expect(drawn(Tool.RECTANGLE, [10, 20], [110, 70])).toEqual([ + { + type: "rect", + originX: "left", + originY: "top", + left: 10, + top: 20, + width: 100, + height: 50, + angle: 0, + bbox: [10, 20, 103, 53], + }, + ]); + expect(drawn(Tool.RECTANGLE, [100, 100], [40, 60])).toEqual([ + expect.objectContaining({ left: 40, top: 60, width: 60, height: 40 }), + ]); + }); + + it("Circle", () => { + expect(drawn(Tool.CIRCLE, [0, 0], [30, 40])).toEqual([ + { + type: "circle", + originX: "left", + originY: "center", + left: 0, + top: 0, + width: 50, + height: 50, + angle: 53.130102, + bbox: [-21.2, -15.9, 74.2, 74.2], + }, + ]); + expect(drawn(Tool.CIRCLE, [50, 50], [10, 20])).toEqual([ + expect.objectContaining({ + angle: -143.130102, + bbox: [-8.3, -3, 74.2, 74.2], + }), + ]); + }); + + it("Arrow", () => { + const [arrow] = drawn(Tool.ARROW, [0, 0], [100, 50]); + expect(arrow).toEqual({ + type: "group", + originX: "left", + originY: "top", + left: -1.5, + top: -1.5, + width: 108.208204, + height: 58.208204, + angle: 0, + bbox: [-1.5, -1.5, 108.208204, 58.208204], + children: [ + expect.objectContaining({ + type: "line", + originX: "center", + originY: "center", + left: -2.604102, + top: -2.604102, + width: 100, + height: 50, + }), + expect.objectContaining({ + type: "triangle", + originX: "center", + originY: "center", + left: 47.395898, + top: 22.395898, + width: 9, + height: 9, + angle: 116.565051, + }), + ], + }); + + const [group] = draw(Tool.ARROW, [0, 0], [100, 50]).getObjects(); + expect(endPoints((group as fabric.Group).getObjects()[0])).toEqual([ + 0, 0, 100, 50, + ]); + + const [backwards] = drawn(Tool.ARROW, [100, 100], [20, 60]); + expect(backwards).toEqual( + expect.objectContaining({ + left: 13.291796, + top: 53.291796, + width: 88.208204, + height: 48.208204, + }) + ); + }); + + it("RectangleLabel", () => { + const [rect, text] = drawn(Tool.RECTANGLE_LABEL, [100, 100], [180, 160]); + expect(rect).toEqual({ + type: "rect", + originX: "left", + originY: "top", + left: 100, + top: 100, + width: 80, + height: 60, + angle: 0, + bbox: [100, 100, 83, 63], + }); + expect(text).toEqual( + expect.objectContaining({ + type: "textbox", + originX: "left", + originY: "top", + left: 100, + top: 88, + width: 83, + height: 13.56, + }) + ); + + const [, backwards] = drawn(Tool.RECTANGLE_LABEL, [100, 100], [40, 60]); + expect(backwards).toEqual( + expect.objectContaining({ left: 40, top: 60, width: 63 }) + ); + }); + }); + + describe("pencil", () => { + it("has a brush with the width and the color of the sketch", () => { + const { canvas } = mount({ + tool: Tool.PENCIL, + lineColor: "green", + lineWidth: 4, + }); + + expect(canvas.freeDrawingBrush).toBeInstanceOf(fabric.PencilBrush); + expect(canvas.freeDrawingBrush?.width).toEqual(4); + expect(canvas.freeDrawingBrush?.color).toEqual("green"); + expect(canvas.isDrawingMode).toBe(true); + }); + + it("draws a path with that width and color, announced once", () => { + const onObjectAdded = jest.fn(); + const { canvas } = mount({ + tool: Tool.PENCIL, + lineColor: "green", + lineWidth: 4, + onObjectAdded, + }); + + stroke(canvas, [ + [10, 10], + [30, 20], + [50, 15], + [70, 40], + ]); + + const [path] = canvas.getObjects() as fabric.Path[]; + expect(canvas.getObjects()).toHaveLength(1); + expect([path.type, path.stroke, path.strokeWidth, path.fill]).toEqual([ + "path", + "green", + 4, + null, + ]); + expect(onObjectAdded).toHaveBeenCalledTimes(1); + expect(onObjectAdded).toHaveBeenCalledWith( + expect.objectContaining({ target: path }) + ); + }); + + it("keeps its brush when the tool changes, and follows the sketch again", () => { + const { canvas, rerender } = mount({ + tool: Tool.PENCIL, + lineColor: "green", + lineWidth: 4, + }); + const brush = canvas.freeDrawingBrush; + + rerender(); + expect(canvas.isDrawingMode).toBe(false); + rerender(); + + expect(canvas.isDrawingMode).toBe(true); + expect(canvas.freeDrawingBrush).toBe(brush); + expect([brush?.color, brush?.width]).toEqual(["blue", 7]); + }); + + it("can be undone and redone", () => { + const { sketch, canvas } = mount({ tool: Tool.PENCIL }); + stroke(canvas, [ + [10, 10], + [30, 20], + [50, 15], + ]); + const [path] = canvas.getObjects(); + + sketch.undo(); + expect(canvas.getObjects()).toHaveLength(0); + sketch.redo(); + + expect(canvas.getObjects()).toEqual([path]); + }); + }); + + describe("undo and redo", () => { + // The undos which take a drawing back: a label is two objects + const drawings: [Tool, Point, Point, number][] = [ + [Tool.LINE, [10, 20], [60, 80], 1], + [Tool.RECTANGLE, [10, 20], [110, 70], 1], + [Tool.CIRCLE, [0, 0], [30, 40], 1], + [Tool.ARROW, [0, 0], [100, 50], 1], + [Tool.RECTANGLE_LABEL, [100, 100], [180, 160], 2], + ]; + const times = (count: number, run: () => void) => + Array.from({ length: count }, run); + + it.each(drawings)( + "bring a %s back and forth, with its size and position", + (tool, from, to, steps) => { + const { sketch, canvas } = mount({ tool, lineColor: "red" }); + drag(canvas, from, to); + const drawn = shapes(canvas); + drag(canvas, to, [to[0] + 30, to[1] + 30]); + const both = shapes(canvas); + + times(steps, sketch.undo); + expect(shapes(canvas)).toEqual(drawn); + times(steps, sketch.redo); + expect(shapes(canvas)).toEqual(both); + } + ); + + // The state restored is the JSON of the object, which keeps 4 decimals. + // Not the line: undo of a moved line puts it at 0,0 (as on fabric 4) + it.each(drawings.filter(([tool]) => tool !== Tool.LINE))( + "restore a %s moved by the user", + (tool, from, to) => { + const warn = jest.spyOn(console, "warn").mockImplementation(); + const { sketch, canvas } = mount({ tool, lineColor: "red" }); + drag(canvas, from, to); + // the last object is the one whose final state the history knows + const object = canvas.getObjects().slice(-1)[0]; + const before = shape(object, 3); + + object.set({ left: object.left + 25, top: object.top + 15 }); + object.setCoords(); + fire(canvas, "object:modified", { target: object }); + const after = shape(object, 3); + expect(after).not.toEqual(before); + + sketch.undo(); + expect(shape(object, 3)).toEqual(before); + sketch.redo(); + expect(shape(object, 3)).toEqual(after); + // the saved state sets nothing fabric refuses (the type), so it has no warning + expect(warn).not.toHaveBeenCalled(); + } + ); + + it.todo( + "restores the position of a line moved by the user (it jumps to 0,0, as on fabric 4)" + ); + + it("restore the state of a free hand path moved by the user", () => { + const { sketch, canvas } = mount({ tool: Tool.PENCIL }); + stroke(canvas, [ + [10, 10], + [30, 20], + [50, 15], + ]); + const [path] = canvas.getObjects(); + const before = shape(path, 3); + + path.set({ left: path.left + 25, top: path.top + 15 }); + path.setCoords(); + fire(canvas, "object:modified", { target: path }); + sketch.undo(); + + expect(shape(path, 3)).toEqual(before); + }); + + it("keep the drawing working after an arrow was restored", () => { + const { sketch, canvas } = mount({ tool: Tool.ARROW }); + drag(canvas, [0, 0], [100, 50]); + const [group] = canvas.getObjects() as fabric.Group[]; + group.set({ left: group.left + 10 }); + fire(canvas, "object:modified", { target: group }); + sketch.undo(); + + group.set({ left: group.left + 5 }); + group.setCoords(); + expect(() => canvas.renderAll()).not.toThrow(); + expect(group.getObjects()).toHaveLength(2); + }); + + it("bring back what removeSelected removed", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + drag(canvas, [10, 20], [110, 70]); + const [rect] = canvas.getObjects(); + canvas.setActiveObject(rect); + + sketch.removeSelected(); + expect(canvas.getObjects()).toHaveLength(0); + sketch.undo(); + + expect(canvas.getObjects()).toEqual([rect]); + }); + }); + + describe("methods", () => { + it("zoom scales the position and the size of objects of any origin", () => { + const { sketch, canvas } = mount({ tool: Tool.LINE }); + drag(canvas, [10, 20], [60, 80]); + const [line] = canvas.getObjects(); + + sketch.zoom(2); + + expect(line.getCenterPoint()).toMatchObject({ x: 70, y: 100 }); + expect([line.scaleX, line.scaleY]).toEqual([2, 2]); + sketch.zoom(0.5); + expect(line.getCenterPoint()).toMatchObject({ x: 35, y: 50 }); + }); + + describe("setBackgroundFromDataUrl", () => { + /** An image of 200 x 100 loads as soon as its source is set */ + const stubImages = () => + jest.spyOn(window, "Image").mockImplementation(() => { + const img = document.createElement("img"); + img.width = 200; + img.height = 100; + Object.defineProperty(img, "src", { + set: () => img.onload?.(new Event("load")), + }); + return img; + }); + + it("covers the canvas from its top left corner", () => { + stubImages(); + const { sketch, canvas } = mount(); + + sketch.setBackgroundFromDataUrl("data:image/png;base64,00"); + + const image = canvas.backgroundImage as fabric.FabricImage; + expect(image).toBeInstanceOf(fabric.FabricImage); + expect([image.originX, image.originY]).toEqual(["left", "top"]); + expect(image.getBoundingRect()).toMatchObject({ + left: 0, + top: 0, + width: 200, + height: 100, + }); + }); + + it("stretches to the canvas, from its top left corner", () => { + stubImages(); + const { sketch, canvas } = mount(); + + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + stretched: true, + }); + + const image = canvas.backgroundImage as fabric.FabricImage; + expect(image.getBoundingRect()).toMatchObject({ + left: 0, + top: 0, + width: canvas.getWidth(), + height: canvas.getHeight(), + }); + }); + + it("is part of the json, and loads again", async () => { + stubImages(); + const { sketch, canvas } = mount(); + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + opacity: 0.5, + }); + const saved = JSON.parse(JSON.stringify(sketch.toJSON())); + + expect(saved.backgroundImage).toMatchObject({ opacity: 0.5 }); + expect(canvas.backgroundImage).toBeDefined(); + }); + + it("is saved with the shapes and the added images, and loading replaces all of them (#58)", async () => { + stubImages(); + jest + .spyOn(fabric.FabricImage, "fromURL") + .mockImplementation((_url, _options, imageOptions) => + Promise.resolve( + new fabric.FabricImage(document.createElement("img"), { + width: 100, + height: 60, + ...imageOptions, + }) + ) + ); + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + drag(canvas, [10, 20], [60, 80]); + sketch.addImg("data:image/png;base64,00"); + sketch.setBackgroundFromDataUrl("data:image/png;base64,00"); + await flush(); + + const saved = JSON.parse(JSON.stringify(sketch.toJSON())); + expect(saved.objects.map((o: { type: string }) => o.type)).toEqual([ + "Rect", + "Image", + ]); + expect(saved.backgroundImage).toBeDefined(); + + // Loading clears the canvas first: the images and the background image + // that are not in the loaded value are gone + sketch.fromJSON({ + ...saved, + objects: [saved.objects[0]], + backgroundImage: undefined, + }); + await flush(); + + expect(canvas.getObjects().map((o) => o.type)).toEqual(["rect"]); + expect(canvas.backgroundImage).toBeFalsy(); + }); + }); + + it("the background color is painted by the canvas", () => { + const { canvas, rerender } = mount({ backgroundColor: "red" }); + expect(canvas.backgroundColor).toEqual("red"); + + rerender(); + + expect(canvas.backgroundColor).toEqual("blue"); + }); + + it("addImg places the image by its top left corner", async () => { + jest + .spyOn(fabric.FabricImage, "fromURL") + .mockImplementation((_url, _options, imageOptions) => + Promise.resolve( + new fabric.FabricImage(document.createElement("img"), { + width: 100, + height: 60, + ...imageOptions, + }) + ) + ); + const { sketch, canvas } = mount(); + + sketch.addImg("data:image/png;base64,00", { left: 15, top: 25, scale: 2 }); + await flush(); + + const [image] = canvas.getObjects(); + expect(image.getBoundingRect()).toMatchObject({ + left: 15, + top: 25, + width: 200, + height: 120, + }); + }); + + it("addText centers the text on the canvas, or places it by its top left corner", () => { + const { sketch, canvas } = mount(); + + sketch.addText("hello"); + sketch.addText("there", { left: 11, top: 22 }); + + const [centered, placed] = canvas.getObjects(); + expect([centered.originX, centered.originY]).toEqual(["left", "top"]); + expect(centered.left).toEqual((canvas.getWidth() - centered.width) / 2); + expect(centered.top).toEqual((canvas.getHeight() - centered.height) / 2); + expect([placed.left, placed.top]).toEqual([11, 22]); + expect([placed.originX, placed.originY]).toEqual(["left", "top"]); + }); + + describe("copy and paste", () => { + const drawTwo = () => { + const sketch = mount({ tool: Tool.RECTANGLE, lineColor: "red" }); + drag(sketch.canvas, [10, 20], [110, 70]); + drag(sketch.canvas, [200, 20], [250, 70]); + sketch.canvas.setActiveObject(sketch.canvas.getObjects()[0]); + return sketch; + }; + + it("work when they are called one after the other", async () => { + const { sketch, canvas } = drawTwo(); + + sketch.copy(); + sketch.paste(); + sketch.paste(); + await flush(); + + expect(canvas.getObjects()).toHaveLength(4); + const [, , first, second] = canvas.getObjects(); + expect([first.left, first.top]).toEqual([20, 30]); + expect([second.left, second.top]).toEqual([30, 40]); + expect(canvas.getActiveObject()).toBe(second); + }); + + it("paste a copy, not the object itself", async () => { + const { sketch, canvas } = drawTwo(); + const [original] = canvas.getObjects(); + + sketch.copy(); + sketch.paste(); + await flush(); + + const pasted = canvas.getObjects()[2]; + expect(pasted).not.toBe(original); + expect(pasted.type).toEqual("rect"); + expect([pasted.width, pasted.height]).toEqual([100, 50]); + expect(pasted.stroke).toEqual("red"); + }); + + it("copy the object as it is when copy is called", async () => { + const { sketch, canvas } = drawTwo(); + const [original] = canvas.getObjects(); + + sketch.copy(); + original.set({ left: 500 }); + sketch.paste(); + await flush(); + + expect(canvas.getObjects()[2].left).toEqual(20); + }); + + it("paste a multiple selection", async () => { + const { sketch, canvas } = drawTwo(); + canvas.setActiveObject( + new fabric.ActiveSelection(canvas.getObjects(), { canvas }) + ); + + sketch.copy(); + sketch.paste(); + await flush(); + + expect(canvas.getObjects()).toHaveLength(4); + expect(sketch.hasSelection()).toBe(true); + }); + + it("paste what was copied last", async () => { + const { sketch, canvas } = drawTwo(); + sketch.copy(); + canvas.setActiveObject(canvas.getObjects()[1]); + sketch.copy(); + sketch.paste(); + await flush(); + + expect(canvas.getObjects()[2].left).toEqual(210); + }); + }); + + it("removeSelected removes every object of a multiple selection", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + drag(canvas, [10, 20], [110, 70]); + drag(canvas, [200, 20], [250, 70]); + canvas.setActiveObject( + new fabric.ActiveSelection(canvas.getObjects(), { canvas }) + ); + + sketch.removeSelected(); + + expect(canvas.getObjects()).toHaveLength(0); + sketch.undo(); + sketch.undo(); + expect(canvas.getObjects()).toHaveLength(2); + }); + }); + + describe("behavior of fabric 4 which the component keeps", () => { + it("draws with the mouse events of the page", () => { + const onMouseDown = jest.fn(); + const onMouseUp = jest.fn(); + const { canvas } = mount({ tool: Tool.LINE, onMouseDown, onMouseUp }); + + canvas.upperCanvasEl.dispatchEvent(domEvent("mousedown", [10, 20])); + document.dispatchEvent(domEvent("mousemove", [60, 80])); + document.dispatchEvent(domEvent("mouseup", [60, 80])); + + const [line] = canvas.getObjects(); + expect(endPoints(line)).toEqual([10, 20, 60, 80]); + expect(onMouseDown).toHaveBeenCalledTimes(1); + expect(onMouseUp).toHaveBeenCalledTimes(1); + }); + + it("ignores right and middle clicks, leaves the context menu alone", () => { + const onMouseDown = jest.fn(); + const { canvas } = mount({ tool: Tool.LINE, onMouseDown }); + + [1, 2].forEach((button) => { + canvas.upperCanvasEl.dispatchEvent(domEvent("mousedown", [10, 20], button)); + document.dispatchEvent(domEvent("mouseup", [10, 20], button)); + }); + + expect(canvas.getObjects()).toHaveLength(0); + expect(onMouseDown).not.toHaveBeenCalled(); + expect(canvas.fireRightClick).toBe(false); + expect(canvas.fireMiddleClick).toBe(false); + expect(canvas.stopContextMenu).toBe(false); + }); + + it("brings the selected object to the front only while it is selected", () => { + const { canvas } = mount(); + + expect(canvas.preserveObjectStacking).toBe(false); + }); + + it("does not announce the objects discarded by clear or by a loaded value", async () => { + const onObjectRemoved = jest.fn(); + const { sketch, canvas } = mount({ + tool: Tool.RECTANGLE, + onObjectRemoved, + }); + drag(canvas, [10, 20], [110, 70]); + drag(canvas, [5, 5], [25, 45]); + + sketch.clear(); + drag(canvas, [10, 20], [110, 70]); + sketch.fromJSON(defaultValue); + await flush(); + + expect(onObjectRemoved).not.toHaveBeenCalled(); + }); + + it("announces a removal by the user, also after a clear", () => { + const onObjectRemoved = jest.fn(); + const { sketch, canvas } = mount({ + tool: Tool.RECTANGLE, + onObjectRemoved, + }); + drag(canvas, [10, 20], [110, 70]); + sketch.clear(); + drag(canvas, [10, 20], [110, 70]); + canvas.setActiveObject(canvas.getObjects()[0]); + + sketch.removeSelected(); + + expect(onObjectRemoved).toHaveBeenCalledTimes(1); + }); + + it("returns the JSON of a clear synchronously, and toJSON gives an object", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + drag(canvas, [10, 20], [110, 70]); + + const json = sketch.toJSON(); + const discarded = sketch.clear(); + + expect(json).not.toBeInstanceOf(Promise); + expect(discarded).toEqual(json); + expect(discarded.objects).toHaveLength(1); + }); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx b/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx index e69de29b..c2d359c7 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx @@ -0,0 +1,1468 @@ +import { createRef, StrictMode } from "react"; +import { render } from "@testing-library/react"; +import * as fabric from "fabric"; +import SketchField from "./SketchField"; +import { SketchProperties, Tool } from "../../types"; + +type Point = [number, number]; +type Identified = fabric.FabricObject & { id?: string }; + +/** The fabric canvas of a mounted SketchField */ +const canvasOf = (sketch: SketchField): fabric.Canvas => sketch["canvas"]; + +/** Mount a SketchField, and give access to its methods and its canvas */ +const mount = (props: SketchProperties = {}) => { + const ref = createRef(); + const view = render(); + const sketch = ref.current as SketchField; + return { + ...view, + sketch, + canvas: canvasOf(sketch), + update: (next: SketchProperties) => + view.rerender(), + }; +}; + +const mouseEvent = (type: string, [x, y]: Point): { e: MouseEvent } => ({ + e: new MouseEvent(type, { clientX: x, clientY: y }), +}); + +/** + * Press the mouse at `from`, move it to `to` and release it. Returns the + * newest object of the canvas, which is the one drawn. + */ +const objectFromDrag = ( + canvas: fabric.Canvas, + from: Point = [0, 0], + to: Point = [10, 10], + id?: string +): Identified => { + fire(canvas, "mouse:down", mouseEvent("mousedown", from)); + fire(canvas, "mouse:move", mouseEvent("mousemove", to)); + fire(canvas, "mouse:up", mouseEvent("mouseup", to)); + const objects = canvas.getObjects(); + const newObj: Identified = objects[objects.length - 1]; + if (id) newObj.id = id; + return newObj; +}; + +/** Let the pending promises (the loading of images, cloning) settle */ +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +/** Fire a canvas event, as the canvas does when the user interacts with it */ +const fire = (canvas: fabric.Canvas, name: string, event: object): void => + (canvas as unknown as fabric.Observable>).fire(name, event); + +const ids = (canvas: fabric.Canvas): (string | undefined)[] => + canvas.getObjects().map((o: Identified) => o.id); + +/** What the browser would lay out for the container of the sketch */ +const layout = (width: number, height: number): void => { + jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(width); + jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(height); +}; + +const rect = (left: number, top: number, fill = "red") => ({ + type: "rect", + left, + top, + width: 30, + height: 40, + fill, +}); + +const START: Point = [10, 10]; +const END: Point = [40, 50]; + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +describe("SketchField", () => { + describe("rendering", () => { + it("renders a canvas inside a container div with default props", () => { + const { container } = render(); + const sketch = container.firstElementChild; + + expect(sketch).toBeInstanceOf(HTMLDivElement); + expect(sketch?.querySelector("canvas")).toBeInTheDocument(); + }); + + it("applies width, height, className and style to the container", () => { + const { container } = render( + + ); + + expect(container.firstElementChild).toHaveClass("sketch"); + expect(container.firstElementChild).toHaveStyle({ + width: "320px", + height: "240px", + border: "1px solid red", + }); + }); + + it("is 512 pixels high unless a height is given", () => { + const { container } = render(); + + expect(container.firstElementChild).toHaveStyle({ height: "512px" }); + }); + + it("keeps its canvas id when it renders again", () => { + const { container, rerender } = render(); + const id = container.querySelector("canvas")?.id; + + rerender(); + + expect(id).toBeTruthy(); + expect(container.querySelector("canvas")?.id).toEqual(id); + }); + + it("mounts, unmounts and mounts again in strict mode", () => { + const ref = createRef(); + const { container } = render( + + + + ); + const sketch = ref.current as SketchField; + const canvas = canvasOf(sketch); + + expect(container.querySelectorAll(".canvas-container")).toHaveLength(1); + objectFromDrag(canvas, START, END); + expect(canvas.getObjects()).toHaveLength(1); + sketch.undo(); + expect(canvas.getObjects()).toHaveLength(0); + }); + }); + + describe("default props", () => { + it("has the defaults of 0.5.x", () => { + expect(SketchField.defaultProps).toMatchObject({ + lineColor: "black", + lineWidth: 10, + fillColor: "transparent", + backgroundColor: "transparent", + opacity: 1.0, + undoSteps: 25, + widthCorrection: 0, + heightCorrection: 0, + forceValue: false, + }); + }); + + it("has event callbacks which do nothing", () => { + const callbacks = [ + "onObjectAdded", + "onObjectModified", + "onObjectRemoved", + "onMouseDown", + "onMouseMove", + "onMouseUp", + "onMouseOut", + "onObjectMoving", + "onObjectScaling", + "onObjectRotating", + ] as const; + callbacks.forEach((name) => { + expect(SketchField.defaultProps[name]({})).toBeUndefined(); + }); + }); + + it("selects objects, without drawing, when no tool is given", () => { + const { canvas } = mount(); + + expect(canvas.isDrawingMode).toBe(false); + expect(canvas.selection).toBe(true); + }); + + it("draws with a black line, 10 pixels wide, by default", () => { + const { canvas } = mount({ tool: Tool.PENCIL }); + + expect(canvas.freeDrawingBrush?.color).toEqual("black"); + expect(canvas.freeDrawingBrush?.width).toEqual(10); + }); + + it("also selects objects when the tool is null, as in 0.5.x", () => { + const { canvas } = mount({ tool: null as unknown as string }); + + expect(canvas.isDrawingMode).toBe(false); + expect(canvas.selection).toBe(true); + }); + }); + + describe("tool", () => { + it("draws with the pencil, using the line color and width", () => { + const { canvas } = mount({ + tool: Tool.PENCIL, + lineColor: "red", + lineWidth: 3, + }); + + expect(canvas.isDrawingMode).toBe(true); + expect(canvas.freeDrawingBrush?.color).toEqual("red"); + expect(canvas.freeDrawingBrush?.width).toEqual(3); + }); + + it("uses the regular pointer when drawing with the pencil (#181)", () => { + const { canvas } = mount({ tool: Tool.PENCIL }); + + expect(canvas.freeDrawingCursor).toEqual("default"); + }); + + it("recreates the tool when it changes", () => { + const { canvas, update } = mount({ tool: Tool.PENCIL }); + expect(canvas.isDrawingMode).toBe(true); + + update({ tool: Tool.SELECT }); + expect(canvas.isDrawingMode).toBe(false); + expect(canvas.selection).toBe(true); + + update({ tool: Tool.RECTANGLE }); + expect(canvas.selection).toBe(false); + objectFromDrag(canvas, START, END); + expect(canvas.getObjects()[0].type).toEqual("rect"); + + update({ tool: Tool.PAN }); + expect(canvas.defaultCursor).toEqual("move"); + + update({ tool: Tool.LINE }); + expect(canvas.defaultCursor).toEqual("default"); + }); + + it("configures the tool again when the line color or width change (#114)", () => { + const { canvas, update } = mount({ + tool: Tool.PENCIL, + lineColor: "red", + lineWidth: 3, + }); + + update({ tool: Tool.PENCIL, lineColor: "blue", lineWidth: 7 }); + + expect(canvas.freeDrawingBrush?.color).toEqual("blue"); + expect(canvas.freeDrawingBrush?.width).toEqual(7); + }); + + it("configures the tool again when the fill color changes", () => { + const { canvas, update } = mount({ + tool: Tool.RECTANGLE, + fillColor: "yellow", + }); + expect(objectFromDrag(canvas, START, END).fill).toEqual("yellow"); + + update({ tool: Tool.RECTANGLE, fillColor: "green" }); + + expect(objectFromDrag(canvas, START, END).fill).toEqual("green"); + }); + + it("does not break on a line color the highlighter cannot convert", () => { + ["rgb(255, 0, 0)", "transparent"].forEach((lineColor) => { + const { canvas, unmount } = mount({ tool: Tool.HIGHLIGHTER, lineColor }); + + // the default line color is used instead + expect(canvas.isDrawingMode).toBe(true); + expect(canvas.freeDrawingBrush?.color).toEqual("rgba(0,0,0,0.4)"); + unmount(); + }); + }); + + it("converts the line color of the highlighter, and follows its changes", () => { + const { canvas, update } = mount({ + tool: Tool.HIGHLIGHTER, + lineColor: "#ff0000", + }); + expect(canvas.freeDrawingBrush?.color).toEqual("rgba(255,0,0,0.4)"); + + expect(() => + update({ tool: Tool.HIGHLIGHTER, lineColor: "rgb(0, 0, 255)" }) + ).not.toThrow(); + expect(canvas.freeDrawingBrush?.color).toEqual("rgba(0,0,0,0.4)"); + + update({ tool: Tool.HIGHLIGHTER, lineColor: "blue" }); + expect(canvas.freeDrawingBrush?.color).toEqual("rgba(0,0,255,0.4)"); + }); + }); + + describe("background", () => { + it("is transparent by default, and follows backgroundColor (#87)", () => { + const { canvas, update } = mount(); + expect(canvas.backgroundColor).toEqual("transparent"); + + update({ backgroundColor: "red" }); + expect(canvas.backgroundColor).toEqual("red"); + + update({ backgroundColor: "rgba(0, 0, 255, 0.5)" }); + expect(canvas.backgroundColor).toEqual("rgba(0, 0, 255, 0.5)"); + }); + + it("is applied when the component mounts", () => { + const { canvas } = mount({ backgroundColor: "#00ff00" }); + + expect(canvas.backgroundColor).toEqual("#00ff00"); + }); + }); + + describe("size", () => { + it("fits the canvas to its container when it mounts", () => { + layout(400, 300); + + const { canvas } = mount(); + + expect(canvas.getWidth()).toEqual(400); + expect(canvas.getHeight()).toEqual(300); + }); + + it("subtracts widthCorrection and heightCorrection", () => { + layout(400, 300); + + const { canvas } = mount({ widthCorrection: 20, heightCorrection: 50 }); + + expect(canvas.getWidth()).toEqual(380); + expect(canvas.getHeight()).toEqual(250); + }); + + it("scales the objects with the canvas", () => { + layout(300, 150); + const { canvas, update } = mount({ tool: Tool.RECTANGLE }); + objectFromDrag(canvas, START, END); + + layout(600, 300); + update({ tool: Tool.RECTANGLE, width: 600 }); + + const [object] = canvas.getObjects(); + expect(canvas.getWidth()).toEqual(600); + expect(object.left).toEqual(START[0] * 2); + expect(object.top).toEqual(START[1] * 2); + expect(object.scaleX).toEqual(2); + }); + + it("fits the canvas again when width or height change", () => { + layout(200, 100); + const { canvas, update } = mount({ width: 200, height: 100 }); + expect(canvas.getWidth()).toEqual(200); + + layout(250, 120); + update({ width: 250, height: 120 }); + + expect(canvas.getWidth()).toEqual(250); + expect(canvas.getHeight()).toEqual(120); + }); + + it("leaves the canvas alone when its container has no size", () => { + layout(0, 0); + const { canvas } = mount({ tool: Tool.RECTANGLE }); + const width = canvas.getWidth(); + objectFromDrag(canvas, START, END); + + window.dispatchEvent(new Event("resize")); + + expect(canvas.getWidth()).toEqual(width); + expect(canvas.getObjects()[0].scaleX).toEqual(1); + }); + + it("follows the window when autoresize is on (default)", () => { + layout(400, 300); + const { canvas } = mount(); + + layout(500, 300); + window.dispatchEvent(new Event("resize")); + + expect(canvas.getWidth()).toEqual(500); + }); + + it("does not follow the window when autoresize is off", () => { + layout(400, 300); + const { canvas } = mount({ autoresize: false }); + + layout(500, 300); + window.dispatchEvent(new Event("resize")); + + expect(canvas.getWidth()).toEqual(400); + }); + + it("removes the resize listener, with the arguments it was added with", () => { + const add = jest.spyOn(window, "addEventListener"); + const remove = jest.spyOn(window, "removeEventListener"); + + const { unmount } = mount(); + unmount(); + + // fabric listens to the window too, only the ones of the component count + const resizes = (spy: jest.SpyInstance) => + spy.mock.calls.filter( + ([type, listener]) => + type === "resize" && listener.name === "_resize_listener" + ); + expect(resizes(add)).toHaveLength(1); + expect(resizes(remove)).toEqual(resizes(add)); + }); + + it("disposes the canvas when it unmounts", () => { + const dispose = jest.spyOn(fabric.Canvas.prototype, "dispose"); + + const { unmount } = mount(); + unmount(); + + expect(dispose).toHaveBeenCalledTimes(1); + }); + }); + + describe("drawing", () => { + it("drags to create a rectangle", () => { + const { canvas } = mount({ tool: Tool.RECTANGLE }); + const bounding = { + left: START[0], + top: START[1], + width: END[0] - START[0], + height: END[1] - START[1], + }; + + // From left-top to right-bottom + const rect1 = objectFromDrag(canvas, START, END); + expect(canvas.getObjects()).toHaveLength(1); + expect(rect1.type).toEqual("rect"); + expect({ + left: rect1.left, + top: rect1.top, + width: rect1.width, + height: rect1.height, + }).toEqual(bounding); + + canvas.remove(rect1); + // From right-bottom to left-top + const rect2 = objectFromDrag(canvas, END, START); + expect(rect2.type).toEqual("rect"); + expect({ + left: rect2.left, + top: rect2.top, + width: rect2.width, + height: rect2.height, + }).toEqual(bounding); + }); + + it("draws with the line color, line width and fill color", () => { + const { canvas } = mount({ + tool: Tool.RECTANGLE, + lineColor: "red", + lineWidth: 4, + fillColor: "blue", + }); + + const object = objectFromDrag(canvas, START, END); + + expect(object.stroke).toEqual("red"); + expect(object.strokeWidth).toEqual(4); + expect(object.fill).toEqual("blue"); + }); + }); + + describe("history", () => { + it("undoes and redoes multiple rectangles added to the canvas", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + + canvas.renderOnAddRemove = false; + objectFromDrag(canvas, START, END, "rect1"); + objectFromDrag(canvas, START, END, "rect2"); + expect(ids(canvas)).toEqual(["rect1", "rect2"]); + + sketch.undo(); + expect(ids(canvas)).toEqual(["rect1"]); + + sketch.undo(); + expect(ids(canvas)).toEqual([]); + + sketch.redo(); + expect(ids(canvas)).toEqual(["rect1"]); + + objectFromDrag(canvas, START, END, "rect3"); + expect(ids(canvas)).toEqual(["rect1", "rect3"]); + + sketch.undo(); + expect(ids(canvas)).toEqual(["rect1"]); + }); + + it("undoes and redoes multiple modifications of a single rectangle", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + const stateOf = () => canvas.getObjects()[0].toJSON(); + + // [Action1] Add new rectangle object and save its state + const stateStack = []; + const rect1 = objectFromDrag(canvas, START, END); + stateStack.push(rect1.toJSON()); + + // [Action2] Change rectangle dimension and save its state + rect1.set({ width: 50, height: 60 }); + rect1.setCoords(); + fire(canvas, "object:modified", { target: rect1 }); + stateStack.push(rect1.toJSON()); + + // [Action3] Change the position and save its state + rect1.set({ left: 20, top: 70 }); + rect1.setCoords(); + fire(canvas, "object:modified", { target: rect1 }); + stateStack.push(rect1.toJSON()); + + // Undo Action3 + sketch.undo(); + expect(stateOf()).toEqual(stateStack[1]); + + // Undo Action2 + sketch.undo(); + expect(stateOf()).toEqual(stateStack[0]); + + // Undo Action1, and then redo Action1 + sketch.undo(); + expect(canvas.getObjects()).toHaveLength(0); + sketch.redo(); + expect(stateOf()).toEqual(stateStack[0]); + + // redo Action2 + sketch.redo(); + expect(stateOf()).toEqual(stateStack[1]); + + // redo Action3 + sketch.redo(); + expect(stateOf()).toEqual(stateStack[2]); + }); + + it("modifies from the state it was undone to", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + const rect1 = objectFromDrag(canvas, START, END); + const original = rect1.toJSON(); + rect1.set({ left: 100 }); + fire(canvas, "object:modified", { target: rect1 }); + sketch.undo(); + + rect1.set({ left: 200 }); + fire(canvas, "object:modified", { target: rect1 }); + sketch.undo(); + + expect(canvas.getObjects()[0].toJSON()).toEqual(original); + }); + + it("ignores the modification of objects it does not know", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + const stranger = new fabric.Rect({ width: 5, height: 5 }); + + fire(canvas, "object:modified", { target: stranger }); + + expect(sketch.canUndo()).toBe(false); + }); + + it("tells whether it can undo and redo", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + expect(sketch.canUndo()).toBe(false); + expect(sketch.canRedo()).toBe(false); + + objectFromDrag(canvas, START, END); + expect(sketch.canUndo()).toBe(true); + expect(sketch.canRedo()).toBe(false); + + sketch.undo(); + expect(sketch.canUndo()).toBe(false); + expect(sketch.canRedo()).toBe(true); + + sketch.redo(); + expect(sketch.canUndo()).toBe(true); + expect(sketch.canRedo()).toBe(false); + }); + + it("leaves the canvas intact when there is nothing to undo or redo", () => { + const onChange = jest.fn(); + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE, onChange }); + objectFromDrag(canvas, START, END); + + expect(() => { + sketch.redo(); + sketch.undo(); + sketch.undo(); + sketch.undo(); + }).not.toThrow(); + + expect(canvas.getObjects()).toHaveLength(0); + // once for the undo, nothing for the ones which had no effect + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("keeps undoSteps steps, and one more for the current one, as in 0.5.x", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE, undoSteps: 2 }); + for (let i = 0; i < 5; i++) objectFromDrag(canvas, START, END); + + let undone = 0; + while (sketch.canUndo()) { + sketch.undo(); + undone++; + } + + expect(undone).toEqual(3); + expect(canvas.getObjects()).toHaveLength(2); + }); + + it("forgets a redo once something new is drawn", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + objectFromDrag(canvas, START, END); + sketch.undo(); + expect(sketch.canRedo()).toBe(true); + + objectFromDrag(canvas, START, END); + + expect(sketch.canRedo()).toBe(false); + }); + + it("calls onChange, without arguments, after undo and redo", () => { + const onChange = jest.fn(); + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE, onChange }); + objectFromDrag(canvas, START, END); + + sketch.undo(); + expect(onChange).toHaveBeenLastCalledWith(); + + onChange.mockClear(); + sketch.redo(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith(); + }); + }); + + describe("callbacks", () => { + it("calls onChange with the DOM event, a moment after mouse up and mouse out", () => { + jest.useFakeTimers(); + const onChange = jest.fn(); + const { canvas } = mount({ tool: Tool.RECTANGLE, onChange }); + + const up = mouseEvent("mouseup", END); + fire(canvas, "mouse:up", up); + expect(onChange).not.toHaveBeenCalled(); + jest.advanceTimersByTime(10); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith(up.e); + + const out = mouseEvent("mouseout", END); + fire(canvas, "mouse:out", out); + jest.advanceTimersByTime(10); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenLastCalledWith(out.e); + }); + + it("does not call onChange after the component is unmounted", () => { + jest.useFakeTimers(); + const onChange = jest.fn(); + const { canvas, unmount } = mount({ tool: Tool.RECTANGLE, onChange }); + fire(canvas, "mouse:up", mouseEvent("mouseup", END)); + + unmount(); + jest.advanceTimersByTime(1000); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("calls the mouse callbacks", () => { + const onMouseDown = jest.fn(); + const onMouseMove = jest.fn(); + const onMouseUp = jest.fn(); + const onMouseOut = jest.fn(); + const { canvas } = mount({ + tool: Tool.RECTANGLE, + onMouseDown, + onMouseMove, + onMouseUp, + onMouseOut, + }); + + objectFromDrag(canvas, START, END); + fire(canvas, "mouse:out", mouseEvent("mouseout", END)); + + expect(onMouseDown).toHaveBeenCalledTimes(1); + expect(onMouseDown.mock.calls[0][0].e).toBeInstanceOf(MouseEvent); + expect(onMouseMove).toHaveBeenCalledTimes(1); + expect(onMouseUp).toHaveBeenCalledTimes(1); + expect(onMouseOut).toHaveBeenCalledTimes(1); + }); + + it("calls onObjectAdded when the user adds an object, not when undo or redo does", () => { + const onObjectAdded = jest.fn(); + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE, onObjectAdded }); + + const object = objectFromDrag(canvas, START, END); + expect(onObjectAdded).toHaveBeenCalledTimes(1); + expect(onObjectAdded.mock.calls[0][0].target).toBe(object); + + sketch.undo(); + sketch.redo(); + expect(onObjectAdded).toHaveBeenCalledTimes(1); + }); + + it("calls onObjectRemoved when an object is removed, also by undo", () => { + const onObjectRemoved = jest.fn(); + const { sketch, canvas } = mount({ + tool: Tool.RECTANGLE, + onObjectRemoved, + }); + const object = objectFromDrag(canvas, START, END); + + sketch.undo(); + + expect(onObjectRemoved).toHaveBeenCalledTimes(1); + expect(onObjectRemoved.mock.calls[0][0].target).toBe(object); + }); + + it("calls onObjectModified, onObjectMoving, onObjectScaling and onObjectRotating", () => { + const onObjectModified = jest.fn(); + const onObjectMoving = jest.fn(); + const onObjectScaling = jest.fn(); + const onObjectRotating = jest.fn(); + const { canvas } = mount({ + tool: Tool.RECTANGLE, + onObjectModified, + onObjectMoving, + onObjectScaling, + onObjectRotating, + }); + const object = objectFromDrag(canvas, START, END); + + fire(canvas, "object:modified", { target: object }); + fire(canvas, "object:moving", { target: object }); + fire(canvas, "object:scaling", { target: object }); + fire(canvas, "object:rotating", { target: object }); + + [onObjectModified, onObjectMoving, onObjectScaling, onObjectRotating].forEach( + (callback) => { + expect(callback).toHaveBeenCalledTimes(1); + expect(callback.mock.calls[0][0].target).toBe(object); + } + ); + }); + + it("does not need any callback", () => { + const { canvas } = mount({ + tool: Tool.RECTANGLE, + onMouseDown: null as unknown as undefined, + }); + + expect(() => objectFromDrag(canvas, START, END)).not.toThrow(); + }); + }); + + describe("value", () => { + beforeEach(() => jest.useFakeTimers()); + + const load = () => jest.advanceTimersByTimeAsync(100); + + it("loads defaultValue when it mounts", async () => { + const { canvas } = mount({ + defaultValue: { objects: [rect(10, 10), rect(50, 50)] }, + }); + expect(canvas.getObjects()).toHaveLength(0); + + await load(); + + expect(canvas.getObjects()).toHaveLength(2); + }); + + it("loads value when it mounts, rather than defaultValue", async () => { + const { canvas } = mount({ + value: { objects: [rect(10, 10)] }, + defaultValue: { objects: [rect(10, 10), rect(50, 50)] }, + }); + + await load(); + + expect(canvas.getObjects()).toHaveLength(1); + }); + + it("loads value when it changes, not when it stays the same", async () => { + const first = { objects: [rect(10, 10)] }; + const second = { objects: [rect(10, 10), rect(50, 50)] }; + const { canvas, update } = mount({ value: first }); + const loadFromJSON = jest.spyOn(canvas, "loadFromJSON"); + await load(); + expect(loadFromJSON).toHaveBeenCalledTimes(1); + + update({ value: second }); + await load(); + expect(canvas.getObjects()).toHaveLength(2); + expect(loadFromJSON).toHaveBeenCalledTimes(2); + + update({ value: second, className: "other" }); + await load(); + expect(loadFromJSON).toHaveBeenCalledTimes(2); + }); + + it("loads value on every update when forceValue is set", async () => { + const value = { objects: [rect(10, 10)] }; + const { canvas, update } = mount({ value, forceValue: true }); + const loadFromJSON = jest.spyOn(canvas, "loadFromJSON"); + await load(); + + update({ value, forceValue: true, className: "other" }); + await load(); + + expect(loadFromJSON).toHaveBeenCalledTimes(2); + }); + + it("calls onChange, without arguments, once the value is loaded", async () => { + const onChange = jest.fn(); + mount({ value: { objects: [rect(10, 10)] }, onChange }); + expect(onChange).not.toHaveBeenCalled(); + + await load(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(); + }); + + it("keeps the background color of the sketch when the value has none", async () => { + const { canvas, update } = mount({ + backgroundColor: "red", + value: { objects: [rect(10, 10)] }, + }); + await load(); + expect(canvas.backgroundColor).toEqual("red"); + + update({ + backgroundColor: "red", + value: { objects: [], background: "blue" }, + }); + await load(); + expect(canvas.backgroundColor).toEqual("blue"); + }); + + it("makes the loaded objects follow the tool", async () => { + const { canvas } = mount({ + tool: Tool.DEFAULT_TOOL, + value: { objects: [rect(10, 10)] }, + }); + + await load(); + + const [object] = canvas.getObjects(); + expect(canvas.selection).toBe(false); + expect(object.selectable).toBe(false); + expect(object.evented).toBe(false); + }); + + it("does not load into a canvas which is gone", async () => { + const { canvas, unmount } = mount({ value: { objects: [rect(10, 10)] } }); + const loadFromJSON = jest.spyOn(canvas, "loadFromJSON"); + + unmount(); + await load(); + + expect(loadFromJSON).not.toHaveBeenCalled(); + }); + }); + + describe("methods", () => { + describe("enableTouchScroll and disableTouchScroll", () => { + it("switch the touch scrolling of the canvas", () => { + const { sketch, canvas } = mount(); + expect(canvas.allowTouchScrolling).toBeFalsy(); + + sketch.enableTouchScroll(); + expect(canvas.allowTouchScrolling).toBe(true); + sketch.enableTouchScroll(); + expect(canvas.allowTouchScrolling).toBe(true); + + sketch.disableTouchScroll(); + expect(canvas.allowTouchScrolling).toBe(false); + sketch.disableTouchScroll(); + expect(canvas.allowTouchScrolling).toBe(false); + }); + + // jsdom does not implement touch-action: it keeps the assigned value only + // on the style object, which is what the component sets. + it("switch the touch-action style that lets the browser scroll", () => { + const { sketch, canvas } = mount(); + + sketch.enableTouchScroll(); + expect(canvas.upperCanvasEl.style.touchAction).toBe("manipulation"); + expect(canvas.lowerCanvasEl.style.touchAction).toBe("manipulation"); + + sketch.disableTouchScroll(); + expect(canvas.upperCanvasEl.style.touchAction).toBe("none"); + expect(canvas.lowerCanvasEl.style.touchAction).toBe("none"); + }); + }); + + describe("addImg", () => { + const stubImages = () => + jest.spyOn(fabric.FabricImage, "fromURL").mockImplementation(() => + Promise.resolve( + new fabric.FabricImage(document.createElement("img"), { + width: 100, + height: 60, + }) + ) + ); + + it("adds the image at the given position and scale", async () => { + const fromURL = stubImages(); + const { sketch, canvas } = mount(); + canvas.renderOnAddRemove = false; + + sketch.addImg("data:image/png;base64,00", { + left: 15, + top: 25, + scale: 2, + }); + await settle(); + + const [image] = canvas.getObjects(); + expect(fromURL).toHaveBeenCalledWith( + "data:image/png;base64,00", + {}, + { originX: "left", originY: "top" } + ); + expect(image.type).toEqual("image"); + expect([image.left, image.top, image.scaleX]).toEqual([15, 25, 2]); + }); + + it("scales the image to half, and places it at random, by default", async () => { + stubImages(); + jest.spyOn(Math, "random").mockReturnValue(0.5); + const { sketch, canvas } = mount(); + canvas.renderOnAddRemove = false; + + sketch.addImg("data:image/png;base64,00"); + await settle(); + + const [image] = canvas.getObjects(); + expect(image.scaleX).toEqual(0.5); + expect(image.left).toEqual(0.5 * (canvas.getWidth() - 100 * 0.5)); + expect(image.top).toEqual(0.5 * (canvas.getHeight() - 60 * 0.5)); + }); + + it("can be undone", async () => { + stubImages(); + const { sketch, canvas } = mount(); + canvas.renderOnAddRemove = false; + sketch.addImg("data:image/png;base64,00"); + await settle(); + + sketch.undo(); + + expect(canvas.getObjects()).toHaveLength(0); + }); + + it("adds nothing when the component was unmounted meanwhile", async () => { + let finish: () => void = () => undefined; + jest.spyOn(fabric.FabricImage, "fromURL").mockImplementation( + () => + new Promise((resolve) => { + const image = new fabric.FabricImage(document.createElement("img")); + finish = () => resolve(image); + }) + ); + const { sketch, canvas, unmount } = mount(); + const add = jest.spyOn(canvas, "add"); + sketch.addImg("data:image/png;base64,00"); + + unmount(); + finish(); + await settle(); + + expect(add).not.toHaveBeenCalled(); + }); + + it("adds nothing, and does not fail, when the image cannot be loaded", async () => { + jest + .spyOn(fabric.FabricImage, "fromURL") + .mockImplementation(() => Promise.reject(new Error("no image"))); + const { sketch, canvas } = mount(); + + sketch.addImg("not-an-image"); + await settle(); + + expect(canvas.getObjects()).toHaveLength(0); + }); + }); + + describe("zoom", () => { + it("scales the objects and their position by the factor", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + objectFromDrag(canvas, START, END); + + sketch.zoom(2); + const [object] = canvas.getObjects(); + expect([object.left, object.top]).toEqual([20, 20]); + expect([object.scaleX, object.scaleY]).toEqual([2, 2]); + + sketch.zoom(0.5); + expect([object.left, object.top]).toEqual([10, 10]); + expect([object.scaleX, object.scaleY]).toEqual([1, 1]); + }); + }); + + describe("toDataURL", () => { + it("exports the canvas as png", () => { + const { sketch } = mount(); + + expect(sketch.toDataURL()).toMatch(/^data:image\/png;base64,/); + }); + + it("takes the format from the options, then from imageFormat", () => { + const { sketch, update } = mount(); + expect(sketch.toDataURL({ format: "jpeg" })).toMatch( + /^data:image\/jpeg;base64,/ + ); + + update({ imageFormat: "jpeg" }); + expect(sketch.toDataURL()).toMatch(/^data:image\/jpeg;base64,/); + expect(sketch.toDataURL({ format: "png" })).toMatch( + /^data:image\/png;base64,/ + ); + }); + + it("passes the options to the canvas", () => { + const { sketch, canvas } = mount(); + const toDataURL = jest.spyOn(canvas, "toDataURL"); + + sketch.toDataURL({ multiplier: 2, left: 1, top: 2, width: 3, height: 4 }); + + expect(toDataURL).toHaveBeenCalledWith( + expect.objectContaining({ + multiplier: 2, + left: 1, + top: 2, + width: 3, + height: 4, + }) + ); + }); + }); + + describe("toJSON", () => { + it("returns the canvas as an object", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + objectFromDrag(canvas, START, END); + objectFromDrag(canvas, START, END); + + const json = sketch.toJSON(); + + expect(typeof json).toEqual("object"); + expect(json.objects).toHaveLength(2); + expect(JSON.parse(JSON.stringify(json))).toEqual(json); + }); + + it("includes the properties asked for", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + objectFromDrag(canvas, START, END, "rect1"); + + expect(sketch.toJSON().objects).toEqual([ + expect.not.objectContaining({ id: "rect1" }), + ]); + expect(sketch.toJSON(["id"]).objects).toEqual([ + expect.objectContaining({ id: "rect1" }), + ]); + }); + }); + + describe("fromJSON", () => { + beforeEach(() => jest.useFakeTimers()); + + it("populates the canvas a moment later, from an object or a string", async () => { + const { sketch, canvas } = mount(); + + sketch.fromJSON({ objects: [rect(10, 10)] }); + expect(canvas.getObjects()).toHaveLength(0); + await jest.advanceTimersByTimeAsync(100); + expect(canvas.getObjects()).toHaveLength(1); + + sketch.fromJSON(JSON.stringify({ objects: [rect(10, 10), rect(5, 5)] })); + await jest.advanceTimersByTimeAsync(100); + expect(canvas.getObjects()).toHaveLength(2); + }); + + it("does nothing without a value", async () => { + const { sketch, canvas } = mount(); + const loadFromJSON = jest.spyOn(canvas, "loadFromJSON"); + + sketch.fromJSON(); + sketch.fromJSON(""); + await jest.advanceTimersByTimeAsync(100); + + expect(loadFromJSON).not.toHaveBeenCalled(); + }); + + it("restores what toJSON returned", async () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + objectFromDrag(canvas, START, END); + const saved = sketch.toJSON(); + canvas.clear(); + + sketch.fromJSON(saved); + await jest.advanceTimersByTimeAsync(100); + + expect(canvas.getObjects()).toHaveLength(1); + expect(canvas.getObjects()[0].type).toEqual("rect"); + }); + + it("starts the history again with the loaded objects", async () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + const stale = objectFromDrag(canvas, START, END); + + sketch.fromJSON({ objects: [rect(10, 10), rect(50, 50)] }); + await jest.advanceTimersByTimeAsync(100); + expect(canvas.getObjects()).toHaveLength(2); + + // undo takes the loaded objects back one by one, and never brings + // back the one which was replaced + sketch.undo(); + sketch.undo(); + expect(sketch.canUndo()).toBe(false); + sketch.redo(); + sketch.redo(); + expect(canvas.getObjects()).toHaveLength(2); + expect(canvas.getObjects()).not.toContain(stale); + }); + }); + + describe("clear", () => { + it("empties the canvas and the history, and returns what was on it", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + objectFromDrag(canvas, START, END); + objectFromDrag(canvas, START, END); + + const discarded = sketch.clear(); + + expect(discarded.objects).toHaveLength(2); + expect(canvas.getObjects()).toHaveLength(0); + expect(sketch.canUndo()).toBe(false); + expect(sketch.canRedo()).toBe(false); + }); + + it("returns the properties asked for", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + objectFromDrag(canvas, START, END, "rect1"); + + expect(sketch.clear(["id"]).objects).toEqual([ + expect.objectContaining({ id: "rect1" }), + ]); + }); + + it("keeps the background color of the sketch", () => { + const { sketch, canvas } = mount({ backgroundColor: "red" }); + + sketch.clear(); + + expect(canvas.backgroundColor).toEqual("red"); + }); + }); + + describe("selection", () => { + it("tells whether there is a selection, and clears it", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + objectFromDrag(canvas, START, END); + expect(sketch.hasSelection()).toBe(false); + + canvas.setActiveObject(canvas.getObjects()[0]); + expect(sketch.hasSelection()).toBe(true); + + sketch.clearSelection(); + expect(sketch.hasSelection()).toBe(false); + expect(canvas.getObjects()).toHaveLength(1); + }); + + it("removes the selected object, and undo brings it back", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + canvas.renderOnAddRemove = false; + objectFromDrag(canvas, START, END, "rect1"); + objectFromDrag(canvas, START, END, "rect2"); + canvas.setActiveObject(canvas.getObjects()[0]); + + sketch.removeSelected(); + expect(ids(canvas)).toEqual(["rect2"]); + expect(sketch.hasSelection()).toBe(false); + + sketch.undo(); + expect(ids(canvas)).toEqual(["rect2", "rect1"]); + }); + + it("removes the object again on redo", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + canvas.renderOnAddRemove = false; + objectFromDrag(canvas, START, END, "rect1"); + canvas.setActiveObject(canvas.getObjects()[0]); + sketch.removeSelected(); + sketch.undo(); + expect(ids(canvas)).toEqual(["rect1"]); + + sketch.redo(); + + expect(ids(canvas)).toEqual([]); + }); + + it("removes every object of a multiple selection, one undo each", () => { + const onObjectRemoved = jest.fn(); + const { sketch, canvas } = mount({ + tool: Tool.RECTANGLE, + onObjectRemoved, + }); + canvas.renderOnAddRemove = false; + objectFromDrag(canvas, START, END, "rect1"); + objectFromDrag(canvas, END, [80, 90], "rect2"); + objectFromDrag(canvas, START, END, "rect3"); + const [first, second] = canvas.getObjects(); + const left = second.left; + canvas.setActiveObject( + new fabric.ActiveSelection([first, second], { canvas }) + ); + + sketch.removeSelected(); + expect(ids(canvas)).toEqual(["rect3"]); + expect(onObjectRemoved).toHaveBeenCalledTimes(2); + + sketch.undo(); + sketch.undo(); + expect(ids(canvas).sort()).toEqual(["rect1", "rect2", "rect3"]); + // the objects come back where they were, not relative to the selection + expect(second.left).toEqual(left); + }); + + it("does nothing without a selection", () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + objectFromDrag(canvas, START, END); + + expect(() => sketch.removeSelected()).not.toThrow(); + + expect(canvas.getObjects()).toHaveLength(1); + }); + }); + + describe("copy and paste", () => { + it("copies the selected object and pastes it, a bit further", async () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + canvas.renderOnAddRemove = false; + objectFromDrag(canvas, START, END, "rect1"); + objectFromDrag(canvas, START, END, "rect2"); + canvas.setActiveObject(canvas.getObjects()[0]); + expect(canvas.getObjects()).toHaveLength(2); + + sketch.copy(); + sketch.paste(); + await settle(); + + expect(canvas.getObjects()).toHaveLength(3); + const pasted = canvas.getObjects()[2]; + expect([pasted.left, pasted.top]).toEqual([START[0] + 10, START[1] + 10]); + expect(canvas.getActiveObject()).toBe(pasted); + }); + + it("pastes further down on every paste", async () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + canvas.renderOnAddRemove = false; + objectFromDrag(canvas, START, END); + canvas.setActiveObject(canvas.getObjects()[0]); + sketch.copy(); + + sketch.paste(); + sketch.paste(); + await settle(); + + const [, first, second] = canvas.getObjects(); + expect(first.left).toEqual(START[0] + 10); + expect(second.left).toEqual(START[0] + 20); + }); + + it("pastes a multiple selection", async () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + canvas.renderOnAddRemove = false; + objectFromDrag(canvas, START, END); + objectFromDrag(canvas, END, [80, 90]); + canvas.setActiveObject( + new fabric.ActiveSelection(canvas.getObjects(), { canvas }) + ); + + sketch.copy(); + sketch.paste(); + await settle(); + + expect(canvas.getObjects()).toHaveLength(4); + }); + + it("can undo a paste", async () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + canvas.renderOnAddRemove = false; + objectFromDrag(canvas, START, END); + canvas.setActiveObject(canvas.getObjects()[0]); + sketch.copy(); + sketch.paste(); + await settle(); + + sketch.undo(); + + expect(canvas.getObjects()).toHaveLength(1); + }); + + it("does nothing without a selection to copy, or without a copy to paste", async () => { + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + objectFromDrag(canvas, START, END); + + expect(() => { + sketch.paste(); + sketch.copy(); + sketch.paste(); + }).not.toThrow(); + await settle(); + + expect(canvas.getObjects()).toHaveLength(1); + }); + }); + + describe("setBackgroundFromDataUrl", () => { + /** An image loads as soon as its source is set */ + const stubImages = () => + jest.spyOn(window, "Image").mockImplementation(() => { + const img = document.createElement("img"); + img.width = 200; + img.height = 100; + Object.defineProperty(img, "src", { + set: () => img.onload?.(new Event("load")), + }); + return img; + }); + + it("sets the image as the background of the canvas", () => { + stubImages(); + const { sketch, canvas } = mount(); + + sketch.setBackgroundFromDataUrl("data:image/png;base64,00"); + + const image = canvas.backgroundImage as fabric.FabricImage; + expect(image).toBeInstanceOf(fabric.FabricImage); + expect(image.scaleX).toEqual(1); + expect(image.scaleY).toEqual(1); + }); + + it("stretches the image to the canvas", () => { + stubImages(); + const { sketch, canvas } = mount(); + + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + stretched: true, + }); + + const image = canvas.backgroundImage as fabric.FabricImage; + expect(image.scaleX).toEqual(canvas.getWidth() / 200); + expect(image.scaleY).toEqual(canvas.getHeight() / 100); + }); + + it("stretches the image to the width, or the height, only", () => { + stubImages(); + const { sketch, canvas } = mount(); + + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + stretchedX: true, + }); + let image = canvas.backgroundImage as fabric.FabricImage; + expect(image.scaleX).toEqual(canvas.getWidth() / 200); + expect(image.scaleY).toEqual(canvas.getWidth() / 200); + + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + stretchedY: true, + }); + image = canvas.backgroundImage as fabric.FabricImage; + expect(image.scaleX).toEqual(canvas.getHeight() / 100); + expect(image.scaleY).toEqual(canvas.getHeight() / 100); + }); + + it("passes the other options to the image", () => { + stubImages(); + const { sketch, canvas } = mount(); + + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + opacity: 0.5, + }); + + expect((canvas.backgroundImage as fabric.FabricImage).opacity).toEqual(0.5); + }); + + it("sets nothing when the component was unmounted meanwhile", () => { + let load: () => void = () => undefined; + jest.spyOn(window, "Image").mockImplementation(() => { + const img = document.createElement("img"); + Object.defineProperty(img, "src", { + set: () => { + load = () => img.onload?.(new Event("load")); + }, + }); + return img; + }); + const { sketch, canvas, unmount } = mount(); + const setBackgroundImage = jest.fn(); + Object.defineProperty(canvas, "backgroundImage", { + configurable: true, + get: () => undefined, + set: setBackgroundImage, + }); + sketch.setBackgroundFromDataUrl("data:image/png;base64,00"); + + unmount(); + + expect(load).not.toThrow(); + expect(setBackgroundImage).not.toHaveBeenCalled(); + }); + + it("asks for the image anonymously", () => { + const setAttribute = jest.spyOn(HTMLImageElement.prototype, "setAttribute"); + const { sketch } = mount(); + + sketch.setBackgroundFromDataUrl("data:image/png;base64,00"); + + expect(setAttribute).toHaveBeenCalledWith("crossOrigin", "anonymous"); + }); + }); + + describe("addText", () => { + it("adds a text at the center of the canvas", () => { + const { sketch, canvas } = mount(); + canvas.renderOnAddRemove = false; + + sketch.addText("hello", { fontSize: 20 }); + + const [text] = canvas.getObjects() as fabric.IText[]; + expect(text.type).toEqual("i-text"); + expect(text.text).toEqual("hello"); + expect(text.fontSize).toEqual(20); + expect(text.left).toEqual((canvas.getWidth() - (text.width ?? 0)) / 2); + expect(text.top).toEqual((canvas.getHeight() - (text.height ?? 0)) / 2); + }); + + it("adds the text where it is asked to", () => { + const { sketch, canvas } = mount(); + canvas.renderOnAddRemove = false; + + sketch.addText("hello", { left: 11, top: 22 }); + + const [text] = canvas.getObjects(); + expect([text.left, text.top]).toEqual([11, 22]); + }); + + it("does not change the options it is given", () => { + const { sketch, canvas } = mount(); + canvas.renderOnAddRemove = false; + const options = { fontSize: 20 }; + + sketch.addText("hello", options); + + expect(options).toEqual({ fontSize: 20 }); + }); + + it("can be undone", () => { + const { sketch, canvas } = mount(); + canvas.renderOnAddRemove = false; + sketch.addText("hello"); + + sketch.undo(); + + expect(canvas.getObjects()).toHaveLength(0); + }); + }); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index 6b2e5dc4..9c825902 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -1,128 +1,621 @@ import * as React from "react"; -import PropTypes from "prop-types"; import { PureComponent } from "react"; +import * as fabric from "fabric"; +import { + BackgroundOptions, + DataUrlOptions, + ImageOptions, + SketchData, + SketchProperties, + SketchValue, + TextOptions, + Tool, +} from "../../types"; +import { uuid4 } from "../../utils"; +import { CanvasEvent, FabricCanvasEventsManager, SketchEventAware } from "./events"; +import { SketchHistory } from "./history/SketchHistory"; import { autoresize } from "./resize"; -import { fabric } from "fabric"; -import { SketchProperties, SketchState } from "../../types"; +import { FabricCanvasTool } from "./tools"; import { create_tool } from "./tools/factory"; -import { EventCallback, FabricCanvasEventsManager } from "./events"; -import { CanvasEventAware, FabricCanvasTool } from "./tools"; -import { uuid4 } from "../../utils"; -import { UndoHistory } from "./history"; /** - * Sketch Tool based on FabricJS for React Applications + * The properties which always have a value, because SketchField provides + * a default for them */ -class SketchField extends PureComponent { - private canvas!: fabric.Canvas | null; - private canvasEl!: HTMLCanvasElement | null; - private containerEl!: HTMLDivElement | null; - private tool!: FabricCanvasTool; +type DefaultedProps = Required< + Pick< + SketchProperties, + | "tool" + | "lineColor" + | "lineWidth" + | "fillColor" + | "backgroundColor" + | "opacity" + | "undoSteps" + | "widthCorrection" + | "heightCorrection" + | "forceValue" + | "autoresize" + | "onObjectAdded" + | "onObjectModified" + | "onObjectRemoved" + | "onMouseDown" + | "onMouseMove" + | "onMouseUp" + | "onMouseOut" + | "onObjectMoving" + | "onObjectScaling" + | "onObjectRotating" + > +>; + +const defaultProps: DefaultedProps = { + tool: Tool.SELECT, + lineColor: "black", + lineWidth: 10, + fillColor: "transparent", + backgroundColor: "transparent", + opacity: 1.0, + undoSteps: 25, + widthCorrection: 0, + heightCorrection: 0, + forceValue: false, + autoresize: true, + onObjectAdded: () => undefined, + onObjectModified: () => undefined, + onObjectRemoved: () => undefined, + onMouseDown: () => undefined, + onMouseMove: () => undefined, + onMouseUp: () => undefined, + onMouseOut: () => undefined, + onObjectMoving: () => undefined, + onObjectScaling: () => undefined, + onObjectRotating: () => undefined, +}; - constructor(props: SketchProperties) { - super(props); +/** + * The canvas of the sketch. Clearing it (also when a value is loaded) does not + * announce the discarded objects as removed, as it never did: the removal of + * an object is something the user does. + */ +class SketchCanvas extends fabric.Canvas { + clearing = false; + + clear(): void { + this.clearing = true; + try { + super.clear(); + } finally { + this.clearing = false; + } } +} - static defaultProps: SketchProperties = { - tool: "pencil", - lineColor: "black", - lineWidth: 1, - }; +/** + * Sketch Tool based on FabricJS for React Applications + * + * The methods of the component (undo, redo, toJSON, ...) are available + * through a ref, from the moment the component is mounted. + */ +class SketchField extends PureComponent { + static defaultProps = defaultProps; - state = { - action: true, - }; + private canvas!: SketchCanvas; + private tool!: FabricCanvasTool; + private history!: SketchHistory; + private mounted = false; + private clipboard: Promise | null = null; + /** The pastes, which run one after the other as cloning is asynchronous */ + private pasting: Promise = Promise.resolve(); + private readonly timers = new Set(); + private readonly canvasEl = React.createRef(); + private readonly containerEl = React.createRef(); + private readonly canvasId = uuid4(); /** * Listener function for the resize of canvas and objects - * @param event the UIEvent of resize event */ - private _resize_listener = (event: UIEvent): void => { - autoresize(this.canvas, this.containerEl); + private readonly _resize_listener = (): void => { + if (this.props.autoresize) this.resize(); + }; + + /** + * Run the callback after the delay, unless the component is unmounted + * in the meantime + */ + private readonly schedule = (callback: () => void, delay: number): void => { + const id = window.setTimeout(() => { + this.timers.delete(id); + callback(); + }, delay); + this.timers.add(id); }; + /** + * Whether the canvas is the one of the mounted component. It is not when a + * callback of the canvas runs after the component was unmounted. + */ + private isCurrent(canvas: SketchCanvas): boolean { + return this.mounted && this.canvas === canvas; + } + /** * Bind Canvas events with Component */ - private _bind_canvas_events(eventAware: CanvasEventAware): void { - // Helper declaration for all canvas events - const on = (name: string, callback: EventCallback): void => { - this.canvas?.on(name, (event: fabric.IEvent) => { - callback({ - event: event, - tool: this.tool, - props: this.props, - state: this.state, - }); - }); + private _bind_canvas_events(eventAware: SketchEventAware): void { + const events: Record = { + "mouse:up": "onMouseUp", + "mouse:down": "onMouseDown", + "mouse:move": "onMouseMove", + "mouse:out": "onMouseOut", + "object:added": "onObjectAdded", + "object:moved": "onObjectMoved", + "object:moving": "onObjectMoving", + "object:modified": "onObjectModified", + "object:removed": "onObjectRemoved", + "object:scaling": "onObjectScaling", + "object:rotating": "onObjectRotating", + "text:event:changed": "onTextChanged", + "text:editing:exited": "onTextEditingExited", + "text:editing:entered": "onTextEditingEntered", + "text:selection:changed": "onTextSelectionChanged", }; - // Fabric Events - on("mouse:up", eventAware.onMouseUp); - on("mouse:down", eventAware.onMouseDown); - on("mouse:move", eventAware.onMouseMove); - on("mouse:out", eventAware.onMouseOut); - on("object:moved", eventAware.onObjectMoved); - on("object:moving", eventAware.onObjectMoving); - on("object:modified", eventAware.onObjectModified); - on("object:removed", eventAware.onObjectRemoved); - on("object:scaling", eventAware.onObjectScaling); - on("object:rotating", eventAware.onObjectRotating); - on("text:event:changed", eventAware.onTextChanged); - on("text:editing:exited", eventAware.onTextEditingExited); - on("text:editing:entered", eventAware.onTextEditingEntered); - on("text:selection:changed", eventAware.onTextSelectionChanged); + // The canvas types its events by name, which is not known here + const canvas = this.canvas as unknown as fabric.Observable< + Record + >; + Object.entries(events).forEach(([name, handler]) => { + canvas.on(name, (event) => { + // Fabric 7 announces each object of a cleared canvas, fabric 4 did not + if (name === "object:removed" && this.canvas.clearing) return; + eventAware[handler]({ event, tool: this.tool, props: this.props }); + }); + }); } - componentDidMount = (): void => { - this.canvas = new fabric.Canvas(this.canvasEl); - this.tool = create_tool(this.canvas, this.props); - this.containerEl && autoresize(this.canvas, this.containerEl); - window && window.addEventListener("resize", this._resize_listener); + componentDidMount(): void { + const { backgroundColor, undoSteps, value, defaultValue } = this.props; + + this.mounted = true; + // The defaults of fabric 7 that differ from the ones of fabric 4 are set back: + // right and middle clicks do not draw, there is no context menu override, + // and a selected object is drawn on top of the others only while selected + this.canvas = new SketchCanvas(this.canvasEl.current as HTMLCanvasElement, { + fireRightClick: false, + fireMiddleClick: false, + stopContextMenu: false, + preserveObjectStacking: false, + }); + this.selectTool(); + this.setBackground(backgroundColor); + + // Initialize History, with maximum number of undo steps + this.history = new SketchHistory(this.canvas, undoSteps); // Bind Events to Actions - this._bind_canvas_events(new FabricCanvasEventsManager(this.canvas, this)); - this._bind_canvas_events(new UndoHistory(10)); - }; - - componentDidUpdate( - prevProps: Readonly, - prevState: Readonly, - snapshot?: any - ): void { - const props: Readonly = this.props; - const state: Readonly = this.state; - - if (prevProps.tool !== props.tool) { - if (this.canvas) { - this.tool = create_tool(this.canvas, props); - } + this._bind_canvas_events( + new FabricCanvasEventsManager(this.history, this.schedule) + ); + + window.addEventListener("resize", this._resize_listener); + this.resize(); + + // initialize canvas with controlled value if exists + this.fromJSON(value || defaultValue); + } + + componentDidUpdate(prevProps: Readonly): void { + const props = this.props; + + if (props.width !== prevProps.width || props.height !== prevProps.height) { + this.resize(); + } + + if (props.tool !== prevProps.tool) { + this.selectTool(); + } else if ( + props.lineColor !== prevProps.lineColor || + props.lineWidth !== prevProps.lineWidth || + props.fillColor !== prevProps.fillColor + ) { + this.configureTool(); + } + + if (props.backgroundColor !== prevProps.backgroundColor) { + this.setBackground(props.backgroundColor); + } + + if (props.value !== prevProps.value || (props.value && props.forceValue)) { + this.fromJSON(props.value); + } + } + + componentWillUnmount(): void { + this.mounted = false; + window.removeEventListener("resize", this._resize_listener); + this.timers.forEach((id) => window.clearTimeout(id)); + this.timers.clear(); + // The canvas element is released at once, so it can be used again (React + // strict mode mounts twice). The rest is freed once the pending render is done. + void this.canvas.dispose(); + } + + /** + * Run the function with the properties of the component. The Highlighter + * throws for a line color it cannot convert (for example "rgb(0, 0, 0)" or + * "transparent"), so in that case the function runs again with the default + * line color instead of breaking the component. + */ + private withToolProps(run: (props: SketchProperties) => void): void { + // The tool used to default to null, which meant no tool at all: the + // objects can be moved, nothing is drawn. That is what "select" does. + const props = { ...this.props, tool: this.props.tool ?? Tool.SELECT }; + try { + run(props); + } catch { + run({ ...props, lineColor: undefined }); } } - componentWillUnmount = (): void => { - window.removeEventListener("resize", this._resize_listener, true); + /** + * Use the tool of the properties, from now on + */ + private selectTool(): void { + this.withToolProps((props) => { + this.tool = create_tool(this.canvas, props); + }); + } + + /** + * Apply the line and fill properties to the tool in use + */ + private configureTool(): void { + this.withToolProps((props) => this.tool.configureCanvas(props)); + } + + /** + * Fit the canvas to its container + */ + private resize(): void { + const container = this.containerEl.current; + if (!container) return; + const { widthCorrection, heightCorrection } = this.props; + autoresize(this.canvas, container, widthCorrection, heightCorrection); + } + + /** + * Sets the background color for this sketch + * + * @param color in rgba or hex format + */ + private setBackground(color?: string): void { + if (!color) return; + this.canvas.backgroundColor = color; + this.canvas.requestRenderAll(); + } + + /** + * Enable touch Scrolling on Canvas + */ + enableTouchScroll = (): void => { + this.setTouchScrolling(true); }; - render = (): JSX.Element => { + /** + * Disable touch Scrolling on Canvas + */ + disableTouchScroll = (): void => { + this.setTouchScrolling(false); + }; + + /** + * Switch the touch scrolling of the canvas. fabric reads allowTouchScrolling + * only when it creates the canvas elements, so the touch-action style that + * lets the browser scroll is set here too, with the values fabric uses. + */ + private setTouchScrolling(allow: boolean): void { + const canvas = this.canvas; + canvas.allowTouchScrolling = allow; + const touchAction = allow ? "manipulation" : "none"; + canvas.upperCanvasEl.style.touchAction = touchAction; + canvas.lowerCanvasEl.style.touchAction = touchAction; + } + + /** + * Add an image as object to the canvas + * + * @param dataUrl the image url or Data Url + * @param options object to pass and change some options when loading image, the format of the object is: + * + * { + * left: , + * top: , + * scale: + * } + */ + addImg = (dataUrl: string, options: ImageOptions = {}): void => { + const canvas = this.canvas; + fabric.FabricImage.fromURL(dataUrl, {}, { originX: "left", originY: "top" }) + .then((image) => { + if (!this.isCurrent(canvas)) return; + const { left, top, scale } = { + left: Math.random() * (canvas.getWidth() - (image.width ?? 0) * 0.5), + top: Math.random() * (canvas.getHeight() - (image.height ?? 0) * 0.5), + scale: 0.5, + ...options, + }; + image.scale(scale); + image.set({ left, top }); + canvas.add(image); + }) + // An image which cannot be loaded is not added + .catch(() => undefined); + }; + + /** + * Zoom the drawing by the factor specified + * + * The zoom factor is a percentage with regards the original, for example if factor is set to 2 + * it will double the size whereas if it is set to 0.5 it will half the size + * + * @param factor the zoom factor + */ + zoom = (factor: number): void => { + this.canvas.getObjects().forEach((obj) => { + obj.scaleX = (obj.scaleX ?? 1) * factor; + obj.scaleY = (obj.scaleY ?? 1) * factor; + obj.left = (obj.left ?? 0) * factor; + obj.top = (obj.top ?? 0) * factor; + obj.setCoords(); + }); + this.canvas.renderAll(); + this.canvas.calcOffset(); + }; + + /** + * Perform an undo operation on canvas, if it cannot undo it will leave the canvas intact + */ + undo = (): void => { + if (this.history.undo()) this.props.onChange?.(); + }; + + /** + * Perform a redo operation on canvas, if it cannot redo it will leave the canvas intact + */ + redo = (): void => { + if (this.history.redo()) this.props.onChange?.(); + }; + + /** + * Delegation method to check if we can perform an undo Operation, useful to disable/enable possible buttons + * + * @returns true if we can undo otherwise false + */ + canUndo = (): boolean => this.history.canUndo(); + + /** + * Delegation method to check if we can perform a redo Operation, useful to disable/enable possible buttons + * + * @returns true if we can redo otherwise false + */ + canRedo = (): boolean => this.history.canRedo(); + + /** + * Exports canvas element to a dataurl image. Note that when multiplier is used, cropping is scaled appropriately + * + * Available Options are + * + * + * + * + * + * + * + * + * + * + * + *
NameTypeArgumentDefaultDescription
format String the imageFormat property, then pngThe format of the output image. Either "jpeg" or "png"
qualityNumber1Quality level (0..1). Only used for jpeg.
multiplierNumber1Multiplier to scale by
leftNumberCropping left offset. Introduced in v1.2.14
topNumberCropping top offset. Introduced in v1.2.14
widthNumberCropping width. Introduced in v1.2.14
heightNumberCropping height. Introduced in v1.2.14
+ * + * @returns URL containing a representation of the object in the format specified by options.format + */ + toDataURL = (options?: DataUrlOptions): string => + this.canvas.toDataURL({ + format: this.props.imageFormat, + ...options, + } as fabric.TDataUrlOptions); + + /** + * Returns JSON representation of canvas + * + * @param propertiesToInclude Array Any properties that you might want to additionally include in the output + * @returns the canvas as a JSON ready object + */ + toJSON = (propertiesToInclude?: string[]): SketchData => + this.canvas.toObject(propertiesToInclude); + + /** + * Populates canvas with data from the specified JSON. + * + * The canvas is populated a moment later, not right away. The history is + * reset, as the objects it knows are replaced. + * + * The JSON is what `toJSON` returns. The JSON of 0.5.x (fabric 4) is loaded too. + * + * @param json JSON string or object + */ + fromJSON = (json?: SketchValue): void => { + if (!json) return; + const canvas = this.canvas; + this.schedule(() => { + this.history.clear(); + void canvas.loadFromJSON(json).then(() => { + if (!this.isCurrent(canvas)) return; + // Loading a value drops the background color unless the value has one + if (!canvas.backgroundColor) this.setBackground(this.props.backgroundColor); + // The loaded objects follow the tool in use, for example they can't be + // selected when drawing + this.configureTool(); + canvas.renderAll(); + this.props.onChange?.(); + }); + }, 100); + }; + + /** + * Clear the content of the canvas, this will also clear history but will return the canvas content as JSON to be + * used as needed in order to undo the clear if possible + * + * @param propertiesToInclude Array Any properties that you might want to additionally include in the output + * @returns JSON object of the canvas just cleared + */ + clear = (propertiesToInclude?: string[]): SketchData => { + const discarded = this.toJSON(propertiesToInclude); + this.canvas.clear(); + this.history.clear(); + // Clearing a canvas drops its background color + this.setBackground(this.props.backgroundColor); + return discarded; + }; + + hasSelection = (): boolean => !!this.canvas.getActiveObject(); + + clearSelection = (): void => { + this.canvas.discardActiveObject(); + this.canvas.requestRenderAll(); + }; + + /** + * Remove selected object from the canvas + */ + removeSelected = (): void => { + const canvas = this.canvas; + const activeObj = canvas.getActiveObject(); + if (!activeObj) return; + const selected = + activeObj instanceof fabric.ActiveSelection + ? activeObj.getObjects() + : [activeObj]; + // Leaving the selection first gives the objects back their own position + canvas.discardActiveObject(); + this.history.remove(selected); + canvas.requestRenderAll(); + }; + + /** + * Copy the selected object, nothing happens when there is no selection + */ + copy = (): void => { + // The copy is made right away, so it is the object as it is now + const copy = this.canvas.getActiveObject()?.clone(); + if (copy) this.clipboard = copy; + }; + + /** + * Paste the copied object, a bit further down and to the right of the + * previous one. Nothing happens when nothing was copied. + */ + paste = (): void => { + const canvas = this.canvas; + const clipboard = this.clipboard; + if (!clipboard) return; + this.pasting = this.pasting + .then(async () => { + const copied = await clipboard; + // clone again, so you can do multiple copies. + const clonedObj = await copied.clone(); + if (!this.isCurrent(canvas)) return; + canvas.discardActiveObject(); + clonedObj.set({ + left: (clonedObj.left ?? 0) + 10, + top: (clonedObj.top ?? 0) + 10, + evented: true, + }); + if (clonedObj instanceof fabric.ActiveSelection) { + // active selection needs a reference to the canvas. + clonedObj.canvas = canvas; + clonedObj.forEachObject((obj) => canvas.add(obj)); + clonedObj.setCoords(); + } else { + canvas.add(clonedObj); + } + copied.top = (copied.top ?? 0) + 10; + copied.left = (copied.left ?? 0) + 10; + canvas.setActiveObject(clonedObj); + canvas.requestRenderAll(); + }) + // A paste which fails must not stop the next ones + .catch(() => undefined); + }; + + /** + * Sets the background from the dataUrl given + * + * @param dataUrl the dataUrl to be used as a background + * @param options stretched, stretchedX and stretchedY scale the image to the canvas, the other + * options are options of the image + */ + setBackgroundFromDataUrl = ( + dataUrl: string, + options: BackgroundOptions = {} + ): void => { + const canvas = this.canvas; + const img = new Image(); + img.setAttribute("crossOrigin", "anonymous"); + const { stretched, stretchedX, stretchedY, ...imageOptions } = options; + img.onload = () => { + if (!this.isCurrent(canvas)) return; + const imgObj = new fabric.FabricImage(img, { + originX: "left", + originY: "top", + }); + if (stretched || stretchedX) imgObj.scaleToWidth(canvas.getWidth()); + if (stretched || stretchedY) imgObj.scaleToHeight(canvas.getHeight()); + imgObj.set(imageOptions); + canvas.backgroundImage = imgObj; + canvas.requestRenderAll(); + }; + img.src = dataUrl; + }; + + /** + * Add a text to the center of the canvas + * + * @param text the text + * @param options options of the text, for example fontSize, fill, left and top. + * The text is centered unless left or top are given. + */ + addText = (text: string, options: TextOptions = {}): void => { + const canvas = this.canvas; + const iText = new fabric.IText(text, { + originX: "left", + originY: "top", + ...options, + } as Partial); + if (options.left === undefined) { + iText.set("left", (canvas.getWidth() - (iText.width ?? 0)) * 0.5); + } + if (options.top === undefined) { + iText.set("top", (canvas.getHeight() - (iText.height ?? 0)) * 0.5); + } + canvas.add(iText); + }; + + render(): React.ReactElement { const { className, style, width, height } = this.props; - const canvasDivStyle = Object.assign( - {}, - style ? style : {}, - width ? { width: width } : {}, - height ? { height: height } : { height: 512 } - ); + const canvasDivStyle = { + ...style, + ...(width ? { width } : {}), + height: height || 512, + }; return ( -
(this.containerEl = elem)}> - (this.canvasEl = elem)}> +
+ Sorry, Canvas HTML5 element is not supported by your browser :(
); - }; + } } export default SketchField; diff --git a/packages/react-sketch/src/components/SketchField/events.ts b/packages/react-sketch/src/components/SketchField/events.ts index 47a013a2..b29e4aaa 100644 --- a/packages/react-sketch/src/components/SketchField/events.ts +++ b/packages/react-sketch/src/components/SketchField/events.ts @@ -1,72 +1,126 @@ -import { fabric } from "fabric"; -import { PureComponent } from "react"; -import { SketchProperties, SketchState } from "../../types"; -import { AbstractEventAware, FabricCanvasTool } from "./tools"; +import type { FabricObject, TPointerEvent } from "fabric"; +import { SketchEvent, SketchProperties } from "../../types"; +import { SketchHistory } from "./history/SketchHistory"; +import { AbstractEventAware, CanvasEventAware, FabricCanvasTool } from "./tools"; + +/** + * The part of a canvas event the sketch looks at. Mouse events have both + * properties, object events have a target, and some have the DOM event too. + */ +export interface CanvasEvent { + e?: TPointerEvent; + target?: FabricObject; +} export interface EventInput { - event: fabric.IEvent; + event: CanvasEvent; tool: FabricCanvasTool; props: SketchProperties; - state: SketchState; } -export interface EventCallback { - (input: EventInput): void; + +/** + * The canvas events a tool can react to, plus the ones of the sketch itself + */ +export interface SketchEventAware extends CanvasEventAware { + // Object management + onObjectAdded(input: EventInput): void; } +/** + * The event as given to the callbacks of the component. The canvas event has + * the same shape, but its types come from the canvas library, which the + * component does not expose. + */ +const sketchEvent = (event: CanvasEvent): SketchEvent => + event as unknown as SketchEvent; + /** * Canvas Events Manager Class + * + * It hands the canvas events to the selected tool, to the history of the + * sketch and to the callbacks given as properties of the component. */ -export class FabricCanvasEventsManager extends AbstractEventAware { - private canvas: fabric.Canvas; - private sketch: PureComponent; +export class FabricCanvasEventsManager + extends AbstractEventAware + implements SketchEventAware +{ + private readonly history: SketchHistory; + private readonly schedule: (callback: () => void, delay: number) => void; + /** + * @param history the history of the sketch + * @param schedule runs the callback after the delay (in milliseconds) + */ constructor( - canvas: fabric.Canvas, - sketch: PureComponent + history: SketchHistory, + schedule: (callback: () => void, delay: number) => void ) { super(); - this.canvas = canvas; - this.sketch = sketch; + this.history = history; + this.schedule = schedule; } /** - * Action when an object is added to the canvas + * Action when an object is added to the canvas. The objects which undo and + * redo add back are not new, so they are not recorded nor announced. */ - public onObjectAdded({ event, tool, props, state }: EventInput): void { - // if (!input.state.action) { - // this.setState({ action: true }); - // return; - // } - // const obj = event.target; - // obj.__version = 1; - // record current object state as json and save as originalState - // const objState = obj.toJSON(); - // obj.__originalState = objState; - // const state = JSON.stringify(objState); - // object, previous state, current state - // this._history.keep([obj, state, state]); - // onObjectAdded(e); + public onObjectAdded({ event, props }: EventInput): void { + if (this.history.replaying) return; + if (event.target) this.history.onObjectAdded(event.target); + props.onObjectAdded?.(sketchEvent(event)); } - public onObjectMoved({ event, tool, props, state }: EventInput): void { - console.log("foo"); + + public onObjectModified({ event, props }: EventInput): void { + if (event.target) this.history.onObjectModified(event.target); + props.onObjectModified?.(sketchEvent(event)); } - public onMouseUp({ event, tool, props, state }: EventInput): void { - tool.doMouseUp(event); + public onObjectRemoved({ event, props }: EventInput): void { + props.onObjectRemoved?.(sketchEvent(event)); + } + + public onObjectMoving({ event, props }: EventInput): void { + props.onObjectMoving?.(sketchEvent(event)); + } + + public onObjectScaling({ event, props }: EventInput): void { + props.onObjectScaling?.(sketchEvent(event)); + } + + public onObjectRotating({ event, props }: EventInput): void { + props.onObjectRotating?.(sketchEvent(event)); } - public onMouseDown({ event, tool, props, state }: EventInput): void { - console.log("down"); + + public onMouseDown({ event, tool, props }: EventInput): void { tool.doMouseDown(event); - // const { onMouseDown } = this.props; - // this._selectedTool.doMouseDown(e); - // onMouseDown(e); + props.onMouseDown?.(sketchEvent(event)); } - public onMouseMove({ event, tool, props, state }: EventInput): void { - console.log("move"); + + public onMouseMove({ event, tool, props }: EventInput): void { tool.doMouseMove(event); + props.onMouseMove?.(sketchEvent(event)); } - public onMouseOut({ event, tool, props, state }: EventInput): void { - console.log("out"); + + public onMouseOut({ event, tool, props }: EventInput): void { tool.doMouseOut(event); + this.notifyChange({ event, tool, props }); + props.onMouseOut?.(sketchEvent(event)); + } + + public onMouseUp({ event, tool, props }: EventInput): void { + tool.doMouseUp(event); + // The drawing is finished, so the state of the new object is final + this.history.onMouseUp(); + this.notifyChange({ event, tool, props }); + props.onMouseUp?.(sketchEvent(event)); + } + + /** + * Tell the application that the sketch may have changed, shortly after + * the mouse was released or left the canvas. + */ + private notifyChange({ event, props }: EventInput): void { + const { onChange } = props; + if (onChange) this.schedule(() => onChange(event.e), 10); } } diff --git a/packages/react-sketch/src/components/SketchField/history/History.spec.ts b/packages/react-sketch/src/components/SketchField/history/History.spec.ts index 69d115ba..27102bd6 100644 --- a/packages/react-sketch/src/components/SketchField/history/History.spec.ts +++ b/packages/react-sketch/src/components/SketchField/history/History.spec.ts @@ -1,23 +1,15 @@ -// @ts-nocheck -/* eslint no-console: 0 */ -/* eslint-env node, mocha */ - -import History from "./index"; - -describe("History", () => { - it("Loads Normally", () => { - require("./index"); - }); +import { UndoHistory } from "./index"; +describe("UndoHistory", () => { it("Undo limit is set", () => { - const instance = new History(); - const instanceWithCustomUndoSteps = new History(15); + const instance = new UndoHistory(); + const instanceWithCustomUndoSteps = new UndoHistory(15); expect(instance.getUndoLimit()).toEqual(10); expect(instanceWithCustomUndoSteps.getUndoLimit()).toEqual(15); }); it("Informs if can undo", () => { - const instance = new History(); + const instance = new UndoHistory(); expect(instance.canUndo()).toBeFalsy(); instance.keep("1"); expect(instance.canUndo()).toBeTruthy(); @@ -26,7 +18,7 @@ describe("History", () => { }); it("Can undo/redo object", () => { - const instance = new History(15, true); + const instance = new UndoHistory(15); instance.keep("1"); instance.keep("2"); expect(instance.canUndo()).toBeTruthy(); @@ -39,7 +31,7 @@ describe("History", () => { }); it("Multiple undo/redo of objects", () => { - const instance = new History(); + const instance = new UndoHistory(); instance.keep("1"); instance.keep("2"); instance.keep("3"); @@ -60,7 +52,7 @@ describe("History", () => { }); it("Redo is reset after a keep of a new object", () => { - const instance = new History(); + const instance = new UndoHistory(); instance.keep("1"); instance.keep("2"); instance.keep("3"); @@ -74,7 +66,7 @@ describe("History", () => { }); it("Can clear history", () => { - const instance = new History(); + const instance = new UndoHistory(); instance.keep("1"); instance.keep("2"); instance.keep("3"); diff --git a/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts b/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts new file mode 100644 index 00000000..1a3941af --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts @@ -0,0 +1,164 @@ +import * as fabric from "fabric"; +import { UndoHistory } from "./index"; + +/** + * One undoable action of the sketch + */ +type HistoryEntry = + | { action: "add"; object: fabric.FabricObject } + | { action: "remove"; object: fabric.FabricObject } + | { action: "modify"; object: fabric.FabricObject; before: string; after: string }; + +/** + * Properties of the serialized state which are not restored: the type cannot + * be set, and the others hold nested objects which the canvas builds itself + */ +const NOT_RESTORED = ["type", "version", "objects", "layoutManager", "clipPath"]; + +/** State of an object, as JSON string */ +const stateOf = (object: fabric.FabricObject): string => + JSON.stringify(object.toJSON()); + +/** + * Records what happens to the objects of a canvas (added, modified, removed) + * and applies undo and redo on them. + */ +export class SketchHistory { + private readonly canvas: fabric.Canvas; + private readonly entries: UndoHistory; + /** Last known state of each object, it is the "before" of its next modification */ + private readonly snapshots = new WeakMap(); + /** The object added since the last mouse up, whose final state is not known yet */ + private pending: fabric.FabricObject | null = null; + private applying = false; + + /** + * @param canvas the canvas to undo/redo on + * @param undoSteps number of steps to maintain + */ + constructor(canvas: fabric.Canvas, undoSteps: number) { + this.canvas = canvas; + this.entries = new UndoHistory(undoSteps); + } + + /** + * True while undo or redo add or remove an object. The additions made then + * are not new actions, so they are neither recorded nor announced. + */ + get replaying(): boolean { + return this.applying; + } + + /** + * Record an object added to the canvas + */ + onObjectAdded(object: fabric.FabricObject): void { + this.snapshots.set(object, stateOf(object)); + this.pending = object; + this.entries.keep({ action: "add", object }); + } + + /** + * Record an object modified on the canvas. Objects which were not added + * through {@link onObjectAdded} (for example a multiple selection) are ignored. + */ + onObjectModified(object: fabric.FabricObject): void { + const before = this.snapshots.get(object); + if (before === undefined) return; + const after = stateOf(object); + this.snapshots.set(object, after); + this.entries.keep({ action: "modify", object, before, after }); + } + + /** + * An object is added while the mouse is down (it is resized by the move of + * the mouse), so its state is only final on mouse up. + */ + onMouseUp(): void { + if (this.pending) { + this.snapshots.set(this.pending, stateOf(this.pending)); + this.pending = null; + } + } + + /** + * Record the removal of the objects and remove them from the canvas + */ + remove(objects: fabric.FabricObject[]): void { + objects.forEach((object) => { + this.entries.keep({ action: "remove", object }); + this.canvas.remove(object); + }); + } + + /** + * Undo the last action + * + * @returns false when there was nothing to undo + */ + undo(): boolean { + const entry = this.entries.getCurrent(); + if (!entry) return false; + this.entries.undo(); + this.apply(entry, false); + return true; + } + + /** + * Redo the last undone action + * + * @returns false when there was nothing to redo + */ + redo(): boolean { + const entry = this.entries.redo(); + if (!entry) return false; + this.apply(entry, true); + return true; + } + + canUndo(): boolean { + return this.entries.canUndo(); + } + + canRedo(): boolean { + return this.entries.canRedo(); + } + + /** + * Forget everything recorded so far + */ + clear(): void { + this.entries.clear(); + this.pending = null; + } + + private apply(entry: HistoryEntry, forward: boolean): void { + const { object } = entry; + this.applying = true; + try { + switch (entry.action) { + case "add": + case "remove": + // redo of an add, and undo of a remove, put the object back + if ((entry.action === "add") === forward) { + this.canvas.add(object); + } else { + this.canvas.remove(object); + } + break; + case "modify": { + const state = forward ? entry.after : entry.before; + const props = JSON.parse(state); + NOT_RESTORED.forEach((key) => delete props[key]); + object.set(props); + object.setCoords(); + this.snapshots.set(object, state); + break; + } + } + this.canvas.renderAll(); + } finally { + this.applying = false; + } + } +} diff --git a/packages/react-sketch/src/components/SketchField/history/index.ts b/packages/react-sketch/src/components/SketchField/history/index.ts index db1f3188..1b819e59 100644 --- a/packages/react-sketch/src/components/SketchField/history/index.ts +++ b/packages/react-sketch/src/components/SketchField/history/index.ts @@ -1,36 +1,32 @@ -// @ts-nocheck - -import { EventInput } from "../events"; -import { AbstractEventAware, CanvasEventAware } from "../tools"; - /** - * Maintains the history of an object + * Maintains the history of an object: the undo and redo stacks, limited to + * the given number of steps */ -export class UndoHistory extends AbstractEventAware { - constructor(undoLimit = 10, debug = false) { - super(); +export class UndoHistory { + private readonly undoLimit: number; + private undoList: T[] = []; + private redoList: T[] = []; + private current: T | null = null; + + constructor(undoLimit = 10) { this.undoLimit = undoLimit; - this.undoList = []; - this.redoList = []; - this.current = null; - this.debug = debug; } /** * Get the limit of undo/redo actions * - * @returns {number|*} the undo limit, as it is configured when constructing the history instance + * @returns the undo limit, as it is configured when constructing the history instance */ - getUndoLimit() { + getUndoLimit(): number { return this.undoLimit; } /** * Get Current state * - * @returns {null|*} + * @returns the last kept object, or null when the history is empty */ - getCurrent() { + getCurrent(): T | null { return this.current; } @@ -39,21 +35,17 @@ export class UndoHistory extends AbstractEventAware { * * This method will set the object as current value and will push the previous "current" object to the undo history * - * @param obj + * @param obj the object to keep */ - keep(obj) { - try { - this.redoList = []; - if (this.current) { - this.undoList.push(this.current); - } - if (this.undoList.length > this.undoLimit) { - this.undoList.shift(); - } - this.current = obj; - } finally { - this.print(); + keep(obj: T): void { + this.redoList = []; + if (this.current !== null) { + this.undoList.push(this.current); + } + if (this.undoList.length > this.undoLimit) { + this.undoList.shift(); } + this.current = obj; } /** @@ -61,23 +53,19 @@ export class UndoHistory extends AbstractEventAware { * * @returns the new current value after the undo operation, else null if no undo operation was possible */ - undo() { - try { - if (this.current) { - this.redoList.push(this.current); - if (this.redoList.length > this.undoLimit) { - this.redoList.shift(); - } - if (this.undoList.length === 0) this.current = null; + undo(): T | null { + if (this.current !== null) { + this.redoList.push(this.current); + if (this.redoList.length > this.undoLimit) { + this.redoList.shift(); } - if (this.undoList.length > 0) { - this.current = this.undoList.pop(); - return this.current; - } - return null; - } finally { - this.print(); + if (this.undoList.length === 0) this.current = null; + } + if (this.undoList.length > 0) { + this.current = this.undoList.pop() as T; + return this.current; } + return null; } /** @@ -85,59 +73,35 @@ export class UndoHistory extends AbstractEventAware { * * @returns the new current value after the redo operation, or null if no redo operation was possible */ - redo() { - try { - if (this.redoList.length > 0) { - if (this.current) this.undoList.push(this.current); - this.current = this.redoList.pop(); - return this.current; - } - return null; - } finally { - this.print(); + redo(): T | null { + if (this.redoList.length > 0) { + if (this.current !== null) this.undoList.push(this.current); + this.current = this.redoList.pop() as T; + return this.current; } + return null; } /** * Checks whether we can perform a redo operation - * - * @returns {boolean} */ - canRedo() { + canRedo(): boolean { return this.redoList.length > 0; } /** * Checks whether we can perform an undo operation - * - * @returns {boolean} */ - canUndo() { + canUndo(): boolean { return this.undoList.length > 0 || this.current !== null; } /** * Clears the history maintained, can be undone */ - clear() { + clear(): void { this.undoList = []; this.redoList = []; this.current = null; - this.print(); - } - - print() { - if (this.debug) { - /* eslint-disable no-console */ - console.log( - this.undoList, - " -> " + this.current + " <- ", - this.redoList.slice(0).reverse() - ); - } - } - - public onObjectAdded({ event, tool, props, state }: EventInput): void { - console.log("history added"); } } diff --git a/packages/react-sketch/src/components/SketchField/resize.ts b/packages/react-sketch/src/components/SketchField/resize.ts index a561c899..bd18905d 100644 --- a/packages/react-sketch/src/components/SketchField/resize.ts +++ b/packages/react-sketch/src/components/SketchField/resize.ts @@ -1,49 +1,47 @@ -// @ts-nocheck +import * as fabric from "fabric"; /** - * Track the resize of the window and update our state + * Fit the canvas to the size of its container, and scale the objects of the + * canvas by the same factors, so the drawing keeps its proportions. + * + * Nothing happens when the container has no size to fit to (it is hidden, or + * there is no layout), because the objects would collapse. * * @param canvas the fabric Canvas Instance - * @param container the HTMLDivElement we want to follow the resize with + * @param container the HTMLElement we want to follow the size of + * @param widthCorrection pixels subtracted from the width of the container + * @param heightCorrection pixels subtracted from the height of the container */ -import { fabric } from "fabric"; - export const autoresize = ( - canvas: fabric.Canvas | null, - container: HTMLDivElement | null + canvas: fabric.Canvas, + container: HTMLElement, + widthCorrection = 0, + heightCorrection = 0 ): void => { - const { offsetWidth, clientHeight } = container; + const width = container.offsetWidth - widthCorrection; + const height = container.clientHeight - heightCorrection; const prevWidth = canvas.getWidth(); const prevHeight = canvas.getHeight(); - const wfactor = parseFloat((offsetWidth / prevWidth).toFixed(2)); - const hfactor = parseFloat((clientHeight / prevHeight).toFixed(2)); - canvas.setWidth(offsetWidth); - canvas.setHeight(clientHeight); - if ("backgroundImage" in canvas) { - // Need to scale background images as well - const bi = canvas.backgroundImage; - if (bi instanceof fabric.Image) { - bi.width = bi.width * wfactor; - bi.height = bi.height * hfactor; - } + if (width <= 0 || height <= 0 || !prevWidth || !prevHeight) return; + + const wfactor = parseFloat((width / prevWidth).toFixed(2)); + const hfactor = parseFloat((height / prevHeight).toFixed(2)); + canvas.setDimensions({ width, height }); + + // Need to scale background images as well + const bi = canvas.backgroundImage; + if (bi instanceof fabric.FabricImage) { + bi.width = (bi.width ?? 0) * wfactor; + bi.height = (bi.height ?? 0) * hfactor; } - const objects = canvas.getObjects(); - for (const i in objects) { - const obj = objects[i]; - const scaleX = obj.scaleX; - const scaleY = obj.scaleY; - const left = obj.left; - const top = obj.top; - const tempScaleX = scaleX * wfactor; - const tempScaleY = scaleY * hfactor; - const tempLeft = left * wfactor; - const tempTop = top * hfactor; - obj.scaleX = tempScaleX; - obj.scaleY = tempScaleY; - obj.left = tempLeft; - obj.top = tempTop; + + canvas.getObjects().forEach((obj) => { + obj.scaleX = (obj.scaleX ?? 1) * wfactor; + obj.scaleY = (obj.scaleY ?? 1) * hfactor; + obj.left = (obj.left ?? 0) * wfactor; + obj.top = (obj.top ?? 0) * hfactor; obj.setCoords(); - } + }); canvas.renderAll(); canvas.calcOffset(); }; diff --git a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts new file mode 100644 index 00000000..418691fa --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts @@ -0,0 +1,102 @@ +import * as fabric from "fabric"; +import { CanvasEvent } from "../../events"; +import { FabricCanvasTool } from "../index"; +import { SketchProperties, Tool } from "../../../../types"; + +export class Arrow extends FabricCanvasTool { + private width: number; + private color?: string; + private isDown: boolean; + private startX: number; + private startY: number; + private line: fabric.Line | null; + private head: fabric.Triangle | null; + + constructor(canvas: fabric.Canvas) { + super(canvas); + this.width = 1; + this.isDown = false; + this.startX = 0; + this.startY = 0; + this.line = null; + this.head = null; + } + + configureCanvas(props: SketchProperties): void { + this.setInteractive(false); + this.width = props.lineWidth ? props.lineWidth : 1; + this.color = props.lineColor ? props.lineColor : "black"; + } + + doMouseDown(event: CanvasEvent): void { + this.isDown = true; + const pointer = this.pointer(event); + this.startX = pointer.x; + this.startY = pointer.y; + this.line = new fabric.Line([pointer.x, pointer.y, pointer.x, pointer.y], { + strokeWidth: this.width, + fill: this.color, + stroke: this.color, + originX: "center", + originY: "center", + selectable: false, + evented: false, + }); + this.head = new fabric.Triangle({ + fill: this.color, + left: pointer.x, + top: pointer.y, + originX: "center", + originY: "center", + height: 3 * this.width, + width: 3 * this.width, + selectable: false, + evented: false, + angle: 90, + }); + this.canvas.add(this.line); + this.canvas.add(this.head); + } + + doMouseMove(event: CanvasEvent): void { + if (!this.isDown) return; + const pointer = this.pointer(event); + this.line?.set({ x2: pointer.x, y2: pointer.y }); + this.line?.setCoords(); + this.head?.set({ + left: pointer.x, + top: pointer.y, + angle: + 90 + + (Math.atan2(pointer.y - this.startY, pointer.x - this.startX) * 180) / + Math.PI, + }); + this.canvas.renderAll(); + } + + /** + * The line and the head are merged into a single group once the arrow is + * drawn (this also runs when the pointer left the canvas while drawing) + */ + doMouseUp(): void { + this.isDown = false; + if (!this.line || !this.head) return; + this.canvas.remove(this.line, this.head); + // fabric 7 centers groups by default, the arrow keeps its top left origin + this.canvas.add( + new fabric.Group([this.line, this.head], { + originX: "left", + originY: "top", + }) + ); + this.line = this.head = null; + } + + doMouseOut(): void { + this.isDown = false; + } + + type(): Tool { + return Tool.ARROW; + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/Arrow/index.ts b/packages/react-sketch/src/components/SketchField/tools/Arrow/index.ts new file mode 100644 index 00000000..3f554668 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Arrow/index.ts @@ -0,0 +1 @@ +export { Arrow } from "./Arrow"; diff --git a/packages/react-sketch/src/components/SketchField/tools/Circle/Circle.ts b/packages/react-sketch/src/components/SketchField/tools/Circle/Circle.ts new file mode 100644 index 00000000..3ba678f7 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Circle/Circle.ts @@ -0,0 +1,73 @@ +import * as fabric from "fabric"; +import { CanvasEvent } from "../../events"; +import { FabricCanvasTool } from "../index"; +import { SketchProperties, Tool } from "../../../../types"; +import { linearDistance } from "../../../../utils"; + +export class Circle extends FabricCanvasTool { + private width?: number; + private color?: string; + private fill?: string; + private isDown: boolean; + private startX: number; + private startY: number; + private circle: fabric.Circle | null; + + constructor(canvas: fabric.Canvas) { + super(canvas); + this.isDown = false; + this.startX = 0; + this.startY = 0; + this.circle = null; + } + + configureCanvas(props: SketchProperties): void { + this.setInteractive(false); + this.width = props.lineWidth ? props.lineWidth : 1; + this.color = props.lineColor ? props.lineColor : "black"; + this.fill = props.fillColor ? props.fillColor : "transparent"; + } + + doMouseDown(event: CanvasEvent): void { + this.isDown = true; + const pointer = this.pointer(event); + this.startX = pointer.x; + this.startY = pointer.y; + this.circle = new fabric.Circle({ + left: this.startX, + top: this.startY, + originX: "left", + originY: "center", + strokeWidth: this.width, + stroke: this.color, + fill: this.fill, + selectable: false, + evented: false, + radius: 1, + }); + this.canvas.add(this.circle); + } + + doMouseMove(event: CanvasEvent): void { + if (!this.isDown) return; + const pointer = this.pointer(event); + // The circle is anchored at its left-center point and rotated towards the + // pointer, so the drag segment is always the circle diameter + this.circle?.set({ + radius: linearDistance({ x: this.startX, y: this.startY }, pointer) / 2, + angle: + (Math.atan2(pointer.y - this.startY, pointer.x - this.startX) * 180) / + Math.PI, + }); + this.circle?.setCoords(); + this.canvas.renderAll(); + } + + doMouseUp(): void { + this.isDown = false; + } + + type(): Tool { + return Tool.CIRCLE; + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/Circle/index.ts b/packages/react-sketch/src/components/SketchField/tools/Circle/index.ts new file mode 100644 index 00000000..0e170510 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Circle/index.ts @@ -0,0 +1 @@ +export { Circle } from "./Circle"; diff --git a/packages/react-sketch/src/components/SketchField/tools/DefaultTool/DefaultTool.ts b/packages/react-sketch/src/components/SketchField/tools/DefaultTool/DefaultTool.ts new file mode 100644 index 00000000..d515d523 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/DefaultTool/DefaultTool.ts @@ -0,0 +1,20 @@ +import * as fabric from "fabric"; +import { FabricCanvasTool } from "../index"; +import { Tool } from "../../../../types"; + +export class DefaultTool extends FabricCanvasTool { + constructor(canvas: fabric.Canvas) { + super(canvas); + } + + configureCanvas(): void { + this.setInteractive(false); + this.canvas.discardActiveObject(); + this.canvas.defaultCursor = "pointer"; + this.canvas.renderAll(); + } + + type(): Tool { + return Tool.DEFAULT_TOOL; + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/DefaultTool/index.ts b/packages/react-sketch/src/components/SketchField/tools/DefaultTool/index.ts new file mode 100644 index 00000000..b0c8e8eb --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/DefaultTool/index.ts @@ -0,0 +1 @@ +export { DefaultTool } from "./DefaultTool"; diff --git a/packages/react-sketch/src/components/SketchField/tools/Highlighter/Highlighter.ts b/packages/react-sketch/src/components/SketchField/tools/Highlighter/Highlighter.ts new file mode 100644 index 00000000..bd902f90 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Highlighter/Highlighter.ts @@ -0,0 +1,26 @@ +import * as fabric from "fabric"; +import { FabricCanvasTool } from "../index"; +import { SketchProperties, Tool } from "../../../../types"; +import { colorNameToHex, hexToRgbA } from "../../../../utils"; + +export class Highlighter extends FabricCanvasTool { + constructor(canvas: fabric.Canvas) { + super(canvas); + } + + /** + * @throws Error when lineColor is neither a hex code nor a known color name + */ + configureCanvas(props: SketchProperties): void { + const color = props.lineColor ? props.lineColor : "black"; + this.canvas.isDrawingMode = true; + this.brush.width = props.lineWidth ? props.lineWidth : 1; + this.brush.color = hexToRgbA( + color.startsWith("#") ? color : (colorNameToHex(color) ?? color) + ); + } + + type(): Tool { + return Tool.HIGHLIGHTER; + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/Highlighter/index.ts b/packages/react-sketch/src/components/SketchField/tools/Highlighter/index.ts new file mode 100644 index 00000000..e8b025c4 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Highlighter/index.ts @@ -0,0 +1 @@ +export { Highlighter } from "./Highlighter"; diff --git a/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts b/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts index 8999b5ad..f8f8a5ff 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts @@ -1,4 +1,5 @@ -import { fabric } from "fabric"; +import * as fabric from "fabric"; +import { CanvasEvent } from "../../events"; import { FabricCanvasTool } from "../index"; import { SketchProperties, Tool } from "../../../../types"; @@ -15,16 +16,20 @@ export class Line extends FabricCanvasTool { } configureCanvas(props: SketchProperties): void { - this.canvas.isDrawingMode = this.canvas.selection = false; - this.canvas.forEachObject((o) => (o.selectable = o.evented = false)); + this.setInteractive(false); this.width = props.lineWidth ? props.lineWidth : 1; this.color = props.lineColor ? props.lineColor : "black"; } - doMouseDown(event: fabric.IEvent): void { + doMouseDown(event: CanvasEvent): void { this.isDown = true; - const pointer = this.canvas.getPointer(event.e); - const points = [pointer.x, pointer.y, pointer.x, pointer.y]; + const pointer = this.pointer(event); + const points: [number, number, number, number] = [ + pointer.x, + pointer.y, + pointer.x, + pointer.y, + ]; this.line = new fabric.Line(points, { strokeWidth: this.width, fill: this.color, @@ -37,19 +42,19 @@ export class Line extends FabricCanvasTool { this.canvas.add(this.line); } - doMouseMove(event: fabric.IEvent): void { + doMouseMove(event: CanvasEvent): void { if (!this.isDown) return; - const pointer = this.canvas.getPointer(event.e); + const pointer = this.pointer(event); this.line?.set({ x2: pointer.x, y2: pointer.y }); this.line?.setCoords(); this.canvas.renderAll(); } - doMouseUp(event: fabric.IEvent): void { + doMouseUp(): void { this.isDown = false; } - doMouseOut(event: fabric.IEvent): void { + doMouseOut(): void { this.isDown = false; } diff --git a/packages/react-sketch/src/components/SketchField/tools/Pan/Pan.ts b/packages/react-sketch/src/components/SketchField/tools/Pan/Pan.ts new file mode 100644 index 00000000..17439a8a --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Pan/Pan.ts @@ -0,0 +1,47 @@ +import * as fabric from "fabric"; +import { CanvasEvent } from "../../events"; +import { FabricCanvasTool } from "../index"; +import { Tool } from "../../../../types"; + +export class Pan extends FabricCanvasTool { + private isDown: boolean; + private startX: number; + private startY: number; + + constructor(canvas: fabric.Canvas) { + super(canvas); + this.isDown = false; + this.startX = 0; + this.startY = 0; + } + + configureCanvas(): void { + this.setInteractive(false); + // Change the cursor to the move grabber + this.canvas.defaultCursor = "move"; + } + + doMouseDown(event: CanvasEvent): void { + this.isDown = true; + const pointer = this.pointer(event); + this.startX = pointer.x; + this.startY = pointer.y; + } + + doMouseMove(event: CanvasEvent): void { + if (!this.isDown) return; + const pointer = this.pointer(event); + this.canvas.relativePan( + new fabric.Point(pointer.x - this.startX, pointer.y - this.startY) + ); + this.canvas.renderAll(); + } + + doMouseUp(): void { + this.isDown = false; + } + + type(): Tool { + return Tool.PAN; + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/Pan/index.ts b/packages/react-sketch/src/components/SketchField/tools/Pan/index.ts new file mode 100644 index 00000000..f0f0fb19 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Pan/index.ts @@ -0,0 +1 @@ +export { Pan } from "./Pan"; diff --git a/packages/react-sketch/src/components/SketchField/tools/Pencil/Pencil.ts b/packages/react-sketch/src/components/SketchField/tools/Pencil/Pencil.ts index e307f747..ea3d61a8 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Pencil/Pencil.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Pencil/Pencil.ts @@ -1,4 +1,4 @@ -import { fabric } from "fabric"; +import * as fabric from "fabric"; import { FabricCanvasTool } from "../index"; import { SketchProperties, Tool } from "../../../../types"; @@ -9,10 +9,10 @@ export class Pencil extends FabricCanvasTool { configureCanvas(config: SketchProperties): void { this.canvas.isDrawingMode = true; - this.canvas.freeDrawingBrush.width = config.lineWidth ? config.lineWidth : 1; - this.canvas.freeDrawingBrush.color = config.lineColor - ? config.lineColor - : "black"; + // The regular pointer, not the crosshair of fabric (issue #181) + this.canvas.freeDrawingCursor = "default"; + this.brush.width = config.lineWidth ? config.lineWidth : 1; + this.brush.color = config.lineColor ? config.lineColor : "black"; } type(): Tool { diff --git a/packages/react-sketch/src/components/SketchField/tools/Rectangle/Rectangle.ts b/packages/react-sketch/src/components/SketchField/tools/Rectangle/Rectangle.ts new file mode 100644 index 00000000..7c217209 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Rectangle/Rectangle.ts @@ -0,0 +1,75 @@ +import * as fabric from "fabric"; +import { CanvasEvent } from "../../events"; +import { FabricCanvasTool } from "../index"; +import { SketchProperties, Tool } from "../../../../types"; + +export class Rectangle extends FabricCanvasTool { + private width?: number; + private color?: string; + private fill?: string; + private isDown: boolean; + private startX: number; + private startY: number; + private rect: fabric.Rect | null; + + constructor(canvas: fabric.Canvas) { + super(canvas); + this.isDown = false; + this.startX = 0; + this.startY = 0; + this.rect = null; + } + + configureCanvas(props: SketchProperties): void { + this.setInteractive(false); + this.width = props.lineWidth ? props.lineWidth : 1; + this.color = props.lineColor ? props.lineColor : "black"; + this.fill = props.fillColor ? props.fillColor : "transparent"; + } + + doMouseDown(event: CanvasEvent): void { + this.isDown = true; + const pointer = this.pointer(event); + this.startX = pointer.x; + this.startY = pointer.y; + this.rect = new fabric.Rect({ + left: this.startX, + top: this.startY, + originX: "left", + originY: "top", + width: 0, + height: 0, + stroke: this.color, + strokeWidth: this.width, + fill: this.fill, + transparentCorners: false, + selectable: false, + evented: false, + strokeUniform: true, + noScaleCache: false, + angle: 0, + }); + this.canvas.add(this.rect); + } + + doMouseMove(event: CanvasEvent): void { + if (!this.isDown) return; + const pointer = this.pointer(event); + this.rect?.set({ + left: Math.min(this.startX, pointer.x), + top: Math.min(this.startY, pointer.y), + width: Math.abs(this.startX - pointer.x), + height: Math.abs(this.startY - pointer.y), + }); + this.rect?.setCoords(); + this.canvas.renderAll(); + } + + doMouseUp(): void { + this.isDown = false; + } + + type(): Tool { + return Tool.RECTANGLE; + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/Rectangle/index.ts b/packages/react-sketch/src/components/SketchField/tools/Rectangle/index.ts new file mode 100644 index 00000000..ad03aa87 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Rectangle/index.ts @@ -0,0 +1 @@ +export { Rectangle } from "./Rectangle"; diff --git a/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabel.ts b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabel.ts new file mode 100644 index 00000000..5ce09c41 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabel.ts @@ -0,0 +1,120 @@ +import * as fabric from "fabric"; +import { CanvasEvent } from "../../events"; +import { FabricCanvasTool } from "../index"; +import { RectangleLabelObject } from "./RectangleLabelObject"; +import { SketchProperties, Tool } from "../../../../types"; + +const MAX_FONT_SIZE = 12; + +export class RectangleLabel extends FabricCanvasTool { + private width?: number; + private color?: string; + private fill?: string; + private isDown: boolean; + private startX: number; + private startY: number; + private label: RectangleLabelObject | null; + + constructor(canvas: fabric.Canvas) { + super(canvas); + this.isDown = false; + this.startX = 0; + this.startY = 0; + this.label = null; + } + + configureCanvas(props: SketchProperties): void { + this.setInteractive(false); + this.width = props.lineWidth ? props.lineWidth : 1; + this.color = props.lineColor ? props.lineColor : "black"; + this.fill = props.fillColor ? props.fillColor : "transparent"; + } + + doMouseDown(event: CanvasEvent): void { + this.isDown = true; + const pointer = this.pointer(event); + this.startX = pointer.x; + this.startY = pointer.y; + const label = new RectangleLabelObject( + this.canvas, + "New drawing", + { + left: this.startX, + top: this.startY, + originX: "left", + originY: "top", + width: 0, + height: 0, + stroke: this.color, + strokeWidth: this.width, + fill: this.fill, + transparentCorners: false, + selectable: false, + evented: false, + strokeUniform: true, + noScaleCache: false, + angle: 0, + }, + { + left: this.startX, + top: this.startY - MAX_FONT_SIZE, + originX: "left", + originY: "top", + width: 0, + fontSize: MAX_FONT_SIZE, + noScaleCache: false, + backgroundColor: this.color, + transparentCorners: true, + hasControls: false, + angle: 0, + } + ); + this.label = label; + + // Shrink the text until it fits the third of the canvas height + const { textbox } = label; + const maxHeight = this.canvas.getHeight() / 3; + while ((textbox.height ?? 0) > maxHeight) { + const fontSize = textbox.fontSize ?? MAX_FONT_SIZE; + textbox.set({ + fontSize: fontSize - 1, + top: this.startY - fontSize - MAX_FONT_SIZE, + }); + } + + this.canvas.add(label.rect); + this.canvas.add(label.textbox); + this.canvas.renderAll(); + } + + doMouseMove(event: CanvasEvent): void { + if (!this.isDown || !this.label) return; + const pointer = this.pointer(event); + const { rect, textbox } = this.label; + const left = Math.min(this.startX, pointer.x); + rect.set({ + left, + top: Math.min(this.startY, pointer.y), + width: Math.abs(this.startX - pointer.x), + height: Math.abs(this.startY - pointer.y), + }); + // The text sits above the rectangle, unless it is dragged upwards + textbox.set({ + left, + top: Math.min(this.startY - MAX_FONT_SIZE, pointer.y), + width: rect.getScaledWidth(), + }); + textbox.setCoords(); + rect.setCoords(); + this.canvas.renderAll(); + } + + doMouseUp(): void { + this.isDown = false; + this.canvas.renderAll(); + } + + type(): Tool { + return Tool.RECTANGLE_LABEL; + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabelObject.ts b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabelObject.ts new file mode 100644 index 00000000..a1db6111 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabelObject.ts @@ -0,0 +1,38 @@ +import * as fabric from "fabric"; +import { CanvasEvent } from "../../events"; + +/** + * A rectangle with a text box on top of it. The text box follows the + * rectangle when it is moved or scaled. + */ +export class RectangleLabelObject { + readonly rect: fabric.Rect; + readonly textbox: fabric.Textbox; + + constructor( + canvas: fabric.Canvas, + text: string, + rectProps: Partial, + textProps: Partial + ) { + this.rect = new fabric.Rect(rectProps); + this.textbox = new fabric.Textbox(text, textProps); + canvas.on("object:scaling", this.update); + canvas.on("object:moving", this.update); + } + + update = (event: CanvasEvent): void => { + if (event.target !== this.rect) return; + this.textbox.set({ + width: this.rect.getScaledWidth(), + scaleX: 1, + scaleY: 1, + top: (this.rect.top ?? 0) - this.textbox.getScaledHeight(), + left: this.rect.left, + }); + }; + + setText(text: string): void { + this.textbox.set({ text }); + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/index.ts b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/index.ts new file mode 100644 index 00000000..f1703555 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/index.ts @@ -0,0 +1 @@ +export { RectangleLabel } from "./RectangleLabel"; diff --git a/packages/react-sketch/src/components/SketchField/tools/Select/Select.ts b/packages/react-sketch/src/components/SketchField/tools/Select/Select.ts new file mode 100644 index 00000000..e8bc2fdb --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Select/Select.ts @@ -0,0 +1,17 @@ +import * as fabric from "fabric"; +import { FabricCanvasTool } from "../index"; +import { Tool } from "../../../../types"; + +export class Select extends FabricCanvasTool { + constructor(canvas: fabric.Canvas) { + super(canvas); + } + + configureCanvas(): void { + this.setInteractive(true); + } + + type(): Tool { + return Tool.SELECT; + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/Select/index.ts b/packages/react-sketch/src/components/SketchField/tools/Select/index.ts new file mode 100644 index 00000000..b6497e64 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Select/index.ts @@ -0,0 +1 @@ +export { Select } from "./Select"; diff --git a/packages/react-sketch/src/components/SketchField/tools/factory.ts b/packages/react-sketch/src/components/SketchField/tools/factory.ts index 6e2c0bf2..6f6d29b7 100644 --- a/packages/react-sketch/src/components/SketchField/tools/factory.ts +++ b/packages/react-sketch/src/components/SketchField/tools/factory.ts @@ -1,12 +1,20 @@ -import { fabric } from "fabric"; +import * as fabric from "fabric"; import { SketchProperties, Tool } from "../../../types"; import { FabricCanvasTool } from "./index"; -import { Pencil } from "./Pencil"; +import { Arrow } from "./Arrow"; +import { Circle } from "./Circle"; +import { DefaultTool } from "./DefaultTool"; +import { Highlighter } from "./Highlighter"; import { Line } from "./Line"; +import { Pan } from "./Pan"; +import { Pencil } from "./Pencil"; +import { Rectangle } from "./Rectangle"; +import { RectangleLabel } from "./RectangleLabel"; +import { Select } from "./Select"; /** * Factory to create the corresponding tool based on the prop passed to - * the Component + * the Component. Unknown tools fall back to the Pencil. * * @param {fabric.Canvas} canvas the fabric Canvas instance instantiated * @param {SketchProperties} props defined to SketchField @@ -15,35 +23,43 @@ export const create_tool = ( canvas: fabric.Canvas, props: SketchProperties ): FabricCanvasTool => { - const tool = props.tool; - let toolInstance: FabricCanvasTool | null = null; + let toolInstance: FabricCanvasTool; - switch (tool) { - default: - case Tool.PENCIL: - toolInstance = new Pencil(canvas); - break; + switch (props.tool) { case Tool.CIRCLE: + toolInstance = new Circle(canvas); break; case Tool.LINE: toolInstance = new Line(canvas); break; case Tool.ARROW: + toolInstance = new Arrow(canvas); break; case Tool.RECTANGLE: + toolInstance = new Rectangle(canvas); + break; + case Tool.RECTANGLE_LABEL: + toolInstance = new RectangleLabel(canvas); break; case Tool.SELECT: + toolInstance = new Select(canvas); break; case Tool.PAN: + toolInstance = new Pan(canvas); break; case Tool.HIGHLIGHTER: + toolInstance = new Highlighter(canvas); break; + case Tool.DEFAULT_TOOL: + toolInstance = new DefaultTool(canvas); + break; + case Tool.PENCIL: + default: + toolInstance = new Pencil(canvas); } - if (!toolInstance) { - throw new Error(); - } - + // Bring the cursor back to default, in case the previous tool changed it + canvas.defaultCursor = "default"; toolInstance.configureCanvas(props); return toolInstance; }; diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index 17884356..1437e889 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -1,9 +1,8 @@ /*eslint @typescript-eslint/no-unused-vars: 0,@typescript-eslint/no-empty-function: 0*/ -import { fabric } from "fabric"; -import { IEvent } from "fabric/fabric-impl"; +import * as fabric from "fabric"; import { SketchProperties, Tool } from "../../../types"; -import { EventInput } from "../events"; +import { CanvasEvent, EventInput } from "../events"; /** * Interface to support canvas events @@ -77,15 +76,41 @@ export abstract class FabricCanvasTool { abstract configureCanvas(props: SketchProperties): void; - doMouseUp(event: IEvent): void {} + doMouseUp(event: CanvasEvent): void {} - doMouseDown(event: IEvent): void {} + doMouseDown(event: CanvasEvent): void {} - doMouseMove(event: IEvent): void {} + doMouseMove(event: CanvasEvent): void {} - doMouseOut(event: IEvent): void {} + doMouseOut(event: CanvasEvent): void {} get canvas(): fabric.Canvas { return this._canvas; } + + /** + * Position of the given canvas event, in canvas coordinates + */ + protected pointer(event: CanvasEvent): { x: number; y: number } { + return this.canvas.getScenePoint(event.e as fabric.TPointerEvent); + } + + /** + * The brush of the free drawing tools. Fabric does not create one by + * itself, so it is created the first time it is needed. + */ + protected get brush(): fabric.BaseBrush { + return (this.canvas.freeDrawingBrush ??= new fabric.PencilBrush(this.canvas)); + } + + /** + * Switch the canvas between "select" mode (objects can be selected and + * moved) and "draw" mode (objects are frozen, so a drag always draws). + * Free drawing is turned off in both. + */ + protected setInteractive(interactive: boolean): void { + this.canvas.isDrawingMode = false; + this.canvas.selection = interactive; + this.canvas.forEachObject((o) => (o.selectable = o.evented = interactive)); + } } diff --git a/packages/react-sketch/src/components/SketchField/tools/tools.spec.ts b/packages/react-sketch/src/components/SketchField/tools/tools.spec.ts new file mode 100644 index 00000000..d2d76542 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/tools.spec.ts @@ -0,0 +1,295 @@ +/** + * @jest-environment jsdom + */ +import "jest-canvas-mock"; +import * as fabric from "fabric"; +import { Tool } from "../../../types"; +import { CanvasEvent } from "../events"; +import { FabricCanvasTool } from "./index"; +import { create_tool } from "./factory"; + +type Point = [number, number]; + +const mouseEvent = (type: string, [x, y]: Point): CanvasEvent => ({ + e: new MouseEvent(type, { clientX: x, clientY: y }), +}); + +/** Fire a canvas event, as the canvas does when the user interacts with it */ +const fire = (canvas: fabric.Canvas, name: string, event: object): void => + (canvas as unknown as fabric.Observable>).fire(name, event); + +const down = (tool: FabricCanvasTool, point: Point) => + tool.doMouseDown(mouseEvent("mousedown", point)); +const move = (tool: FabricCanvasTool, point: Point) => + tool.doMouseMove(mouseEvent("mousemove", point)); +const up = (tool: FabricCanvasTool, point: Point) => + tool.doMouseUp(mouseEvent("mouseup", point)); + +/** Press the mouse at `from`, move it to `to` and release it */ +const drag = (tool: FabricCanvasTool, from: Point, to: Point) => { + down(tool, from); + move(tool, to); + up(tool, to); +}; + +describe("Tools", () => { + let canvas: fabric.Canvas; + + beforeEach(() => { + canvas = new fabric.Canvas(document.createElement("canvas")); + }); + + afterEach(() => { + canvas.dispose(); + }); + + describe("factory", () => { + it.each(Object.values(Tool))("creates the %s tool", (name) => { + const tool = create_tool(canvas, { tool: name, lineColor: "red" }); + expect(tool.type()).toEqual(name); + }); + + it("falls back to the pencil for unknown tools", () => { + const tool = create_tool(canvas, { tool: "does-not-exist" }); + expect(tool.type()).toEqual(Tool.PENCIL); + }); + + it("resets the cursor changed by the previous tool", () => { + create_tool(canvas, { tool: Tool.PAN }); + expect(canvas.defaultCursor).toEqual("move"); + create_tool(canvas, { tool: Tool.LINE }); + expect(canvas.defaultCursor).toEqual("default"); + }); + }); + + describe("Line", () => { + it("draws a line following the mouse", () => { + const tool = create_tool(canvas, { + tool: Tool.LINE, + lineColor: "red", + lineWidth: 3, + }); + down(tool, [10, 20]); + move(tool, [60, 80]); + up(tool, [60, 80]); + + const [line] = canvas.getObjects() as fabric.Line[]; + expect(canvas.getObjects()).toHaveLength(1); + expect([line.x1, line.y1, line.x2, line.y2]).toEqual([10, 20, 60, 80]); + expect(line.stroke).toEqual("red"); + expect(line.strokeWidth).toEqual(3); + }); + + it("stops drawing once the mouse is released", () => { + const tool = create_tool(canvas, { tool: Tool.LINE }); + drag(tool, [10, 20], [60, 80]); + move(tool, [5, 5]); + + const [line] = canvas.getObjects() as fabric.Line[]; + expect([line.x2, line.y2]).toEqual([60, 80]); + }); + }); + + describe("Rectangle", () => { + it("draws a rectangle with the given colors", () => { + const tool = create_tool(canvas, { + tool: Tool.RECTANGLE, + lineColor: "red", + lineWidth: 3, + fillColor: "blue", + }); + drag(tool, [10, 20], [110, 70]); + + const [rect] = canvas.getObjects() as fabric.Rect[]; + expect(canvas.getObjects()).toHaveLength(1); + expect([rect.left, rect.top, rect.width, rect.height]).toEqual([ + 10, 20, 100, 50, + ]); + expect(rect.stroke).toEqual("red"); + expect(rect.strokeWidth).toEqual(3); + expect(rect.fill).toEqual("blue"); + }); + + it("is transparent by default", () => { + const tool = create_tool(canvas, { tool: Tool.RECTANGLE }); + down(tool, [10, 20]); + + const [rect] = canvas.getObjects() as fabric.Rect[]; + expect(rect.fill).toEqual("transparent"); + }); + + it("draws towards the top left and back", () => { + const tool = create_tool(canvas, { tool: Tool.RECTANGLE }); + down(tool, [100, 100]); + move(tool, [40, 60]); + + const [rect] = canvas.getObjects() as fabric.Rect[]; + expect([rect.left, rect.top, rect.width, rect.height]).toEqual([ + 40, 60, 60, 40, + ]); + + move(tool, [150, 130]); + expect([rect.left, rect.top, rect.width, rect.height]).toEqual([ + 100, 100, 50, 30, + ]); + }); + }); + + describe("Circle", () => { + it("draws a circle having the drag segment as diameter", () => { + const tool = create_tool(canvas, { + tool: Tool.CIRCLE, + lineColor: "red", + lineWidth: 3, + fillColor: "blue", + }); + drag(tool, [0, 0], [30, 40]); + + const [circle] = canvas.getObjects() as fabric.Circle[]; + expect(canvas.getObjects()).toHaveLength(1); + expect(circle.radius).toEqual(25); + expect(circle.angle).toBeCloseTo(53.13, 2); + expect([circle.left, circle.top]).toEqual([0, 0]); + expect(circle.stroke).toEqual("red"); + expect(circle.strokeWidth).toEqual(3); + expect(circle.fill).toEqual("blue"); + }); + }); + + describe("Arrow", () => { + it("draws a line with a head pointing at the mouse", () => { + const tool = create_tool(canvas, { tool: Tool.ARROW, lineWidth: 3 }); + down(tool, [0, 0]); + move(tool, [100, 0]); + + const [line, head] = canvas.getObjects(); + expect(canvas.getObjects()).toHaveLength(2); + expect(line.type).toEqual("line"); + expect(head.type).toEqual("triangle"); + expect([head.left, head.top, head.angle]).toEqual([100, 0, 90]); + + move(tool, [0, 100]); + expect(head.angle).toEqual(180); + }); + + it("groups the line and the head on release", () => { + const tool = create_tool(canvas, { tool: Tool.ARROW }); + drag(tool, [0, 0], [100, 50]); + + const [group] = canvas.getObjects() as fabric.Group[]; + expect(canvas.getObjects()).toHaveLength(1); + expect(group.type).toEqual("group"); + expect(group.getObjects().map((o) => o.type)).toEqual(["line", "triangle"]); + + // a release without a preceding press adds nothing + up(tool, [100, 50]); + expect(canvas.getObjects()).toHaveLength(1); + }); + }); + + describe("RectangleLabel", () => { + it("draws a rectangle with a label on top of it", () => { + const tool = create_tool(canvas, { + tool: Tool.RECTANGLE_LABEL, + lineColor: "red", + }); + drag(tool, [100, 100], [180, 160]); + + const [rect, text] = canvas.getObjects() as [fabric.Rect, fabric.Textbox]; + expect(canvas.getObjects()).toHaveLength(2); + expect([rect.left, rect.top, rect.width, rect.height]).toEqual([ + 100, 100, 80, 60, + ]); + expect(text.text).toEqual("New drawing"); + expect(text.backgroundColor).toEqual("red"); + }); + + it("moves the label along with the rectangle", () => { + const tool = create_tool(canvas, { tool: Tool.RECTANGLE_LABEL }); + drag(tool, [100, 100], [180, 160]); + + const [rect, text] = canvas.getObjects() as [fabric.Rect, fabric.Textbox]; + rect.set({ left: 200, top: 150 }); + fire(canvas, "object:moving", { target: rect }); + + expect(text.left).toEqual(200); + expect(text.top).toEqual(150 - text.getScaledHeight()); + }); + }); + + describe("Pan", () => { + it("moves the viewport while the mouse is pressed", () => { + const tool = create_tool(canvas, { tool: Tool.PAN }); + down(tool, [10, 10]); + move(tool, [30, 25]); + up(tool, [30, 25]); + move(tool, [100, 100]); + + const [, , , , x, y] = canvas.viewportTransform as number[]; + expect([x, y]).toEqual([20, 15]); + }); + }); + + describe("Select and DefaultTool", () => { + it("toggle the interactivity of the existing objects", () => { + const rect = new fabric.Rect({ width: 5, height: 5 }); + canvas.add(rect); + + create_tool(canvas, { tool: Tool.LINE }); + expect([canvas.selection, rect.selectable, rect.evented]).toEqual([ + false, + false, + false, + ]); + + create_tool(canvas, { tool: Tool.SELECT }); + expect([canvas.selection, rect.selectable, rect.evented]).toEqual([ + true, + true, + true, + ]); + + canvas.setActiveObject(rect); + create_tool(canvas, { tool: Tool.DEFAULT_TOOL }); + expect(canvas.getActiveObject()).toBeUndefined(); + expect([canvas.selection, rect.selectable, rect.evented]).toEqual([ + false, + false, + false, + ]); + expect(canvas.defaultCursor).toEqual("pointer"); + }); + }); + + describe("Pencil and Highlighter", () => { + it("configure the free drawing brush", () => { + create_tool(canvas, { + tool: Tool.PENCIL, + lineColor: "green", + lineWidth: 4, + }); + expect(canvas.isDrawingMode).toBe(true); + expect(canvas.freeDrawingBrush?.color).toEqual("green"); + expect(canvas.freeDrawingBrush?.width).toEqual(4); + + create_tool(canvas, { + tool: Tool.HIGHLIGHTER, + lineColor: "#ff0000", + lineWidth: 8, + }); + expect(canvas.freeDrawingBrush?.color).toEqual("rgba(255,0,0,0.4)"); + expect(canvas.freeDrawingBrush?.width).toEqual(8); + }); + + it("highlights with color names", () => { + create_tool(canvas, { tool: Tool.HIGHLIGHTER, lineColor: "Red" }); + expect(canvas.freeDrawingBrush?.color).toEqual("rgba(255,0,0,0.4)"); + }); + + it("rejects highlighter colors it cannot convert", () => { + expect(() => + create_tool(canvas, { tool: Tool.HIGHLIGHTER, lineColor: "nope" }) + ).toThrow(/Bad hex color/); + }); + }); +}); diff --git a/packages/react-sketch/src/index.spec.tsx b/packages/react-sketch/src/index.spec.tsx new file mode 100644 index 00000000..4121317a --- /dev/null +++ b/packages/react-sketch/src/index.spec.tsx @@ -0,0 +1,51 @@ +import { render } from "@testing-library/react"; +import Sketch, { SketchField, Tools, Types } from "./index"; + +describe("exports", () => { + it("exports the component by name", () => { + expect(SketchField).toBeDefined(); + expect(typeof SketchField).toEqual("function"); + }); + + it("exports Tools with the names and values of 0.5.x", () => { + expect(Tools).toEqual({ + Select: "select", + Pencil: "pencil", + Line: "line", + Arrow: "arrow", + Rectangle: "rectangle", + RectangleLabel: "rectangle-label", + Circle: "circle", + Pan: "pan", + DefaultTool: "default-tool", + Highlighter: "highlighter", + }); + }); + + it("has a tool in Tools for every Tool", () => { + expect(Object.values(Tools).sort()).toEqual(Object.values(Types.Tool).sort()); + }); + + it("exports the types namespace, with the Tool enumeration", () => { + expect(Types.Tool.PENCIL).toEqual("pencil"); + }); + + describe("default export", () => { + it("is the component", () => { + expect(Sketch).toBe(SketchField); + const { container } = render(); + + expect(container.querySelector("canvas")).toBeInTheDocument(); + }); + + it("carries SketchField and Tools, as in 0.5.x", () => { + expect(Sketch.SketchField).toBe(SketchField); + expect(Sketch.Tools).toBe(Tools); + expect(Sketch.Tools.Pencil).toEqual("pencil"); + + const { container } = render(); + + expect(container.querySelector("canvas")).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/react-sketch/src/index.ts b/packages/react-sketch/src/index.ts index 60f177a6..c5d14924 100644 --- a/packages/react-sketch/src/index.ts +++ b/packages/react-sketch/src/index.ts @@ -1,7 +1,14 @@ import * as Types from "./types"; +import { Tools } from "./types"; import SketchField from "./components/SketchField"; -export { SketchField }; -export { Types }; +/** + * The default export is the component itself, which also carries `SketchField` + * and `Tools`. That keeps both `` and the 0.5.x use + * (`Sketch.SketchField`, `Sketch.Tools`) working. + */ +const Sketch = Object.assign(SketchField, { SketchField, Tools }); -export default SketchField; +export { SketchField, Tools, Types }; + +export default Sketch; diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index 6db8b413..6c91c04b 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -1,5 +1,4 @@ import * as CSS from "csstype"; -import PropTypes from "prop-types"; /** * Enumeration which declares the various tools available for @@ -11,215 +10,191 @@ export enum Tool { ARROW = "arrow", PENCIL = "pencil", RECTANGLE = "rectangle", + RECTANGLE_LABEL = "rectangle-label", SELECT = "select", PAN = "pan", HIGHLIGHTER = "highlighter", + DEFAULT_TOOL = "default-tool", } -interface SketchStyle { - className?: string; - style?: CSS.Properties; - autoresize?: boolean; - height?: number; +/** + * The tools by their legacy (0.5.x) names, as in + * `import { Tools } from "react-sketch"`. The values are the ones of {@link Tool}. + */ +export const Tools = { + Select: Tool.SELECT, + Pencil: Tool.PENCIL, + Line: Tool.LINE, + Arrow: Tool.ARROW, + Rectangle: Tool.RECTANGLE, + RectangleLabel: Tool.RECTANGLE_LABEL, + Circle: Tool.CIRCLE, + Pan: Tool.PAN, + DefaultTool: Tool.DEFAULT_TOOL, + Highlighter: Tool.HIGHLIGHTER, +} as const; + +/** + * An object of the sketch, as seen by the event callbacks. This is the + * canvas (fabric) object, with the properties which are the same in every + * version of the underlying library. + */ +export interface SketchObject { + /** Kind of the object, for example "rect", "line", "path", "i-text" */ + type?: string; + /** Any other property of the object, including custom ones set by the application */ + [property: string]: unknown; +} + +/** + * Event passed to the object and mouse callbacks of {@link SketchProperties} + */ +export interface SketchEvent { + /** The original DOM event, when the event was triggered by the user */ + e?: Event; + /** The object the event is about (object events only) */ + target?: SketchObject; + /** Any other property of the canvas event */ + [property: string]: unknown; +} + +/** + * Content of a sketch, as returned by `toJSON` and accepted by `value`, + * `defaultValue` and `fromJSON`: an object, or the same object as JSON string. + */ +export type SketchValue = object | string; + +/** + * Content of a sketch as returned by `toJSON` and `clear` + */ +export type SketchData = Record; + +/** + * Options of `toDataURL` + */ +export interface DataUrlOptions { + /** Format of the image, "png" or "jpeg". Defaults to the `imageFormat` property, then "png" */ + format?: string; + /** Quality level (0..1). Only used for jpeg. Defaults to 1 */ + quality?: number; + /** Multiplier to scale by. Defaults to 1 */ + multiplier?: number; + /** Cropping left offset */ + left?: number; + /** Cropping top offset */ + top?: number; + /** Cropping width */ width?: number; + /** Cropping height */ + height?: number; } -interface SketchTool { - tool: Tool | string; // tool type - lineColor?: string; // the color of the line - lineWidth?: number; // The width of the line - fillColor?: string; // the fill color of the shape when applicable - backgroundColor?: string; // the background color of the sketch +/** + * Options of `addImg` + */ +export interface ImageOptions { + /** Distance from the left of the canvas. Defaults to a random position */ + left?: number; + /** Distance from the top of the canvas. Defaults to a random position */ + top?: number; + /** Initial scale of the image. Defaults to 0.5 */ + scale?: number; } -export interface SketchState { - action: boolean; +/** + * Options of `setBackgroundFromDataUrl` + */ +export interface BackgroundOptions { + /** Scale the image to the width and the height of the canvas */ + stretched?: boolean; + /** Scale the image to the width of the canvas */ + stretchedX?: boolean; + /** Scale the image to the height of the canvas */ + stretchedY?: boolean; + /** Any other option is passed to the canvas as an image option, for example `opacity` */ + [option: string]: unknown; } -export interface SketchProperties extends SketchTool, SketchStyle {} +/** + * Options of `addText`: the options of a canvas text object, for example + * `fontSize`, `fill`, `left`, `top` + */ +export type TextOptions = Record; -// declare module "react-sketch123123" { -// import * as React from "react"; -// -// export class SketchField extends React.PureComponent<{ -// // the color of the line -// lineColor?: string; -// // The width of the line -// lineWidth?: number; -// // the opacity of the object -// opacity?: number; -// // number of undo/redo steps to maintain -// undoSteps?: number; -// // The tool to use, can be pencil, rectangle, circle, brush; -// tool?: string; -// // image format when calling toDataURL -// imageFormat?: string; -// // Sketch data for controlling sketch from -// // outside the component -// value?: object; -// // Set to true if you wish to force load the given value, even if it is the same -// forceValue?: boolean; -// // Specify some width correction which will be applied on auto resize -// widthCorrection?: number; -// // Specify some height correction which will be applied on auto resize -// heightCorrection?: number; -// // Specify action on change -// onChange?: (evt: any) => void; -// // Default initial value -// defaultValue?: {}; -// // Sketch width -// width?: number; -// // Sketch height -// height?: number; -// // Class name to pass to container div of canvas -// className?: string; -// // Style options to pass to container div of canvas -// style?: {}; -// }> { -// /** -// * Enable touch Scrolling on Canvas -// */ -// enableTouchScroll(): void; -// -// /** -// * Disable touch Scrolling on Canvas -// */ -// disableTouchScroll(): void; -// -// /** -// * Add an image as object to the canvas -// * -// * @param dataUrl the image url or Data Url -// * @param options object to pass and change some options when loading image, the format of the object is: -// * -// * { -// * left: , -// * top: , -// * scale: -// * } -// */ -// addImg( -// dataUrl: string, -// options?: { left?: number; top?: number; scale?: number } -// ): void; -// -// /** -// * Zoom the drawing by the factor specified -// * -// * The zoom factor is a percentage with regards the original, for example if factor is set to 2 -// * it will double the size whereas if it is set to 0.5 it will half the size -// * -// * @param factor the zoom factor -// */ -// zoom(factor: number): void; -// -// /** -// * Perform an undo operation on canvas, if it cannot undo it will leave the canvas intact -// */ -// undo(): void; -// -// /** -// * Perform a redo operation on canvas, if it cannot redo it will leave the canvas intact -// */ -// redo(): void; -// -// /** -// * Delegation method to check if we can perform an undo Operation, useful to disable/enable possible buttons -// * -// * @returns {*} true if we can undo otherwise false -// */ -// canUndo(): boolean; -// -// /** -// * Delegation method to check if we can perform a redo Operation, useful to disable/enable possible buttons -// * -// * @returns {*} true if we can redo otherwise false -// */ -// canRedo(): boolean; -// -// /** -// * Exports canvas element to a dataurl image. Note that when multiplier is used, cropping is scaled appropriately -// * -// * Available Options are -// * -// * -// * -// * -// * -// * -// * -// * -// * -// * -// * -// *
NameTypeArgumentDefaultDescription
format String pngThe format of the output image. Either "jpeg" or "png"
qualityNumber1Quality level (0..1). Only used for jpeg.
multiplierNumber1Multiplier to scale by
leftNumberCropping left offset. Introduced in v1.2.14
topNumberCropping top offset. Introduced in v1.2.14
widthNumberCropping width. Introduced in v1.2.14
heightNumberCropping height. Introduced in v1.2.14
-// * -// * @returns {String} URL containing a representation of the object in the format specified by options.format -// */ -// toDataURL(options?: { -// format?: string; -// quality?: number; -// multiplier?: number; -// left?: number; -// top?: number; -// width?: number; -// height?: number; -// }): string; -// -// /** -// * Returns JSON representation of canvas -// * -// * @param propertiesToInclude Array Any properties that you might want to additionally include in the output -// * @returns {string} JSON string -// */ -// toJSON(propertiesToInclude?: ArrayLike): string; -// -// /** -// * Populates canvas with data from the specified JSON. -// * -// * JSON format must conform to the one of fabric.Canvas#toDatalessJSON -// * -// * @param json JSON string or object -// */ -// fromJSON(json: string): void; -// -// /** -// * Clear the content of the canvas, this will also clear history but will return the canvas content as JSON to be -// * used as needed in order to undo the clear if possible -// * -// * @param propertiesToInclude Array Any properties that you might want to additionally include in the output -// * @returns {string} JSON string of the canvas just cleared -// */ -// clear(propertiesToInclude?: ArrayLike): string; -// -// hasSelection(): boolean; -// clearSelection(): void; -// -// /** -// * Remove selected object from the canvas -// */ -// removeSelected(): void; -// -// copy(): void; -// -// paste(): void; -// -// /** -// * Sets the background from the dataUrl given -// * -// * @param dataUrl the dataUrl to be used as a background -// * @param options -// */ -// setBackgroundFromDataUrl( -// dataUrl: string, -// options?: { -// stretched?: boolean; -// stretchedX?: boolean; -// stretchedY?: boolean; -// [name: string]: any; -// } -// ): void; -// -// addText(text: string, options?: {}): void; -// } -// } +/** + * Properties of the SketchField component + */ +export interface SketchProperties { + /** + * The tool to use. See {@link Tool} (or `Tools`). Defaults to "select" + * (the objects can be moved, nothing is drawn), which is what the + * component did when no tool was given. + */ + tool?: Tool | string; + /** The color of the line. Defaults to "black" */ + lineColor?: string; + /** The width of the line. Defaults to 10 */ + lineWidth?: number; + /** The fill color of the shape when applicable. Defaults to "transparent" */ + fillColor?: string; + /** The background color of the sketch, in rgba or hex format. Defaults to "transparent" */ + backgroundColor?: string; + /** The opacity of the object. Accepted for compatibility, it is not applied (as in 0.5.x). Defaults to 1.0 */ + opacity?: number; + /** Number of undo/redo steps to maintain, read when the component mounts. Defaults to 25 */ + undoSteps?: number; + /** Image format used by `toDataURL` when it is not given in its options, "png" or "jpeg" */ + imageFormat?: string; + /** + * Sketch data for controlling the sketch from outside the component. It is + * loaded again when it changes (or on every update when `forceValue` is set) + */ + value?: SketchValue; + /** Set to true to load the given `value` on every update, even when it did not change. Defaults to false */ + forceValue?: boolean; + /** Initial value of the sketch, loaded when the component mounts */ + defaultValue?: SketchValue; + /** Correction in pixels subtracted from the width of the container when the canvas is resized. Defaults to 0 */ + widthCorrection?: number; + /** Correction in pixels subtracted from the height of the container when the canvas is resized. Defaults to 0 */ + heightCorrection?: number; + /** + * Resize the canvas, and scale its objects, when the window is resized. + * The canvas is always fitted to its container when the component mounts + * and when `width` or `height` change. Defaults to true + */ + autoresize?: boolean; + /** Width of the sketch container, in pixels */ + width?: number; + /** Height of the sketch container, in pixels. Defaults to 512 */ + height?: number; + /** Class name of the container div of the canvas */ + className?: string; + /** Style of the container div of the canvas */ + style?: CSS.Properties; + /** + * Called after a drawing was finished (mouse up, mouse out) and after undo, redo and + * loading a value. The argument is the DOM event of the former, nothing in the latter + */ + onChange?: (event?: Event) => void; + /** Called when an object is added by the user, not when undo or redo add it back */ + onObjectAdded?: (event: SketchEvent) => void; + /** Called when an object is modified by the user (moved, scaled, rotated) */ + onObjectModified?: (event: SketchEvent) => void; + /** Called when an object is removed from the sketch */ + onObjectRemoved?: (event: SketchEvent) => void; + /** Called on mouse down on the canvas */ + onMouseDown?: (event: SketchEvent) => void; + /** Called on mouse move on the canvas */ + onMouseMove?: (event: SketchEvent) => void; + /** Called on mouse up on the canvas */ + onMouseUp?: (event: SketchEvent) => void; + /** Called when the mouse leaves the canvas */ + onMouseOut?: (event: SketchEvent) => void; + /** Called while an object is moved */ + onObjectMoving?: (event: SketchEvent) => void; + /** Called while an object is scaled */ + onObjectScaling?: (event: SketchEvent) => void; + /** Called while an object is rotated */ + onObjectRotating?: (event: SketchEvent) => void; +} diff --git a/packages/react-sketch/src/utils/index.ts b/packages/react-sketch/src/utils/index.ts index 8282a216..7ccca167 100644 --- a/packages/react-sketch/src/utils/index.ts +++ b/packages/react-sketch/src/utils/index.ts @@ -1,8 +1,47 @@ +interface Point { + x: number; + y: number; +} + +/** + * Determine the mouse (or touch) position relative to the event target, + * excluding the target border + * + * @param event the mouse or touch event + * @returns tuple of position x,y + */ +export const pointerPosition = ( + event: MouseEvent | TouchEvent +): [number, number] => { + const target = event.target as HTMLElement; + const style = window.getComputedStyle(target); + const rect = target.getBoundingClientRect(); + const point = "changedTouches" in event ? event.changedTouches[0] : event; + return [ + point.clientX - parseInt(style.borderLeftWidth, 10) - rect.left, + point.clientY - parseInt(style.borderTopWidth, 10) - rect.top, + ]; +}; + +/** + * Calculate the distance of two x,y points + * + * @param point1 an object with x,y attributes representing the start point + * @param point2 an object with x,y attributes representing the end point + * + * @returns {number} + */ +export const linearDistance = (point1: Point, point2: Point): number => { + const xs = point2.x - point1.x; + const ys = point2.y - point1.y; + return Math.sqrt(xs * xs + ys * ys); +}; + /** * Return a random uuid of the form xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx * @returns {string} */ -export const uuid4 = () => { +export const uuid4 = (): string => { let uuid = "", ii; for (ii = 0; ii < 32; ii += 1) { @@ -26,3 +65,180 @@ export const uuid4 = () => { } return uuid; }; + +/** + * Convert a hex color code to its rgba equivalent (with 0.4 alpha) + * + * @param hex is a color code in the form of #rgb or #rrggbb + * @returns {string} + * @throws Error when the given value is not a valid hex color code + */ +export const hexToRgbA = (hex: string): string => { + if (!/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) { + throw new Error(`Bad hex color: "${hex}"`); + } + let digits = hex.substring(1).split(""); + if (digits.length === 3) { + digits = [digits[0], digits[0], digits[1], digits[1], digits[2], digits[2]]; + } + const value = parseInt(digits.join(""), 16); + const [r, g, b] = [(value >> 16) & 255, (value >> 8) & 255, value & 255]; + return `rgba(${r},${g},${b},0.4)`; +}; + +const COLORS: Record = { + aliceblue: "#f0f8ff", + antiquewhite: "#faebd7", + aqua: "#00ffff", + aquamarine: "#7fffd4", + azure: "#f0ffff", + beige: "#f5f5dc", + bisque: "#ffe4c4", + black: "#000000", + blanchedalmond: "#ffebcd", + blue: "#0000ff", + blueviolet: "#8a2be2", + brown: "#a52a2a", + burlywood: "#deb887", + cadetblue: "#5f9ea0", + chartreuse: "#7fff00", + chocolate: "#d2691e", + coral: "#ff7f50", + cornflowerblue: "#6495ed", + cornsilk: "#fff8dc", + crimson: "#dc143c", + cyan: "#00ffff", + darkblue: "#00008b", + darkcyan: "#008b8b", + darkgoldenrod: "#b8860b", + darkgray: "#a9a9a9", + darkgreen: "#006400", + darkkhaki: "#bdb76b", + darkmagenta: "#8b008b", + darkolivegreen: "#556b2f", + darkorange: "#ff8c00", + darkorchid: "#9932cc", + darkred: "#8b0000", + darksalmon: "#e9967a", + darkseagreen: "#8fbc8f", + darkslateblue: "#483d8b", + darkslategray: "#2f4f4f", + darkturquoise: "#00ced1", + darkviolet: "#9400d3", + deeppink: "#ff1493", + deepskyblue: "#00bfff", + dimgray: "#696969", + dodgerblue: "#1e90ff", + firebrick: "#b22222", + floralwhite: "#fffaf0", + forestgreen: "#228b22", + fuchsia: "#ff00ff", + gainsboro: "#dcdcdc", + ghostwhite: "#f8f8ff", + gold: "#ffd700", + goldenrod: "#daa520", + gray: "#808080", + green: "#008000", + greenyellow: "#adff2f", + honeydew: "#f0fff0", + hotpink: "#ff69b4", + indianred: "#cd5c5c", + indigo: "#4b0082", + ivory: "#fffff0", + khaki: "#f0e68c", + lavender: "#e6e6fa", + lavenderblush: "#fff0f5", + lawngreen: "#7cfc00", + lemonchiffon: "#fffacd", + lightblue: "#add8e6", + lightcoral: "#f08080", + lightcyan: "#e0ffff", + lightgoldenrodyellow: "#fafad2", + lightgrey: "#d3d3d3", + lightgreen: "#90ee90", + lightpink: "#ffb6c1", + lightsalmon: "#ffa07a", + lightseagreen: "#20b2aa", + lightskyblue: "#87cefa", + lightslategray: "#778899", + lightsteelblue: "#b0c4de", + lightyellow: "#ffffe0", + lime: "#00ff00", + limegreen: "#32cd32", + linen: "#faf0e6", + magenta: "#ff00ff", + maroon: "#800000", + mediumaquamarine: "#66cdaa", + mediumblue: "#0000cd", + mediumorchid: "#ba55d3", + mediumpurple: "#9370db", + mediumseagreen: "#3cb371", + mediumslateblue: "#7b68ee", + mediumspringgreen: "#00fa9a", + mediumturquoise: "#48d1cc", + mediumvioletred: "#c71585", + midnightblue: "#191970", + mintcream: "#f5fffa", + mistyrose: "#ffe4e1", + moccasin: "#ffe4b5", + navajowhite: "#ffdead", + navy: "#000080", + oldlace: "#fdf5e6", + olive: "#808000", + olivedrab: "#6b8e23", + orange: "#ffa500", + orangered: "#ff4500", + orchid: "#da70d6", + palegoldenrod: "#eee8aa", + palegreen: "#98fb98", + paleturquoise: "#afeeee", + palevioletred: "#db7093", + papayawhip: "#ffefd5", + peachpuff: "#ffdab9", + peru: "#cd853f", + pink: "#ffc0cb", + plum: "#dda0dd", + powderblue: "#b0e0e6", + purple: "#800080", + rebeccapurple: "#663399", + red: "#ff0000", + rosybrown: "#bc8f8f", + royalblue: "#4169e1", + saddlebrown: "#8b4513", + salmon: "#fa8072", + sandybrown: "#f4a460", + seagreen: "#2e8b57", + seashell: "#fff5ee", + sienna: "#a0522d", + silver: "#c0c0c0", + skyblue: "#87ceeb", + slateblue: "#6a5acd", + slategray: "#708090", + snow: "#fffafa", + springgreen: "#00ff7f", + steelblue: "#4682b4", + tan: "#d2b48c", + teal: "#008080", + thistle: "#d8bfd8", + tomato: "#ff6347", + turquoise: "#40e0d0", + violet: "#ee82ee", + wheat: "#f5deb3", + white: "#ffffff", + whitesmoke: "#f5f5f5", + yellow: "#ffff00", + yellowgreen: "#9acd32", +}; + +/** + * Convert a CSS color name to its hex equivalent + * + * @param color is a color name (case insensitive) + * @returns the hex code, or undefined when the name is unknown + */ +export const colorNameToHex = (color: string): string | undefined => { + const name = color.toLowerCase(); + return Object.prototype.hasOwnProperty.call(COLORS, name) + ? COLORS[name] + : undefined; +}; diff --git a/packages/react-sketch/src/utils/utils.spec.ts b/packages/react-sketch/src/utils/utils.spec.ts new file mode 100644 index 00000000..a8752f33 --- /dev/null +++ b/packages/react-sketch/src/utils/utils.spec.ts @@ -0,0 +1,86 @@ +/** + * @jest-environment jsdom + */ +import { + colorNameToHex, + hexToRgbA, + linearDistance, + pointerPosition, + uuid4, +} from "./index"; + +describe("Utils", () => { + it("Generates random uuid", () => { + expect(uuid4()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); + expect(uuid4()).not.toEqual(uuid4()); + }); + + describe("linearDistance", () => { + it("Calculates the distance of two points", () => { + expect(linearDistance({ x: 0, y: 0 }, { x: 3, y: 4 })).toEqual(5); + expect(linearDistance({ x: 3, y: 4 }, { x: 0, y: 0 })).toEqual(5); + expect(linearDistance({ x: 7, y: 7 }, { x: 7, y: 7 })).toEqual(0); + }); + }); + + describe("hexToRgbA", () => { + it("Converts hex codes to rgba", () => { + expect(hexToRgbA("#ff0000")).toEqual("rgba(255,0,0,0.4)"); + expect(hexToRgbA("#0F8")).toEqual("rgba(0,255,136,0.4)"); + }); + + it("Rejects invalid hex codes", () => { + expect(() => hexToRgbA("red")).toThrow("Bad hex color"); + expect(() => hexToRgbA("#12")).toThrow("Bad hex color"); + expect(() => hexToRgbA("#gggggg")).toThrow("Bad hex color"); + }); + }); + + describe("colorNameToHex", () => { + it("Converts color names to hex, ignoring the case", () => { + expect(colorNameToHex("red")).toEqual("#ff0000"); + expect(colorNameToHex("RebeccaPurple")).toEqual("#663399"); + expect(colorNameToHex("indianred")).toEqual("#cd5c5c"); + }); + + it("Returns undefined for unknown names", () => { + expect(colorNameToHex("not-a-color")).toBeUndefined(); + expect(colorNameToHex("constructor")).toBeUndefined(); + }); + }); + + describe("pointerPosition", () => { + let element: HTMLDivElement; + + beforeEach(() => { + element = document.createElement("div"); + element.style.borderLeftWidth = "2px"; + element.style.borderTopWidth = "3px"; + element.getBoundingClientRect = () => ({ left: 10, top: 20 }) as DOMRect; + document.body.appendChild(element); + }); + + afterEach(() => { + element.remove(); + }); + + it("Is relative to the target, excluding its border", () => { + let position: [number, number] | undefined; + element.addEventListener("click", (event) => { + position = pointerPosition(event); + }); + element.dispatchEvent(new MouseEvent("click", { clientX: 50, clientY: 70 })); + expect(position).toEqual([38, 47]); + }); + + it("Supports touch events", () => { + const event = { + target: element, + changedTouches: [{ clientX: 30, clientY: 40 }], + } as unknown as TouchEvent; + expect(pointerPosition(event)).toEqual([18, 17]); + }); + }); +}); diff --git a/packages/react-sketch/tsconfig.build.json b/packages/react-sketch/tsconfig.build.json new file mode 100644 index 00000000..e637c9fd --- /dev/null +++ b/packages/react-sketch/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "**/*.spec.ts", "**/*.spec.tsx"] +} diff --git a/packages/react-sketch/tsconfig.json b/packages/react-sketch/tsconfig.json index bc72df58..336527e1 100644 --- a/packages/react-sketch/tsconfig.json +++ b/packages/react-sketch/tsconfig.json @@ -1,8 +1,4 @@ { "extends": "../tsconfig.packages.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "lib": ["dom", "dom.iterable", "esnext"] - } + "include": ["src"] } diff --git a/packages/react-sketch/tsconfig.spec.json b/packages/react-sketch/tsconfig.spec.json new file mode 100644 index 00000000..0fc1c0fe --- /dev/null +++ b/packages/react-sketch/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "commonjs", + "jsx": "react-jsx", + "lib": ["dom", "dom.iterable", "esnext"], + "types": ["jest", "node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["src", "jest.setup.ts"] +} diff --git a/packages/react-sketch/webpack.config.js b/packages/react-sketch/webpack.config.js index 225680ba..030b1043 100644 --- a/packages/react-sketch/webpack.config.js +++ b/packages/react-sketch/webpack.config.js @@ -1,35 +1,45 @@ const path = require("path"); -const TerserPlugin = require("terser-webpack-plugin"); -const { CleanWebpackPlugin } = require("clean-webpack-plugin"); + +const umdExternal = (name, root) => ({ + commonjs: name, + commonjs2: name, + amd: name, + root, +}); module.exports = { entry: { index: "./src/index.ts", }, - performance: { - hints: false, - }, - optimization: { - minimize: true, - minimizer: [new TerserPlugin()], + // react and fabric are provided by the consumer, never bundled. The script + // tag build of fabric (7.x) defines window.fabric with the named exports, + // which is what `import * as fabric from "fabric"` reads from the global. + externals: { + react: umdExternal("react", "React"), + "react/jsx-runtime": umdExternal("react/jsx-runtime", "ReactJSXRuntime"), + fabric: umdExternal("fabric", "fabric"), }, output: { filename: "[name].js", - library: "ReactSketch", - libraryTarget: "umd", + library: { name: "ReactSketch", type: "umd" }, + globalObject: "this", path: path.resolve(__dirname, "dist"), + clean: true, }, resolve: { extensions: [".ts", ".tsx", ".js"], }, devtool: "source-map", - plugins: [new CleanWebpackPlugin()], module: { rules: [ { test: /\.tsx?$/, - loader: "awesome-typescript-loader", + loader: "ts-loader", exclude: /node_modules/, + options: { + configFile: path.resolve(__dirname, "tsconfig.json"), + onlyCompileBundledFiles: true, + }, }, ], }, diff --git a/packages/showcase/README.md b/packages/showcase/README.md new file mode 100644 index 00000000..df896885 --- /dev/null +++ b/packages/showcase/README.md @@ -0,0 +1,33 @@ +# showcase + +Demo application for [react-sketch](../react-sketch). It is private and is not published to npm. + +It shows a `SketchField` with a tools panel for the properties of the component, an actions toolbar for +its methods (undo, redo, clear, download, copy, paste, zoom), and the usage scenarios `defaultValue`, +`value` with `forceValue`, and `addImg`. + +## Run + +From the root of the repository: + +```sh +yarn install +yarn start +``` + +The application is served at . It imports `react-sketch` from the sources of +`packages/react-sketch` (webpack alias), so the library does not have to be built first. + +## Scripts + +Run them from the root of the repository, or with `yarn workspace showcase