Compare commits

...

65 Commits

Author SHA1 Message Date
ffa4cea61c refactor: FONT_FAMILY and related helpers 2023-08-25 21:05:45 +02:00
de1ebad755 fix: regression in indexing when adding elements to frame (#6904) 2023-08-18 16:34:01 +02:00
9cd5e15917 fix: stabilize selectedElementIds when box selecting (#6912) 2023-08-18 16:14:57 +02:00
8101a351db fix: resetting deleted elements on duplication (#6906) 2023-08-18 00:28:26 +02:00
49e9a2ab33 build(deps): bump @excalidraw/excalidraw from 0.15.2 to 0.15.3 in /dev-docs (#6896)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-17 13:47:32 +02:00
d140d1b8b3 fix: make canvas compos memoize appState on props they declare (#6897) 2023-08-17 13:39:15 +02:00
1bd416002c fix: scope --color-selection retrieval to given instance (#6886)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-08-16 21:59:37 +00:00
991f5570ce build(deps): bump word-wrap from 1.2.3 to 1.2.5 (#6892)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.5.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.5)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-16 16:11:43 +05:30
3cf8259e71 build(deps): bump semver from 6.3.0 to 6.3.1 in /src/packages/utils (#6755)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-16 11:38:36 +02:00
9b727025fd build(deps): bump semver from 6.3.0 to 6.3.1 in /src/packages/excalidraw (#6754)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-16 11:38:16 +02:00
59b53eb9cb build(deps): bump protobufjs from 6.11.3 to 6.11.4 (#6890)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-16 11:25:31 +02:00
9e0bfd178e refactor: factor out shape generation from renderElement.ts pt 2 (#6878) 2023-08-14 13:52:25 +02:00
c29f19a88b perf: Limiting the suggested binding to fix performance issue (#6877) 2023-08-14 13:11:08 +02:00
2b14a5c233 build: increase limit for bundle by 1kb (#6880) 2023-08-14 15:47:18 +05:30
a376bd9495 feat: partition main canvas vertically (#6759)
Co-authored-by: Marcel Mraz <marcel.mraz@adacta-fintech.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-08-12 22:56:59 +02:00
3ea07076ad feat: support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically (#6546)
* feat: support creating text containers programatically

* fix

* fix

* fix

* fix

* update api to use label

* fix api and support individual shapes and text element

* update test case in package example

* support creating arrows and line

* support labelled arrows

* add in package example

* fix alignment

* better types

* fix

* keep element as is unless we support prog api

* fix tests

* fix lint

* ignore

* support arrow bindings via start and end in api

* fix lint

* fix coords

* support id as well for elements

* preserve bindings if present and fix testcases

* preserve bindings for labelled arrows

* support ids, clean up code and move the api related stuff to transform.ts

* allow multiple arrows to bind to single element

* fix singular elements

* fix single text element, unique id and tests

* fix lint

* fix

* support binding arrow to text element

* fix creation of regular text

* use same stroke color as parent for text containers and height 0 for linear element by default

* fix types

* fix

* remove more ts ignore

* remove ts ignore

* remove

* Add coverage script

* Add tests

* fix tests

* make type optional when id present

* remove type when id provided in tests

* Add more tests

* tweak

* let host call convertToExcalidrawElements when using programmatic API

* remove convertToExcalidrawElements call from restore

* lint

* update snaps

* Add new type excalidraw-api/clipboard for programmatic api

* cleanup

* rename tweak

* tweak

* make image attributes optional and better ts check

* support image via programmatic API

* fix lint

* more types

* make fileId mandatory for image and export convertToExcalidrawElements

* fix

* small tweaks

* update snaps

* fix

* use Object.assign instead of mutateElement

* lint

* preserve z-index by pushing all elements first and then add bindings

* instantiate instead of closure for storing elements

* use element API to create regular text, diamond, ellipse and rectangle

* fix snaps

* udpdate api

* ts fixes

* make `convertToExcalidrawElements` more typesafe

* update snaps

* refactor the approach so that order of elements doesn't matter

* Revert "update snaps"

This reverts commit 621dfadccf.

* review fixes

* rename ExcalidrawProgrammaticElement -> ExcalidrawELementSkeleton

* Add tests

* give preference to first element when duplicate ids found

* use console.error

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
2023-08-09 16:41:15 +05:30
ded0222e8d docs: add note for tests that have requisites (#6856)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2023-08-05 09:51:50 +00:00
e7983bc493 fix: webpack config exclude statement to system agnostic (#6857) 2023-08-05 11:49:57 +02:00
083bcf802c fix: remove embeddable from generic elements (#6853) 2023-08-04 15:16:55 +02:00
bb985eba3a fix: resizing arrow labels (#6789)
* fix arrow labels resizing

- min arrow labels width based on font size
- labels width and padding in % of container's width
- resize labels simply multiplying by scale

* remove no longer needed getContainerDims

* fix arrow labels font size not updated on change font size action

* fix bound arrows not updated right after resize

* fix test

* fix 3+ point arrow label resizing with shift

* fix bound text not scaling when resizing with shift & n or s handle

* fix arrow labels width not updating when moving a 2-point arrow point with shift

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-08-02 15:34:21 +05:30
23c88a38d0 build(deps): bump semver from 5.7.1 to 5.7.2 in /dev-docs (#6757)
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-01 16:14:08 +05:30
b85d5fa12b test: add coverage report for PR (#6840)
* test: add coverage report for PR

* gh token

* fix

* fix

* add reporter

* fix

* enable v8 for coverage

* no watch

* test

* add threshold

* fix

* change name so the action isn't required

* change job name

* rename job so it doesn't collid with test required check

* remove log
2023-08-01 16:13:04 +05:30
9391a09e54 build(deps): bump word-wrap from 1.2.3 to 1.2.5 (#6811)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.5.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.5)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-01 11:07:16 +05:30
50450a7dab chore: update required node version to 18 (#6837) 2023-07-31 23:47:41 +02:00
fc9a9a2571 fix: eye-dropper not working with app offset correctly on non-1 dPR (#6835) 2023-07-31 16:36:30 +02:00
6126c34dc0 fix: add self destroying service-worker.js to migrate everyone from CRA to Vite (#6833)
* fix: add self destroying service-worker.js to migrate everyone from CRA to VITE

* add comment

* don't add service-worker.js to app pre-cache
2023-07-31 19:56:50 +05:30
12e37e3dd2 fix: forgotten REACT_APP env variables (#6834) 2023-07-31 15:29:24 +02:00
9ca27c62c7 feat: add support for simplePDF in Web-Embeds (#6810) 2023-07-29 10:02:15 +02:00
1acc646534 feat: add support for val.town embeds (#6821) 2023-07-28 16:55:40 +02:00
66bac50de3 fix: refresh sw when browser refreshed (#6824)
* fix: refresh sw when browser refreshed

* dummy test

* enable immediate reload

* test

* fix

* autoupdate

* don't immediate

* test

* testing testing

* test

* test

* its same

* revert logs

* fix types

---------
2023-07-28 20:17:24 +05:30
5dd1efde8a build: update to node 18 in docker (#6822) 2023-07-28 12:06:33 +05:30
a7c590d459 feat: render bold lines in grid (#6779)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-07-27 22:41:44 +00:00
48924688c7 build: migrate to Vite 🚀 (#6818)
* init

* add: vite dev build working

* fix: href serving from public

* feat: add ejs plugin

* feat: migrated env files and ejs templating

* chore: add types related to envs

* chore: add vite-env types

* feat: support vite pwa

* chore: upgrade vite pwa

* chore: pin node version to 16.18.1

* chore: preserve use of nodejs 14

* refactor: preserve REACT_APP as env prefix

* chore: support esm environment variables

* fix ts config

* use VITE prefix and remove vite-plugin-env-compatible

* introduce import-meta-loader for building pacakge as webpack isn't compatible with import.meta syntax

* lint

* remove import.meta.env in main.js

* set debug flag to false

* migrate to vitest and use jest-canvas-mock 2.4.0 so its comp
atible with vite

* integrate vitest-ui

* fix most of teh test

* snaps

* Add script for testing with vite ui

* fix all tests related to mocking

* fix more test

* fix more

* fix flip.test.tsx

* fix contentxmenu snaps

* fix regression snaps

* fix excalidraw.test.tsx and this makes all tests finally pass :)

* use node 16

* specify node version

* use node 16 in lint as well

* fix mobile.test.tsx

* use node 16

* add style-loader

* upgrade to node 18

* fix lint package.json

* support eslint with vite

* fix lint

* fix lint

* fix ts

* remove pwa/sw stuff

* use env vars in EJS the vite way

* fix lint

* move remainig jest mock/spy to vite

* don't cache locales

* fix regex

* add fonts cache

* tweak

* add custom service worker

* upgrade vite and create font cache again

* cache fonts.css and locales

* tweak

* use manifestTransforms for filtering locales

* use assets js pattern for locales

* add font.css to globIgnore so its pushed to fonts cache

* create a separate chunk for locales with rollup

* remove manifestTransforms and fix glob pattern for locales to filter from workbox pre-cache

* push sourcemaps in production

* add comments in config

* lint

* use node 18

* disable pwa in dev

* fix

* fix

* increase limit of bundle

* upgrade vite-pwa to latest

* remove public/workbox so workbox assets are not precached

* fon't club en.json and percentages.json with manual locales chunk to fix first load+offline mode

* tweak regex

* remove happy-dom as its not used

* add comment

* use any instead of ts-ignore

* cleanup

* remove jest-canvas-mock resolution as vite-canvas-mock was patched locking deps at 2.4.0

* use same theme color present in entry point

* remove vite-plugin-eslint as it improves DX significantly

* integrate vite-plugin-checker for ts errors

* add nabla/vite-plugin-eslint

* use eslint from checker only

* add env variable VITE_APP_COLLAPSE_OVERLAY for collapsing the checker overlay

* tweak vite checker overlay badge position

* Enable eslint behind flag as its not working well with windows with non WSL

* make port configurable

* open the browser when server ready

* enable eslint by default

---------

Co-authored-by: Weslley Braga <weslley@bambee.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-07-27 23:50:11 +05:30
8af9ea3cf3 fix: adding to selection via shift box-select (#6815) 2023-07-27 12:50:08 +02:00
cbd908097f chore: typo - Collab.ts (#6812) 2023-07-26 23:30:25 +02:00
c1247742ea fix: prevent binding focus NaN value (#6803)
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-07-26 23:28:11 +02:00
8104068bd5 revert: "build: Migrate to Vite 🚀" (#6814)
Revert "build: Migrate to Vite 🚀 (#6713)"

This reverts commit e93bbc5776.
2023-07-26 22:34:06 +05:30
dcc75ed007 feat: adds support for stackblitz.com embeds (#6813)
feat: adds stackblitz.com embeds
2023-07-26 21:01:18 +05:30
e93bbc5776 build: Migrate to Vite 🚀 (#6713)
* init

* add: vite dev build working

* fix: href serving from public

* feat: add ejs plugin

* feat: migrated env files and ejs templating

* chore: add types related to envs

* chore: add vite-env types

* feat: support vite pwa

* chore: upgrade vite pwa

* chore: pin node version to 16.18.1

* chore: preserve use of nodejs 14

* refactor: preserve REACT_APP as env prefix

* chore: support esm environment variables

* fix ts config

* use VITE prefix and remove vite-plugin-env-compatible

* introduce import-meta-loader for building pacakge as webpack isn't compatible with import.meta syntax

* lint

* remove import.meta.env in main.js

* set debug flag to false

* migrate to vitest and use jest-canvas-mock 2.4.0 so its comp
atible with vite

* integrate vitest-ui

* fix most of teh test

* snaps

* Add script for testing with vite ui

* fix all tests related to mocking

* fix more test

* fix more

* fix flip.test.tsx

* fix contentxmenu snaps

* fix regression snaps

* fix excalidraw.test.tsx and this makes all tests finally pass :)

* use node 16

* specify node version

* use node 16 in lint as well

* fix mobile.test.tsx

* use node 16

* add style-loader

* upgrade to node 18

* fix lint package.json

* support eslint with vite

* fix lint

* fix lint

* fix ts

* remove pwa/sw stuff

* use env vars in EJS the vite way

* fix lint

* move remainig jest mock/spy to vite

* don't cache locales

* fix regex

* add fonts cache

* tweak

* add custom service worker

* upgrade vite and create font cache again

* cache fonts.css and locales

* tweak

* use manifestTransforms for filtering locales

* use assets js pattern for locales

* add font.css to globIgnore so its pushed to fonts cache

* create a separate chunk for locales with rollup

* remove manifestTransforms and fix glob pattern for locales to filter from workbox pre-cache

* push sourcemaps in production

* add comments in config

* lint

* use node 18

* disable pwa in dev

* fix

* fix

* increase limit of bundle

* upgrade vite-pwa to latest

* remove public/workbox so workbox assets are not precached

* fon't club en.json and percentages.json with manual locales chunk to fix first load+offline mode

* tweak regex

* remove happy-dom as its not used

* add comment

* use any instead of ts-ignore

* cleanup

* remove jest-canvas-mock resolution as vite-canvas-mock was patched locking deps at 2.4.0

* use same theme color present in entry point

---------

Co-authored-by: Weslley Braga <weslley@bambee.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-07-26 15:55:20 +05:30
2a2495175b build: increase limit to 290 kB for prod bundle (#6809) 2023-07-26 08:49:39 +05:30
b57b3b573d feat: introducing Web-Embeds (alias iframe element) (#6691)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-07-24 16:51:53 +02:00
744e5b2ab3 fix: use pull request in semantic workflow for better security (#6799)
* fix: use pull request in semantic workflow for security

* add types
2023-07-24 13:01:42 +05:30
d36c72c435 fix: don't show canvasBackground label when UIOptions.canvasActions.changeViewBackgroundColor is false (#6781)
* fix(components/main-menu): not show canvasBackground

* chore(components/main-menu): add data-testid attr to canvasBackground label

* test(tests/packages/excalidraw): check whether canvasbackground label is rendered when changeviewbackground is false

* test: update snapshots

* fix(tests/packages/excalidraw): change to lowercase canvas background test id

* change to pull request target for size-limit

* Revert "change to pull request target for size-limit"

This reverts commit baf1ca2677.

* Add test

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-07-24 12:33:54 +05:30
e57dc405fa chore: add style-loader as deps instead of using from react-scripts (#6791) 2023-07-21 13:48:48 +05:30
41ed019bc2 chore: remove size-limit deps from root package.json (#6790)
* chore: remove size-limit deps from root package.json

* add size limit preset
2023-07-21 13:35:20 +05:30
f7c3644342 refactor: add typeScript support to enforce valid translation keys (#6776) 2023-07-20 18:15:32 +02:00
5e3550fc14 ci: structured build output from size-limit (#6788)
* ci: better build output from size-limit

* add size-limit.json

* try with pull request target

* fix

* revert pull request target
2023-07-20 13:54:13 +05:30
70888327a3 fix: use subdirectory for @excalidraw/excalidraw size limit (#6787)
* fix: use subdirectory for @excalidraw/excalidraw size limit

* fix

* update yml

* update path

* fix

* fix

* better
2023-07-19 22:07:18 +05:30
9fc15d81a0 ci: introduce bundle size for package @excalidraw/excalidraw (#6785)
* ci: update bundle size limit

* change the size script to track bundle size on the package excalidraw

* fix build command

* fix

* remove

* fix

* update script

* fix
2023-07-19 21:19:10 +05:30
a80ac4c748 ci: add bundle size limit action (#6783)
* ci: add bundle size limit action

* chore: fix lint

* ci: fix

* ci: fix workflow

* ci: fix workflow

* add size limit deps

* use node 18

---------

Co-authored-by: Nitin Kumar <nitin.kumar@razorpay.com>
2023-07-19 13:55:50 +05:30
9f76f8677b feat: cache most of element selection (#6747) 2023-07-17 01:09:44 +02:00
2e46e27490 fix: use actual dock state to not close docked library on insert (#6766) 2023-07-14 20:21:02 +02:00
cf0413338e feat: support customizing what parts of frames are rendered (#6752) 2023-07-10 17:13:44 +02:00
49e4289878 feat: make appState.selectedElementIds more stable (#6745) 2023-07-08 23:33:34 +02:00
3ddcc48e4c fix: UI disappears when pressing the eyedropper shortcut on mobile (#6725) 2023-06-29 12:39:44 +02:00
29a5e982c3 feat: support scrollToContent opts.fitToViewport (#6581)
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Arnošt Pleskot <arnostpleskot@gmail.com>
2023-06-29 12:36:38 +02:00
b33fa6d6f6 fix: stronger enforcement of normalizeLink (#6728)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-29 12:14:42 +02:00
b7350f9707 fix: elements in non-existing frame getting removed (#6708)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-22 22:10:08 +00:00
8dfa2a98bb fix: scrollbars renders but disable (#6706) 2023-06-22 14:35:01 +02:00
fb01ce2a00 fix: typo in chart.ts (#6696)
fix: typo
2023-06-21 11:43:37 +05:30
3d57112480 chore: Update translations from Crowdin (#6677) 2023-06-19 17:10:09 +02:00
Are
7558a4e2be feat: overwrite confirmation dialogs (#6658)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-19 17:08:12 +02:00
6d56634289 fix: do not bind text to container using text tool when it has text already (#6694)
* fix: do not bind text to container using text tool when it has text already

* Update src/element/textWysiwyg.test.tsx
2023-06-19 17:28:45 +05:30
0aa1e66486 feat: simple analitycs (#6683)
* Simple analytics for iframe and webpage

* added logic for tracking specific categories of events to reduce it

* enviroment vars clean up

* fix: lint for index.html
2023-06-19 11:18:28 +02:00
7f7128ec09 fix: don't allow binding text to images (#6693) 2023-06-19 13:47:28 +05:30
291 changed files with 25692 additions and 23124 deletions

5
.codesandbox/Dockerfile Normal file
View File

@ -0,0 +1,5 @@
FROM node:18-bullseye
# Vite wants to open the browser using `open`, so we
# need to install those utils.
RUN apt update -y && apt install -y xdg-utils

View File

@ -27,7 +27,10 @@
"start": {
"name": "Start Excalidraw",
"command": "yarn start",
"runAtStart": true
"runAtStart": true,
"preview": {
"port": 3000
}
},
"test": {
"name": "Run Tests",
@ -37,7 +40,11 @@
"install-deps": {
"name": "Install Dependencies",
"command": "yarn install",
"restartOn": { "files": ["yarn.lock"] }
"restartOn": {
"files": ["yarn.lock"],
"branch": false,
"resume": false
}
}
}
}

View File

@ -1,34 +1,39 @@
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
REACT_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_WS_SERVER_URL=http://localhost:3002
# set this only if using the collaboration workflow we use on excalidraw.com
REACT_APP_PORTAL_URL=
VITE_APP_PORTAL_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
# put these in your .env.local, or make sure you don't commit!
# must be lowercase `true` when turned on
#
# whether to enable Service Workers in development
REACT_APP_DEV_ENABLE_SW=
VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
VITE_APP_DISABLE_TRACKING=true
FAST_REFRESH=false
# MATOMO
REACT_APP_MATOMO_URL=
REACT_APP_CDN_MATOMO_TRACKER_URL=
REACT_APP_MATOMO_SITE_ID=
# The port the run the dev server
VITE_APP_PORT=3000
#Debug flags
# To enable bounding box for text containers
REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=
VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=
# Set this flag to false if you want to open the overlay by default
VITE_APP_COLLAPSE_OVERLAY=true
# Set this flag to false to disable eslint
VITE_APP_ENABLE_ESLINT=true

View File

@ -1,24 +1,15 @@
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
REACT_APP_PORTAL_URL=https://portal.excalidraw.com
VITE_APP_PORTAL_URL=https://portal.excalidraw.com
# Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
REACT_APP_WS_SERVER_URL=
# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
VITE_APP_WS_SERVER_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# production-only vars
# GOOGLE ANALYTICS
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
# MATOMO
REACT_APP_MATOMO_URL=https://excalidraw.matomo.cloud/
REACT_APP_CDN_MATOMO_TRACKER_URL=//cdn.matomo.cloud/excalidraw.matomo.cloud/matomo.js
REACT_APP_MATOMO_SITE_ID=1
REACT_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_DISABLE_TRACKING=

View File

@ -12,10 +12,10 @@ jobs:
- uses: actions/checkout@v2
with:
fetch-depth: 2
- name: Setup Node.js 14.x
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 14.x
node-version: 18.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}

View File

@ -32,10 +32,10 @@ jobs:
with:
ref: ${{ steps.sha.outputs.result }}
fetch-depth: 2
- name: Setup Node.js 14.x
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 14.x
node-version: 18.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}

View File

@ -9,10 +9,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Setup Node.js 14.x
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 14.x
node-version: 18.x
- name: Install and lint
run: |

View File

@ -14,10 +14,10 @@ jobs:
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js 14.x
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 14.x
node-version: 18.x
- name: Create report file
run: |

View File

@ -1,7 +1,7 @@
name: Semantic PR title
on:
pull_request_target:
pull_request:
types:
- opened
- edited

View File

@ -10,10 +10,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js 14.x
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 14.x
node-version: 18.x
- name: Install and build
run: |
yarn --frozen-lockfile

30
.github/workflows/size-limit.yml vendored Normal file
View File

@ -0,0 +1,30 @@
name: "Bundle Size check @excalidraw/excalidraw"
on:
pull_request:
branches:
- master
jobs:
size:
runs-on: ubuntu-latest
env:
CI_JOB_NUMBER: 1
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 18.x
- name: Install
run: yarn --frozen-lockfile
- name: Install in src/packages/excalidraw
run: yarn --frozen-lockfile
working-directory: src/packages/excalidraw
env:
CI: true
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:umd
skip_step: install
directory: src/packages/excalidraw

26
.github/workflows/test-coverage-pr.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: Test Coverage PR
on:
pull_request:
jobs:
coverage:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v2
- name: "Install Node"
uses: actions/setup-node@v2
with:
node-version: "18.x"
- name: "Install Deps"
run: yarn --frozen-lockfile
- name: "Test Coverage"
run: yarn test:coverage
- name: "Report Coverage"
if: always() # Also generate the report if tests are failing
uses: davelosert/vitest-coverage-report-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js 14.x
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 14.x
node-version: 18.x
- name: Install and test
run: |
yarn --frozen-lockfile

2
.gitignore vendored
View File

@ -26,3 +26,5 @@ src/packages/excalidraw/example/public/bundle.js
src/packages/excalidraw/example/public/excalidraw-assets-dev
src/packages/excalidraw/example/public/excalidraw.development.js
coverage
dev-dist
html

2
.nvmrc
View File

@ -1 +1 @@
14
18

View File

@ -1,4 +1,4 @@
FROM node:14-alpine AS build
FROM node:18 AS build
WORKDIR /opt/node_app

View File

@ -10,13 +10,13 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
`FONT_FAMILY` contains all the font families used in `Excalidraw` as explained below
| Font Family | Description |
| ----------- | ---------------------- |
| `Virgil` | The `handwritten` font |
| `Helvetica` | The `Normal` Font |
| `Cascadia` | The `Code` Font |
| Font Family | Description |
| ------------ | ------------------------------------------- |
| `HAND_DRAWN` | The handwritten font (by default, `Virgil`) |
| `NORMAL` | The regular font (by default, `Helvetica`) |
| `CODE` | The code font (by default, `Cascadia`) |
Defaults to `FONT_FAMILY.Virgil` unless passed in `initialData.appState.currentItemFontFamily`.
Defaults to `HAND_DRAWN` unless passed in `initialData.appState.currentItemFontFamily`.
### THEME

View File

@ -29,6 +29,8 @@ All `props` are *optional*.
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
### Storing custom data on Excalidraw elements
@ -215,7 +217,6 @@ Indicates whether to bind keyboard events to `document`. Disabled by default, me
Enable this if you want Excalidraw to handle keyboard even if the component isn't focused (e.g. a user is interacting with the navbar, sidebar, or similar).
### autoFocus
This prop indicates whether to `focus` the Excalidraw component on page load. Defaults to false.
@ -228,3 +229,12 @@ Allows you to override `id` generation for files added on canvas (images). By de
(file: File) => string | Promise<string>
```
### validateEmbeddable
```tsx
validateEmbeddable?: boolean | string[] | RegExp | RegExp[] | ((link: string) => boolean | undefined)
```
This is an optional property. By default we support a handful of well-known sites. You may allow additional sites or disallow the default ones by supplying a custom validator. If you pass `true`, all URLs will be allowed. You can also supply a list of hostnames, RegExp (or list of RegExp objects), or a function. If the function returns `undefined`, the built-in validator will be used.
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.

View File

@ -306,30 +306,32 @@ This is the history API. history.clear() will clear the history.
## scrollToContent
<pre>
(<br />
{" "}
target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>{" "}
&#124;{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>
[],
<br />
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
&#125;
<br />) => void
</pre>
```tsx
(
target?: ExcalidrawElement | ExcalidrawElement[],
opts?:
| {
fitToContent?: boolean;
animate?: boolean;
duration?: number;
}
| {
fitToViewport?: boolean;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
) => void
```
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
| Attribute | type | default | Description |
| --- | --- | --- | --- |
| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) &#124; [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. Note that the zoom range is between 10%-100%. |
| opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. |
| opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) |
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |

View File

@ -121,3 +121,16 @@ function App() {
);
}
```
## renderEmbeddable
<pre>
(element: NonDeleted&lt;ExcalidrawEmbeddableElement&gt;, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>) => JSX.Element | null
</pre>
Allows you to replace the renderer for embeddable elements (which renders `<iframe>` elements).
| Parameter | Type | Description |
| --- | --- | --- |
| `element` | `NonDeleted<ExcalidrawEmbeddableElement>` | The embeddable element to be rendered. |
| `appState` | `AppState` | The current state of the UI. |

View File

@ -69,6 +69,10 @@ It's also a good idea to consider if your change should include additional tests
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
:::note
Some checks, such as the `lint` and `test`, require approval from the maintainers to run.
They will appear as `Expected — Waiting for status to be reported` in the PR checks when they are waiting for approval.
:::
## Translating

View File

@ -18,7 +18,7 @@
"@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.15.2",
"@excalidraw/excalidraw": "0.15.3",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3",

View File

@ -1631,10 +1631,10 @@
url-loader "^4.1.1"
webpack "^5.73.0"
"@excalidraw/excalidraw@0.15.2":
version "0.15.2"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2.tgz#7dba4f6e10c52015a007efb75a9fc1afe598574c"
integrity sha512-rTI02kgWSTXiUdIkBxt9u/581F3eXcqQgJdIxmz54TFtG3ughoxO5fr4t7Fr2LZIturBPqfocQHGKZ0t2KLKgw==
"@excalidraw/excalidraw@0.15.3":
version "0.15.3"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.3.tgz#5dea570f76451adf68bc24d4bfdd67a375cfeab1"
integrity sha512-/gpY7fgMO/AEaFLWnPqzbY8H7ly+/zocFf7D0Is5sWNMD2mhult5tana12lXKLSJ6EAz7ubo1A7LajXzvJXJDA==
"@hapi/hoek@^9.0.0":
version "9.3.0"
@ -6611,19 +6611,19 @@ semver@7.0.0:
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
semver@^5.4.1:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
version "5.7.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
version "6.3.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
version "7.3.7"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies:
lru-cache "^6.0.0"

View File

@ -78,8 +78,7 @@
}
</style>
<!------------------------------------------------------------------------->
<% if (process.env.NODE_ENV === "production") { %>
<% if ("%PROD%" === "true") { %>
<script>
// Redirect Excalidraw+ users which have auto-redirect enabled.
//
@ -100,41 +99,35 @@
</script>
<% } %>
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<link
rel="preload"
href="Virgil.woff2"
href="/Virgil.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="Cascadia.woff2"
href="/Cascadia.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="manifest"
href="manifest.json"
style="--pwacompat-splash-font: 24px Virgil"
/>
<link rel="stylesheet" href="fonts.css" type="text/css" />
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD==="true" ) { %>
<link rel="stylesheet" href="/fonts.css" type="text/css" />
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
<script>
{
const _WebSocket = window.WebSocket;
window.WebSocket = function (url) {
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
console.info(
"[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
"[!!!] Live reload is disabled via VITE_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
);
} else {
return new _WebSocket(url);
@ -148,33 +141,6 @@
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- Fathom - privacy-friendly analytics -->
<script
src="https://cdn.usefathom.com/script.js"
data-site="VMSBUEYA"
defer
></script>
<!-- / Fathom -->
<!-- LEGACY GOOGLE ANALYTICS -->
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
</script>
<% } %>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style>
@ -227,17 +193,40 @@
<h1 class="visually-hidden">Excalidraw</h1>
</header>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
<!-- 100% privacy friendly analytics -->
<script
async
defer
src="https://scripts.simpleanalyticscdn.com/latest.js"
></script>
<noscript
><img
src="https://queue.simpleanalyticscdn.com/noscript.gif"
alt=""
referrerpolicy="no-referrer-when-downgrade"
/></noscript>
<script>
// need to load this script dynamically bcs. of iframe embed tracking
var scriptEle = document.createElement("script");
scriptEle.setAttribute(
"src",
"https://scripts.simpleanalyticscdn.com/latest.js",
);
scriptEle.setAttribute("type", "text/javascript");
scriptEle.setAttribute("defer", true);
scriptEle.setAttribute("async", true);
// if iframe
if (window.self !== window.top) {
scriptEle.setAttribute("data-auto-collect", true);
}
document.body.appendChild(scriptEle);
// if iframe
if (window.self !== window.top) {
scriptEle.addEventListener("load", () => {
if (window.sa_pageview) {
window.window.sa_event(action, {
category: "iframe",
label: "embed",
value: window.location.pathname,
});
}
});
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>

View File

@ -19,6 +19,7 @@
]
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
@ -31,6 +32,7 @@
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
"eslint-plugin-react": "7.32.2",
"fake-indexeddb": "3.1.7",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
@ -50,26 +52,13 @@
"pwacompat": "2.0.17",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-scripts": "5.0.1",
"roughjs": "4.5.2",
"sass": "1.51.0",
"socket.io-client": "2.3.1",
"tunnel-rat": "0.1.2",
"workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4",
"workbox-core": "^6.5.4",
"workbox-expiration": "^6.5.4",
"workbox-google-analytics": "^6.5.4",
"workbox-navigation-preload": "^6.5.4",
"workbox-precaching": "^6.5.4",
"workbox-range-requests": "^6.5.4",
"workbox-routing": "^6.5.4",
"workbox-strategies": "^6.5.4",
"workbox-streams": "^6.5.4"
"tunnel-rat": "0.1.2"
},
"devDependencies": {
"@excalidraw/eslint-config": "1.0.0",
"@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0",
"@types/jest": "27.4.0",
@ -80,48 +69,43 @@
"@types/react-dom": "18.0.6",
"@types/resize-observer-browser": "0.1.7",
"@types/socket.io-client": "1.4.36",
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0",
"@vitest/ui": "0.32.2",
"chai": "4.3.6",
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
"eslint-config-react-app": "7.0.1",
"eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1",
"husky": "7.0.4",
"jest-canvas-mock": "2.4.0",
"jsdom": "22.1.0",
"lint-staged": "12.3.7",
"pepjs": "0.5.3",
"prettier": "2.6.2",
"rewire": "6.0.0",
"typescript": "4.9.4"
"typescript": "4.9.4",
"vite": "4.4.2",
"vite-plugin-checker": "0.6.1",
"vite-plugin-ejs": "1.6.4",
"vite-plugin-pwa": "0.16.4",
"vite-plugin-svgr": "2.4.0",
"vitest": "0.34.1",
"vitest-canvas-mock": "0.3.2"
},
"engines": {
"node": ">=14.0.0"
"node": ">=18.0.0"
},
"homepage": ".",
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}"
],
"coveragePathIgnorePatterns": [
"<rootDir>/locales",
"<rootDir>/src/packages/excalidraw/dist/",
"<rootDir>/src/packages/excalidraw/types",
"<rootDir>/src/packages/excalidraw/example"
],
"transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access|canvas-roundrect-polyfill)/)"
],
"resetMocks": false
},
"name": "excalidraw",
"prettier": "@excalidraw/prettier-config",
"private": true,
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true react-scripts build",
"build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ./scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"eject": "react-scripts eject",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code",
@ -129,19 +113,21 @@
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start",
"start": "vite",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
"test:app": "react-scripts test --passWithNoTests",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest --config vitest.config.ts",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
"test:other": "yarn prettier --list-different",
"test:typecheck": "tsc",
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
"test:update": "yarn test:app --update --watch=false",
"test": "yarn test:app",
"test:coverage": "react-scripts test --passWithNoTests --coverage --watchAll",
"test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui",
"autorelease": "node scripts/autorelease.js",
"prerelease": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
"release": "node scripts/release.js"
}
}

20
public/service-worker.js Normal file
View File

@ -0,0 +1,20 @@
// Since we migrated to Vite, the service worker strategy changed, in CRA it was a custom service worker named service-worker.js and in Vite its sw.js handled by vite-plugin-pwa
// Due to this the existing CRA users were not able to migrate to Vite or any new changes post Vite unless browser is hard refreshed
// Hence adding a self destroying worker so all CRA service workers are destroyed and migrated to Vite
// We should remove this code after sometime when we are confident that
// all users have migrated to Vite
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", () => {
self.registration
.unregister()
.then(() => {
return self.clients.matchAll();
})
.then((clients) => {
clients.forEach((client) => client.navigate(client.url));
});
});

View File

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.backgroundSync=function(t,e,s){"use strict";try{self["workbox:background-sync:4.3.1"]&&_()}catch(t){}const i=3,n="workbox-background-sync",a="requests",r="queueName";class c{constructor(t){this.t=t,this.s=new s.DBWrapper(n,i,{onupgradeneeded:this.i})}async pushEntry(t){delete t.id,t.queueName=this.t,await this.s.add(a,t)}async unshiftEntry(t){const[e]=await this.s.getAllMatching(a,{count:1});e?t.id=e.id-1:delete t.id,t.queueName=this.t,await this.s.add(a,t)}async popEntry(){return this.h({direction:"prev"})}async shiftEntry(){return this.h({direction:"next"})}async getAll(){return await this.s.getAllMatching(a,{index:r,query:IDBKeyRange.only(this.t)})}async deleteEntry(t){await this.s.delete(a,t)}async h({direction:t}){const[e]=await this.s.getAllMatching(a,{direction:t,index:r,query:IDBKeyRange.only(this.t),count:1});if(e)return await this.deleteEntry(e.id),e}i(t){const e=t.target.result;t.oldVersion>0&&t.oldVersion<i&&e.objectStoreNames.contains(a)&&e.deleteObjectStore(a),e.createObjectStore(a,{autoIncrement:!0,keyPath:"id"}).createIndex(r,r,{unique:!1})}}const h=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];class o{static async fromRequest(t){const e={url:t.url,headers:{}};"GET"!==t.method&&(e.body=await t.clone().arrayBuffer());for(const[s,i]of t.headers.entries())e.headers[s]=i;for(const s of h)void 0!==t[s]&&(e[s]=t[s]);return new o(e)}constructor(t){"navigate"===t.mode&&(t.mode="same-origin"),this.o=t}toObject(){const t=Object.assign({},this.o);return t.headers=Object.assign({},this.o.headers),t.body&&(t.body=t.body.slice(0)),t}toRequest(){return new Request(this.o.url,this.o)}clone(){return new o(this.toObject())}}const u="workbox-background-sync",y=10080,w=new Set;class d{constructor(t,{onSync:s,maxRetentionTime:i}={}){if(w.has(t))throw new e.WorkboxError("duplicate-queue-name",{name:t});w.add(t),this.u=t,this.l=s||this.replayRequests,this.q=i||y,this.m=new c(this.u),this.p()}get name(){return this.u}async pushRequest(t){await this.g(t,"push")}async unshiftRequest(t){await this.g(t,"unshift")}async popRequest(){return this.R("pop")}async shiftRequest(){return this.R("shift")}async getAll(){const t=await this.m.getAll(),e=Date.now(),s=[];for(const i of t){const t=60*this.q*1e3;e-i.timestamp>t?await this.m.deleteEntry(i.id):s.push(f(i))}return s}async g({request:t,metadata:e,timestamp:s=Date.now()},i){const n={requestData:(await o.fromRequest(t.clone())).toObject(),timestamp:s};e&&(n.metadata=e),await this.m[`${i}Entry`](n),this.k?this.D=!0:await this.registerSync()}async R(t){const e=Date.now(),s=await this.m[`${t}Entry`]();if(s){const i=60*this.q*1e3;return e-s.timestamp>i?this.R(t):f(s)}}async replayRequests(){let t;for(;t=await this.shiftRequest();)try{await fetch(t.request.clone())}catch(s){throw await this.unshiftRequest(t),new e.WorkboxError("queue-replay-failed",{name:this.u})}}async registerSync(){if("sync"in registration)try{await registration.sync.register(`${u}:${this.u}`)}catch(t){}}p(){"sync"in registration?self.addEventListener("sync",t=>{if(t.tag===`${u}:${this.u}`){const e=async()=>{let e;this.k=!0;try{await this.l({queue:this})}catch(t){throw e=t}finally{!this.D||e&&!t.lastChance||await this.registerSync(),this.k=!1,this.D=!1}};t.waitUntil(e())}}):this.l({queue:this})}static get _(){return w}}const f=t=>{const e={request:new o(t.requestData).toRequest(),timestamp:t.timestamp};return t.metadata&&(e.metadata=t.metadata),e};return t.Queue=d,t.Plugin=class{constructor(...t){this.v=new d(...t),this.fetchDidFail=this.fetchDidFail.bind(this)}async fetchDidFail({request:t}){await this.v.pushRequest({request:t})}},t}({},workbox.core._private,workbox.core._private);
//# sourceMappingURL=workbox-background-sync.prod.js.map

View File

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.broadcastUpdate=function(e,t){"use strict";try{self["workbox:broadcast-update:4.3.1"]&&_()}catch(e){}const s=(e,t,s)=>{return!s.some(s=>e.headers.has(s)&&t.headers.has(s))||s.every(s=>{const n=e.headers.has(s)===t.headers.has(s),a=e.headers.get(s)===t.headers.get(s);return n&&a})},n="workbox",a=1e4,i=["content-length","etag","last-modified"],o=async({channel:e,cacheName:t,url:s})=>{const n={type:"CACHE_UPDATED",meta:"workbox-broadcast-update",payload:{cacheName:t,updatedURL:s}};if(e)e.postMessage(n);else{const e=await clients.matchAll({type:"window"});for(const t of e)t.postMessage(n)}};class c{constructor({headersToCheck:e,channelName:t,deferNoticationTimeout:s}={}){this.t=e||i,this.s=t||n,this.i=s||a,this.o()}notifyIfUpdated({oldResponse:e,newResponse:t,url:n,cacheName:a,event:i}){if(!s(e,t,this.t)){const e=(async()=>{i&&i.request&&"navigate"===i.request.mode&&await this.h(i),await this.l({channel:this.u(),cacheName:a,url:n})})();if(i)try{i.waitUntil(e)}catch(e){}return e}}async l(e){await o(e)}u(){return"BroadcastChannel"in self&&!this.p&&(this.p=new BroadcastChannel(this.s)),this.p}h(e){if(!this.m.has(e)){const s=new t.Deferred;this.m.set(e,s);const n=setTimeout(()=>{s.resolve()},this.i);s.promise.then(()=>clearTimeout(n))}return this.m.get(e).promise}o(){this.m=new Map,self.addEventListener("message",e=>{if("WINDOW_READY"===e.data.type&&"workbox-window"===e.data.meta&&this.m.size>0){for(const e of this.m.values())e.resolve();this.m.clear()}})}}return e.BroadcastCacheUpdate=c,e.Plugin=class{constructor(e){this.l=new c(e)}cacheDidUpdate({cacheName:e,oldResponse:t,newResponse:s,request:n,event:a}){t&&this.l.notifyIfUpdated({cacheName:e,oldResponse:t,newResponse:s,event:a,url:n.url})}},e.broadcastUpdate=o,e.responsesAreSame=s,e}({},workbox.core._private);
//# sourceMappingURL=workbox-broadcast-update.prod.js.map

View File

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.cacheableResponse=function(t){"use strict";try{self["workbox:cacheable-response:4.3.1"]&&_()}catch(t){}class s{constructor(t={}){this.t=t.statuses,this.s=t.headers}isResponseCacheable(t){let s=!0;return this.t&&(s=this.t.includes(t.status)),this.s&&s&&(s=Object.keys(this.s).some(s=>t.headers.get(s)===this.s[s])),s}}return t.CacheableResponse=s,t.Plugin=class{constructor(t){this.i=new s(t)}cacheWillUpdate({response:t}){return this.i.isResponseCacheable(t)?t:null}},t}({});
//# sourceMappingURL=workbox-cacheable-response.prod.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.expiration=function(t,e,s,i,a,n){"use strict";try{self["workbox:expiration:4.3.1"]&&_()}catch(t){}const h="workbox-expiration",c="cache-entries",r=t=>{const e=new URL(t,location);return e.hash="",e.href};class o{constructor(t){this.t=t,this.s=new e.DBWrapper(h,1,{onupgradeneeded:t=>this.i(t)})}i(t){const e=t.target.result.createObjectStore(c,{keyPath:"id"});e.createIndex("cacheName","cacheName",{unique:!1}),e.createIndex("timestamp","timestamp",{unique:!1}),s.deleteDatabase(this.t)}async setTimestamp(t,e){t=r(t),await this.s.put(c,{url:t,timestamp:e,cacheName:this.t,id:this.h(t)})}async getTimestamp(t){return(await this.s.get(c,this.h(t))).timestamp}async expireEntries(t,e){const s=await this.s.transaction(c,"readwrite",(s,i)=>{const a=s.objectStore(c),n=[];let h=0;a.index("timestamp").openCursor(null,"prev").onsuccess=(({target:s})=>{const a=s.result;if(a){const s=a.value;s.cacheName===this.t&&(t&&s.timestamp<t||e&&h>=e?n.push(a.value):h++),a.continue()}else i(n)})}),i=[];for(const t of s)await this.s.delete(c,t.id),i.push(t.url);return i}h(t){return this.t+"|"+r(t)}}class u{constructor(t,e={}){this.o=!1,this.u=!1,this.l=e.maxEntries,this.p=e.maxAgeSeconds,this.t=t,this.m=new o(t)}async expireEntries(){if(this.o)return void(this.u=!0);this.o=!0;const t=this.p?Date.now()-1e3*this.p:void 0,e=await this.m.expireEntries(t,this.l),s=await caches.open(this.t);for(const t of e)await s.delete(t);this.o=!1,this.u&&(this.u=!1,this.expireEntries())}async updateTimestamp(t){await this.m.setTimestamp(t,Date.now())}async isURLExpired(t){return await this.m.getTimestamp(t)<Date.now()-1e3*this.p}async delete(){this.u=!1,await this.m.expireEntries(1/0)}}return t.CacheExpiration=u,t.Plugin=class{constructor(t={}){this.D=t,this.p=t.maxAgeSeconds,this.g=new Map,t.purgeOnQuotaError&&n.registerQuotaErrorCallback(()=>this.deleteCacheAndMetadata())}k(t){if(t===a.cacheNames.getRuntimeName())throw new i.WorkboxError("expire-custom-caches-only");let e=this.g.get(t);return e||(e=new u(t,this.D),this.g.set(t,e)),e}cachedResponseWillBeUsed({event:t,request:e,cacheName:s,cachedResponse:i}){if(!i)return null;let a=this.N(i);const n=this.k(s);n.expireEntries();const h=n.updateTimestamp(e.url);if(t)try{t.waitUntil(h)}catch(t){}return a?i:null}N(t){if(!this.p)return!0;const e=this._(t);return null===e||e>=Date.now()-1e3*this.p}_(t){if(!t.headers.has("date"))return null;const e=t.headers.get("date"),s=new Date(e).getTime();return isNaN(s)?null:s}async cacheDidUpdate({cacheName:t,request:e}){const s=this.k(t);await s.updateTimestamp(e.url),await s.expireEntries()}async deleteCacheAndMetadata(){for(const[t,e]of this.g)await caches.delete(t),await e.delete();this.g=new Map}},t}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private,workbox.core);
//# sourceMappingURL=workbox-expiration.prod.js.map

View File

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.navigationPreload=function(t){"use strict";try{self["workbox:navigation-preload:4.3.1"]&&_()}catch(t){}function e(){return Boolean(self.registration&&self.registration.navigationPreload)}return t.disable=function(){e()&&self.addEventListener("activate",t=>{t.waitUntil(self.registration.navigationPreload.disable().then(()=>{}))})},t.enable=function(t){e()&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{t&&self.registration.navigationPreload.setHeaderValue(t)}))})},t.isSupported=e,t}({});
//# sourceMappingURL=workbox-navigation-preload.prod.js.map

View File

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.googleAnalytics=function(e,t,o,n,a,c,w){"use strict";try{self["workbox:google-analytics:4.3.1"]&&_()}catch(e){}const r=/^\/(\w+\/)?collect/,s=e=>async({queue:t})=>{let o;for(;o=await t.shiftRequest();){const{request:n,timestamp:a}=o,c=new URL(n.url);try{const w="POST"===n.method?new URLSearchParams(await n.clone().text()):c.searchParams,r=a-(Number(w.get("qt"))||0),s=Date.now()-r;if(w.set("qt",s),e.parameterOverrides)for(const t of Object.keys(e.parameterOverrides)){const o=e.parameterOverrides[t];w.set(t,o)}"function"==typeof e.hitFilter&&e.hitFilter.call(null,w),await fetch(new Request(c.origin+c.pathname,{body:w.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(e){throw await t.unshiftRequest(o),e}}},i=e=>{const t=({url:e})=>"www.google-analytics.com"===e.hostname&&r.test(e.pathname),o=new w.NetworkOnly({plugins:[e]});return[new n.Route(t,o,"GET"),new n.Route(t,o,"POST")]},l=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,t,"GET")},m=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,t,"GET")},u=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,t,"GET")};return e.initialize=((e={})=>{const n=o.cacheNames.getGoogleAnalyticsName(e.cacheName),c=new t.Plugin("workbox-google-analytics",{maxRetentionTime:2880,onSync:s(e)}),w=[u(n),l(n),m(n),...i(c)],r=new a.Router;for(const e of w)r.registerRoute(e);r.addFetchListener()}),e}({},workbox.backgroundSync,workbox.core._private,workbox.routing,workbox.routing,workbox.strategies,workbox.strategies);
//# sourceMappingURL=workbox-offline-ga.prod.js.map

View File

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.precaching=function(t,e,n,s,c){"use strict";try{self["workbox:precaching:4.3.1"]&&_()}catch(t){}const o=[],i={get:()=>o,add(t){o.push(...t)}};const a="__WB_REVISION__";function r(t){if(!t)throw new c.WorkboxError("add-to-cache-list-unexpected-type",{entry:t});if("string"==typeof t){const e=new URL(t,location);return{cacheKey:e.href,url:e.href}}const{revision:e,url:n}=t;if(!n)throw new c.WorkboxError("add-to-cache-list-unexpected-type",{entry:t});if(!e){const t=new URL(n,location);return{cacheKey:t.href,url:t.href}}const s=new URL(n,location),o=new URL(n,location);return o.searchParams.set(a,e),{cacheKey:o.href,url:s.href}}class l{constructor(t){this.t=e.cacheNames.getPrecacheName(t),this.s=new Map}addToCacheList(t){for(const e of t){const{cacheKey:t,url:n}=r(e);if(this.s.has(n)&&this.s.get(n)!==t)throw new c.WorkboxError("add-to-cache-list-conflicting-entries",{firstEntry:this.s.get(n),secondEntry:t});this.s.set(n,t)}}async install({event:t,plugins:e}={}){const n=[],s=[],c=await caches.open(this.t),o=await c.keys(),i=new Set(o.map(t=>t.url));for(const t of this.s.values())i.has(t)?s.push(t):n.push(t);const a=n.map(n=>this.o({event:t,plugins:e,url:n}));return await Promise.all(a),{updatedURLs:n,notUpdatedURLs:s}}async activate(){const t=await caches.open(this.t),e=await t.keys(),n=new Set(this.s.values()),s=[];for(const c of e)n.has(c.url)||(await t.delete(c),s.push(c.url));return{deletedURLs:s}}async o({url:t,event:e,plugins:o}){const i=new Request(t,{credentials:"same-origin"});let a,r=await s.fetchWrapper.fetch({event:e,plugins:o,request:i});for(const t of o||[])"cacheWillUpdate"in t&&(a=t.cacheWillUpdate.bind(t));if(!(a?a({event:e,request:i,response:r}):r.status<400))throw new c.WorkboxError("bad-precaching-response",{url:t,status:r.status});r.redirected&&(r=await async function(t){const e=t.clone(),n="body"in e?Promise.resolve(e.body):e.blob(),s=await n;return new Response(s,{headers:e.headers,status:e.status,statusText:e.statusText})}(r)),await n.cacheWrapper.put({event:e,plugins:o,request:i,response:r,cacheName:this.t,matchOptions:{ignoreSearch:!0}})}getURLsToCacheKeys(){return this.s}getCachedURLs(){return[...this.s.keys()]}getCacheKeyForURL(t){const e=new URL(t,location);return this.s.get(e.href)}}let u;const h=()=>(u||(u=new l),u);const d=(t,e)=>{const n=h().getURLsToCacheKeys();for(const s of function*(t,{ignoreURLParametersMatching:e,directoryIndex:n,cleanURLs:s,urlManipulation:c}={}){const o=new URL(t,location);o.hash="",yield o.href;const i=function(t,e){for(const n of[...t.searchParams.keys()])e.some(t=>t.test(n))&&t.searchParams.delete(n);return t}(o,e);if(yield i.href,n&&i.pathname.endsWith("/")){const t=new URL(i);t.pathname+=n,yield t.href}if(s){const t=new URL(i);t.pathname+=".html",yield t.href}if(c){const t=c({url:o});for(const e of t)yield e.href}}(t,e)){const t=n.get(s);if(t)return t}};let w=!1;const f=t=>{w||((({ignoreURLParametersMatching:t=[/^utm_/],directoryIndex:n="index.html",cleanURLs:s=!0,urlManipulation:c=null}={})=>{const o=e.cacheNames.getPrecacheName();addEventListener("fetch",e=>{const i=d(e.request.url,{cleanURLs:s,directoryIndex:n,ignoreURLParametersMatching:t,urlManipulation:c});if(!i)return;let a=caches.open(o).then(t=>t.match(i)).then(t=>t||fetch(i));e.respondWith(a)})})(t),w=!0)},y=t=>{const e=h(),n=i.get();t.waitUntil(e.install({event:t,plugins:n}).catch(t=>{throw t}))},p=t=>{const e=h(),n=i.get();t.waitUntil(e.activate({event:t,plugins:n}))},L=t=>{h().addToCacheList(t),t.length>0&&(addEventListener("install",y),addEventListener("activate",p))};return t.addPlugins=(t=>{i.add(t)}),t.addRoute=f,t.cleanupOutdatedCaches=(()=>{addEventListener("activate",t=>{const n=e.cacheNames.getPrecacheName();t.waitUntil((async(t,e="-precache-")=>{const n=(await caches.keys()).filter(n=>n.includes(e)&&n.includes(self.registration.scope)&&n!==t);return await Promise.all(n.map(t=>caches.delete(t))),n})(n).then(t=>{}))})}),t.getCacheKeyForURL=(t=>{return h().getCacheKeyForURL(t)}),t.precache=L,t.precacheAndRoute=((t,e)=>{L(t),f(e)}),t.PrecacheController=l,t}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private);
//# sourceMappingURL=workbox-precaching.prod.js.map

View File

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.rangeRequests=function(e,n){"use strict";try{self["workbox:range-requests:4.3.1"]&&_()}catch(e){}async function t(e,t){try{if(206===t.status)return t;const s=e.headers.get("range");if(!s)throw new n.WorkboxError("no-range-header");const a=function(e){const t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new n.WorkboxError("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new n.WorkboxError("single-range-only",{normalizedRangeHeader:t});const s=/(\d*)-(\d*)/.exec(t);if(null===s||!s[1]&&!s[2])throw new n.WorkboxError("invalid-range-values",{normalizedRangeHeader:t});return{start:""===s[1]?null:Number(s[1]),end:""===s[2]?null:Number(s[2])}}(s),r=await t.blob(),i=function(e,t,s){const a=e.size;if(s>a||t<0)throw new n.WorkboxError("range-not-satisfiable",{size:a,end:s,start:t});let r,i;return null===t?(r=a-s,i=a):null===s?(r=t,i=a):(r=t,i=s+1),{start:r,end:i}}(r,a.start,a.end),o=r.slice(i.start,i.end),u=o.size,l=new Response(o,{status:206,statusText:"Partial Content",headers:t.headers});return l.headers.set("Content-Length",u),l.headers.set("Content-Range",`bytes ${i.start}-${i.end-1}/`+r.size),l}catch(e){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}}return e.createPartialResponse=t,e.Plugin=class{async cachedResponseWillBeUsed({request:e,cachedResponse:n}){return n&&e.headers.has("range")?await t(e,n):n}},e}({},workbox.core._private);
//# sourceMappingURL=workbox-range-requests.prod.js.map

View File

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.routing=function(t,e,r){"use strict";try{self["workbox:routing:4.3.1"]&&_()}catch(t){}const s="GET",n=t=>t&&"object"==typeof t?t:{handle:t};class o{constructor(t,e,r){this.handler=n(e),this.match=t,this.method=r||s}}class i extends o{constructor(t,{whitelist:e=[/./],blacklist:r=[]}={}){super(t=>this.t(t),t),this.s=e,this.o=r}t({url:t,request:e}){if("navigate"!==e.mode)return!1;const r=t.pathname+t.search;for(const t of this.o)if(t.test(r))return!1;return!!this.s.some(t=>t.test(r))}}class u extends o{constructor(t,e,r){super(({url:e})=>{const r=t.exec(e.href);return r?e.origin!==location.origin&&0!==r.index?null:r.slice(1):null},e,r)}}class c{constructor(){this.i=new Map}get routes(){return this.i}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,r=this.handleRequest({request:e,event:t});r&&t.respondWith(r)})}addCacheListener(){self.addEventListener("message",async t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,r=Promise.all(e.urlsToCache.map(t=>{"string"==typeof t&&(t=[t]);const e=new Request(...t);return this.handleRequest({request:e})}));t.waitUntil(r),t.ports&&t.ports[0]&&(await r,t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const r=new URL(t.url,location);if(!r.protocol.startsWith("http"))return;let s,{params:n,route:o}=this.findMatchingRoute({url:r,request:t,event:e}),i=o&&o.handler;if(!i&&this.u&&(i=this.u),i){try{s=i.handle({url:r,request:t,event:e,params:n})}catch(t){s=Promise.reject(t)}return s&&this.h&&(s=s.catch(t=>this.h.handle({url:r,event:e,err:t}))),s}}findMatchingRoute({url:t,request:e,event:r}){const s=this.i.get(e.method)||[];for(const n of s){let s,o=n.match({url:t,request:e,event:r});if(o)return Array.isArray(o)&&o.length>0?s=o:o.constructor===Object&&Object.keys(o).length>0&&(s=o),{route:n,params:s}}return{}}setDefaultHandler(t){this.u=n(t)}setCatchHandler(t){this.h=n(t)}registerRoute(t){this.i.has(t.method)||this.i.set(t.method,[]),this.i.get(t.method).push(t)}unregisterRoute(t){if(!this.i.has(t.method))throw new r.WorkboxError("unregister-route-but-not-found-with-method",{method:t.method});const e=this.i.get(t.method).indexOf(t);if(!(e>-1))throw new r.WorkboxError("unregister-route-route-not-registered");this.i.get(t.method).splice(e,1)}}let a;const h=()=>(a||((a=new c).addFetchListener(),a.addCacheListener()),a);return t.NavigationRoute=i,t.RegExpRoute=u,t.registerNavigationRoute=((t,r={})=>{const s=e.cacheNames.getPrecacheName(r.cacheName),n=new i(async()=>{try{const e=await caches.match(t,{cacheName:s});if(e)return e;throw new Error(`The cache ${s} did not have an entry for `+`${t}.`)}catch(e){return fetch(t)}},{whitelist:r.whitelist,blacklist:r.blacklist});return h().registerRoute(n),n}),t.registerRoute=((t,e,s="GET")=>{let n;if("string"==typeof t){const r=new URL(t,location);n=new o(({url:t})=>t.href===r.href,e,s)}else if(t instanceof RegExp)n=new u(t,e,s);else if("function"==typeof t)n=new o(t,e,s);else{if(!(t instanceof o))throw new r.WorkboxError("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});n=t}return h().registerRoute(n),n}),t.Route=o,t.Router=c,t.setCatchHandler=(t=>{h().setCatchHandler(t)}),t.setDefaultHandler=(t=>{h().setDefaultHandler(t)}),t}({},workbox.core._private,workbox.core._private);
//# sourceMappingURL=workbox-routing.prod.js.map

View File

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.strategies=function(e,t,s,n,r){"use strict";try{self["workbox:strategies:4.3.1"]&&_()}catch(e){}class i{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));let n,i=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(!i)try{i=await this.u(t,e)}catch(e){n=e}if(!i)throw new r.WorkboxError("no-response",{url:t.url,error:n});return i}async u(e,t){const r=await n.fetchWrapper.fetch({request:e,event:t,fetchOptions:this.i,plugins:this.s}),i=r.clone(),h=s.cacheWrapper.put({cacheName:this.t,request:e,response:i,event:t,plugins:this.s});if(t)try{t.waitUntil(h)}catch(e){}return r}}class h{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));const n=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(!n)throw new r.WorkboxError("no-response",{url:t.url});return n}}const u={cacheWillUpdate:({response:e})=>200===e.status||0===e.status?e:null};class a{constructor(e={}){if(this.t=t.cacheNames.getRuntimeName(e.cacheName),e.plugins){let t=e.plugins.some(e=>!!e.cacheWillUpdate);this.s=t?e.plugins:[u,...e.plugins]}else this.s=[u];this.o=e.networkTimeoutSeconds,this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){const s=[];"string"==typeof t&&(t=new Request(t));const n=[];let i;if(this.o){const{id:r,promise:h}=this.l({request:t,event:e,logs:s});i=r,n.push(h)}const h=this.q({timeoutId:i,request:t,event:e,logs:s});n.push(h);let u=await Promise.race(n);if(u||(u=await h),!u)throw new r.WorkboxError("no-response",{url:t.url});return u}l({request:e,logs:t,event:s}){let n;return{promise:new Promise(t=>{n=setTimeout(async()=>{t(await this.p({request:e,event:s}))},1e3*this.o)}),id:n}}async q({timeoutId:e,request:t,logs:r,event:i}){let h,u;try{u=await n.fetchWrapper.fetch({request:t,event:i,fetchOptions:this.i,plugins:this.s})}catch(e){h=e}if(e&&clearTimeout(e),h||!u)u=await this.p({request:t,event:i});else{const e=u.clone(),n=s.cacheWrapper.put({cacheName:this.t,request:t,response:e,event:i,plugins:this.s});if(i)try{i.waitUntil(n)}catch(e){}}return u}p({event:e,request:t}){return s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s})}}class c{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.i=e.fetchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){let s,i;"string"==typeof t&&(t=new Request(t));try{i=await n.fetchWrapper.fetch({request:t,event:e,fetchOptions:this.i,plugins:this.s})}catch(e){s=e}if(!i)throw new r.WorkboxError("no-response",{url:t.url,error:s});return i}}class o{constructor(e={}){if(this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],e.plugins){let t=e.plugins.some(e=>!!e.cacheWillUpdate);this.s=t?e.plugins:[u,...e.plugins]}else this.s=[u];this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));const n=this.u({request:t,event:e});let i,h=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(h){if(e)try{e.waitUntil(n)}catch(i){}}else try{h=await n}catch(e){i=e}if(!h)throw new r.WorkboxError("no-response",{url:t.url,error:i});return h}async u({request:e,event:t}){const r=await n.fetchWrapper.fetch({request:e,event:t,fetchOptions:this.i,plugins:this.s}),i=s.cacheWrapper.put({cacheName:this.t,request:e,response:r.clone(),event:t,plugins:this.s});if(t)try{t.waitUntil(i)}catch(e){}return r}}const l={cacheFirst:i,cacheOnly:h,networkFirst:a,networkOnly:c,staleWhileRevalidate:o},q=e=>{const t=l[e];return e=>new t(e)},w=q("cacheFirst"),p=q("cacheOnly"),v=q("networkFirst"),y=q("networkOnly"),m=q("staleWhileRevalidate");return e.CacheFirst=i,e.CacheOnly=h,e.NetworkFirst=a,e.NetworkOnly=c,e.StaleWhileRevalidate=o,e.cacheFirst=w,e.cacheOnly=p,e.networkFirst=v,e.networkOnly=y,e.staleWhileRevalidate=m,e}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private);
//# sourceMappingURL=workbox-strategies.prod.js.map

View File

@ -1,2 +0,0 @@
this.workbox=this.workbox||{},this.workbox.streams=function(e){"use strict";try{self["workbox:streams:4.3.1"]&&_()}catch(e){}function n(e){const n=e.map(e=>Promise.resolve(e).then(e=>(function(e){return e.body&&e.body.getReader?e.body.getReader():e.getReader?e.getReader():new Response(e).body.getReader()})(e)));let t,r;const s=new Promise((e,n)=>{t=e,r=n});let o=0;return{done:s,stream:new ReadableStream({pull(e){return n[o].then(e=>e.read()).then(r=>{if(r.done)return++o>=n.length?(e.close(),void t()):this.pull(e);e.enqueue(r.value)}).catch(e=>{throw r(e),e})},cancel(){t()}})}}function t(e={}){const n=new Headers(e);return n.has("content-type")||n.set("content-type","text/html"),n}function r(e,r){const{done:s,stream:o}=n(e),a=t(r);return{done:s,response:new Response(o,{headers:a})}}let s=void 0;function o(){if(void 0===s)try{new ReadableStream({start(){}}),s=!0}catch(e){s=!1}return s}return e.concatenate=n,e.concatenateToResponse=r,e.isSupported=o,e.strategy=function(e,n){return async({event:s,url:a,params:c})=>{if(o()){const{done:t,response:o}=r(e.map(e=>e({event:s,url:a,params:c})),n);return s.waitUntil(t),o}const i=await Promise.all(e.map(e=>e({event:s,url:a,params:c})).map(async e=>{const n=await e;return n instanceof Response?n.blob():n})),u=t(n);return new Response(new Blob(i),{headers:u})}},e}({});
//# sourceMappingURL=workbox-streams.prod.js.map

View File

@ -1,2 +0,0 @@
!function(){"use strict";try{self["workbox:sw:4.3.1"]&&_()}catch(t){}const t="https://storage.googleapis.com/workbox-cdn/releases/4.3.1",e={backgroundSync:"background-sync",broadcastUpdate:"broadcast-update",cacheableResponse:"cacheable-response",core:"core",expiration:"expiration",googleAnalytics:"offline-ga",navigationPreload:"navigation-preload",precaching:"precaching",rangeRequests:"range-requests",routing:"routing",strategies:"strategies",streams:"streams"};self.workbox=new class{constructor(){return this.v={},this.t={debug:"localhost"===self.location.hostname,modulePathPrefix:null,modulePathCb:null},this.s=this.t.debug?"dev":"prod",this.o=!1,new Proxy(this,{get(t,s){if(t[s])return t[s];const o=e[s];return o&&t.loadModule(`workbox-${o}`),t[s]}})}setConfig(t={}){if(this.o)throw new Error("Config must be set before accessing workbox.* modules");Object.assign(this.t,t),this.s=this.t.debug?"dev":"prod"}loadModule(t){const e=this.i(t);try{importScripts(e),this.o=!0}catch(s){throw console.error(`Unable to import module '${t}' from '${e}'.`),s}}i(e){if(this.t.modulePathCb)return this.t.modulePathCb(e,this.t.debug);let s=[t];const o=`${e}.${this.s}.js`,r=this.t.modulePathPrefix;return r&&""===(s=r.split("/"))[s.length-1]&&s.splice(s.length-1,1),s.push(o),s.join("/")}}}();
//# sourceMappingURL=workbox-sw.js.map

View File

@ -1,2 +0,0 @@
try{self["workbox:window:4.3.1"]&&_()}catch(n){}var n=function(n,t){return new Promise(function(i){var e=new MessageChannel;e.port1.onmessage=function(n){return i(n.data)},n.postMessage(t,[e.port2])})};function t(n,t){for(var i=0;i<t.length;i++){var e=t[i];e.enumerable=e.enumerable||!1,e.configurable=!0,"value"in e&&(e.writable=!0),Object.defineProperty(n,e.key,e)}}function i(n){if(void 0===n)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return n}try{self["workbox:core:4.3.1"]&&_()}catch(n){}var e=function(){var n=this;this.promise=new Promise(function(t,i){n.resolve=t,n.reject=i})},r=function(n,t){return new URL(n,location).href===new URL(t,location).href},o=function(n,t){Object.assign(this,t,{type:n})};function u(n){return function(){for(var t=[],i=0;i<arguments.length;i++)t[i]=arguments[i];try{return Promise.resolve(n.apply(this,t))}catch(n){return Promise.reject(n)}}}function a(n,t,i){return i?t?t(n):n:(n&&n.then||(n=Promise.resolve(n)),t?n.then(t):n)}function s(){}var c=function(c){var f,h;function v(n,t){var r;return void 0===t&&(t={}),(r=c.call(this)||this).t=n,r.i=t,r.o=0,r.u=new e,r.s=new e,r.h=new e,r.v=r.v.bind(i(i(r))),r.l=r.l.bind(i(i(r))),r.g=r.g.bind(i(i(r))),r.m=r.m.bind(i(i(r))),r}h=c,(f=v).prototype=Object.create(h.prototype),f.prototype.constructor=f,f.__proto__=h;var l,w,g,d=v.prototype;return d.register=u(function(n){var t,i,e=this,u=(void 0===n?{}:n).immediate,c=void 0!==u&&u;return t=function(){return e.p=Boolean(navigator.serviceWorker.controller),e.P=e.R(),a(e.k(),function(n){e.B=n,e.P&&(e.O=e.P,e.s.resolve(e.P),e.h.resolve(e.P),e.j(e.P),e.P.addEventListener("statechange",e.l,{once:!0}));var t=e.B.waiting;return t&&r(t.scriptURL,e.t)&&(e.O=t,Promise.resolve().then(function(){e.dispatchEvent(new o("waiting",{sw:t,wasWaitingBeforeRegister:!0}))})),e.O&&e.u.resolve(e.O),e.B.addEventListener("updatefound",e.g),navigator.serviceWorker.addEventListener("controllerchange",e.m,{once:!0}),"BroadcastChannel"in self&&(e.C=new BroadcastChannel("workbox"),e.C.addEventListener("message",e.v)),navigator.serviceWorker.addEventListener("message",e.v),e.B})},(i=function(){if(!c&&"complete"!==document.readyState)return function(n,t){if(!t)return n&&n.then?n.then(s):Promise.resolve()}(new Promise(function(n){return addEventListener("load",n)}))}())&&i.then?i.then(t):t(i)}),d.getSW=u(function(){return this.O||this.u.promise}),d.messageSW=u(function(t){return a(this.getSW(),function(i){return n(i,t)})}),d.R=function(){var n=navigator.serviceWorker.controller;if(n&&r(n.scriptURL,this.t))return n},d.k=u(function(){var n=this;return function(n,t){try{var i=n()}catch(n){return t(n)}return i&&i.then?i.then(void 0,t):i}(function(){return a(navigator.serviceWorker.register(n.t,n.i),function(t){return n.L=performance.now(),t})},function(n){throw n})}),d.j=function(t){n(t,{type:"WINDOW_READY",meta:"workbox-window"})},d.g=function(){var n=this.B.installing;this.o>0||!r(n.scriptURL,this.t)||performance.now()>this.L+6e4?(this.W=n,this.B.removeEventListener("updatefound",this.g)):(this.O=n,this.u.resolve(n)),++this.o,n.addEventListener("statechange",this.l)},d.l=function(n){var t=this,i=n.target,e=i.state,r=i===this.W,u=r?"external":"",a={sw:i,originalEvent:n};!r&&this.p&&(a.isUpdate=!0),this.dispatchEvent(new o(u+e,a)),"installed"===e?this._=setTimeout(function(){"installed"===e&&t.B.waiting===i&&t.dispatchEvent(new o(u+"waiting",a))},200):"activating"===e&&(clearTimeout(this._),r||this.s.resolve(i))},d.m=function(n){var t=this.O;t===navigator.serviceWorker.controller&&(this.dispatchEvent(new o("controlling",{sw:t,originalEvent:n})),this.h.resolve(t))},d.v=function(n){var t=n.data;this.dispatchEvent(new o("message",{data:t,originalEvent:n}))},l=v,(w=[{key:"active",get:function(){return this.s.promise}},{key:"controlling",get:function(){return this.h.promise}}])&&t(l.prototype,w),g&&t(l,g),v}(function(){function n(){this.D={}}var t=n.prototype;return t.addEventListener=function(n,t){this.T(n).add(t)},t.removeEventListener=function(n,t){this.T(n).delete(t)},t.dispatchEvent=function(n){n.target=this,this.T(n.type).forEach(function(t){return t(n)})},t.T=function(n){return this.D[n]=this.D[n]||new Set},n}());export{c as Workbox,n as messageSW};
//# sourceMappingURL=workbox-window.prod.es5.mjs.map

View File

@ -1,2 +0,0 @@
try{self["workbox:window:4.3.1"]&&_()}catch(t){}const t=(t,s)=>new Promise(i=>{let e=new MessageChannel;e.port1.onmessage=(t=>i(t.data)),t.postMessage(s,[e.port2])});try{self["workbox:core:4.3.1"]&&_()}catch(t){}class s{constructor(){this.promise=new Promise((t,s)=>{this.resolve=t,this.reject=s})}}class i{constructor(){this.t={}}addEventListener(t,s){this.s(t).add(s)}removeEventListener(t,s){this.s(t).delete(s)}dispatchEvent(t){t.target=this,this.s(t.type).forEach(s=>s(t))}s(t){return this.t[t]=this.t[t]||new Set}}const e=(t,s)=>new URL(t,location).href===new URL(s,location).href;class n{constructor(t,s){Object.assign(this,s,{type:t})}}const h=200,a=6e4;class o extends i{constructor(t,i={}){super(),this.i=t,this.h=i,this.o=0,this.l=new s,this.g=new s,this.u=new s,this.m=this.m.bind(this),this.v=this.v.bind(this),this.p=this.p.bind(this),this._=this._.bind(this)}async register({immediate:t=!1}={}){t||"complete"===document.readyState||await new Promise(t=>addEventListener("load",t)),this.C=Boolean(navigator.serviceWorker.controller),this.W=this.L(),this.S=await this.B(),this.W&&(this.R=this.W,this.g.resolve(this.W),this.u.resolve(this.W),this.P(this.W),this.W.addEventListener("statechange",this.v,{once:!0}));const s=this.S.waiting;return s&&e(s.scriptURL,this.i)&&(this.R=s,Promise.resolve().then(()=>{this.dispatchEvent(new n("waiting",{sw:s,wasWaitingBeforeRegister:!0}))})),this.R&&this.l.resolve(this.R),this.S.addEventListener("updatefound",this.p),navigator.serviceWorker.addEventListener("controllerchange",this._,{once:!0}),"BroadcastChannel"in self&&(this.T=new BroadcastChannel("workbox"),this.T.addEventListener("message",this.m)),navigator.serviceWorker.addEventListener("message",this.m),this.S}get active(){return this.g.promise}get controlling(){return this.u.promise}async getSW(){return this.R||this.l.promise}async messageSW(s){const i=await this.getSW();return t(i,s)}L(){const t=navigator.serviceWorker.controller;if(t&&e(t.scriptURL,this.i))return t}async B(){try{const t=await navigator.serviceWorker.register(this.i,this.h);return this.U=performance.now(),t}catch(t){throw t}}P(s){t(s,{type:"WINDOW_READY",meta:"workbox-window"})}p(){const t=this.S.installing;this.o>0||!e(t.scriptURL,this.i)||performance.now()>this.U+a?(this.k=t,this.S.removeEventListener("updatefound",this.p)):(this.R=t,this.l.resolve(t)),++this.o,t.addEventListener("statechange",this.v)}v(t){const s=t.target,{state:i}=s,e=s===this.k,a=e?"external":"",o={sw:s,originalEvent:t};!e&&this.C&&(o.isUpdate=!0),this.dispatchEvent(new n(a+i,o)),"installed"===i?this.D=setTimeout(()=>{"installed"===i&&this.S.waiting===s&&this.dispatchEvent(new n(a+"waiting",o))},h):"activating"===i&&(clearTimeout(this.D),e||this.g.resolve(s))}_(t){const s=this.R;s===navigator.serviceWorker.controller&&(this.dispatchEvent(new n("controlling",{sw:s,originalEvent:t})),this.u.resolve(s))}m(t){const{data:s}=t;this.dispatchEvent(new n("message",{data:s,originalEvent:t}))}}export{o as Workbox,t as messageSW};
//# sourceMappingURL=workbox-window.prod.mjs.map

View File

@ -1,2 +0,0 @@
!function(n,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((n=n||self).workbox={})}(this,function(n){"use strict";try{self["workbox:window:4.3.1"]&&_()}catch(n){}var t=function(n,t){return new Promise(function(i){var e=new MessageChannel;e.port1.onmessage=function(n){return i(n.data)},n.postMessage(t,[e.port2])})};function i(n,t){for(var i=0;i<t.length;i++){var e=t[i];e.enumerable=e.enumerable||!1,e.configurable=!0,"value"in e&&(e.writable=!0),Object.defineProperty(n,e.key,e)}}function e(n){if(void 0===n)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return n}try{self["workbox:core:4.3.1"]&&_()}catch(n){}var r=function(){var n=this;this.promise=new Promise(function(t,i){n.resolve=t,n.reject=i})},o=function(n,t){return new URL(n,location).href===new URL(t,location).href},u=function(n,t){Object.assign(this,t,{type:n})};function s(n){return function(){for(var t=[],i=0;i<arguments.length;i++)t[i]=arguments[i];try{return Promise.resolve(n.apply(this,t))}catch(n){return Promise.reject(n)}}}function a(n,t,i){return i?t?t(n):n:(n&&n.then||(n=Promise.resolve(n)),t?n.then(t):n)}function c(){}var f=function(n){var f,h;function v(t,i){var o;return void 0===i&&(i={}),(o=n.call(this)||this).t=t,o.i=i,o.o=0,o.u=new r,o.s=new r,o.h=new r,o.v=o.v.bind(e(e(o))),o.l=o.l.bind(e(e(o))),o.g=o.g.bind(e(e(o))),o.m=o.m.bind(e(e(o))),o}h=n,(f=v).prototype=Object.create(h.prototype),f.prototype.constructor=f,f.__proto__=h;var l,w,d,g=v.prototype;return g.register=s(function(n){var t,i,e=this,r=(void 0===n?{}:n).immediate,s=void 0!==r&&r;return t=function(){return e.p=Boolean(navigator.serviceWorker.controller),e.P=e.j(),a(e.O(),function(n){e.R=n,e.P&&(e._=e.P,e.s.resolve(e.P),e.h.resolve(e.P),e.k(e.P),e.P.addEventListener("statechange",e.l,{once:!0}));var t=e.R.waiting;return t&&o(t.scriptURL,e.t)&&(e._=t,Promise.resolve().then(function(){e.dispatchEvent(new u("waiting",{sw:t,wasWaitingBeforeRegister:!0}))})),e._&&e.u.resolve(e._),e.R.addEventListener("updatefound",e.g),navigator.serviceWorker.addEventListener("controllerchange",e.m,{once:!0}),"BroadcastChannel"in self&&(e.B=new BroadcastChannel("workbox"),e.B.addEventListener("message",e.v)),navigator.serviceWorker.addEventListener("message",e.v),e.R})},(i=function(){if(!s&&"complete"!==document.readyState)return function(n,t){if(!t)return n&&n.then?n.then(c):Promise.resolve()}(new Promise(function(n){return addEventListener("load",n)}))}())&&i.then?i.then(t):t(i)}),g.getSW=s(function(){return this._||this.u.promise}),g.messageSW=s(function(n){return a(this.getSW(),function(i){return t(i,n)})}),g.j=function(){var n=navigator.serviceWorker.controller;if(n&&o(n.scriptURL,this.t))return n},g.O=s(function(){var n=this;return function(n,t){try{var i=n()}catch(n){return t(n)}return i&&i.then?i.then(void 0,t):i}(function(){return a(navigator.serviceWorker.register(n.t,n.i),function(t){return n.C=performance.now(),t})},function(n){throw n})}),g.k=function(n){t(n,{type:"WINDOW_READY",meta:"workbox-window"})},g.g=function(){var n=this.R.installing;this.o>0||!o(n.scriptURL,this.t)||performance.now()>this.C+6e4?(this.L=n,this.R.removeEventListener("updatefound",this.g)):(this._=n,this.u.resolve(n)),++this.o,n.addEventListener("statechange",this.l)},g.l=function(n){var t=this,i=n.target,e=i.state,r=i===this.L,o=r?"external":"",s={sw:i,originalEvent:n};!r&&this.p&&(s.isUpdate=!0),this.dispatchEvent(new u(o+e,s)),"installed"===e?this.W=setTimeout(function(){"installed"===e&&t.R.waiting===i&&t.dispatchEvent(new u(o+"waiting",s))},200):"activating"===e&&(clearTimeout(this.W),r||this.s.resolve(i))},g.m=function(n){var t=this._;t===navigator.serviceWorker.controller&&(this.dispatchEvent(new u("controlling",{sw:t,originalEvent:n})),this.h.resolve(t))},g.v=function(n){var t=n.data;this.dispatchEvent(new u("message",{data:t,originalEvent:n}))},l=v,(w=[{key:"active",get:function(){return this.s.promise}},{key:"controlling",get:function(){return this.h.promise}}])&&i(l.prototype,w),d&&i(l,d),v}(function(){function n(){this.D={}}var t=n.prototype;return t.addEventListener=function(n,t){this.M(n).add(t)},t.removeEventListener=function(n,t){this.M(n).delete(t)},t.dispatchEvent=function(n){n.target=this,this.M(n.type).forEach(function(t){return t(n)})},t.M=function(n){return this.D[n]=this.D[n]||new Set},n}());n.Workbox=f,n.messageSW=t,Object.defineProperty(n,"__esModule",{value:!0})});
//# sourceMappingURL=workbox-window.prod.umd.js.map

View File

@ -1,30 +1,29 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random";
import { t } from "../i18n";
import { LIBRARY_DISABLED_TYPES } from "../constants";
export const actionAddToLibrary = register({
name: "addToLibrary",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
if (selectedElements.some((element) => element.type === "image")) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: "Support for adding images to the library coming soon!",
},
};
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t(`errors.libraryElementTypeError.${type}`),
},
};
}
}
return app.library

View File

@ -13,19 +13,18 @@ import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
appState: AppState,
_: unknown,
app: AppClassProperties,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly
@ -36,12 +35,10 @@ const alignActionsPredicate = (
const alignSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
alignment: Alignment,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const selectedElements = app.scene.getSelectedElements(appState);
const updatedElements = alignElements(selectedElements, alignment);
@ -50,6 +47,7 @@ const alignSelectedElements = (
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
app,
);
};
@ -57,10 +55,10 @@ export const actionAlignTop = register({
name: "alignTop",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "start",
axis: "y",
}),
@ -69,9 +67,9 @@ export const actionAlignTop = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={AlignTopIcon}
onClick={() => updateData(null)}
@ -88,10 +86,10 @@ export const actionAlignBottom = register({
name: "alignBottom",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "end",
axis: "y",
}),
@ -100,9 +98,9 @@ export const actionAlignBottom = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={AlignBottomIcon}
onClick={() => updateData(null)}
@ -119,10 +117,10 @@ export const actionAlignLeft = register({
name: "alignLeft",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "start",
axis: "x",
}),
@ -131,9 +129,9 @@ export const actionAlignLeft = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={AlignLeftIcon}
onClick={() => updateData(null)}
@ -150,10 +148,10 @@ export const actionAlignRight = register({
name: "alignRight",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "end",
axis: "x",
}),
@ -162,9 +160,9 @@ export const actionAlignRight = register({
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={AlignRightIcon}
onClick={() => updateData(null)}
@ -181,19 +179,19 @@ export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "center",
axis: "y",
}),
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={CenterVerticallyIcon}
onClick={() => updateData(null)}
@ -208,19 +206,19 @@ export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, {
elements: alignSelectedElements(elements, appState, app, {
position: "center",
axis: "x",
}),
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState)}
hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button"
icon={CenterHorizontallyIcon}
onClick={() => updateData(null)}

View File

@ -4,12 +4,13 @@ import {
VERTICAL_ALIGN,
TEXT_ALIGN,
} from "../constants";
import { getNonDeletedElements, isTextElement, newElement } from "../element";
import { isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement";
import {
computeBoundTextPosition,
computeContainerDimensionForBoundText,
getBoundTextElement,
getFontString,
measureText,
redrawTextBoundingBox,
} from "../element/textElement";
@ -29,25 +30,21 @@ import {
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "../element/types";
import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { getFontString } from "../utils";
import { Mutable } from "../utility-types";
import { register } from "./register";
export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return selectedElements.some((element) => hasBoundTextElement(element));
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
@ -92,8 +89,8 @@ export const actionBindText = register({
name: "bindText",
contextItemLabel: "labels.bindText",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.length === 2) {
const textElement =
@ -116,11 +113,8 @@ export const actionBindText = register({
}
return false;
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
let textElement: ExcalidrawTextElement;
let container: ExcalidrawTextContainer;
@ -200,18 +194,15 @@ export const actionWrapTextInContainer = register({
name: "wrapTextInContainer",
contextItemLabel: "labels.createContainerFromText",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
const areTextElements = selectedElements.every((el) => isTextElement(el));
return selectedElements.length > 0 && areTextElements;
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
const containerIds: AppState["selectedElementIds"] = {};
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
for (const textElement of selectedElements) {
if (isTextElement(textElement)) {

View File

@ -6,7 +6,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
@ -20,7 +20,6 @@ import {
isHandToolActive,
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { Bounds } from "../element/bounds";
export const actionChangeViewBackgroundColor = register({
@ -226,52 +225,93 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue;
};
export const zoomToFitElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
zoomToSelection: boolean,
) => {
const nonDeletedElements = getNonDeletedElements(elements);
const selectedElements = getSelectedElements(nonDeletedElements, appState);
const commonBounds =
zoomToSelection && selectedElements.length > 0
? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
: getCommonBounds(
excludeElementsInFramesFromSelection(nonDeletedElements),
);
const newZoom = {
value: zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
}),
};
export const zoomToFit = ({
targetElements,
appState,
fitToViewport = false,
viewportZoomFactor = 0.7,
}: {
targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
}) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
let newZoomValue;
let scrollX;
let scrollY;
if (fitToViewport) {
const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1;
newZoomValue =
Math.min(
appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight,
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, 0.1),
30.0,
) as NormalizedZoomValue;
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else {
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
});
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: { value: newZoomValue },
});
scrollX = centerScroll.scrollX;
scrollY = centerScroll.scrollY;
}
return {
appState: {
...appState,
...centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
scrollX,
scrollY,
zoom: { value: newZoomValue },
},
commitToHistory: false,
};
};
export const actionZoomToSelected = register({
name: "zoomToSelection",
// Note, this action differs from actionZoomToFitSelection in that it doesn't
// zoom beyond 100%. In other words, if the content is smaller than viewport
// size, it won't be zoomed in.
export const actionZoomToFitSelectionInViewport = register({
name: "zoomToFitSelectionInViewport",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements,
appState,
fitToViewport: false,
});
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
@ -279,11 +319,31 @@ export const actionZoomToSelected = register({
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFitSelection = register({
name: "zoomToFitSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements,
appState,
fitToViewport: true,
});
},
// NOTE this action should use shift-2 per figma, alas
keyTest: (event) =>
event.code === CODES.THREE &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFit = register({
name: "zoomToFit",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
perform: (elements, appState) =>
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
@ -336,6 +396,7 @@ export const actionToggleEraserTool = register({
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,
@ -362,7 +423,7 @@ export const actionToggleHandTool = register({
type: "hand",
lastActiveToolBeforeEraser: appState.activeTool,
});
setCursor(app.canvas, CURSOR_TYPE.GRAB);
setCursor(app.interactiveCanvas, CURSOR_TYPE.GRAB);
}
return {
@ -370,6 +431,7 @@ export const actionToggleHandTool = register({
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,

View File

@ -7,7 +7,6 @@ import {
probablySupportsClipboardWriteText,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element";
import { t } from "../i18n";
@ -16,7 +15,8 @@ export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const elementsToCopy = getSelectedElements(elements, appState, {
const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
@ -75,14 +75,11 @@ export const actionCopyAsSvg = register({
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
try {
await exportCanvas(
"clipboard-svg",
@ -122,14 +119,11 @@ export const actionCopyAsPng = register({
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
try {
await exportCanvas(
"clipboard",
@ -177,14 +171,11 @@ export const actionCopyAsPng = register({
export const copyText = register({
name: "copyText",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
);
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
const text = selectedElements
.reduce((acc: string[], element) => {
@ -199,12 +190,15 @@ export const copyText = register({
commitToHistory: false,
};
},
predicate: (elements, appState) => {
predicate: (elements, appState, _, app) => {
return (
probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).some(isTextElement)
app.scene
.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})
.some(isTextElement)
);
},
contextItemLabel: "labels.copyText",

View File

@ -158,6 +158,7 @@ export const actionDeleteSelected = register({
...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }),
multiElement: null,
activeEmbeddable: null,
},
commitToHistory: isSomeElementSelected(
getNonDeletedElements(elements),

View File

@ -9,19 +9,13 @@ import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly
@ -32,12 +26,10 @@ const enableActionGroup = (
const distributeSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
distribution: Distribution,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const selectedElements = app.scene.getSelectedElements(appState);
const updatedElements = distributeElements(selectedElements, distribution);
@ -46,16 +38,17 @@ const distributeSelectedElements = (
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
app,
);
};
export const distributeHorizontally = register({
name: "distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: distributeSelectedElements(elements, appState, {
elements: distributeSelectedElements(elements, appState, app, {
space: "between",
axis: "x",
}),
@ -64,9 +57,9 @@ export const distributeHorizontally = register({
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!enableActionGroup(appState, app)}
type="button"
icon={DistributeHorizontallyIcon}
onClick={() => updateData(null)}
@ -82,10 +75,10 @@ export const distributeHorizontally = register({
export const distributeVertically = register({
name: "distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
appState,
elements: distributeSelectedElements(elements, appState, {
elements: distributeSelectedElements(elements, appState, app, {
space: "between",
axis: "y",
}),
@ -94,9 +87,9 @@ export const distributeVertically = register({
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!enableActionGroup(appState, app)}
type="button"
icon={DistributeVerticallyIcon}
onClick={() => updateData(null)}

View File

@ -259,21 +259,25 @@ const duplicateElements = (
return {
elements: finalElements,
appState: selectGroupsForSelectedElements(
{
...appState,
selectedGroupIds: {},
selectedElementIds: nextElementsToSelect.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
},
{},
),
},
getNonDeletedElements(finalElements),
),
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: appState.editingGroupId,
selectedElementIds: nextElementsToSelect.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
},
{},
),
},
getNonDeletedElements(finalElements),
appState,
null,
),
},
};
};

View File

@ -1,7 +1,6 @@
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { arrayToMap } from "../utils";
import { register } from "./register";
@ -11,14 +10,15 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
export const actionToggleElementLock = register({
name: "toggleElementLock",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return !selectedElements.some(
(element) => element.locked && element.frameId,
);
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, {
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
@ -46,8 +46,9 @@ export const actionToggleElementLock = register({
commitToHistory: true,
};
},
contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, {
contextItemLabel: (elements, appState, app) => {
const selected = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
if (selected.length === 1 && selected[0].type !== "frame") {
@ -60,12 +61,13 @@ export const actionToggleElementLock = register({
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
},
keyTest: (event, appState, elements) => {
keyTest: (event, appState, elements, app) => {
return (
event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
getSelectedElements(elements, appState, {
app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
}).length > 0
);

View File

@ -65,7 +65,7 @@ export const actionChangeExportScale = register({
);
const scaleButtonTitle = `${t(
"buttons.scale",
"imageExportDialog.label.scale",
)} ${s}x (${width}x${height})`;
return (
@ -102,7 +102,7 @@ export const actionChangeExportBackground = register({
checked={appState.exportBackground}
onChange={(checked) => updateData(checked)}
>
{t("labels.withBackground")}
{t("imageExportDialog.label.withBackground")}
</CheckboxItem>
),
});
@ -121,8 +121,8 @@ export const actionChangeExportEmbedScene = register({
checked={appState.exportEmbedScene}
onChange={(checked) => updateData(checked)}
>
{t("labels.exportEmbedScene")}
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
{t("imageExportDialog.label.embedScene")}
<Tooltip label={t("imageExportDialog.tooltip.embedScene")} long={true}>
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
</Tooltip>
</CheckboxItem>
@ -277,7 +277,7 @@ export const actionExportWithDarkMode = register({
onChange={(theme: Theme) => {
updateData(theme === THEME.DARK);
}}
title={t("labels.toggleExportColorScheme")}
title={t("imageExportDialog.label.darkMode")}
/>
</div>
),

View File

@ -19,7 +19,12 @@ import { AppState } from "../types";
export const actionFinalize = register({
name: "finalize",
trackEvent: false,
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
perform: (
elements,
appState,
_,
{ interactiveCanvas, focusContainer, scene },
) => {
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
@ -125,13 +130,6 @@ export const actionFinalize = register({
{ x, y },
);
}
if (
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (
@ -139,7 +137,7 @@ export const actionFinalize = register({
appState.activeTool.type !== "freedraw") ||
!multiPointElement
) {
resetCursor(canvas);
resetCursor(interactiveCanvas);
}
let activeTool: AppState["activeTool"];
@ -167,6 +165,7 @@ export const actionFinalize = register({
multiPointElement
? appState.activeTool
: activeTool,
activeEmbeddable: null,
draggingElement: null,
multiElement: null,
editingElement: null,

View File

@ -17,11 +17,12 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
trackEvent: { category: "element" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "horizontal"),
appState,
app,
),
appState,
commitToHistory: true,
@ -34,11 +35,12 @@ export const actionFlipHorizontal = register({
export const actionFlipVertical = register({
name: "flipVertical",
trackEvent: { category: "element" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
return {
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "vertical"),
appState,
app,
),
appState,
commitToHistory: true,

View File

@ -3,19 +3,12 @@ import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { AppClassProperties, AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils";
import { register } from "./register";
const isSingleFrameSelected = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return selectedElements.length === 1 && selectedElements[0].type === "frame";
};
@ -23,11 +16,8 @@ const isSingleFrameSelected = (
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0];
if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameElements(
@ -55,17 +45,15 @@ export const actionSelectAllElementsInFrame = register({
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0];
if (selectedFrame && selectedFrame.type === "frame") {
return {
@ -87,11 +75,12 @@ export const actionRemoveAllElementsFromFrame = register({
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionToggleFrameRendering = register({
name: "toggleFrameRendering",
export const actionupdateFrameRendering = register({
name: "updateFrameRendering",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
@ -99,13 +88,16 @@ export const actionToggleFrameRendering = register({
elements,
appState: {
...appState,
shouldRenderFrames: !appState.shouldRenderFrames,
frameRendering: {
...appState.frameRendering,
enabled: !appState.frameRendering.enabled,
},
},
commitToHistory: false,
};
},
contextItemLabel: "labels.toggleFrameRendering",
checked: (appState: AppState) => appState.shouldRenderFrames,
contextItemLabel: "labels.updateFrameRendering",
checked: (appState: AppState) => appState.frameRendering.enabled,
});
export const actionSetFrameAsActiveTool = register({
@ -116,7 +108,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame",
});
setCursorForShape(app.canvas, {
setCursorForShape(app.interactiveCanvas, {
...appState,
activeTool: nextActiveTool,
});

View File

@ -4,7 +4,7 @@ import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { UngroupIcon, GroupIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { isSomeElementSelected } from "../scene";
import {
getSelectedGroupIds,
selectGroup,
@ -22,7 +22,7 @@ import {
ExcalidrawFrameElement,
ExcalidrawTextElement,
} from "../element/types";
import { AppState } from "../types";
import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
@ -51,14 +51,12 @@ const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
);
@ -68,13 +66,10 @@ export const actionGroup = register({
name: "group",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, commitToHistory: false };
@ -154,22 +149,26 @@ export const actionGroup = register({
];
return {
appState: selectGroup(
newGroupId,
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
appState: {
...appState,
...selectGroup(
newGroupId,
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
},
elements: nextElements,
commitToHistory: true,
};
},
contextItemLabel: "labels.group",
predicate: (elements, appState) => enableActionGroup(elements, appState),
predicate: (elements, appState, _, app) =>
enableActionGroup(elements, appState, app),
keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!enableActionGroup(elements, appState, app)}
type="button"
icon={<GroupIcon theme={appState.theme} />}
onClick={() => updateData(null)}
@ -191,7 +190,7 @@ export const actionUngroup = register({
let nextElements = [...elements];
const selectedElements = getSelectedElements(nextElements, appState);
const selectedElements = app.scene.getSelectedElements(appState);
const frames = selectedElements
.filter((element) => element.frameId)
.map((element) =>
@ -216,8 +215,10 @@ export const actionUngroup = register({
});
const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
appState,
getNonDeletedElements(nextElements),
appState,
null,
);
frames.forEach((frame) => {
@ -232,11 +233,20 @@ export const actionUngroup = register({
});
// remove binded text elements from selection
boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false),
updateAppState.selectedElementIds = Object.entries(
updateAppState.selectedElementIds,
).reduce(
(acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
if (selected && !boundTextElementIds.includes(id)) {
acc[id] = true;
}
return acc;
},
{},
);
return {
appState: updateAppState,
appState: { ...appState, ...updateAppState },
elements: nextElements,
commitToHistory: true,
};

View File

@ -1,8 +1,6 @@
import { getNonDeletedElements } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { register } from "./register";
export const actionToggleLinearEditor = register({
@ -10,21 +8,18 @@ export const actionToggleLinearEditor = register({
trackEvent: {
category: "element",
},
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
return true;
}
return false;
},
perform(elements, appState, _, app) {
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement;
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
@ -38,14 +33,11 @@ export const actionToggleLinearEditor = register({
commitToHistory: false,
};
},
contextItemLabel: (elements, appState) => {
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement;
contextItemLabel: (elements, appState, app) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit"
: "labels.lineEditor.edit";

View File

@ -74,7 +74,7 @@ import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
FontFamilyValues,
FontFamilyId,
TextAlign,
VerticalAlign,
} from "../element/types";
@ -689,22 +689,22 @@ export const actionChangeFontFamily = register({
},
PanelComponent: ({ elements, appState, updateData }) => {
const options: {
value: FontFamilyValues;
value: FontFamilyId;
text: string;
icon: JSX.Element;
}[] = [
{
value: FONT_FAMILY.Virgil,
value: FONT_FAMILY.HAND_DRAWN.fontFamilyId,
text: t("labels.handDrawn"),
icon: FreedrawIcon,
},
{
value: FONT_FAMILY.Helvetica,
value: FONT_FAMILY.NORMAL.fontFamilyId,
text: t("labels.normal"),
icon: FontFamilyNormalIcon,
},
{
value: FONT_FAMILY.Cascadia,
value: FONT_FAMILY.CODE.fontFamilyId,
text: t("labels.code"),
icon: FontFamilyCodeIcon,
},
@ -713,7 +713,7 @@ export const actionChangeFontFamily = register({
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamilyValues | false>
<ButtonIconSelect<FontFamilyId | false>
group="font-family"
options={options}
value={getFormValue(

View File

@ -28,20 +28,24 @@ export const actionSelectAll = register({
}, {});
return {
appState: selectGroupsForSelectedElements(
{
...appState,
selectedLinearElement:
// single linear element selected
Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0])
? new LinearElementEditor(elements[0], app.scene)
: null,
editingGroupId: null,
selectedElementIds,
},
getNonDeletedElements(elements),
),
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: null,
selectedElementIds,
},
getNonDeletedElements(elements),
appState,
app,
),
selectedLinearElement:
// single linear element selected
Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0])
? new LinearElementEditor(elements[0], app.scene)
: null,
},
commitToHistory: true,
};
},

View File

@ -90,6 +90,7 @@ export class ActionManager {
event,
this.getAppState(),
this.getElementsIncludingDeleted(),
this.app,
),
);
@ -168,6 +169,7 @@ export class ActionManager {
appState={this.getAppState()}
updateData={updateData}
appProps={this.app.props}
app={this.app}
data={data}
/>
);

View File

@ -82,7 +82,8 @@ export type ActionName =
| "zoomOut"
| "resetZoom"
| "zoomToFit"
| "zoomToSelection"
| "zoomToFitSelection"
| "zoomToFitSelectionInViewport"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
@ -118,8 +119,9 @@ export type ActionName =
| "toggleHandTool"
| "selectAllElementsInFrame"
| "removeAllElementsFromFrame"
| "toggleFrameRendering"
| "updateFrameRendering"
| "setFrameAsActiveTool"
| "setEmbeddableAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer";
@ -129,6 +131,7 @@ export type PanelComponentProps = {
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
data?: Record<string, any>;
app: AppClassProperties;
};
export interface Action {
@ -140,12 +143,14 @@ export interface Action {
event: React.KeyboardEvent | KeyboardEvent,
appState: AppState,
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
) => boolean;
contextItemLabel?:
| string
| ((
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
) => string);
predicate?: (
elements: readonly ExcalidrawElement[],

View File

@ -5,19 +5,18 @@ export const trackEvent = (
value?: number,
) => {
try {
// place here categories that you want to track as events
// KEEP IN MIND THE PRICING
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
// Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value });
if (typeof window === "undefined" || process.env.JEST_WORKER_ID) {
if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) {
return;
}
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
return;
}
if (window.sa_event) {
@ -27,14 +26,6 @@ export const trackEvent = (
value,
});
}
if (window.fathom) {
window.fathom.trackEvent(action, {
category,
label,
value,
});
}
} catch (error) {
console.error("error during analytics", error);
}

View File

@ -38,6 +38,7 @@ export const getDefaultAppState = (): Omit<
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
cursorButton: "up",
activeEmbeddable: null,
draggingElement: null,
editingElement: null,
editingGroupId: null,
@ -84,7 +85,7 @@ export const getDefaultAppState = (): Omit<
showStats: false,
startBoundElement: null,
suggestedBindings: [],
shouldRenderFrames: true,
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
editingFrame: null,
elementsToHighlight: null,
@ -139,6 +140,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
@ -191,7 +193,7 @@ const APP_STATE_STORAGE_CONF = (<
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
shouldRenderFrames: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false },
elementsToHighlight: { browser: false, export: false, server: false },

View File

@ -6,7 +6,6 @@ import {
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
ENV,
VERTICAL_ALIGN,
} from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element";
@ -180,7 +179,7 @@ const commonProps = {
locked: false,
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {
const getChartDimensions = (spreadsheet: Spreadsheet) => {
const chartWidth =
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
@ -250,7 +249,7 @@ const chartLines = (
groupId: string,
backgroundColor: string,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const xLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
@ -313,7 +312,7 @@ const chartBaseElements = (
backgroundColor: string,
debug?: boolean,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const title = spreadsheet.title
? newTextElement({
@ -384,7 +383,7 @@ const chartTypeBar = (
y,
groupId,
backgroundColor,
process.env.NODE_ENV === ENV.DEVELOPMENT,
import.meta.env.DEV,
),
];
};
@ -473,7 +472,7 @@ const chartTypeLine = (
y,
groupId,
backgroundColor,
process.env.NODE_ENV === ENV.DEVELOPMENT,
import.meta.env.DEV,
),
line,
...lines,

View File

@ -24,6 +24,7 @@ export interface ClipboardData {
files?: BinaryFiles;
text?: string;
errorMessage?: string;
programmaticAPI?: boolean;
}
let CLIPBOARD = "";
@ -48,6 +49,7 @@ const clipboardContainsElements = (
[
EXPORT_DATA_TYPES.excalidraw,
EXPORT_DATA_TYPES.excalidrawClipboard,
EXPORT_DATA_TYPES.excalidrawClipboardWithAPI,
].includes(contents?.type) &&
Array.isArray(contents.elements)
) {
@ -191,6 +193,8 @@ export const parseClipboard = async (
try {
const systemClipboardData = JSON.parse(systemClipboard);
const programmaticAPI =
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
if (clipboardContainsElements(systemClipboardData)) {
return {
elements: systemClipboardData.elements,
@ -198,6 +202,7 @@ export const parseClipboard = async (
text: isPlainPaste
? JSON.stringify(systemClipboardData.elements, null, 2)
: undefined,
programmaticAPI,
};
}
} catch (e) {}

View File

@ -21,7 +21,7 @@ export type ColorPickerColor =
export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{ black: string; white: string; transparent: string }
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
>;
// used general type instead of specific type (ColorPalette) to support custom colors

View File

@ -36,7 +36,7 @@ import {
import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { extraToolsIcon, frameToolIcon } from "./icons";
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({
@ -213,13 +213,13 @@ export const SelectedShapeActions = ({
};
export const ShapesSwitcher = ({
canvas,
interactiveCanvas,
activeTool,
setAppState,
onImageAction,
appState,
}: {
canvas: HTMLCanvasElement | null;
interactiveCanvas: HTMLCanvasElement | null;
activeTool: UIAppState["activeTool"];
setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
@ -266,10 +266,11 @@ export const ShapesSwitcher = ({
});
setAppState({
activeTool: nextActiveTool,
activeEmbeddable: null,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, {
setCursorForShape(interactiveCanvas, {
...appState,
activeTool: nextActiveTool,
});
@ -283,39 +284,72 @@ export const ShapesSwitcher = ({
<div className="App-toolbar__divider" />
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={frameToolIcon}
checked={activeTool.type === "frame"}
name="editor-current-shape"
title={`${capitalizeString(
t("toolBar.frame"),
)}${KEYS.F.toLocaleUpperCase()}`}
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
aria-label={capitalizeString(t("toolBar.frame"))}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
<>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={frameToolIcon}
checked={activeTool.type === "frame"}
name="editor-current-shape"
title={`${capitalizeString(
t("toolBar.frame"),
)}${KEYS.F.toLocaleUpperCase()}`}
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
aria-label={capitalizeString(t("toolBar.frame"))}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "frame", "ui");
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "frame", "ui");
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
/>
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}}
/>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={EmbedIcon}
checked={activeTool.type === "embeddable"}
name="editor-current-shape"
title={capitalizeString(t("toolBar.embeddable"))}
aria-label={capitalizeString(t("toolBar.embeddable"))}
data-testid={`toolbar-embeddable`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "embeddable", "ui");
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}}
/>
</>
) : (
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
@ -347,6 +381,22 @@ export const ShapesSwitcher = ({
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}

View File

@ -4,15 +4,16 @@ import { reseed } from "../random";
import { render, queryByTestId } from "../tests/test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { vi } from "vitest";
const renderScene = jest.spyOn(Renderer, "renderScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
describe("Test <App/>", () => {
beforeEach(async () => {
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
localStorage.clear();
renderScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ import {
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { ColorPaletteCustom } from "../../colors";
import { t } from "../../i18n";
import { TranslationKeys, t } from "../../i18n";
interface PickerColorListProps {
palette: ColorPaletteCustom;
@ -48,7 +48,11 @@ const PickerColorList = ({
(Array.isArray(value) ? value[activeShade] : value) || "transparent";
const keybinding = colorPickerHotkeyBindings[index];
const label = t(`colors.${key.replace(/\d+/, "")}`, null, "");
const label = t(
`colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
null,
"",
);
return (
<button

View File

@ -1,6 +1,6 @@
import clsx from "clsx";
import { Popover } from "./Popover";
import { t } from "../i18n";
import { t, TranslationKeys } from "../i18n";
import "./ContextMenu.scss";
import {
@ -82,9 +82,15 @@ export const ContextMenu = React.memo(
let label = "";
if (item.contextItemLabel) {
if (typeof item.contextItemLabel === "function") {
label = t(item.contextItemLabel(elements, appState));
label = t(
item.contextItemLabel(
elements,
appState,
actionManager.app,
) as unknown as TranslationKeys,
);
} else {
label = t(item.contextItemLabel);
label = t(item.contextItemLabel as unknown as TranslationKeys);
}
}

View File

@ -17,16 +17,34 @@ import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai";
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
export interface DialogProps {
children: React.ReactNode;
className?: string;
size?: "small" | "regular" | "wide";
size?: DialogSize;
onCloseRequest(): void;
title: React.ReactNode | false;
autofocus?: boolean;
closeOnClickOutside?: boolean;
}
function getDialogSize(size: DialogSize): number {
if (size && typeof size === "number") {
return size;
}
switch (size) {
case "small":
return 550;
case "wide":
return 1024;
case "regular":
default:
return 800;
}
}
export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
@ -85,9 +103,7 @@ export const Dialog = (props: DialogProps) => {
<Modal
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
}
maxWidth={getDialogSize(props.size)}
onCloseRequest={onClose}
closeOnClickOutside={props.closeOnClickOutside}
>

View File

@ -8,9 +8,9 @@ import { mutateElement } from "../element/mutateElement";
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
import { useOutsideClick } from "../hooks/useOutsideClick";
import { KEYS } from "../keys";
import { invalidateShapeForElement } from "../renderer/renderElement";
import { getSelectedElements } from "../scene";
import Scene from "../scene/Scene";
import { ShapeCache } from "../scene/ShapeCache";
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
import "./EyeDropper.scss";
@ -58,7 +58,7 @@ export const EyeDropper: React.FC<{
return;
}
let currentColor = COLOR_PALETTE.black;
let currentColor: string = COLOR_PALETTE.black;
let isHoldingPointerDown = false;
const ctx = app.canvas.getContext("2d")!;
@ -77,8 +77,8 @@ export const EyeDropper: React.FC<{
colorPreviewDiv.style.left = `${clientX + 20}px`;
const pixel = ctx.getImageData(
clientX * window.devicePixelRatio - appState.offsetLeft,
clientY * window.devicePixelRatio - appState.offsetTop,
(clientX - appState.offsetLeft) * window.devicePixelRatio,
(clientY - appState.offsetTop) * window.devicePixelRatio,
1,
1,
).data;
@ -98,7 +98,7 @@ export const EyeDropper: React.FC<{
},
false,
);
invalidateShapeForElement(element);
ShapeCache.delete(element);
}
Scene.getScene(
metaStuffRef.current.selectedElements[0],

View File

@ -2,20 +2,140 @@
.excalidraw {
.ExcButton {
&--color-primary {
color: var(--input-bg-color);
--text-color: transparent;
--border-color: transparent;
--back-color: transparent;
--accent-color: var(--color-primary);
--accent-color-hover: var(--color-primary-darker);
--accent-color-active: var(--color-primary-darkest);
color: var(--text-color);
background-color: var(--back-color);
border-color: var(--border-color);
&--color-primary {
&.ExcButton--variant-filled {
--text-color: var(--input-bg-color);
--back-color: var(--color-primary);
&:hover {
--back-color: var(--color-primary-darker);
}
&:active {
--back-color: var(--color-primary-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-primary);
--border-color: var(--color-primary);
--back-color: var(--input-bg-color);
&:hover {
--text-color: var(--color-primary-darker);
--border-color: var(--color-primary-darker);
}
&:active {
--text-color: var(--color-primary-darkest);
--border-color: var(--color-primary-darkest);
}
}
}
&--color-danger {
color: var(--input-bg-color);
&.ExcButton--variant-filled {
--text-color: var(--color-danger-text);
--back-color: var(--color-danger-dark);
--accent-color: var(--color-danger);
--accent-color-hover: #d65550;
--accent-color-active: #d1413c;
&:hover {
--back-color: var(--color-danger-darker);
}
&:active {
--back-color: var(--color-danger-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-danger);
--border-color: var(--color-danger);
--back-color: transparent;
&:hover {
--text-color: var(--color-danger-darkest);
--border-color: var(--color-danger-darkest);
}
&:active {
--text-color: var(--color-danger-darker);
--border-color: var(--color-danger-darker);
}
}
}
&--color-muted {
&.ExcButton--variant-filled {
--text-color: var(--island-bg-color);
--back-color: var(--color-gray-50);
&:hover {
--back-color: var(--color-gray-60);
}
&:active {
--back-color: var(--color-gray-80);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-muted-background);
--border-color: var(--color-muted);
--back-color: var(--island-bg-color);
&:hover {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darker);
}
&:active {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darkest);
}
}
}
&--color-warning {
&.ExcButton--variant-filled {
--text-color: black;
--back-color: var(--color-warning-dark);
&:hover {
--back-color: var(--color-warning-darker);
}
&:active {
--back-color: var(--color-warning-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-warning-dark);
--border-color: var(--color-warning-dark);
--back-color: var(--input-bg-color);
&:hover {
--text-color: var(--color-warning-darker);
--border-color: var(--color-warning-darker);
}
&:active {
--text-color: var(--color-warning-darkest);
--border-color: var(--color-warning-darkest);
}
}
}
display: flex;
@ -25,6 +145,8 @@
flex-wrap: nowrap;
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: "Assistant";
@ -33,9 +155,9 @@
transition: all 150ms ease-out;
&--size-large {
font-weight: 400;
font-weight: 600;
font-size: 0.875rem;
height: 3rem;
min-height: 3rem;
padding: 0.5rem 1.5rem;
gap: 0.75rem;
@ -45,48 +167,22 @@
&--size-medium {
font-weight: 600;
font-size: 0.75rem;
height: 2.5rem;
min-height: 2.5rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
letter-spacing: normal;
}
&--variant-filled {
background: var(--accent-color);
border: 1px solid transparent;
&:hover {
background: var(--accent-color-hover);
}
&:active {
background: var(--accent-color-active);
}
}
&--variant-outlined,
&--variant-icon {
border: 1px solid var(--accent-color);
color: var(--accent-color);
background: transparent;
&:hover {
border: 1px solid var(--accent-color-hover);
color: var(--accent-color-hover);
}
&:active {
border: 1px solid var(--accent-color-active);
color: var(--accent-color-active);
}
}
&--variant-icon {
padding: 0.5rem 0.75rem;
width: 3rem;
}
&--fullWidth {
width: 100%;
}
&__icon {
width: 1.25rem;
height: 1.25rem;

View File

@ -4,7 +4,7 @@ import clsx from "clsx";
import "./FilledButton.scss";
export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor = "primary" | "danger";
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
export type ButtonSize = "medium" | "large";
export type FilledButtonProps = {
@ -17,6 +17,7 @@ export type FilledButtonProps = {
color?: ButtonColor;
size?: ButtonSize;
className?: string;
fullWidth?: boolean;
startIcon?: React.ReactNode;
};
@ -31,6 +32,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
variant = "filled",
color = "primary",
size = "medium",
fullWidth,
className,
},
ref,
@ -42,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
`ExcButton--color-${color}`,
`ExcButton--variant-${variant}`,
`ExcButton--size-${size}`,
{ "ExcButton--fullWidth": fullWidth },
className,
)}
onClick={onClick}

View File

@ -1,7 +1,5 @@
import { t } from "../i18n";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { Device, UIAppState } from "../types";
import { AppClassProperties, Device, UIAppState } from "../types";
import {
isImageElement,
isLinearElement,
@ -15,17 +13,12 @@ import "./HintViewer.scss";
interface HintViewerProps {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean;
device: Device;
app: AppClassProperties;
}
const getHints = ({
appState,
elements,
isMobile,
device,
}: HintViewerProps) => {
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
@ -51,11 +44,15 @@ const getHints = ({
return t("hints.text");
}
if (activeTool.type === "embeddable") {
return t("hints.embeddable");
}
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
return t("hints.placeImage");
}
const selectedElements = getSelectedElements(elements, appState);
const selectedElements = app.scene.getSelectedElements(appState);
if (
isResizing &&
@ -115,15 +112,15 @@ const getHints = ({
export const HintViewer = ({
appState,
elements,
isMobile,
device,
app,
}: HintViewerProps) => {
let hint = getHints({
appState,
elements,
isMobile,
device,
app,
});
if (!hint) {
return null;

View File

@ -34,7 +34,7 @@ const JSONExportModal = ({
actionManager: ActionManager;
onCloseRequest: () => void;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
canvas: HTMLCanvasElement;
}) => {
const { onExportToBackend } = exportOpts;
return (
@ -100,7 +100,7 @@ export const JSONExportDialog = ({
files: BinaryFiles;
actionManager: ActionManager;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
canvas: HTMLCanvasElement;
setAppState: React.Component<any, UIAppState>["setState"];
}) => {
const handleClose = React.useCallback(() => {

View File

@ -41,6 +41,7 @@ import { jotaiScope } from "../jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState";
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
@ -56,7 +57,8 @@ interface LayerUIProps {
actionManager: ActionManager;
appState: UIAppState;
files: BinaryFiles;
canvas: HTMLCanvasElement | null;
canvas: HTMLCanvasElement;
interactiveCanvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
@ -71,6 +73,7 @@ interface LayerUIProps {
onExportImage: AppClassProperties["onExportImage"];
renderWelcomeScreen: boolean;
children?: React.ReactNode;
app: AppClassProperties;
}
const DefaultMainMenu: React.FC<{
@ -99,6 +102,15 @@ const DefaultMainMenu: React.FC<{
);
};
const DefaultOverwriteConfirmDialog = () => {
return (
<OverwriteConfirmDialog __fallback>
<OverwriteConfirmDialog.Actions.SaveToDisk />
<OverwriteConfirmDialog.Actions.ExportToImage />
</OverwriteConfirmDialog>
);
};
const LayerUI = ({
actionManager,
appState,
@ -106,6 +118,7 @@ const LayerUI = ({
setAppState,
elements,
canvas,
interactiveCanvas,
onLockToggle,
onHandToolToggle,
onPenModeToggle,
@ -117,6 +130,7 @@ const LayerUI = ({
onExportImage,
renderWelcomeScreen,
children,
app,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@ -230,9 +244,9 @@ const LayerUI = ({
>
<HintViewer
appState={appState}
elements={elements}
isMobile={device.isMobile}
device={device}
app={app}
/>
{heading}
<Stack.Row gap={1}>
@ -260,7 +274,7 @@ const LayerUI = ({
<ShapesSwitcher
appState={appState}
canvas={canvas}
interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
@ -343,6 +357,7 @@ const LayerUI = ({
>
{t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />}
@ -374,6 +389,7 @@ const LayerUI = ({
/>
)}
<ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && (
@ -387,8 +403,9 @@ const LayerUI = ({
}
/>
)}
{device.isMobile && !eyeDropperState && (
{device.isMobile && (
<MobileMenu
app={app}
appState={appState}
elements={elements}
actionManager={actionManager}
@ -398,7 +415,7 @@ const LayerUI = ({
onLockToggle={onLockToggle}
onHandToolToggle={onHandToolToggle}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
interactiveCanvas={interactiveCanvas}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
@ -449,7 +466,7 @@ const LayerUI = ({
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState, canvas),
...calculateScrollCenter(elements, appState),
}));
}}
>
@ -492,8 +509,18 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
return false;
}
const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
const {
canvas: _pC,
interactiveCanvas: _pIC,
appState: prevAppState,
...prev
} = prevProps;
const {
canvas: _nC,
interactiveCanvas: _nIC,
appState: nextAppState,
...next
} = nextProps;
return (
isShallowEqual(

View File

@ -29,6 +29,7 @@ import "./LibraryMenu.scss";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { isShallowEqual } from "../utils";
import { NonDeletedExcalidrawElement } from "../element/types";
import { LIBRARY_DISABLED_TYPES } from "../constants";
export const isLibraryMenuOpenAtom = atom(false);
@ -68,11 +69,12 @@ export const LibraryMenuContent = ({
libraryItems: LibraryItems,
) => {
trackEvent("element", "addToLibrary", "ui");
if (processedElements.some((element) => element.type === "image")) {
return setAppState({
errorMessage:
"Support for adding images to the library coming soon!",
});
for (const type of LIBRARY_DISABLED_TYPES) {
if (processedElements.some((element) => element.type === type)) {
return setAppState({
errorMessage: t(`errors.libraryElementTypeError.${type}`),
});
}
}
const nextItems: LibraryItems = [
{
@ -197,6 +199,7 @@ export const LibraryMenu = () => {
setAppState({
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
});
}, [setAppState]);

View File

@ -16,7 +16,7 @@ const LibraryMenuBrowseButton = ({
return (
<a
className="library-menu-browse-button"
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
href={`${import.meta.env.VITE_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary

View File

@ -12,6 +12,11 @@
box-sizing: border-box;
border-radius: var(--border-radius-lg);
svg {
// to prevent clicks on links and such
pointer-events: none;
}
&--hover {
border-color: var(--color-primary);
}

View File

@ -1,5 +1,11 @@
import React from "react";
import { AppState, Device, ExcalidrawProps, UIAppState } from "../types";
import {
AppClassProperties,
AppState,
Device,
ExcalidrawProps,
UIAppState,
} from "../types";
import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import Stack from "./Stack";
@ -30,7 +36,7 @@ type MobileMenuProps = {
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null;
interactiveCanvas: HTMLCanvasElement | null;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: (
@ -41,6 +47,7 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean;
app: AppClassProperties;
};
export const MobileMenu = ({
@ -51,13 +58,14 @@ export const MobileMenu = ({
onLockToggle,
onHandToolToggle,
onPenModeToggle,
canvas,
interactiveCanvas,
onImageAction,
renderTopRightUI,
renderCustomStats,
renderSidebars,
device,
renderWelcomeScreen,
app,
}: MobileMenuProps) => {
const {
WelcomeScreenCenterTunnel,
@ -77,7 +85,7 @@ export const MobileMenu = ({
<Stack.Row gap={1}>
<ShapesSwitcher
appState={appState}
canvas={canvas}
interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
@ -119,9 +127,9 @@ export const MobileMenu = ({
</Section>
<HintViewer
appState={appState}
elements={elements}
isMobile={true}
device={device}
app={app}
/>
</FixedSideContainer>
);
@ -194,7 +202,7 @@ export const MobileMenu = ({
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState, canvas),
...calculateScrollCenter(elements, appState),
}));
}}
>

View File

@ -3,7 +3,7 @@
.excalidraw {
&.excalidraw-modal-container {
position: absolute;
z-index: 10;
z-index: var(--zIndex-modal);
}
.Modal {

View File

@ -0,0 +1,126 @@
@import "../../css/variables.module";
.excalidraw {
.OverwriteConfirm {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
isolation: isolate;
h3 {
margin: 0;
font-weight: 700;
font-size: 1.3125rem;
line-height: 130%;
align-self: flex-start;
color: var(--text-primary-color);
}
&__Description {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
gap: 1rem;
@include isMobile {
flex-direction: column;
text-align: center;
}
padding: 2.5rem;
background: var(--color-danger-background);
border-radius: 0.5rem;
font-family: "Assistant";
font-style: normal;
font-weight: 400;
font-size: 1rem;
line-height: 150%;
color: var(--color-danger-color);
&__spacer {
flex-grow: 1;
}
&__icon {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2.5rem;
background: var(--color-danger-icon-background);
width: 3.5rem;
height: 3.5rem;
padding: 0.75rem;
svg {
color: var(--color-danger-icon-color);
width: 1.5rem;
height: 1.5rem;
}
}
&.OverwriteConfirm__Description--color-warning {
background: var(--color-warning-background);
color: var(--color-warning-color);
.OverwriteConfirm__Description__icon {
background: var(--color-warning-icon-background);
flex: 0 0 auto;
svg {
color: var(--color-warning-icon-color);
}
}
}
}
&__Actions {
display: flex;
flex-direction: row;
align-items: stretch;
justify-items: stretch;
justify-content: center;
gap: 1.5rem;
@include isMobile {
flex-direction: column;
}
&__Action {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
gap: 0.75rem;
flex-basis: 50%;
flex-grow: 0;
&__content {
height: 100%;
font-size: 0.875rem;
text-align: center;
}
h4 {
font-weight: 700;
font-size: 1.125rem;
line-height: 130%;
margin: 0;
color: var(--text-primary-color);
}
}
}
}
}

View File

@ -0,0 +1,76 @@
import React from "react";
import { useAtom } from "jotai";
import { useTunnels } from "../../context/tunnels";
import { jotaiScope } from "../../jotai";
import { Dialog } from "../Dialog";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
import { FilledButton } from "../FilledButton";
import { alertTriangleIcon } from "../icons";
import { Actions, Action } from "./OverwriteConfirmActions";
import "./OverwriteConfirm.scss";
export type OverwriteConfirmDialogProps = {
children: React.ReactNode;
};
const OverwriteConfirmDialog = Object.assign(
withInternalFallback(
"OverwriteConfirmDialog",
({ children }: OverwriteConfirmDialogProps) => {
const { OverwriteConfirmDialogTunnel } = useTunnels();
const [overwriteConfirmState, setState] = useAtom(
overwriteConfirmStateAtom,
jotaiScope,
);
if (!overwriteConfirmState.active) {
return null;
}
const handleClose = () => {
overwriteConfirmState.onClose();
setState((state) => ({ ...state, active: false }));
};
const handleConfirm = () => {
overwriteConfirmState.onConfirm();
setState((state) => ({ ...state, active: false }));
};
return (
<OverwriteConfirmDialogTunnel.In>
<Dialog onCloseRequest={handleClose} title={false} size={916}>
<div className="OverwriteConfirm">
<h3>{overwriteConfirmState.title}</h3>
<div
className={`OverwriteConfirm__Description OverwriteConfirm__Description--color-${overwriteConfirmState.color}`}
>
<div className="OverwriteConfirm__Description__icon">
{alertTriangleIcon}
</div>
<div>{overwriteConfirmState.description}</div>
<div className="OverwriteConfirm__Description__spacer"></div>
<FilledButton
color={overwriteConfirmState.color}
size="large"
label={overwriteConfirmState.actionLabel}
onClick={handleConfirm}
/>
</div>
<Actions>{children}</Actions>
</div>
</Dialog>
</OverwriteConfirmDialogTunnel.In>
);
},
),
{
Actions,
Action,
},
);
export { OverwriteConfirmDialog };

View File

@ -0,0 +1,85 @@
import React from "react";
import { FilledButton } from "../FilledButton";
import { useExcalidrawActionManager, useExcalidrawSetAppState } from "../App";
import { actionSaveFileToDisk } from "../../actions";
import { useI18n } from "../../i18n";
import { actionChangeExportEmbedScene } from "../../actions/actionExport";
export type ActionProps = {
title: string;
children: React.ReactNode;
actionLabel: string;
onClick: () => void;
};
export const Action = ({
title,
children,
actionLabel,
onClick,
}: ActionProps) => {
return (
<div className="OverwriteConfirm__Actions__Action">
<h4>{title}</h4>
<div className="OverwriteConfirm__Actions__Action__content">
{children}
</div>
<FilledButton
variant="outlined"
color="muted"
label={actionLabel}
size="large"
fullWidth
onClick={onClick}
/>
</div>
);
};
export const ExportToImage = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const setAppState = useExcalidrawSetAppState();
return (
<Action
title={t("overwriteConfirm.action.exportToImage.title")}
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
onClick={() => {
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
setAppState({ openDialog: "imageExport" });
}}
>
{t("overwriteConfirm.action.exportToImage.description")}
</Action>
);
};
export const SaveToDisk = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
return (
<Action
title={t("overwriteConfirm.action.saveToDisk.title")}
actionLabel={t("overwriteConfirm.action.saveToDisk.button")}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk, "ui");
}}
>
{t("overwriteConfirm.action.saveToDisk.description")}
</Action>
);
};
const Actions = Object.assign(
({ children }: { children: React.ReactNode }) => {
return <div className="OverwriteConfirm__Actions">{children}</div>;
},
{
ExportToImage,
SaveToDisk,
},
);
export { Actions };

View File

@ -0,0 +1,46 @@
import { atom } from "jotai";
import { jotaiStore } from "../../jotai";
import React from "react";
export type OverwriteConfirmState =
| {
active: true;
title: string;
description: React.ReactNode;
actionLabel: string;
color: "danger" | "warning";
onClose: () => void;
onConfirm: () => void;
onReject: () => void;
}
| { active: false };
export const overwriteConfirmStateAtom = atom<OverwriteConfirmState>({
active: false,
});
export async function openConfirmModal({
title,
description,
actionLabel,
color,
}: {
title: string;
description: React.ReactNode;
actionLabel: string;
color: "danger" | "warning";
}) {
return new Promise<boolean>((resolve) => {
jotaiStore.set(overwriteConfirmStateAtom, {
active: true,
onConfirm: () => resolve(true),
onClose: () => resolve(false),
onReject: () => resolve(false),
title,
description,
actionLabel,
color,
});
});
}

View File

@ -319,7 +319,7 @@ const PublishLibrary = ({
formData.append("twitterHandle", libraryData.twitterHandle);
formData.append("website", libraryData.website);
fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
fetch(`${import.meta.env.VITE_APP_LIBRARY_BACKEND}/submit`, {
method: "post",
body: formData,
})

View File

@ -3,7 +3,7 @@ import { t } from "../i18n";
import { useExcalidrawContainer } from "./App";
export const Section: React.FC<{
heading: string;
heading: "canvasActions" | "selectedShapeActions" | "shapes";
children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode);
className?: string;
}> = ({ heading, children, ...props }) => {

View File

@ -0,0 +1,91 @@
@import "../css/variables.module";
.excalidraw {
.ShareableLinkDialog {
display: flex;
flex-direction: column;
gap: 1.5rem;
color: var(--text-primary-color);
::selection {
background: var(--color-primary-light-darker);
}
h3 {
font-family: "Assistant";
font-weight: 700;
font-size: 1.313rem;
line-height: 130%;
margin: 0;
}
&__popover {
@keyframes RoomDialog__popover__scaleIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
box-sizing: border-box;
z-index: 100;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
padding: 0.125rem 0.5rem;
gap: 0.125rem;
height: 1.125rem;
border: none;
border-radius: 0.6875rem;
font-family: "Assistant";
font-style: normal;
font-weight: 600;
font-size: 0.75rem;
line-height: 110%;
background: var(--color-success-lighter);
color: var(--color-success);
& > svg {
width: 0.875rem;
height: 0.875rem;
}
transform-origin: var(--radix-popover-content-transform-origin);
animation: RoomDialog__popover__scaleIn 150ms ease-out;
}
&__linkRow {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 0.75rem;
}
&__description {
border-top: 1px solid var(--color-gray-20);
padding: 0.5rem 0.5rem 0;
font-weight: 400;
font-size: 0.75rem;
line-height: 150%;
& p {
margin: 0;
}
& p + p {
margin-top: 1em;
}
}
}
}

View File

@ -0,0 +1,91 @@
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../clipboard";
import { useI18n } from "../i18n";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { FilledButton } from "./FilledButton";
import { copyIcon, tablerCheckIcon } from "./icons";
import "./ShareableLinkDialog.scss";
export type ShareableLinkDialogProps = {
link: string;
onCloseRequest: () => void;
setErrorMessage: (error: string) => void;
};
export const ShareableLinkDialog = ({
link,
onCloseRequest,
setErrorMessage,
}: ShareableLinkDialogProps) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(link);
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
setErrorMessage(error.message);
}
ref.current?.select();
};
return (
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
<div className="ShareableLinkDialog">
<h3>Shareable link</h3>
<div className="ShareableLinkDialog__linkRow">
<TextField
ref={ref}
label="Link"
readonly
fullWidth
value={link}
selectOnRender
/>
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton
size="large"
label="Copy link"
startIcon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareableLinkDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div>
<div className="ShareableLinkDialog__description">
🔒 {t("alerts.uploadedSecurly")}
</div>
</div>
</Dialog>
);
};

View File

@ -10,6 +10,7 @@ import {
waitFor,
withExcalidrawDimensions,
} from "../../tests/test-utils";
import { vi } from "vitest";
export const assertSidebarDockButton = async <T extends boolean>(
hasDockButton: T,
@ -205,7 +206,7 @@ describe("Sidebar", () => {
});
it("<Sidebar.Header> should render close button", async () => {
const onStateChange = jest.fn();
const onStateChange = vi.fn();
const CustomExcalidraw = () => {
return (
<Excalidraw

View File

@ -53,7 +53,7 @@ export const SidebarInner = forwardRef(
}: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
ref: React.ForwardedRef<HTMLDivElement>,
) => {
if (process.env.NODE_ENV === "development" && onDock && docked == null) {
if (import.meta.env.DEV && onDock && docked == null) {
console.warn(
"Sidebar: `docked` must be set when `onDock` is supplied for the sidebar to be user-dockable. To hide this message, either pass `docked` or remove `onDock`",
);

View File

@ -1,4 +1,10 @@
import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react";
import {
forwardRef,
useRef,
useImperativeHandle,
KeyboardEvent,
useLayoutEffect,
} from "react";
import clsx from "clsx";
import "./TextField.scss";
@ -12,6 +18,7 @@ export type TextFieldProps = {
readonly?: boolean;
fullWidth?: boolean;
selectOnRender?: boolean;
label?: string;
placeholder?: string;
@ -19,13 +26,28 @@ export type TextFieldProps = {
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
(
{ value, onChange, label, fullWidth, placeholder, readonly, onKeyDown },
{
value,
onChange,
label,
fullWidth,
placeholder,
readonly,
selectOnRender,
onKeyDown,
},
ref,
) => {
const innerRef = useRef<HTMLInputElement | null>(null);
useImperativeHandle(ref, () => innerRef.current!);
useLayoutEffect(() => {
if (selectOnRender) {
innerRef.current?.select();
}
}, [selectOnRender]);
return (
<div
className={clsx("ExcTextField", {

View File

@ -6,7 +6,7 @@
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
position: fixed;
z-index: 1000;
z-index: var(--zIndex-popup);
padding: 8px;
border-radius: 6px;

View File

@ -3,6 +3,7 @@ import { render } from "@testing-library/react";
import fallbackLangData from "../locales/en.json";
import Trans from "./Trans";
import { TranslationKeys } from "../i18n";
describe("Test <Trans/>", () => {
it("should translate the the strings correctly", () => {
@ -18,24 +19,27 @@ describe("Test <Trans/>", () => {
const { getByTestId } = render(
<>
<div data-testid="test1">
<Trans i18nKey="transTest.key1" audience="world" />
<Trans
i18nKey={"transTest.key1" as unknown as TranslationKeys}
audience="world"
/>
</div>
<div data-testid="test2">
<Trans
i18nKey="transTest.key2"
i18nKey={"transTest.key2" as unknown as TranslationKeys}
link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
<div data-testid="test3">
<Trans
i18nKey="transTest.key3"
i18nKey={"transTest.key3" as unknown as TranslationKeys}
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
/>
</div>
<div data-testid="test4">
<Trans
i18nKey="transTest.key4"
i18nKey={"transTest.key4" as unknown as TranslationKeys}
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
bold={(el) => <strong>{el}</strong>}
@ -43,7 +47,7 @@ describe("Test <Trans/>", () => {
</div>
<div data-testid="test5">
<Trans
i18nKey="transTest.key5"
i18nKey={"transTest.key5" as unknown as TranslationKeys}
connect-link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>

View File

@ -1,6 +1,6 @@
import React from "react";
import { useI18n } from "../i18n";
import { TranslationKeys, useI18n } from "../i18n";
// Used for splitting i18nKey into tokens in Trans component
// Example:
@ -153,7 +153,7 @@ const Trans = ({
children,
...props
}: {
i18nKey: string;
i18nKey: TranslationKeys;
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
}) => {
const { t } = useI18n();

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <App/> should show error modal when using brave and measureText API is not working 1`] = `
exports[`Test <App/> > should show error modal when using brave and measureText API is not working 1`] = `
<div
data-testid="brave-measure-text-error"
>

View File

@ -0,0 +1,226 @@
import React, { useEffect, useRef } from "react";
import { renderInteractiveScene } from "../../renderer/renderScene";
import {
isRenderThrottlingEnabled,
isShallowEqual,
sceneCoordsToViewportCoords,
} from "../../utils";
import { CURSOR_TYPE } from "../../constants";
import { t } from "../../i18n";
import type { DOMAttributes } from "react";
import type { AppState, InteractiveCanvasAppState } from "../../types";
import type {
InteractiveCanvasRenderConfig,
RenderInteractiveSceneCallback,
} from "../../scene/types";
import type { NonDeletedExcalidrawElement } from "../../element/types";
type InteractiveCanvasProps = {
containerRef: React.RefObject<HTMLDivElement>;
canvas: HTMLCanvasElement | null;
elements: readonly NonDeletedExcalidrawElement[];
visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[];
versionNonce: number | undefined;
selectionNonce: number | undefined;
scale: number;
appState: InteractiveCanvasAppState;
renderInteractiveSceneCallback: (
data: RenderInteractiveSceneCallback,
) => void;
handleCanvasRef: (canvas: HTMLCanvasElement | null) => void;
onContextMenu: Exclude<
DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
undefined
>;
onPointerMove: Exclude<
DOMAttributes<HTMLCanvasElement>["onPointerMove"],
undefined
>;
onPointerUp: Exclude<
DOMAttributes<HTMLCanvasElement>["onPointerUp"],
undefined
>;
onPointerCancel: Exclude<
DOMAttributes<HTMLCanvasElement>["onPointerCancel"],
undefined
>;
onTouchMove: Exclude<
DOMAttributes<HTMLCanvasElement>["onTouchMove"],
undefined
>;
onPointerDown: Exclude<
DOMAttributes<HTMLCanvasElement>["onPointerDown"],
undefined
>;
onDoubleClick: Exclude<
DOMAttributes<HTMLCanvasElement>["onDoubleClick"],
undefined
>;
};
const InteractiveCanvas = (props: InteractiveCanvasProps) => {
const isComponentMounted = useRef(false);
useEffect(() => {
if (!isComponentMounted.current) {
isComponentMounted.current = true;
return;
}
const cursorButton: {
[id: string]: string | undefined;
} = {};
const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
{};
const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
{};
const pointerUsernames: { [id: string]: string } = {};
const pointerUserStates: { [id: string]: string } = {};
props.appState.collaborators.forEach((user, socketId) => {
if (user.selectedElementIds) {
for (const id of Object.keys(user.selectedElementIds)) {
if (!(id in remoteSelectedElementIds)) {
remoteSelectedElementIds[id] = [];
}
remoteSelectedElementIds[id].push(socketId);
}
}
if (!user.pointer) {
return;
}
if (user.username) {
pointerUsernames[socketId] = user.username;
}
if (user.userState) {
pointerUserStates[socketId] = user.userState;
}
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
{
sceneX: user.pointer.x,
sceneY: user.pointer.y,
},
props.appState,
);
cursorButton[socketId] = user.button;
});
const selectionColor =
(props.containerRef?.current &&
getComputedStyle(props.containerRef.current).getPropertyValue(
"--color-selection",
)) ||
"#6965db";
renderInteractiveScene(
{
canvas: props.canvas,
elements: props.elements,
visibleElements: props.visibleElements,
selectedElements: props.selectedElements,
scale: window.devicePixelRatio,
appState: props.appState,
renderConfig: {
remotePointerViewportCoords: pointerViewportCoords,
remotePointerButton: cursorButton,
remoteSelectedElementIds,
remotePointerUsernames: pointerUsernames,
remotePointerUserStates: pointerUserStates,
selectionColor,
renderScrollbars: false,
},
callback: props.renderInteractiveSceneCallback,
},
isRenderThrottlingEnabled(),
);
});
return (
<canvas
className="excalidraw__canvas interactive"
style={{
width: props.appState.width,
height: props.appState.height,
cursor: props.appState.viewModeEnabled
? CURSOR_TYPE.GRAB
: CURSOR_TYPE.AUTO,
}}
width={props.appState.width * props.scale}
height={props.appState.height * props.scale}
ref={props.handleCanvasRef}
onContextMenu={props.onContextMenu}
onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp}
onPointerCancel={props.onPointerCancel}
onTouchMove={props.onTouchMove}
onPointerDown={props.onPointerDown}
onDoubleClick={
props.appState.viewModeEnabled ? undefined : props.onDoubleClick
}
>
{t("labels.drawingCanvas")}
</canvas>
);
};
const getRelevantAppStateProps = (
appState: AppState,
): InteractiveCanvasAppState => ({
zoom: appState.zoom,
scrollX: appState.scrollX,
scrollY: appState.scrollY,
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
editingGroupId: appState.editingGroupId,
editingLinearElement: appState.editingLinearElement,
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
selectionElement: appState.selectionElement,
selectedGroupIds: appState.selectedGroupIds,
selectedLinearElement: appState.selectedLinearElement,
multiElement: appState.multiElement,
isBindingEnabled: appState.isBindingEnabled,
suggestedBindings: appState.suggestedBindings,
isRotating: appState.isRotating,
elementsToHighlight: appState.elementsToHighlight,
openSidebar: appState.openSidebar,
showHyperlinkPopup: appState.showHyperlinkPopup,
collaborators: appState.collaborators, // Necessary for collab. sessions
activeEmbeddable: appState.activeEmbeddable,
});
const areEqual = (
prevProps: InteractiveCanvasProps,
nextProps: InteractiveCanvasProps,
) => {
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
if (
prevProps.selectionNonce !== nextProps.selectionNonce ||
prevProps.versionNonce !== nextProps.versionNonce ||
prevProps.scale !== nextProps.scale ||
// we need to memoize on element arrays because they may have renewed
// even if versionNonce didn't change (e.g. we filter elements out based
// on appState)
prevProps.elements !== nextProps.elements ||
prevProps.visibleElements !== nextProps.visibleElements ||
prevProps.selectedElements !== nextProps.selectedElements
) {
return false;
}
// Comparing the interactive appState for changes in case of some edge cases
return isShallowEqual(
// asserting AppState because we're being passed the whole AppState
// but resolve to only the InteractiveCanvas-relevant props
getRelevantAppStateProps(prevProps.appState as AppState),
getRelevantAppStateProps(nextProps.appState as AppState),
);
};
export default React.memo(InteractiveCanvas, areEqual);

View File

@ -0,0 +1,110 @@
import React, { useEffect, useRef } from "react";
import { RoughCanvas } from "roughjs/bin/canvas";
import { renderStaticScene } from "../../renderer/renderScene";
import { isRenderThrottlingEnabled, isShallowEqual } from "../../utils";
import type { AppState, StaticCanvasAppState } from "../../types";
import type { StaticCanvasRenderConfig } from "../../scene/types";
import type { NonDeletedExcalidrawElement } from "../../element/types";
type StaticCanvasProps = {
canvas: HTMLCanvasElement;
rc: RoughCanvas;
elements: readonly NonDeletedExcalidrawElement[];
visibleElements: readonly NonDeletedExcalidrawElement[];
versionNonce: number | undefined;
selectionNonce: number | undefined;
scale: number;
appState: StaticCanvasAppState;
renderConfig: StaticCanvasRenderConfig;
};
const StaticCanvas = (props: StaticCanvasProps) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const isComponentMounted = useRef(false);
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) {
return;
}
const canvas = props.canvas;
if (!isComponentMounted.current) {
isComponentMounted.current = true;
wrapper.replaceChildren(canvas);
canvas.classList.add("excalidraw__canvas", "static");
}
canvas.style.width = `${props.appState.width}px`;
canvas.style.height = `${props.appState.height}px`;
canvas.width = props.appState.width * props.scale;
canvas.height = props.appState.height * props.scale;
renderStaticScene(
{
canvas,
rc: props.rc,
scale: props.scale,
elements: props.elements,
visibleElements: props.visibleElements,
appState: props.appState,
renderConfig: props.renderConfig,
},
isRenderThrottlingEnabled(),
);
});
return <div className="excalidraw__canvas-wrapper" ref={wrapperRef} />;
};
const getRelevantAppStateProps = (
appState: AppState,
): StaticCanvasAppState => ({
zoom: appState.zoom,
scrollX: appState.scrollX,
scrollY: appState.scrollY,
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize,
frameRendering: appState.frameRendering,
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId,
});
const areEqual = (
prevProps: StaticCanvasProps,
nextProps: StaticCanvasProps,
) => {
if (
prevProps.versionNonce !== nextProps.versionNonce ||
prevProps.scale !== nextProps.scale ||
// we need to memoize on element arrays because they may have renewed
// even if versionNonce didn't change (e.g. we filter elements out based
// on appState)
prevProps.elements !== nextProps.elements ||
prevProps.visibleElements !== nextProps.visibleElements
) {
return false;
}
return isShallowEqual(
// asserting AppState because we're being passed the whole AppState
// but resolve to only the StaticCanvas-relevant props
getRelevantAppStateProps(prevProps.appState as AppState),
getRelevantAppStateProps(nextProps.appState as AppState),
);
};
export default React.memo(StaticCanvas, areEqual);

Some files were not shown because too many files have changed in this diff Show More