Compare commits

...

121 Commits

Author SHA1 Message Date
dwelle 75e2d9e359 remove debug 2021-11-22 23:20:40 +01:00
dwelle 6592517122 import lazily 2021-11-22 22:11:10 +01:00
dwelle bd953a6287 feat: compress non-transparent PNGs as JPGs and allow larger dimensions 2021-11-22 17:39:59 +01:00
zsviczian b6ef953dc9 fix: SVG export in dark mode with embedded bitmap image (#4285)
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-11-20 15:31:51 +01:00
David Luzar 620b662085 chore: bump typescript@4.5.2 (#4283)
* chore: bump typescript@4.5.2

* bump typescript and fix tsconfig for npm package
2021-11-19 19:51:28 +01:00
David Luzar 1c11df011a fix: new FS API not working on Linux (#4280) 2021-11-19 14:20:42 +01:00
David Luzar 59e9651547 feat: log FS abortError to console (#4279) 2021-11-19 10:54:23 +01:00
Aakansha Doshi 1c48d122e0 fix: url -> URL for consistency (#4277) 2021-11-19 11:21:23 +05:30
Aakansha Doshi e4d02fb275 feat: Add validation for website and remove validation for library item name (#4269)
* Github->GitHub

* allow numbers

* remove validation for lib/item name
2021-11-18 10:24:26 +00:00
David Luzar 34a382ace9 fix: prevent adding images to library via contextMenu (#4264) 2021-11-17 20:06:26 +00:00
David Luzar e60e48e67d fix: account for libraries v2 when prompting (#4263) 2021-11-17 19:54:40 +00:00
Aakansha Doshi 84d1d9993c feat: Allow publishing libraries from UI (#4115)
* feat: Allow publishing libraries from UI

* Add status for each library item and show publish only for unpublished libs

* Add publish library dialog

* Pass the data to publish the library

* pass lib blob

* Handle old and new libraries when importing

* Better error handling

* Show publish success when library submitted for review

* don't close library when publish success dialog open

* Support multiple libs deletion and publish

* Set status to published once library submitted for review

* Save  to LS after library published

* unique key for publish and delete

* fix layout shift when hover and also highlight selected library items

* design improvements

* migrate old library to the new one

* fix

* fix tests

* use i18n

* Support submit type in toolbutton

* Use html5 form validation, add asteriks for required fields, add twitter handle, mark github handle optional

* Add twitter handle in form state

* revert html5 validation as fetch is giving some issues :/

* clarify types around LibraryItems

* Add website optional field

* event.preventDefault to make htm5 form validationw work

* improve png generation by drawing a bounding box rect and aligining pngs to support multiple libs png

* remove ts-ignore

* add placeholders for fields

* decrease clickable area for checkbox by 0.5em

* add checkbox background color

* rename `items` to `elements`

* improve checkbox hit area

* show selected library items in publish dialog

* decrease dimensions by 3px to improve jerky experience when opening/closing library menu

* Don't close publish dialog when clicked outside

* Show selected library actions only when any library item selected and use icons instead of button

* rename library to libraryItems in excalidrawLib and added migration

* change icon and swap bg/color

* use blue brand color for hover/selected states

* prompt for confirmation when deleting library items

* separate unpublished items from published

* factor `LibraryMenu` into own file

* i18n and minor fixes for unpublished items

* fix not rendering empty cells when library empty

* don't render published section if empty and unpublished is not

* Add edit name functionality for library items

* fix

* edit lib name with onchange/blur

* bump library version

* prefer response error message

* add library urls to ENV vars

* mark lib item name as required

* Use input only for lib item name

* better error validation for lib items

* fix label styling for lib items

* design and i18n fixes

* Save publish dialog data to local storage and clear once published

* Add a note about MIT License

* Add note for guidelines

* Add tooltip for publish button

* Show spinner in submit button when submission is in progress

* assign id for older lib items when installed and set status as published for all lib when installed

* update export icon and support export library for selected items

* move LibraryMenuItems into its own component as its best to keep one comp per file

* fix spec

* Refactoring the library actions for reusablility

* show only load when items not present

* close on click outside in publish dialog

* ad dialog description and tweak copy

* vertically center input labels

* align input styles

* move author name input to other usernames

* rename param

* inline to simplify

* fix to not inline `undefined` class names

* fix version & include only latest lib schema in library export type

* await response callback

* refactor types

* refactor

* i18n

* align casing & tweaks

* move ls logic to publishLibrary

* support removal of item inside publish dialog

* fix labels for trash icon when items selected

* replace window.confirm for removal libs with confirm dialog

* fix input/textarea styling

* move library item menu scss to its own file

* use blue for load and cyan for publish

* reduce margin for submit and make submit => Submit

* Make library items header sticky

* move publish icon to left so there is no jerkiness when unpublish items selected

* update url

* fix grid gap between lib items

* Mark older items imported from initial data as unpublished

* add text to publish button on non-mobile

* add items counter

* fix test

* show personal and excal libs sections and personal goes first

* show toast on adding to library via contextMenu

* Animate plus icon and not the pending item

* fix snap

* use i18n when no item in publish dialog

* tweak style of new lib item

* show empty cells for both sections and set status as published for installed libs

* fix

* push selected item first in unpublished section

* set status as published for imported from webiste but unpublished for json

* Add items to the begining of library

* add `created` library item attr

* fix test

* use `defaultValue` instead of `value`

* fix dark theme styles

* fix toggle button not closing library

* close library menu on Escape

* tweak publish dialog item remove style

* fix remove icon in publish dialog

Co-authored-by: dwelle <luzar.david@gmail.com>
2021-11-17 23:53:43 +05:30
Aakansha Doshi 3ff9744b39 feat: create confirm dialog to use instead of window.confirm (#4256)
* feat: create confirm dialog to use instead of window.confirm

* move confirm to right

* add types

* less margin
2021-11-16 18:55:56 +05:30
dependabot[bot] b9abcc825a chore(deps-dev): bump webpack in /src/packages/utils (#4245)
Bumps [webpack](https://github.com/webpack/webpack) from 5.62.1 to 5.64.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.62.1...v5.64.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-14 10:47:31 +01:00
dependabot[bot] 9679eaf74c chore(deps-dev): bump @types/web from 0.0.46 to 0.0.47 (#4249)
Bumps [@types/web](https://github.com/microsoft/TypeScript-DOM-Lib-Generator) from 0.0.46 to 0.0.47.
- [Release notes](https://github.com/microsoft/TypeScript-DOM-Lib-Generator/releases)
- [Commits](https://github.com/microsoft/TypeScript-DOM-Lib-Generator/compare/@types/web@0.0.46...@types/web@0.0.47)

---
updated-dependencies:
- dependency-name: "@types/web"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-14 10:46:42 +01:00
dependabot[bot] 284747d742 chore(deps-dev): bump terser-webpack-plugin in /src/packages/excalidraw (#4243)
Bumps [terser-webpack-plugin](https://github.com/webpack-contrib/terser-webpack-plugin) from 5.2.4 to 5.2.5.
- [Release notes](https://github.com/webpack-contrib/terser-webpack-plugin/releases)
- [Changelog](https://github.com/webpack-contrib/terser-webpack-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/terser-webpack-plugin/compare/v5.2.4...v5.2.5)

---
updated-dependencies:
- dependency-name: terser-webpack-plugin
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-14 10:46:18 +01:00
dependabot[bot] 876f85fd7a chore(deps-dev): bump webpack in /src/packages/excalidraw (#4244)
Bumps [webpack](https://github.com/webpack/webpack) from 5.62.1 to 5.64.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.62.1...v5.64.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-14 10:45:55 +01:00
dependabot[bot] efc2bbed21 chore(deps): bump browser-fs-access from 0.21.0 to 0.21.1 (#4248)
Bumps [browser-fs-access](https://github.com/GoogleChromeLabs/browser-fs-access) from 0.21.0 to 0.21.1.
- [Release notes](https://github.com/GoogleChromeLabs/browser-fs-access/releases)
- [Commits](https://github.com/GoogleChromeLabs/browser-fs-access/compare/v0.21.0...v0.21.1)

---
updated-dependencies:
- dependency-name: browser-fs-access
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-14 10:45:29 +01:00
dependabot[bot] 61d193b87b chore(deps-dev): bump lint-staged from 11.2.6 to 12.0.1 (#4250)
Bumps [lint-staged](https://github.com/okonet/lint-staged) from 11.2.6 to 12.0.1.
- [Release notes](https://github.com/okonet/lint-staged/releases)
- [Commits](https://github.com/okonet/lint-staged/compare/v11.2.6...v12.0.1)

---
updated-dependencies:
- dependency-name: lint-staged
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-14 10:45:06 +01:00
dependabot[bot] 3989d6a989 chore(deps): bump roughjs from 4.4.5 to 4.5.0 (#4246)
Bumps [roughjs](https://github.com/pshihn/rough) from 4.4.5 to 4.5.0.
- [Release notes](https://github.com/pshihn/rough/releases)
- [Changelog](https://github.com/rough-stuff/rough/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pshihn/rough/commits)

---
updated-dependencies:
- dependency-name: roughjs
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-14 10:44:44 +01:00
Excalidraw Bot f6559b65ef chore: Update translations from Crowdin (#4201) 2021-11-12 12:13:34 +01:00
Thomas Steiner bc6b066c07 Remove outdated OT info (#4232) 2021-11-09 12:16:32 +01:00
dependabot[bot] 6370d517a2 chore(deps-dev): bump css-loader in /src/packages/excalidraw (#4209)
Bumps [css-loader](https://github.com/webpack-contrib/css-loader) from 6.5.0 to 6.5.1.
- [Release notes](https://github.com/webpack-contrib/css-loader/releases)
- [Changelog](https://github.com/webpack-contrib/css-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/css-loader/compare/v6.5.0...v6.5.1)

---
updated-dependencies:
- dependency-name: css-loader
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-08 17:45:06 +00:00
dependabot[bot] b8a37c42e4 chore(deps): bump @tldraw/vec from 0.0.132 to 0.1.3 (#4215)
Bumps [@tldraw/vec](https://github.com/tldraw/vec) from 0.0.132 to 0.1.3.
- [Release notes](https://github.com/tldraw/vec/releases)
- [Commits](https://github.com/tldraw/vec/commits)

---
updated-dependencies:
- dependency-name: "@tldraw/vec"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-08 18:42:21 +01:00
dependabot[bot] 76763b80a9 chore(deps-dev): bump @types/web from 0.0.45 to 0.0.46 (#4214)
Bumps [@types/web](https://github.com/microsoft/TypeScript-DOM-Lib-Generator) from 0.0.45 to 0.0.46.
- [Release notes](https://github.com/microsoft/TypeScript-DOM-Lib-Generator/releases)
- [Commits](https://github.com/microsoft/TypeScript-DOM-Lib-Generator/compare/@types/web@0.0.45...@types/web@0.0.46)

---
updated-dependencies:
- dependency-name: "@types/web"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-08 18:41:59 +01:00
dependabot[bot] d2a2c9d6b5 chore(deps-dev): bump webpack in /src/packages/utils (#4211)
Bumps [webpack](https://github.com/webpack/webpack) from 5.61.0 to 5.62.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.61.0...v5.62.1)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-08 17:41:50 +00:00
dependabot[bot] 3a72f347d2 chore(deps-dev): bump webpack in /src/packages/excalidraw (#4208)
Bumps [webpack](https://github.com/webpack/webpack) from 5.61.0 to 5.62.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.61.0...v5.62.1)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-08 18:41:13 +01:00
dependabot[bot] c1d9456235 chore(deps-dev): bump mini-css-extract-plugin (#4207)
Bumps [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) from 2.4.3 to 2.4.4.
- [Release notes](https://github.com/webpack-contrib/mini-css-extract-plugin/releases)
- [Changelog](https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/mini-css-extract-plugin/compare/v2.4.3...v2.4.4)

---
updated-dependencies:
- dependency-name: mini-css-extract-plugin
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-08 18:40:45 +01:00
dependabot[bot] c4f8b98208 chore(deps-dev): bump css-loader in /src/packages/utils (#4210)
Bumps [css-loader](https://github.com/webpack-contrib/css-loader) from 6.5.0 to 6.5.1.
- [Release notes](https://github.com/webpack-contrib/css-loader/releases)
- [Changelog](https://github.com/webpack-contrib/css-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/css-loader/compare/v6.5.0...v6.5.1)

---
updated-dependencies:
- dependency-name: css-loader
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-08 18:40:18 +01:00
dependabot[bot] b6eb57d3f1 chore(deps): bump @testing-library/jest-dom from 5.14.1 to 5.15.0 (#4212)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 5.14.1 to 5.15.0.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v5.14.1...v5.15.0)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-08 18:39:52 +01:00
dependabot[bot] 473b8ca0ca chore(deps): bump @types/react from 17.0.33 to 17.0.34 (#4216)
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 17.0.33 to 17.0.34.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-08 18:39:24 +01:00
dependabot[bot] 45206c4ef1 chore(deps-dev): bump firebase-tools from 9.21.0 to 9.22.0 (#4219)
Bumps [firebase-tools](https://github.com/firebase/firebase-tools) from 9.21.0 to 9.22.0.
- [Release notes](https://github.com/firebase/firebase-tools/releases)
- [Commits](https://github.com/firebase/firebase-tools/compare/v9.21.0...v9.22.0)

---
updated-dependencies:
- dependency-name: firebase-tools
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-08 18:38:55 +01:00
dependabot[bot] 56b4a29aaa chore(deps): bump @types/react-dom from 17.0.10 to 17.0.11 (#4218)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-07 15:00:24 +01:00
dependabot[bot] bb4dda64b5 chore(deps): bump roughjs from 4.4.4 to 4.4.5 (#4221)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-07 14:57:09 +01:00
Lipis 39e53b4ae7 feat: Allow letters in IDs for storing files in backend (#4224) 2021-11-07 14:37:13 +01:00
David Luzar 6143d5195a refactor: deduplicate encryption helpers (#4146) 2021-11-07 14:33:21 +01:00
David Luzar f59e608f18 fix: Skia rendering issues (#4200) 2021-11-04 15:55:10 +01:00
Excalidraw Bot 6b24592e4a chore: Update translations from Crowdin (#4150) 2021-11-04 14:10:21 +02:00
Lipis 7b442997dc chore: Update docker action to v2 (#4198) 2021-11-04 14:10:00 +02:00
Lipis 4bfc5bbcaa chore: Update i18next-browser-languagedetector (#4196) 2021-11-03 21:07:13 +00:00
Lipis 2b29b9a96d chore: Consistent case for clear canvas, change font of buttons and clean up unused strings (#4195)
* chore: Consistent case for clear canvas and font

* Remove unused

* remove
2021-11-03 21:31:27 +02:00
David Luzar cc201a6d80 fix: ellipse roughness when 0 (#4194) 2021-11-03 12:50:54 +01:00
Lipis 5be58b59e0 fix: Proper string for invalid SVG (#4191) 2021-11-03 10:10:58 +01:00
Lipis f1eb969565 feat: Remove support for V1 unencrypted backend (#4189) 2021-11-02 14:52:25 +02:00
Lipis 8d4f455cd3 chore: Update Typescript to 4.4.4 (#4188) 2021-11-02 14:24:16 +02:00
dependabot[bot] 60262cb4cc chore(deps): bump idb-keyval from 5.1.3 to 6.0.3 (#4181)
Bumps [idb-keyval](https://github.com/jakearchibald/idb-keyval) from 5.1.3 to 6.0.3.
- [Release notes](https://github.com/jakearchibald/idb-keyval/releases)
- [Changelog](https://github.com/jakearchibald/idb-keyval/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jakearchibald/idb-keyval/compare/v5.1.3...v6.0.3)

---
updated-dependencies:
- dependency-name: idb-keyval
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-02 09:44:05 +02:00
Lipis 7501c24f22 feat: Use separate backend for local storage (#4187) 2021-11-02 09:33:27 +02:00
dependabot[bot] 00d81aa982 chore(deps-dev): bump @babel/plugin-transform-typescript (#4160)
Bumps [@babel/plugin-transform-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-typescript) from 7.15.8 to 7.16.1.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.1/packages/babel-plugin-transform-typescript)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:36:09 +00:00
dependabot[bot] 67fe156d06 chore(deps-dev): bump @babel/plugin-transform-async-to-generator (#4162)
Bumps [@babel/plugin-transform-async-to-generator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-async-to-generator) from 7.14.5 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-plugin-transform-async-to-generator)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-async-to-generator"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:31:12 +00:00
dependabot[bot] ef433233d1 chore(deps-dev): bump @babel/preset-typescript in /src/packages/utils (#4161)
Bumps [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) from 7.15.0 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/preset-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:24:26 +00:00
Lipis 1c7056bdaa chore: bump Prettier to the latest (#4185) 2021-11-01 15:24:05 +02:00
dependabot[bot] 277ffaacb9 chore(deps-dev): bump css-loader in /src/packages/excalidraw (#4122)
Bumps [css-loader](https://github.com/webpack-contrib/css-loader) from 5.2.6 to 6.5.0.
- [Release notes](https://github.com/webpack-contrib/css-loader/releases)
- [Changelog](https://github.com/webpack-contrib/css-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/css-loader/compare/v5.2.6...v6.5.0)

---
updated-dependencies:
- dependency-name: css-loader
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:23:38 +00:00
dependabot[bot] 2a3e242cfd chore(deps-dev): bump @babel/plugin-transform-runtime (#4164)
Bumps [@babel/plugin-transform-runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-runtime) from 7.15.8 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-plugin-transform-runtime)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-runtime"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:20:22 +00:00
dependabot[bot] b1c6051d6b chore(deps-dev): bump webpack in /src/packages/utils (#4134)
Bumps [webpack](https://github.com/webpack/webpack) from 5.50.0 to 5.61.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.50.0...v5.61.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:19:04 +00:00
dependabot[bot] 8df9742463 chore(deps-dev): bump webpack-cli in /src/packages/excalidraw (#4132)
Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 4.7.2 to 4.9.1.
- [Release notes](https://github.com/webpack/webpack-cli/releases)
- [Changelog](https://github.com/webpack/webpack-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-cli/compare/webpack-cli@4.7.2...webpack-cli@4.9.1)

---
updated-dependencies:
- dependency-name: webpack-cli
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:18:24 +00:00
dependabot[bot] 9fdc382d71 chore(deps-dev): bump @babel/plugin-transform-arrow-functions (#4165)
Bumps [@babel/plugin-transform-arrow-functions](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-arrow-functions) from 7.14.5 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-plugin-transform-arrow-functions)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-arrow-functions"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:15:16 +00:00
dependabot[bot] f70d11c2d1 chore(deps-dev): bump @babel/plugin-transform-typescript (#4167)
Bumps [@babel/plugin-transform-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-typescript) from 7.15.8 to 7.16.1.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.1/packages/babel-plugin-transform-typescript)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 15:11:04 +02:00
dependabot[bot] 05e54d6785 chore(deps-dev): bump mini-css-extract-plugin (#4094)
Bumps [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) from 1.6.1 to 2.4.3.
- [Release notes](https://github.com/webpack-contrib/mini-css-extract-plugin/releases)
- [Changelog](https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/mini-css-extract-plugin/compare/v1.6.1...v2.4.3)

---
updated-dependencies:
- dependency-name: mini-css-extract-plugin
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:10:36 +00:00
dependabot[bot] 795a6e4546 chore(deps-dev): bump webpack-cli in /src/packages/utils (#4118)
Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 4.7.2 to 4.9.1.
- [Release notes](https://github.com/webpack/webpack-cli/releases)
- [Changelog](https://github.com/webpack/webpack-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-cli/compare/webpack-cli@4.7.2...webpack-cli@4.9.1)

---
updated-dependencies:
- dependency-name: webpack-cli
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:09:32 +00:00
dependabot[bot] a01a4ad739 chore(deps): bump @testing-library/react from 11.2.6 to 12.1.2 (#4177)
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 11.2.6 to 12.1.2.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v11.2.6...v12.1.2)

---
updated-dependencies:
- dependency-name: "@testing-library/react"
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 13:03:24 +00:00
dependabot[bot] e09b96ac6f chore(deps-dev): bump @babel/core in /src/packages/excalidraw (#4166)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.15.8 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 12:56:22 +00:00
dependabot[bot] d48fb17718 chore(deps-dev): bump webpack in /src/packages/excalidraw (#4144)
Bumps [webpack](https://github.com/webpack/webpack) from 5.50.0 to 5.61.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.50.0...v5.61.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 18:24:40 +05:30
dependabot[bot] ede3c4af82 chore(deps-dev): bump postcss-loader in /src/packages/excalidraw (#4169)
Bumps [postcss-loader](https://github.com/webpack-contrib/postcss-loader) from 6.1.1 to 6.2.0.
- [Release notes](https://github.com/webpack-contrib/postcss-loader/releases)
- [Changelog](https://github.com/webpack-contrib/postcss-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/postcss-loader/compare/v6.1.1...v6.2.0)

---
updated-dependencies:
- dependency-name: postcss-loader
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:53:43 +02:00
dependabot[bot] 8bcfd97fc5 chore(deps-dev): bump css-loader in /src/packages/utils (#4119)
Bumps [css-loader](https://github.com/webpack-contrib/css-loader) from 6.2.0 to 6.5.0.
- [Release notes](https://github.com/webpack-contrib/css-loader/releases)
- [Changelog](https://github.com/webpack-contrib/css-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/css-loader/compare/v6.2.0...v6.5.0)

---
updated-dependencies:
- dependency-name: css-loader
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:50:49 +02:00
dependabot[bot] 5721c6dfb5 chore(deps): bump nanoid from 3.1.22 to 3.1.30 (#4176)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.1.22 to 3.1.30.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.1.22...3.1.30)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 12:47:06 +00:00
dependabot[bot] 9b1f77c3be chore(deps-dev): bump @babel/plugin-transform-async-to-generator (#4168)
Bumps [@babel/plugin-transform-async-to-generator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-async-to-generator) from 7.14.5 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-plugin-transform-async-to-generator)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-async-to-generator"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 12:43:37 +00:00
David Luzar 3369035f40 feat: add hint around canvas panning (#4159) 2021-11-01 13:36:06 +01:00
dependabot[bot] dbc7a8599b chore(deps): bump @tldraw/vec from 0.0.106 to 0.0.132 (#4175)
Bumps [@tldraw/vec](https://github.com/tldraw/tldraw/tree/HEAD/packages/vec) from 0.0.106 to 0.0.132.
- [Release notes](https://github.com/tldraw/tldraw/releases)
- [Commits](https://github.com/tldraw/tldraw/commits/HEAD/packages/vec)

---
updated-dependencies:
- dependency-name: "@tldraw/vec"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:35:17 +02:00
dependabot[bot] 09f649daf7 chore(deps-dev): bump @types/pako from 1.0.1 to 1.0.2 (#4170)
Bumps [@types/pako](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pako) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pako)

---
updated-dependencies:
- dependency-name: "@types/pako"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:33:09 +02:00
dependabot[bot] d357664850 chore(deps): bump perfect-freehand from 1.0.15 to 1.0.16 (#4178)
Bumps [perfect-freehand](https://github.com/steveruizok/perfect-freehand) from 1.0.15 to 1.0.16.
- [Release notes](https://github.com/steveruizok/perfect-freehand/releases)
- [Commits](https://github.com/steveruizok/perfect-freehand/compare/v1.0.15...v1.0.16)

---
updated-dependencies:
- dependency-name: perfect-freehand
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:32:37 +02:00
dependabot[bot] f973fdfa89 chore(deps): bump fake-indexeddb from 3.1.3 to 3.1.7 (#4172)
Bumps [fake-indexeddb](https://github.com/dumbmatter/fakeIndexedDB) from 3.1.3 to 3.1.7.
- [Release notes](https://github.com/dumbmatter/fakeIndexedDB/releases)
- [Changelog](https://github.com/dumbmatter/fakeIndexedDB/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dumbmatter/fakeIndexedDB/compare/v3.1.3...v3.1.7)

---
updated-dependencies:
- dependency-name: fake-indexeddb
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:31:54 +02:00
dependabot[bot] c15bc50f17 chore(deps-dev): bump @babel/preset-env in /src/packages/excalidraw (#4163)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.15.8 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:31:34 +02:00
dependabot[bot] c2d0107cc5 chore(deps): bump sass from 1.32.10 to 1.43.4 (#4174)
Bumps [sass](https://github.com/sass/dart-sass) from 1.32.10 to 1.43.4.
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.32.10...1.43.4)

---
updated-dependencies:
- dependency-name: sass
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:31:13 +02:00
dependabot[bot] c43fac31a1 chore(deps): bump @types/react from 17.0.3 to 17.0.33 (#4179)
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 17.0.3 to 17.0.33.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:30:43 +02:00
dependabot[bot] 9dfaf1752b chore(deps): bump open-color from 1.8.0 to 1.9.1 (#4180)
Bumps [open-color](https://github.com/yeun/open-color) from 1.8.0 to 1.9.1.
- [Release notes](https://github.com/yeun/open-color/releases)
- [Changelog](https://github.com/yeun/open-color/blob/master/build_release)
- [Commits](https://github.com/yeun/open-color/compare/v1.8.0...v1.9.1)

---
updated-dependencies:
- dependency-name: open-color
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:30:17 +02:00
dependabot[bot] d9a1eb2f01 chore(deps-dev): bump firebase-tools from 9.9.0 to 9.21.0 (#4184)
Bumps [firebase-tools](https://github.com/firebase/firebase-tools) from 9.9.0 to 9.21.0.
- [Release notes](https://github.com/firebase/firebase-tools/releases)
- [Commits](https://github.com/firebase/firebase-tools/compare/v9.9.0...v9.21.0)

---
updated-dependencies:
- dependency-name: firebase-tools
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 14:29:56 +02:00
dependabot[bot] f1e17a320f chore(deps-dev): bump @babel/preset-env in /src/packages/utils (#4151)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.15.8 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 10:05:36 +00:00
dependabot[bot] 75ecd818b3 chore(deps-dev): bump webpack-bundle-analyzer in /src/packages/utils (#4158)
Bumps [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) from 4.4.2 to 4.5.0.
- [Release notes](https://github.com/webpack-contrib/webpack-bundle-analyzer/releases)
- [Changelog](https://github.com/webpack-contrib/webpack-bundle-analyzer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/webpack-bundle-analyzer/compare/v4.4.2...v4.5.0)

---
updated-dependencies:
- dependency-name: webpack-bundle-analyzer
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 10:05:03 +00:00
Lipis a7abc71f6a chore: Update dependabot limits (#4145) 2021-11-01 12:03:16 +02:00
dependabot[bot] 6d0f0c8f21 chore(deps-dev): bump @babel/plugin-transform-runtime (#4152)
Bumps [@babel/plugin-transform-runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-runtime) from 7.15.8 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-plugin-transform-runtime)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-runtime"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-01 12:00:41 +02:00
David Luzar 790e6da500 fix: images not initialized correctly (#4157)
* fix: image not initialized correctly due to not renewing `state.pendingImageElement`

* ensure we replace elements on update

* set file as errored on >= 400 status respones
2021-11-01 10:44:57 +01:00
dependabot[bot] 8df1a11535 chore(deps-dev): bump @babel/plugin-transform-arrow-functions (#4148)
Bumps [@babel/plugin-transform-arrow-functions](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-arrow-functions) from 7.14.5 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-plugin-transform-arrow-functions)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-arrow-functions"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-31 10:00:11 +00:00
dependabot[bot] b61ee56dc8 chore(deps-dev): bump @babel/core in /src/packages/utils (#4149)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.15.8 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-31 11:55:32 +02:00
David Luzar c61f95a327 fix: image-related fixes (#4147)
* flush queues on portal close

* fix mouse broadcast race condition

* stop mutating image elements when updating status

to fix race condition when closing/opening collab room

* check `files` when resolving `LayerUI`

* fix displaying AbortError
2021-10-30 23:40:35 +02:00
Excalidraw Bot d6d629f416 chore: Update translations from Crowdin (#4109) 2021-10-30 19:26:54 +03:00
dependabot[bot] 65dec605f2 chore(deps-dev): bump @babel/preset-typescript (#4143)
Bumps [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) from 7.15.0 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/preset-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-30 16:20:16 +00:00
dependabot[bot] cacec0b5c4 chore(deps-dev): bump @babel/preset-react in /src/packages/excalidraw (#4140)
Bumps [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) from 7.14.5 to 7.16.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.16.0/packages/babel-preset-react)

---
updated-dependencies:
- dependency-name: "@babel/preset-react"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-30 12:03:51 +03:00
dependabot[bot] 87a302d7e9 chore(deps-dev): bump sass-loader in /src/packages/excalidraw (#4139)
Bumps [sass-loader](https://github.com/webpack-contrib/sass-loader) from 12.1.0 to 12.3.0.
- [Release notes](https://github.com/webpack-contrib/sass-loader/releases)
- [Changelog](https://github.com/webpack-contrib/sass-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/sass-loader/compare/v12.1.0...v12.3.0)

---
updated-dependencies:
- dependency-name: sass-loader
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-30 11:34:17 +03:00
dependabot[bot] 899b36c206 chore(deps-dev): bump babel-loader in /src/packages/excalidraw (#4138)
Bumps [babel-loader](https://github.com/babel/babel-loader) from 8.2.2 to 8.2.3.
- [Release notes](https://github.com/babel/babel-loader/releases)
- [Changelog](https://github.com/babel/babel-loader/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel-loader/compare/v8.2.2...v8.2.3)

---
updated-dependencies:
- dependency-name: babel-loader
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-30 00:41:13 +00:00
dependabot[bot] 534cbef982 chore(deps-dev): bump autoprefixer in /src/packages/excalidraw (#4137)
Bumps [autoprefixer](https://github.com/postcss/autoprefixer) from 10.3.1 to 10.4.0.
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.3.1...10.4.0)

---
updated-dependencies:
- dependency-name: autoprefixer
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-30 00:01:45 +03:00
dependabot[bot] b7f118404e chore(deps-dev): bump webpack-bundle-analyzer (#4121)
Bumps [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) from 4.4.2 to 4.5.0.
- [Release notes](https://github.com/webpack-contrib/webpack-bundle-analyzer/releases)
- [Changelog](https://github.com/webpack-contrib/webpack-bundle-analyzer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/webpack-bundle-analyzer/compare/v4.4.2...v4.5.0)

---
updated-dependencies:
- dependency-name: webpack-bundle-analyzer
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 18:37:49 +03:00
dependabot[bot] aab5067718 chore(deps-dev): bump @types/resize-observer-browser from 0.1.5 to 0.1.6 (#4135)
Bumps [@types/resize-observer-browser](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/resize-observer-browser) from 0.1.5 to 0.1.6.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/resize-observer-browser)

---
updated-dependencies:
- dependency-name: "@types/resize-observer-browser"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 15:36:23 +00:00
dependabot[bot] b679da02ee chore(deps-dev): bump terser-webpack-plugin in /src/packages/excalidraw (#4128)
Bumps [terser-webpack-plugin](https://github.com/webpack-contrib/terser-webpack-plugin) from 5.1.4 to 5.2.4.
- [Release notes](https://github.com/webpack-contrib/terser-webpack-plugin/releases)
- [Changelog](https://github.com/webpack-contrib/terser-webpack-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/terser-webpack-plugin/compare/v5.1.4...v5.2.4)

---
updated-dependencies:
- dependency-name: terser-webpack-plugin
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 18:26:30 +03:00
dependabot[bot] ec652820ea chore(deps): bump @types/jest from 26.0.22 to 27.0.2 (#4131)
Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 26.0.22 to 27.0.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest)

---
updated-dependencies:
- dependency-name: "@types/jest"
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 18:26:15 +03:00
dependabot[bot] 5d941ed107 chore(deps-dev): bump @babel/preset-env in /src/packages/utils (#4129)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.15.0 to 7.15.8.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.15.8/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 18:25:59 +03:00
dependabot[bot] adc478ca34 chore(deps-dev): bump @babel/preset-env in /src/packages/excalidraw (#4130)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.14.9 to 7.15.8.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.15.8/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 18:25:47 +03:00
David Luzar f1202adb15 feat: stop using production services for development (#4113) 2021-10-29 17:13:28 +02:00
dependabot[bot] fd439cf38a chore(deps-dev): bump babel-loader in /src/packages/utils (#4124)
Bumps [babel-loader](https://github.com/babel/babel-loader) from 8.2.2 to 8.2.3.
- [Release notes](https://github.com/babel/babel-loader/releases)
- [Changelog](https://github.com/babel/babel-loader/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel-loader/compare/v8.2.2...v8.2.3)

---
updated-dependencies:
- dependency-name: babel-loader
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 11:32:52 +00:00
dependabot[bot] 83c63be846 chore(deps-dev): bump @babel/preset-typescript (#4127)
Bumps [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) from 7.14.5 to 7.15.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.15.0/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/preset-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 11:26:28 +00:00
dependabot[bot] b59d49dd7f chore(deps-dev): bump sass-loader in /src/packages/utils (#4126)
Bumps [sass-loader](https://github.com/webpack-contrib/sass-loader) from 12.1.0 to 12.3.0.
- [Release notes](https://github.com/webpack-contrib/sass-loader/releases)
- [Changelog](https://github.com/webpack-contrib/sass-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/sass-loader/compare/v12.1.0...v12.3.0)

---
updated-dependencies:
- dependency-name: sass-loader
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 11:25:02 +00:00
dependabot[bot] 0116b70edf chore(deps): bump @testing-library/jest-dom from 5.11.10 to 5.14.1 (#4125)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 5.11.10 to 5.14.1.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v5.11.10...v5.14.1)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 11:24:54 +00:00
dependabot[bot] 3f390d4858 chore(deps-dev): bump ts-loader in /src/packages/excalidraw (#4002)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 9.2.4 to 9.2.6.
- [Release notes](https://github.com/TypeStrong/ts-loader/releases)
- [Changelog](https://github.com/TypeStrong/ts-loader/blob/main/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/ts-loader/compare/v9.2.4...v9.2.6)

---
updated-dependencies:
- dependency-name: ts-loader
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 11:24:16 +00:00
dependabot[bot] fdde73bff4 chore(deps-dev): bump @babel/core in /src/packages/excalidraw (#4069)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.14.8 to 7.15.8.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.15.8/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 11:21:07 +00:00
dependabot[bot] 90a416e265 chore(deps): bump @types/react-dom from 17.0.3 to 17.0.10 (#4120)
Bumps [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) from 17.0.3 to 17.0.10.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 14:17:59 +03:00
dependabot[bot] a828b2e5de chore(deps-dev): bump @babel/plugin-transform-typescript (#4044)
Bumps [@babel/plugin-transform-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-typescript) from 7.14.6 to 7.15.8.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.15.8/packages/babel-plugin-transform-typescript)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 11:16:44 +00:00
dependabot[bot] 7c51d3c24c chore(deps-dev): bump ts-loader in /src/packages/utils (#4117)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 9.2.4 to 9.2.6.
- [Release notes](https://github.com/TypeStrong/ts-loader/releases)
- [Changelog](https://github.com/TypeStrong/ts-loader/blob/main/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/ts-loader/compare/v9.2.4...v9.2.6)

---
updated-dependencies:
- dependency-name: ts-loader
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 11:13:03 +00:00
dependabot[bot] 4d2d6f181a chore(deps): bump tar from 4.4.15 to 4.4.19 (#3953)
Bumps [tar](https://github.com/npm/node-tar) from 4.4.15 to 4.4.19.
- [Release notes](https://github.com/npm/node-tar/releases)
- [Changelog](https://github.com/npm/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-tar/compare/v4.4.15...v4.4.19)

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

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 11:10:33 +00:00
dependabot[bot] 071416f6ef chore(deps-dev): bump @babel/plugin-transform-typescript (#4045)
Bumps [@babel/plugin-transform-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-typescript) from 7.14.6 to 7.15.8.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.15.8/packages/babel-plugin-transform-typescript)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 14:10:19 +03:00
dependabot[bot] d675b07089 chore(deps-dev): bump @babel/core in /src/packages/utils (#4043)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.14.8 to 7.15.8.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.15.8/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 14:09:53 +03:00
dependabot[bot] 3975fd592a chore(deps-dev): bump @babel/plugin-transform-runtime (#4042)
Bumps [@babel/plugin-transform-runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-runtime) from 7.14.5 to 7.15.8.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.15.8/packages/babel-plugin-transform-runtime)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-runtime"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 14:09:37 +03:00
dependabot[bot] 34a9a4dac6 chore(deps-dev): bump @babel/preset-typescript in /src/packages/utils (#3928)
Bumps [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) from 7.14.5 to 7.15.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.15.0/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/preset-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-29 14:08:37 +03:00
Lipis 78e419b790 chore(deps-dev): Upgrade commit hooks to Husky 7 (#4116)
* Upgrade to Husky 7

* Husky
2021-10-29 14:06:13 +03:00
Jonas Bleyl 8d8769ba4e feat: add triangle arrowhead (#4024)
Co-authored-by: ad1992 <aakansha1216@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-10-27 19:27:39 +02:00
David Luzar d89fb3371b fix: rewrite collab element reconciliation to fix z-index issues (#4076) 2021-10-27 15:14:20 +02:00
Excalidraw Bot 8410972cff chore: Update translations from Crowdin (#4047)
* New translations en.json (Occitan)

* New translations en.json (Catalan)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Bengali)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Occitan)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Indonesian)

* New translations en.json (Punjabi)

* New translations en.json (Persian)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Polish)

* New translations en.json (Dutch)

* New translations en.json (Catalan)

* New translations en.json (Bulgarian)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Chinese Simplified)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Czech)

* New translations en.json (Korean)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Italian)

* New translations en.json (Dutch)

* New translations en.json (Chinese Traditional)

* New translations en.json (Norwegian Bokmal)

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* New translations en.json (Ukrainian)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (German)

* Auto commit: Calculate translation coverage

* New translations en.json (German)

* New translations en.json (Occitan)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Indonesian)

* New translations en.json (Punjabi)

* New translations en.json (Persian)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Polish)

* New translations en.json (Dutch)

* New translations en.json (Catalan)

* New translations en.json (Bulgarian)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Chinese Simplified)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Czech)

* New translations en.json (Korean)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

* New translations en.json (Occitan)

* Auto commit: Calculate translation coverage

* New translations en.json (Occitan)

* New translations en.json (Norwegian Bokmal)

* Auto commit: Calculate translation coverage

* New translations en.json (Occitan)

* Auto commit: Calculate translation coverage

* New translations en.json (Indonesian)

* Auto commit: Calculate translation coverage

* New translations en.json (Indonesian)

* New translations en.json (Kazakh)

* Auto commit: Calculate translation coverage

* New translations en.json (Japanese)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional)

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Portuguese)

* Auto commit: Calculate translation coverage

* New translations en.json (Finnish)

* Auto commit: Calculate translation coverage

* New translations en.json (Sinhala)

* Auto commit: Calculate translation coverage

* New translations en.json (German)

* Auto commit: Calculate translation coverage

* New translations en.json (Portuguese, Brazilian)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Tamil)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Swedish)

* Auto commit: Calculate translation coverage

* New translations en.json (Swedish)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Chinese Traditional, Hong Kong)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* Auto commit: Calculate translation coverage

* New translations en.json (Sinhala)

* Auto commit: Calculate translation coverage

* New translations en.json (Sinhala)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Chinese Simplified)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage
2021-10-27 12:41:00 +05:30
Thomas Steiner 2c8d041987 Migrate the implementation back to browser-fs-access (#4106) 2021-10-27 08:57:10 +02:00
Aakansha Doshi 94519c8250 fix: redirect excalidraw.com/about to for-webex.excalidraw.com (#4104) 2021-10-26 17:01:05 +02:00
David Luzar add8a1b1a7 fix: redirect to webex LP instead of rewrite to fix SW (#4103) 2021-10-26 16:02:30 +02:00
Aakansha Doshi 516e7656f3 feat: Add rewrite to webex landing page (#4102)
* feat: Add rewrite to webex landing page

* blacklist webex url

* dont cache webex

* Unregister sw for webex

* fix

* fix

* reload in callback

Co-authored-by: dwelle <luzar.david@gmail.com>
2021-10-26 18:19:41 +05:30
David Luzar d7cdee37bf feat: switch collab server (#4092) 2021-10-24 11:47:45 +02:00
David Luzar 5c5b8c517f fix: clear image/shape cache of affected elements when adding files (#4089) 2021-10-23 14:17:04 +02:00
162 changed files with 10521 additions and 4560 deletions
+8 -5
View File
@@ -1,5 +1,8 @@
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/ REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
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"}' REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
REACT_APP_SOCKET_SERVER_URL=http://localhost:3000
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"}'
+10
View File
@@ -1 +1,11 @@
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/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
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"}'
# production-only vars
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13 REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"extends": ["@excalidraw/eslint-config", "react-app"], "extends": ["@excalidraw/eslint-config", "react-app"],
"rules": { "rules": {
"import/no-anonymous-default-export": "off" "import/no-anonymous-default-export": "off",
"no-restricted-globals": "off"
} }
} }
+3
View File
@@ -10,6 +10,7 @@ updates:
- lipis - lipis
assignees: assignees:
- lipis - lipis
open-pull-requests-limit: 20
- package-ecosystem: npm - package-ecosystem: npm
directory: /src/packages/excalidraw/ directory: /src/packages/excalidraw/
@@ -21,6 +22,7 @@ updates:
- ad1992 - ad1992
assignees: assignees:
- ad1992 - ad1992
open-pull-requests-limit: 20
- package-ecosystem: npm - package-ecosystem: npm
directory: /src/packages/utils/ directory: /src/packages/utils/
@@ -32,3 +34,4 @@ updates:
- ad1992 - ad1992
assignees: assignees:
- ad1992 - ad1992
open-pull-requests-limit: 20
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: docker/build-push-action@v1 - uses: docker/build-push-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
+1
View File
@@ -0,0 +1 @@
yarn lint-staged
+29 -28
View File
@@ -19,28 +19,28 @@
] ]
}, },
"dependencies": { "dependencies": {
"@dwelle/browser-fs-access": "0.21.3",
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.11.10", "@testing-library/jest-dom": "5.15.0",
"@testing-library/react": "11.2.6", "@testing-library/react": "12.1.2",
"@tldraw/vec": "0.0.106", "@tldraw/vec": "0.1.3",
"@types/jest": "26.0.22", "@types/jest": "27.0.2",
"@types/pica": "5.1.3", "@types/pica": "5.1.3",
"@types/react": "17.0.3", "@types/react": "17.0.34",
"@types/react-dom": "17.0.3", "@types/react-dom": "17.0.11",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.21.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"fake-indexeddb": "3.1.3", "fake-indexeddb": "3.1.7",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.0", "i18next-browser-languagedetector": "6.1.2",
"idb-keyval": "5.1.3", "idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.1.22", "nanoid": "3.1.30",
"open-color": "1.8.0", "open-color": "1.9.1",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "1.0.15", "perfect-freehand": "1.0.16",
"png-chunk-text": "1.0.0", "png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0", "png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0", "png-chunks-extract": "1.0.0",
@@ -49,39 +49,39 @@
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"roughjs": "4.4.1", "roughjs": "4.5.0",
"sass": "1.32.10", "sass": "1.43.4",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"typescript": "4.2.4" "typescript": "4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@excalidraw/eslint-config": "1.0.0", "@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.2.22",
"@types/lodash.throttle": "4.1.6", "@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.1", "@types/pako": "1.0.2",
"@types/resize-observer-browser": "0.1.5", "@types/resize-observer-browser": "0.1.6",
"chai": "4.3.4",
"eslint-config-prettier": "8.3.0", "eslint-config-prettier": "8.3.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.9.0", "firebase-tools": "9.22.0",
"husky": "4.3.8", "husky": "7.0.4",
"jest-canvas-mock": "2.3.1", "jest-canvas-mock": "2.3.1",
"lint-staged": "10.5.4", "lint-staged": "12.0.1",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"prettier": "2.2.1", "prettier": "2.4.1",
"rewire": "5.0.0" "rewire": "5.0.0"
}, },
"resolutions": {
"@typescript-eslint/typescript-estree": "5.3.0"
},
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
}, },
"homepage": ".", "homepage": ".",
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"jest": { "jest": {
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)" "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
], ],
"resetMocks": false "resetMocks": false
}, },
@@ -100,6 +100,7 @@
"fix": "yarn fix:other && yarn fix:code", "fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js", "locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js", "locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start", "start": "react-scripts start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
-12
View File
@@ -13,18 +13,6 @@
<meta name="theme-color" content="#000" /> <meta name="theme-color" content="#000" />
<!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) -->
<meta
http-equiv="origin-trial"
content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ=="
/>
<!-- File Handling (https://web.dev/file-handling/) -->
<meta
http-equiv="origin-trial"
content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9"
/>
<!-- General tags --> <!-- General tags -->
<meta <meta
name="description" name="description"
-1
View File
@@ -26,7 +26,6 @@
} }
} }
], ],
"capture_links": "new-client",
"share_target": { "share_target": {
"action": "/web-share-target", "action": "/web-share-target",
"method": "POST", "method": "POST",
+2 -2
View File
@@ -15,8 +15,8 @@ const publish = () => {
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir }); execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
execSync(`yarn run build:umd`, { cwd: excalidrawDir }); execSync(`yarn run build:umd`, { cwd: excalidrawDir });
execSync(`yarn --cwd ${excalidrawDir} publish`); execSync(`yarn --cwd ${excalidrawDir} publish`);
} catch (e) { } catch (error) {
console.error(e); console.error(error);
} }
}; };
+12 -9
View File
@@ -1,11 +1,16 @@
const { readdirSync, writeFileSync } = require("fs"); const { readdirSync, writeFileSync } = require("fs");
const files = readdirSync(`${__dirname}/../src/locales`); const files = readdirSync(`${__dirname}/../src/locales`);
const flatten = (object) => const flatten = (object = {}, result = {}, extraKey = "") => {
Object.keys(object).reduce( for (const key in object) {
(initial, current) => ({ ...initial, ...object[current] }), if (typeof object[key] !== "object") {
{}, result[extraKey + key] = object[key];
); } else {
flatten(object[key], result, `${extraKey}${key}.`);
}
}
return result;
};
const locales = files.filter( const locales = files.filter(
(file) => file !== "README.md" && file !== "percentages.json", (file) => file !== "README.md" && file !== "percentages.json",
@@ -19,10 +24,8 @@ for (let index = 0; index < locales.length; index++) {
const allKeys = Object.keys(data); const allKeys = Object.keys(data);
const translatedKeys = allKeys.filter((item) => data[item] !== ""); const translatedKeys = allKeys.filter((item) => data[item] !== "");
const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
const percentage = (100 * translatedKeys.length) / allKeys.length; percentages[currentLocale.replace(".json", "")] = percentage;
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
} }
writeFileSync( writeFileSync(
+21 -6
View File
@@ -5,7 +5,9 @@ const THRESSHOLD = 85;
const crowdinMap = { const crowdinMap = {
"ar-SA": "en-ar", "ar-SA": "en-ar",
"bg-BG": "en-bg", "bg-BG": "en-bg",
"bn-BD": "en-bn",
"ca-ES": "en-ca", "ca-ES": "en-ca",
"da-DK": "en-da",
"de-DE": "en-de", "de-DE": "en-de",
"el-GR": "en-el", "el-GR": "en-el",
"es-ES": "en-es", "es-ES": "en-es",
@@ -31,11 +33,14 @@ const crowdinMap = {
"pt-PT": "en-pt", "pt-PT": "en-pt",
"ro-RO": "en-ro", "ro-RO": "en-ro",
"ru-RU": "en-ru", "ru-RU": "en-ru",
"si-LK": "en-silk",
"sk-SK": "en-sk", "sk-SK": "en-sk",
"sv-SE": "en-sv", "sv-SE": "en-sv",
"ta-IN": "en-ta",
"tr-TR": "en-tr", "tr-TR": "en-tr",
"uk-UA": "en-uk", "uk-UA": "en-uk",
"zh-CN": "en-zhcn", "zh-CN": "en-zhcn",
"zh-HK": "en-zhhk",
"zh-TW": "en-zhtw", "zh-TW": "en-zhtw",
"lv-LV": "en-lv", "lv-LV": "en-lv",
"cs-CZ": "en-cs", "cs-CZ": "en-cs",
@@ -45,7 +50,10 @@ const crowdinMap = {
const flags = { const flags = {
"ar-SA": "🇸🇦", "ar-SA": "🇸🇦",
"bg-BG": "🇧🇬", "bg-BG": "🇧🇬",
"bn-BD": "🇧🇩",
"ca-ES": "🏳", "ca-ES": "🏳",
"cs-CZ": "🇨🇿",
"da-DK": "🇩🇰",
"de-DE": "🇩🇪", "de-DE": "🇩🇪",
"el-GR": "🇬🇷", "el-GR": "🇬🇷",
"es-ES": "🇪🇸", "es-ES": "🇪🇸",
@@ -59,7 +67,9 @@ const flags = {
"it-IT": "🇮🇹", "it-IT": "🇮🇹",
"ja-JP": "🇯🇵", "ja-JP": "🇯🇵",
"kab-KAB": "🏳", "kab-KAB": "🏳",
"kk-KZ": "🇰🇿",
"ko-KR": "🇰🇷", "ko-KR": "🇰🇷",
"lv-LV": "🇱🇻",
"my-MM": "🇲🇲", "my-MM": "🇲🇲",
"nb-NO": "🇳🇴", "nb-NO": "🇳🇴",
"nl-NL": "🇳🇱", "nl-NL": "🇳🇱",
@@ -71,21 +81,24 @@ const flags = {
"pt-PT": "🇵🇹", "pt-PT": "🇵🇹",
"ro-RO": "🇷🇴", "ro-RO": "🇷🇴",
"ru-RU": "🇷🇺", "ru-RU": "🇷🇺",
"si-LK": "🇱🇰",
"sk-SK": "🇸🇰", "sk-SK": "🇸🇰",
"sv-SE": "🇸🇪", "sv-SE": "🇸🇪",
"ta-IN": "🇮🇳",
"tr-TR": "🇹🇷", "tr-TR": "🇹🇷",
"uk-UA": "🇺🇦", "uk-UA": "🇺🇦",
"zh-CN": "🇨🇳", "zh-CN": "🇨🇳",
"zh-HK": "🇭🇰",
"zh-TW": "🇹🇼", "zh-TW": "🇹🇼",
"lv-LV": "🇱🇻",
"cs-CZ": "🇨🇿",
"kk-KZ": "🇰🇿",
}; };
const languages = { const languages = {
"ar-SA": "العربية", "ar-SA": "العربية",
"bg-BG": "Български", "bg-BG": "Български",
"bn-BD": "Bengali",
"ca-ES": "Català", "ca-ES": "Català",
"cs-CZ": "Česky",
"da-DK": "Dansk",
"de-DE": "Deutsch", "de-DE": "Deutsch",
"el-GR": "Ελληνικά", "el-GR": "Ελληνικά",
"es-ES": "Español", "es-ES": "Español",
@@ -99,7 +112,9 @@ const languages = {
"it-IT": "Italiano", "it-IT": "Italiano",
"ja-JP": "日本語", "ja-JP": "日本語",
"kab-KAB": "Taqbaylit", "kab-KAB": "Taqbaylit",
"kk-KZ": "Қазақ тілі",
"ko-KR": "한국어", "ko-KR": "한국어",
"lv-LV": "Latviešu",
"my-MM": "Burmese", "my-MM": "Burmese",
"nb-NO": "Norsk bokmål", "nb-NO": "Norsk bokmål",
"nl-NL": "Nederlands", "nl-NL": "Nederlands",
@@ -111,15 +126,15 @@ const languages = {
"pt-PT": "Português", "pt-PT": "Português",
"ro-RO": "Română", "ro-RO": "Română",
"ru-RU": "Русский", "ru-RU": "Русский",
"si-LK": "සිංහල",
"sk-SK": "Slovenčina", "sk-SK": "Slovenčina",
"sv-SE": "Svenska", "sv-SE": "Svenska",
"ta-IN": "Tamil",
"tr-TR": "Türkçe", "tr-TR": "Türkçe",
"uk-UA": "Українська", "uk-UA": "Українська",
"zh-CN": "简体中文", "zh-CN": "简体中文",
"zh-HK": "繁體中文 (香港)",
"zh-TW": "繁體中文", "zh-TW": "繁體中文",
"lv-LV": "Latviešu",
"cs-CZ": "Česky",
"kk-KZ": "Қазақ тілі",
}; };
const percentages = fs.readFileSync( const percentages = fs.readFileSync(
+2 -2
View File
@@ -25,8 +25,8 @@ const release = async (nextVersion) => {
); );
/* eslint-disable no-console */ /* eslint-disable no-console */
console.log("Done!"); console.log("Done!");
} catch (e) { } catch (error) {
console.error(e); console.error(error);
process.exit(1); process.exit(1);
} }
}; };
+2 -2
View File
@@ -28,8 +28,8 @@ const getCommitHashForLastVersion = async () => {
`git log --format=format:"%H" --grep=${commitMessage}`, `git log --format=format:"%H" --grep=${commitMessage}`,
); );
return stdout; return stdout;
} catch (e) { } catch (error) {
console.error(e); console.error(error);
} }
}; };
+45 -11
View File
@@ -2,22 +2,56 @@ import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement"; import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random";
import { t } from "../i18n";
export const actionAddToLibrary = register({ export const actionAddToLibrary = register({
name: "addToLibrary", name: "addToLibrary",
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( if (elements.some((element) => element.type === "image")) {
getNonDeletedElements(elements), return {
appState, commitToHistory: false,
); appState: {
...appState,
errorMessage: "Support for adding images to the library coming soon!",
},
};
}
app.library.loadLibrary().then((items) => { return app.library
app.library.saveLibrary([ .loadLibrary()
...items, .then((items) => {
selectedElements.map(deepCopyElement), return app.library.saveLibrary([
]); {
}); id: randomId(),
return false; status: "unpublished",
elements: getSelectedElements(
getNonDeletedElements(elements),
appState,
).map(deepCopyElement),
created: Date.now(),
},
...items,
]);
})
.then(() => {
return {
commitToHistory: false,
appState: {
...appState,
toastMessage: t("toast.addedToLibrary"),
},
};
})
.catch((error) => {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
},
};
});
}, },
contextItemLabel: "labels.addToLibrary", contextItemLabel: "labels.addToLibrary",
}); });
+2 -2
View File
@@ -56,7 +56,7 @@ export const actionCopyAsSvg = register({
return { return {
commitToHistory: false, commitToHistory: false,
}; };
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
return { return {
appState: { appState: {
@@ -106,7 +106,7 @@ export const actionCopyAsPng = register({
}, },
commitToHistory: false, commitToHistory: false,
}; };
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
return { return {
appState: { appState: {
+2 -4
View File
@@ -110,10 +110,8 @@ export const actionDeleteSelected = register({
}; };
} }
let { let { elements: nextElements, appState: nextAppState } =
elements: nextElements, deleteSelectedElements(elements, appState);
appState: nextAppState,
} = deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion( fixBindingsAfterDeletion(
nextElements, nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]), elements.filter(({ id }) => appState.selectedElementIds[id]),
+8 -3
View File
@@ -151,9 +151,11 @@ export const actionSaveToActiveFile = register({
: null, : null,
}, },
}; };
} catch (error) { } catch (error: any) {
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else {
console.warn(error);
} }
return { commitToHistory: false }; return { commitToHistory: false };
} }
@@ -181,9 +183,11 @@ export const actionSaveFileToDisk = register({
app.files, app.files,
); );
return { commitToHistory: false, appState: { ...appState, fileHandle } }; return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error) { } catch (error: any) {
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else {
console.warn(error);
} }
return { commitToHistory: false }; return { commitToHistory: false };
} }
@@ -219,8 +223,9 @@ export const actionLoadScene = register({
files, files,
commitToHistory: true, commitToHistory: true,
}; };
} catch (error) { } catch (error: any) {
if (error?.name === "AbortError") { if (error?.name === "AbortError") {
console.warn(error);
return false; return false;
} }
return { return {
+2 -5
View File
@@ -19,11 +19,8 @@ export const actionFinalize = register({
name: "finalize", name: "finalize",
perform: (elements, appState, _, { canvas, focusContainer }) => { perform: (elements, appState, _, { canvas, focusContainer }) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { const { elementId, startBindingElement, endBindingElement } =
elementId, appState.editingLinearElement;
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (element) { if (element) {
+2 -3
View File
@@ -99,9 +99,8 @@ export const actionGroup = register({
// to the z order of the highest element in the layer stack // to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId); const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1]; const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = updatedElements.lastIndexOf( const lastGroupElementIndex =
lastElementInGroup, updatedElements.lastIndexOf(lastElementInGroup);
);
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1); const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = updatedElements const elementsBeforeGroup = updatedElements
.slice(0, lastGroupElementIndex) .slice(0, lastGroupElementIndex)
+17
View File
@@ -6,6 +6,7 @@ import {
ArrowheadArrowIcon, ArrowheadArrowIcon,
ArrowheadBarIcon, ArrowheadBarIcon,
ArrowheadDotIcon, ArrowheadDotIcon,
ArrowheadTriangleIcon,
ArrowheadNoneIcon, ArrowheadNoneIcon,
EdgeRoundIcon, EdgeRoundIcon,
EdgeSharpIcon, EdgeSharpIcon,
@@ -738,6 +739,14 @@ export const actionChangeArrowhead = register({
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />, icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
keyBinding: "r", keyBinding: "r",
}, },
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: (
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
),
keyBinding: "t",
},
]} ]}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
@@ -780,6 +789,14 @@ export const actionChangeArrowhead = register({
keyBinding: "r", keyBinding: "r",
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />, icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
}, },
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: (
<ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
),
keyBinding: "t",
},
]} ]}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
+1 -13
View File
@@ -1,13 +1,6 @@
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { getCommonBounds } from "./element"; import { Box, getCommonBoundingBox } from "./element/bounds";
interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@@ -88,8 +81,3 @@ const calculateTranslation = (
(groupBoundingBox[min] + groupBoundingBox[max]) / 2, (groupBoundingBox[min] + groupBoundingBox[max]) / 2,
}; };
}; };
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { minX, minY, maxX, maxY };
};
+4 -5
View File
@@ -96,10 +96,9 @@ const APP_STATE_STORAGE_CONF = (<
/** server (shareLink/collab/...) */ /** server (shareLink/collab/...) */
server: boolean; server: boolean;
}, },
T extends Record<keyof AppState, Values> T extends Record<keyof AppState, Values>,
>( >(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }, config)({
) => config)({
theme: { browser: true, export: false, server: false }, theme: { browser: true, export: false, server: false },
collaborators: { browser: false, export: false, server: false }, collaborators: { browser: false, export: false, server: false },
currentChartType: { browser: true, export: false, server: false }, currentChartType: { browser: true, export: false, server: false },
@@ -172,7 +171,7 @@ const APP_STATE_STORAGE_CONF = (<
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <
ExportType extends "export" | "browser" | "server" ExportType extends "export" | "browser" | "server",
>( >(
appState: Partial<AppState>, appState: Partial<AppState>,
exportType: ExportType, exportType: ExportType,
+4 -4
View File
@@ -74,7 +74,7 @@ export const copyToClipboard = async (
try { try {
PREFER_APP_CLIPBOARD = false; PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json); await copyTextToSystemClipboard(json);
} catch (error) { } catch (error: any) {
PREFER_APP_CLIPBOARD = true; PREFER_APP_CLIPBOARD = true;
console.error(error); console.error(error);
} }
@@ -87,7 +87,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
try { try {
return JSON.parse(CLIPBOARD); return JSON.parse(CLIPBOARD);
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
return {}; return {};
} }
@@ -179,7 +179,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
// not focused // not focused
await navigator.clipboard.writeText(text || ""); await navigator.clipboard.writeText(text || "");
copied = true; copied = true;
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
} }
} }
@@ -219,7 +219,7 @@ const copyTextViaExecCommand = (text: string) => {
textarea.setSelectionRange(0, textarea.value.length); textarea.setSelectionRange(0, textarea.value.length);
success = document.execCommand("copy"); success = document.execCommand("copy");
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
} }
+112 -73
View File
@@ -43,7 +43,8 @@ import {
import { import {
APP_NAME, APP_NAME,
CURSOR_TYPE, CURSOR_TYPE,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_JPG,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER,
DEFAULT_UI_OPTIONS, DEFAULT_UI_OPTIONS,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD, DRAGGING_THRESHOLD,
@@ -72,7 +73,7 @@ import {
import { loadFromBlob } from "../data"; import { loadFromBlob } from "../data";
import { isValidLibrary } from "../data/json"; import { isValidLibrary } from "../data/json";
import Library from "../data/library"; import Library from "../data/library";
import { restore, restoreElements } from "../data/restore"; import { restore, restoreElements, restoreLibraryItems } from "../data/restore";
import { import {
dragNewElement, dragNewElement,
dragSelectedElements, dragSelectedElements,
@@ -113,7 +114,11 @@ import {
updateBoundElements, updateBoundElements,
} from "../element/binding"; } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion, mutateElement } from "../element/mutateElement"; import {
bumpVersion,
mutateElement,
newElementWith,
} from "../element/mutateElement";
import { deepCopyElement, newFreeDrawElement } from "../element/newElement"; import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
import { import {
isBindingElement, isBindingElement,
@@ -218,6 +223,7 @@ import {
} from "../data/blob"; } from "../data/blob";
import { import {
getInitializedImageElements, getInitializedImageElements,
hasTransparentPixels,
loadHTMLImageElement, loadHTMLImageElement,
normalizeSVG, normalizeSVG,
updateImageCache as _updateImageCache, updateImageCache as _updateImageCache,
@@ -617,7 +623,7 @@ class App extends React.Component<AppProps, AppState> {
this.onBlur(); this.onBlur();
}; };
private disableEvent: EventHandlerNonNull = (event) => { private disableEvent: EventListener = (event) => {
event.preventDefault(); event.preventDefault();
}; };
@@ -651,17 +657,19 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
token === this.id || token === this.id ||
window.confirm( window.confirm(
t("alerts.confirmAddLibrary", { numShapes: json.library.length }), t("alerts.confirmAddLibrary", {
numShapes: (json.libraryItems || json.library || []).length,
}),
) )
) { ) {
await this.library.importLibrary(blob); await this.library.importLibrary(blob, "published");
// hack to rerender the library items after import // hack to rerender the library items after import
if (this.state.isLibraryOpen) { if (this.state.isLibraryOpen) {
this.setState({ isLibraryOpen: false }); this.setState({ isLibraryOpen: false });
} }
this.setState({ isLibraryOpen: true }); this.setState({ isLibraryOpen: true });
} }
} catch (error) { } catch (error: any) {
window.alert(t("alerts.errorLoadingLibrary")); window.alert(t("alerts.errorLoadingLibrary"));
console.error(error); console.error(error);
} finally { } finally {
@@ -728,7 +736,10 @@ class App extends React.Component<AppProps, AppState> {
try { try {
initialData = (await this.props.initialData) || null; initialData = (await this.props.initialData) || null;
if (initialData?.libraryItems) { if (initialData?.libraryItems) {
this.libraryItemsFromStorage = initialData.libraryItems; this.libraryItemsFromStorage = restoreLibraryItems(
initialData.libraryItems,
"unpublished",
) as LibraryItems;
} }
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@@ -790,7 +801,8 @@ class App extends React.Component<AppProps, AppState> {
}; };
public async componentDidMount() { public async componentDidMount() {
this.excalidrawContainerValue.container = this.excalidrawContainerRef.current; this.excalidrawContainerValue.container =
this.excalidrawContainerRef.current;
if ( if (
process.env.NODE_ENV === ENV.TEST || process.env.NODE_ENV === ENV.TEST ||
@@ -832,10 +844,8 @@ class App extends React.Component<AppProps, AppState> {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
// compute isMobile state // compute isMobile state
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
const { const { width, height } =
width, this.excalidrawContainerRef.current!.getBoundingClientRect();
height,
} = this.excalidrawContainerRef.current!.getBoundingClientRect();
this.isMobile = this.isMobile =
width < MQ_MAX_WIDTH_PORTRAIT || width < MQ_MAX_WIDTH_PORTRAIT ||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE); (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE);
@@ -1239,9 +1249,8 @@ class App extends React.Component<AppProps, AppState> {
async (event: ClipboardEvent | null) => { async (event: ClipboardEvent | null) => {
// #686 // #686
const target = document.activeElement; const target = document.activeElement;
const isExcalidrawActive = this.excalidrawContainerRef.current?.contains( const isExcalidrawActive =
target, this.excalidrawContainerRef.current?.contains(target);
);
if (!isExcalidrawActive) { if (!isExcalidrawActive) {
return; return;
} }
@@ -1292,8 +1301,8 @@ class App extends React.Component<AppProps, AppState> {
if ((await this.props.onPaste(data, event)) === false) { if ((await this.props.onPaste(data, event)) === false) {
return; return;
} }
} catch (e) { } catch (error: any) {
console.error(e); console.error(error);
} }
} }
if (data.errorMessage) { if (data.errorMessage) {
@@ -1520,19 +1529,23 @@ class App extends React.Component<AppProps, AppState> {
}, new Map<FileId, BinaryFileData>()); }, new Map<FileId, BinaryFileData>());
this.files = { ...this.files, ...Object.fromEntries(filesMap) }; this.files = { ...this.files, ...Object.fromEntries(filesMap) };
this.addNewImagesToImageCache();
// bump versions for elements that reference added files so that // bump versions for elements that reference added files so that
// we/host apps can detect the change // we/host apps can detect the change, and invalidate the image & shape
// cache
this.scene.getElements().forEach((element) => { this.scene.getElements().forEach((element) => {
if ( if (
isInitializedImageElement(element) && isInitializedImageElement(element) &&
filesMap.has(element.fileId) filesMap.has(element.fileId)
) { ) {
this.imageCache.delete(element.fileId);
invalidateShapeForElement(element);
bumpVersion(element); bumpVersion(element);
} }
}); });
this.scene.informMutation(); this.scene.informMutation();
this.addNewImagesToImageCache();
}, },
); );
@@ -2199,7 +2212,10 @@ class App extends React.Component<AppProps, AppState> {
})); }));
this.resetShouldCacheIgnoreZoomDebounced(); this.resetShouldCacheIgnoreZoomDebounced();
} else { } else {
gesture.lastCenter = gesture.initialDistance = gesture.initialScale = null; gesture.lastCenter =
gesture.initialDistance =
gesture.initialScale =
null;
} }
if (isHoldingSpace || isPanning || isDraggingScrollBar) { if (isHoldingSpace || isPanning || isDraggingScrollBar) {
@@ -2508,13 +2524,11 @@ class App extends React.Component<AppProps, AppState> {
); );
} }
const onPointerMove = this.onPointerMoveFromPointerDownHandler( const onPointerMove =
pointerDownState, this.onPointerMoveFromPointerDownHandler(pointerDownState);
);
const onPointerUp = this.onPointerUpFromPointerDownHandler( const onPointerUp =
pointerDownState, this.onPointerUpFromPointerDownHandler(pointerDownState);
);
const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState); const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState);
const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState); const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState);
@@ -2719,10 +2733,11 @@ class App extends React.Component<AppProps, AppState> {
allHitElements: [], allHitElements: [],
wasAddedToSelection: false, wasAddedToSelection: false,
hasBeenDuplicated: false, hasBeenDuplicated: false,
hasHitCommonBoundingBoxOfSelectedElements: this.isHittingCommonBoundingBoxOfSelectedElements( hasHitCommonBoundingBoxOfSelectedElements:
origin, this.isHittingCommonBoundingBoxOfSelectedElements(
selectedElements, origin,
), selectedElements,
),
}, },
drag: { drag: {
hasOccurred: false, hasOccurred: false,
@@ -2799,14 +2814,15 @@ class App extends React.Component<AppProps, AppState> {
const elements = this.scene.getElements(); const elements = this.scene.getElements();
const selectedElements = getSelectedElements(elements, this.state); const selectedElements = getSelectedElements(elements, this.state);
if (selectedElements.length === 1 && !this.state.editingLinearElement) { if (selectedElements.length === 1 && !this.state.editingLinearElement) {
const elementWithTransformHandleType = getElementWithTransformHandleType( const elementWithTransformHandleType =
elements, getElementWithTransformHandleType(
this.state, elements,
pointerDownState.origin.x, this.state,
pointerDownState.origin.y, pointerDownState.origin.x,
this.state.zoom, pointerDownState.origin.y,
event.pointerType, this.state.zoom,
); event.pointerType,
);
if (elementWithTransformHandleType != null) { if (elementWithTransformHandleType != null) {
this.setState({ this.setState({
resizingElement: elementWithTransformHandleType.element, resizingElement: elementWithTransformHandleType.element,
@@ -2882,9 +2898,10 @@ class App extends React.Component<AppProps, AppState> {
); );
const hitElement = pointerDownState.hit.element; const hitElement = pointerDownState.hit.element;
const someHitElementIsSelected = pointerDownState.hit.allHitElements.some( const someHitElementIsSelected =
(element) => this.isASelectedElement(element), pointerDownState.hit.allHitElements.some((element) =>
); this.isASelectedElement(element),
);
if ( if (
(hitElement === null || !someHitElementIsSelected) && (hitElement === null || !someHitElementIsSelected) &&
!event.shiftKey && !event.shiftKey &&
@@ -3546,8 +3563,8 @@ class App extends React.Component<AppProps, AppState> {
? { ? {
// if using ctrl/cmd, select the hitElement only if we // if using ctrl/cmd, select the hitElement only if we
// haven't box-selected anything else // haven't box-selected anything else
[pointerDownState.hit.element [pointerDownState.hit.element.id]:
.id]: !elementsWithinSelection.length, !elementsWithinSelection.length,
} }
: null), : null),
}, },
@@ -3695,7 +3712,7 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
}, },
); );
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
this.scene.replaceAllElements( this.scene.replaceAllElements(
this.scene this.scene
@@ -3964,7 +3981,7 @@ class App extends React.Component<AppProps, AppState> {
await normalizeSVG(await imageFile.text()), await normalizeSVG(await imageFile.text()),
imageFile.name, imageFile.name,
); );
} catch (error) { } catch (error: any) {
console.warn(error); console.warn(error);
throw new Error(t("errors.svgImageInsertError")); throw new Error(t("errors.svgImageInsertError"));
} }
@@ -3986,20 +4003,30 @@ class App extends React.Component<AppProps, AppState> {
const existingFileData = this.files[fileId]; const existingFileData = this.files[fileId];
if (!existingFileData?.dataURL) { if (!existingFileData?.dataURL) {
try { try {
imageFile = await resizeImageFile( if (!(await hasTransparentPixels(imageFile))) {
imageFile, const _imageFile = await resizeImageFile(imageFile, {
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_JPG,
); outputType: MIME_TYPES.jpg,
} catch (error) { });
if (_imageFile.size > MAX_ALLOWED_FILE_BYTES) {
imageFile = await resizeImageFile(imageFile, {
maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER,
outputType: MIME_TYPES.jpg,
});
} else {
imageFile = _imageFile;
}
} else {
imageFile = await resizeImageFile(imageFile, {
maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER,
});
}
} catch (error: any) {
console.error("error trying to resing image file on insertion", error); console.error("error trying to resing image file on insertion", error);
} }
if (imageFile.size > MAX_ALLOWED_FILE_BYTES) { if (imageFile.size > MAX_ALLOWED_FILE_BYTES) {
throw new Error( throw new Error(t("errors.fileTooBig"));
t("errors.fileTooBig", {
maxSize: `${Math.trunc(MAX_ALLOWED_FILE_BYTES / 1024 / 1024)}MB`,
}),
);
} }
} }
@@ -4051,7 +4078,7 @@ class App extends React.Component<AppProps, AppState> {
this.initializeImageDimensions(imageElement, true); this.initializeImageDimensions(imageElement, true);
} }
resolve(imageElement); resolve(imageElement);
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
reject(new Error(t("errors.imageInsertError"))); reject(new Error(t("errors.imageInsertError")));
} finally { } finally {
@@ -4082,7 +4109,7 @@ class App extends React.Component<AppProps, AppState> {
imageElement, imageElement,
showCursorImagePreview, showCursorImagePreview,
}); });
} catch (error) { } catch (error: any) {
mutateElement(imageElement, { mutateElement(imageElement, {
isDeleted: true, isDeleted: true,
}); });
@@ -4098,7 +4125,9 @@ class App extends React.Component<AppProps, AppState> {
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property
const cursorImageSizePx = 96; const cursorImageSizePx = 96;
const imagePreview = await resizeImageFile(imageFile, cursorImageSizePx); const imagePreview = await resizeImageFile(imageFile, {
maxWidthOrHeight: cursorImageSizePx,
});
let previewDataURL = await getDataURL(imagePreview); let previewDataURL = await getDataURL(imagePreview);
@@ -4177,9 +4206,11 @@ class App extends React.Component<AppProps, AppState> {
}, },
); );
} }
} catch (error) { } catch (error: any) {
if (error.name !== "AbortError") { if (error.name !== "AbortError") {
console.error(error); console.error(error);
} else {
console.warn(error);
} }
this.setState( this.setState(
{ {
@@ -4262,16 +4293,24 @@ class App extends React.Component<AppProps, AppState> {
if (updatedFiles.has(element.fileId)) { if (updatedFiles.has(element.fileId)) {
invalidateShapeForElement(element); invalidateShapeForElement(element);
} }
if (erroredFiles.has(element.fileId)) {
mutateElement(
element,
{ status: "error" },
/* informMutation */ false,
);
}
} }
} }
if (erroredFiles.size) {
this.scene.replaceAllElements(
this.scene.getElementsIncludingDeleted().map((element) => {
if (
isInitializedImageElement(element) &&
erroredFiles.has(element.fileId)
) {
return newElementWith(element, {
status: "error",
});
}
return element;
}),
);
}
return { updatedFiles, erroredFiles }; return { updatedFiles, erroredFiles };
}; };
@@ -4413,7 +4452,9 @@ class App extends React.Component<AppProps, AppState> {
// This will only work as of Chrome 86, // This will only work as of Chrome 86,
// but can be safely ignored on older releases. // but can be safely ignored on older releases.
const item = event.dataTransfer.items[0]; const item = event.dataTransfer.items[0];
(file as any).handle = await (item as any).getAsFileSystemHandle(); (file as any).handle = await (
item as any
).getAsFileSystemHandle();
} catch (error: any) { } catch (error: any) {
console.warn(error.name, error.message); console.warn(error.name, error.message);
} }
@@ -4535,10 +4576,8 @@ class App extends React.Component<AppProps, AppState> {
const type = element ? "element" : "canvas"; const type = element ? "element" : "canvas";
const container = this.excalidrawContainerRef.current!; const container = this.excalidrawContainerRef.current!;
const { const { top: offsetTop, left: offsetLeft } =
top: offsetTop, container.getBoundingClientRect();
left: offsetLeft,
} = container.getBoundingClientRect();
const left = event.clientX - offsetLeft; const left = event.clientX - offsetLeft;
const top = event.clientY - offsetTop; const top = event.clientY - offsetTop;
+8 -5
View File
@@ -7,15 +7,18 @@ import "./CheckboxItem.scss";
export const CheckboxItem: React.FC<{ export const CheckboxItem: React.FC<{
checked: boolean; checked: boolean;
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
}> = ({ children, checked, onChange }) => { className?: string;
}> = ({ children, checked, onChange, className }) => {
return ( return (
<div <div
className={clsx("Checkbox", { "is-checked": checked })} className={clsx("Checkbox", className, { "is-checked": checked })}
onClick={(event) => { onClick={(event) => {
onChange(!checked); onChange(!checked);
((event.currentTarget as HTMLDivElement).querySelector( (
".Checkbox-box", (event.currentTarget as HTMLDivElement).querySelector(
) as HTMLButtonElement).focus(); ".Checkbox-box",
) as HTMLButtonElement
).focus();
}} }}
> >
<button className="Checkbox-box" role="checkbox" aria-checked={checked}> <button className="Checkbox-box" role="checkbox" aria-checked={checked}>
+9 -33
View File
@@ -1,11 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "./App"; import { useIsMobile } from "./App";
import { Dialog } from "./Dialog";
import { trash } from "./icons"; import { trash } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import "./ClearCanvas.scss"; import ConfirmDialog from "./ConfirmDialog";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
@@ -26,39 +25,16 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
/> />
{showDialog && ( {showDialog && (
<Dialog <ConfirmDialog
onCloseRequest={toggleDialog} onConfirm={() => {
onConfirm();
toggleDialog();
}}
onCancel={toggleDialog}
title={t("clearCanvasDialog.title")} title={t("clearCanvasDialog.title")}
className="clear-canvas"
small={true}
> >
<> <p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p> </ConfirmDialog>
<div className="clear-canvas-buttons">
<ToolButton
type="button"
title={t("buttons.clear")}
aria-label={t("buttons.clear")}
label={t("buttons.clear")}
onClick={() => {
onConfirm();
toggleDialog();
}}
data-testid="confirm-clear-canvas-button"
className="clear-canvas--confirm"
/>
<ToolButton
type="button"
title={t("buttons.cancel")}
aria-label={t("buttons.cancel")}
label={t("buttons.cancel")}
onClick={toggleDialog}
data-testid="cancel-clear-canvas-button"
className="clear-canvas--cancel"
/>
</div>
</>
</Dialog>
)} )}
</> </>
); );
@@ -1,22 +1,21 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.clear-canvas { .confirm-dialog {
&-buttons { &-buttons {
display: flex; display: flex;
padding: 0.2rem 0; padding: 0.2rem 0;
justify-content: flex-end; justify-content: flex-end;
}
.ToolIcon__icon {
min-width: 2.5rem;
width: auto;
font-size: 1rem;
}
.ToolIcon__icon { .ToolIcon_type_button {
min-width: 2.5rem; margin-left: 0.8rem;
width: auto; padding: 0 0.5rem;
font-size: 1rem;
}
.ToolIcon_type_button {
margin-left: 1.5rem;
padding: 0 0.5rem;
}
} }
&__content { &__content {
@@ -34,9 +33,5 @@
color: $oc-white; color: $oc-white;
} }
} }
&--cancel.ToolIcon_type_button {
background-color: $oc-gray-2;
}
} }
} }
+52
View File
@@ -0,0 +1,52 @@
import { t } from "../i18n";
import { Dialog, DialogProps } from "./Dialog";
import { ToolButton } from "./ToolButton";
import "./ConfirmDialog.scss";
interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void;
onCancel: () => void;
confirmText?: string;
cancelText?: string;
}
const ConfirmDialog = (props: Props) => {
const {
onConfirm,
onCancel,
children,
confirmText = t("buttons.confirm"),
cancelText = t("buttons.cancel"),
className = "",
...rest
} = props;
return (
<Dialog
onCloseRequest={onCancel}
small={true}
{...rest}
className={`confirm-dialog ${className}`}
>
{children}
<div className="confirm-dialog-buttons">
<ToolButton
type="button"
title={cancelText}
aria-label={cancelText}
label={cancelText}
onClick={onCancel}
className="confirm-dialog--cancel"
/>
<ToolButton
type="button"
title={confirmText}
aria-label={confirmText}
label={confirmText}
onClick={onConfirm}
className="confirm-dialog--confirm"
/>
</div>
</Dialog>
);
};
export default ConfirmDialog;
+6 -2
View File
@@ -10,7 +10,7 @@ import { Island } from "./Island";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { AppState } from "../types"; import { AppState } from "../types";
export const Dialog = (props: { export interface DialogProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
small?: boolean; small?: boolean;
@@ -18,7 +18,10 @@ export const Dialog = (props: {
title: React.ReactNode; title: React.ReactNode;
autofocus?: boolean; autofocus?: boolean;
theme?: AppState["theme"]; theme?: AppState["theme"];
}) => { closeOnClickOutside?: boolean;
}
export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>(); const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement); const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer(); const { id } = useExcalidrawContainer();
@@ -81,6 +84,7 @@ export const Dialog = (props: {
maxWidth={props.small ? 550 : 800} maxWidth={props.small ? 550 : 800}
onCloseRequest={onClose} onCloseRequest={onClose}
theme={props.theme} theme={props.theme}
closeOnClickOutside={props.closeOnClickOutside}
> >
<Island ref={setIslandNode}> <Island ref={setIslandNode}>
<h2 id={`${id}-dialog-title`} className="Dialog__title"> <h2 id={`${id}-dialog-title`} className="Dialog__title">
+15 -3
View File
@@ -11,14 +11,16 @@ import {
} from "../element/typeChecks"; } from "../element/typeChecks";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
interface Hint { interface HintViewerProps {
appState: AppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean;
} }
const getHints = ({ appState, elements }: Hint) => { const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState; const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
if (elementType === "arrow" || elementType === "line") { if (elementType === "arrow" || elementType === "line") {
if (!multiMode) { if (!multiMode) {
return t("hints.linearElement"); return t("hints.linearElement");
@@ -39,6 +41,7 @@ const getHints = ({ appState, elements }: Hint) => {
} }
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
if ( if (
isResizing && isResizing &&
lastPointerDownWith === "mouse" && lastPointerDownWith === "mouse" &&
@@ -74,13 +77,22 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.text_editing"); return t("hints.text_editing");
} }
if (elementType === "selection" && !selectedElements.length && !isMobile) {
return t("hints.canvasPanning");
}
return null; return null;
}; };
export const HintViewer = ({ appState, elements }: Hint) => { export const HintViewer = ({
appState,
elements,
isMobile,
}: HintViewerProps) => {
let hint = getHints({ let hint = getHints({
appState, appState,
elements, elements,
isMobile,
}); });
if (!hint) { if (!hint) {
return null; return null;
+1 -1
View File
@@ -90,7 +90,7 @@
.picker-content { .picker-content {
padding: 0.5rem; padding: 0.5rem;
display: grid; display: grid;
grid-auto-flow: column; grid-template-columns: repeat(3, auto);
grid-gap: 0.5rem; grid-gap: 0.5rem;
border-radius: 4px; border-radius: 4px;
:root[dir="rtl"] & { :root[dir="rtl"] & {
-36
View File
@@ -1,42 +1,6 @@
@import "open-color/open-color"; @import "open-color/open-color";
.excalidraw { .excalidraw {
.layer-ui__library {
margin: auto;
display: flex;
align-items: center;
justify-content: center;
.layer-ui__library-header {
display: flex;
align-items: center;
width: 100%;
margin: 2px 0;
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
a {
margin-inline-start: auto;
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
padding-inline-end: 18px;
white-space: nowrap;
}
}
}
.layer-ui__library-message {
padding: 10px 20px;
max-width: 200px;
}
.layer-ui__library-items {
max-height: 50vh;
overflow: auto;
}
.layer-ui__wrapper { .layer-ui__wrapper {
z-index: var(--zIndex-layerUI); z-index: var(--zIndex-layerUI);
+51 -352
View File
@@ -1,29 +1,15 @@
import clsx from "clsx"; import clsx from "clsx";
import React, { import React, { useCallback } from "react";
RefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { CLASSES } from "../constants"; import { CLASSES } from "../constants";
import { exportCanvas } from "../data"; import { exportCanvas } from "../data";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import { isTextElement, showSelectedShapeActions } from "../element"; import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
import { useIsMobile } from "../components/App"; import { useIsMobile } from "../components/App";
import { calculateScrollCenter, getSelectedElements } from "../scene"; import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
AppProps,
AppState,
ExcalidrawProps,
BinaryFiles,
LibraryItem,
LibraryItems,
} from "../types";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
@@ -32,10 +18,8 @@ import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
import { FixedSideContainer } from "./FixedSideContainer"; import { FixedSideContainer } from "./FixedSideContainer";
import { HintViewer } from "./HintViewer"; import { HintViewer } from "./HintViewer";
import { exportFile, load, trash } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import "./LayerUI.scss"; import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit";
import { LoadingMessage } from "./LoadingMessage"; import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { MobileMenu } from "./MobileMenu"; import { MobileMenu } from "./MobileMenu";
@@ -43,13 +27,13 @@ import { PasteChartDialog } from "./PasteChartDialog";
import { Section } from "./Section"; import { Section } from "./Section";
import { HelpDialog } from "./HelpDialog"; import { HelpDialog } from "./HelpDialog";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import Library from "../data/library"; import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog"; import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton"; import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { LibraryMenu } from "./LibraryMenu";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -81,302 +65,6 @@ interface LayerUIProps {
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
} }
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
if (
event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
cb(event);
};
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
};
const LibraryMenuItems = ({
libraryItems,
onRemoveFromLibrary,
onAddToLibrary,
onInsertShape,
pendingElements,
theme,
setAppState,
setLibraryItems,
libraryReturnUrl,
focusContainer,
library,
files,
id,
}: {
libraryItems: LibraryItems;
pendingElements: LibraryItem;
onRemoveFromLibrary: (index: number) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
library: Library;
id: string;
}) => {
const isMobile = useIsMobile();
const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
const CELLS_PER_ROW = isMobile ? 4 : 6;
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
const rows = [];
let addedPendingElements = false;
const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname;
rows.push(
<div className="layer-ui__library-header" key="library-header">
<ToolButton
key="import"
type="button"
title={t("buttons.load")}
aria-label={t("buttons.load")}
icon={load}
onClick={() => {
importLibraryFromJSON(library)
.then(() => {
// Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
})
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
{!!libraryItems.length && (
<>
<ToolButton
key="export"
type="button"
title={t("buttons.export")}
aria-label={t("buttons.export")}
icon={exportFile}
onClick={() => {
saveLibraryAsJSON(library)
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
<ToolButton
key="reset"
type="button"
title={t("buttons.resetLibrary")}
aria-label={t("buttons.resetLibrary")}
icon={trash}
onClick={() => {
if (window.confirm(t("alerts.resetLibrary"))) {
library.resetLibrary();
setLibraryItems([]);
focusContainer();
}
}}
/>
</>
)}
<a
href={`https://libraries.excalidraw.com?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
</div>,
);
for (let row = 0; row < numRows; row++) {
const y = CELLS_PER_ROW * row;
const children = [];
for (let x = 0; x < CELLS_PER_ROW; x++) {
const shouldAddPendingElements: boolean =
pendingElements.length > 0 &&
!addedPendingElements &&
y + x >= libraryItems.length;
addedPendingElements = addedPendingElements || shouldAddPendingElements;
children.push(
<Stack.Col key={x}>
<LibraryUnit
elements={libraryItems[y + x]}
files={files}
pendingElements={
shouldAddPendingElements ? pendingElements : undefined
}
onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
onClick={
shouldAddPendingElements
? onAddToLibrary.bind(null, pendingElements)
: onInsertShape.bind(null, libraryItems[y + x])
}
/>
</Stack.Col>,
);
}
rows.push(
<Stack.Row align="center" gap={1} key={row}>
{children}
</Stack.Row>,
);
}
return (
<Stack.Col align="start" gap={1} className="layer-ui__library-items">
{rows}
</Stack.Col>
);
};
const LibraryMenu = ({
onClickOutside,
onInsertShape,
pendingElements,
onAddToLibrary,
theme,
setAppState,
files,
libraryReturnUrl,
focusContainer,
library,
id,
}: {
pendingElements: LibraryItem;
onClickOutside: (event: MouseEvent) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
library: Library;
id: string;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => {
// If click on the library icon, do nothing.
if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
return;
}
onClickOutside(event);
});
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const loadingTimerRef = useRef<number | null>(null);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = window.setTimeout(() => {
resolve("loading");
}, 100);
}),
library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, [library]);
const removeFromLibrary = useCallback(
async (indexToRemove) => {
const items = await library.loadLibrary();
const nextItems = items.filter((_, index) => index !== indexToRemove);
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setLibraryItems(nextItems);
},
[library, setAppState],
);
const addToLibrary = useCallback(
async (elements: LibraryItem) => {
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
});
}
const items = await library.loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
setLibraryItems(nextItems);
},
[onAddToLibrary, library, setAppState],
);
return loadingState === "preloading" ? null : (
<Island padding={1} ref={ref} className="layer-ui__library">
{loadingState === "loading" ? (
<div className="layer-ui__library-message">
{t("labels.libraryLoadingMessage")}
</div>
) : (
<LibraryMenuItems
libraryItems={libraryItems}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
setLibraryItems={setLibraryItems}
libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
theme={theme}
files={files}
id={id}
/>
)}
</Island>
);
};
const LayerUI = ({ const LayerUI = ({
actionManager, actionManager,
appState, appState,
@@ -426,34 +114,34 @@ const LayerUI = ({
return null; return null;
} }
const createExporter = (type: ExportType): ExportCB => async ( const createExporter =
exportedElements, (type: ExportType): ExportCB =>
) => { async (exportedElements) => {
const fileHandle = await exportCanvas( const fileHandle = await exportCanvas(
type, type,
exportedElements, exportedElements,
appState, appState,
files, files,
{ {
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
name: appState.name, name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,
}, },
) )
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
setAppState({ errorMessage: error.message }); setAppState({ errorMessage: error.message });
}); });
if ( if (
appState.exportEmbedScene && appState.exportEmbedScene &&
fileHandle && fileHandle &&
isImageFileHandle(fileHandle) isImageFileHandle(fileHandle)
) { ) {
setAppState({ fileHandle }); setAppState({ fileHandle });
} }
}; };
return ( return (
<ImageExportDialog <ImageExportDialog
@@ -561,12 +249,15 @@ const LayerUI = ({
</Section> </Section>
); );
const closeLibrary = useCallback( const closeLibrary = useCallback(() => {
(event) => { const isDialogOpen = !!document.querySelector(".Dialog");
setAppState({ isLibraryOpen: false });
}, // Prevent closing if any dialog is open
[setAppState], if (isDialogOpen) {
); return;
}
setAppState({ isLibraryOpen: false });
}, [setAppState]);
const deselectItems = useCallback(() => { const deselectItems = useCallback(() => {
setAppState({ setAppState({
@@ -578,7 +269,7 @@ const LayerUI = ({
const libraryMenu = appState.isLibraryOpen ? ( const libraryMenu = appState.isLibraryOpen ? (
<LibraryMenu <LibraryMenu
pendingElements={getSelectedElements(elements, appState)} pendingElements={getSelectedElements(elements, appState)}
onClickOutside={closeLibrary} onClose={closeLibrary}
onInsertShape={onInsertElements} onInsertShape={onInsertElements}
onAddToLibrary={deselectItems} onAddToLibrary={deselectItems}
setAppState={setAppState} setAppState={setAppState}
@@ -588,6 +279,7 @@ const LayerUI = ({
theme={appState.theme} theme={appState.theme}
files={files} files={files}
id={id} id={id}
appState={appState}
/> />
) : null; ) : null;
@@ -624,7 +316,11 @@ const LayerUI = ({
padding={1} padding={1}
className={clsx({ "zen-mode": zenModeEnabled })} className={clsx({ "zen-mode": zenModeEnabled })}
> >
<HintViewer appState={appState} elements={elements} /> <HintViewer
appState={appState}
elements={elements}
isMobile={isMobile}
/>
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
<ShapesSwitcher <ShapesSwitcher
@@ -705,7 +401,8 @@ const LayerUI = ({
{!viewModeEnabled && ( {!viewModeEnabled && (
<div <div
className={clsx("undo-redo-buttons zen-mode-transition", { className={clsx("undo-redo-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled, "layer-ui__wrapper__footer-left--transition-bottom":
zenModeEnabled,
})} })}
> >
{actionManager.renderAction("undo", { size: "small" })} {actionManager.renderAction("undo", { size: "small" })}
@@ -719,7 +416,8 @@ const LayerUI = ({
className={clsx( className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition", "layer-ui__wrapper__footer-center zen-mode-transition",
{ {
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled, "layer-ui__wrapper__footer-left--transition-bottom":
zenModeEnabled,
}, },
)} )}
> >
@@ -845,6 +543,7 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
prev.renderCustomFooter === next.renderCustomFooter && prev.renderCustomFooter === next.renderCustomFooter &&
prev.langCode === next.langCode && prev.langCode === next.langCode &&
prev.elements === next.elements && prev.elements === next.elements &&
prev.files === next.files &&
keys.every((key) => prevAppState[key] === nextAppState[key]) keys.every((key) => prevAppState[key] === nextAppState[key])
); );
}; };
+55
View File
@@ -0,0 +1,55 @@
@import "open-color/open-color";
.excalidraw {
.layer-ui__library {
margin: auto;
display: flex;
align-items: center;
justify-content: center;
.layer-ui__library-header {
display: flex;
align-items: center;
width: 100%;
margin: 2px 0;
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
a {
margin-inline-start: auto;
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
padding-inline-end: 18px;
white-space: nowrap;
}
}
}
.layer-ui__library-message {
padding: 10px 20px;
max-width: 200px;
}
.publish-library-success {
.Dialog__content {
display: flex;
flex-direction: column;
}
&-close.ToolIcon_type_button {
background-color: $oc-blue-6;
align-self: flex-end;
&:hover {
background-color: $oc-blue-8;
}
.ToolIcon__icon {
width: auto;
font-size: 1rem;
color: $oc-white;
padding: 0 0.5rem;
}
}
}
}
+287
View File
@@ -0,0 +1,287 @@
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
import Library from "../data/library";
import { t } from "../i18n";
import { randomId } from "../random";
import {
LibraryItems,
LibraryItem,
AppState,
BinaryFiles,
ExcalidrawProps,
} from "../types";
import { Dialog } from "./Dialog";
import { Island } from "./Island";
import PublishLibrary from "./PublishLibrary";
import { ToolButton } from "./ToolButton";
import "./LibraryMenu.scss";
import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants";
import { KEYS } from "../keys";
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
if (
event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
cb(event);
};
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
};
const getSelectedItems = (
libraryItems: LibraryItems,
selectedItems: LibraryItem["id"][],
) => libraryItems.filter((item) => selectedItems.includes(item.id));
export const LibraryMenu = ({
onClose,
onInsertShape,
pendingElements,
onAddToLibrary,
theme,
setAppState,
files,
libraryReturnUrl,
focusContainer,
library,
id,
appState,
}: {
pendingElements: LibraryItem["elements"];
onClose: () => void;
onInsertShape: (elements: LibraryItem["elements"]) => void;
onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
library: Library;
id: string;
appState: AppState;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => {
// If click on the library icon, do nothing.
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
onClose();
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
onClose();
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [onClose]);
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
useState(false);
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
url: string;
authorName: string;
}>(null);
const loadingTimerRef = useRef<number | null>(null);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = window.setTimeout(() => {
resolve("loading");
}, 100);
}),
library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, [library]);
const removeFromLibrary = useCallback(async () => {
const items = await library.loadLibrary();
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
setLibraryItems(nextItems);
}, [library, setAppState, selectedItems, setSelectedItems]);
const resetLibrary = useCallback(() => {
library.resetLibrary();
setLibraryItems([]);
focusContainer();
}, [library, focusContainer]);
const addToLibrary = useCallback(
async (elements: LibraryItem["elements"]) => {
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
});
}
const items = await library.loadLibrary();
const nextItems: LibraryItems = [
{
status: "unpublished",
elements,
id: randomId(),
created: Date.now(),
},
...items,
];
onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
setLibraryItems(nextItems);
},
[onAddToLibrary, library, setAppState],
);
const renderPublishSuccess = useCallback(() => {
return (
<Dialog
onCloseRequest={() => setPublishLibSuccess(null)}
title={t("publishSuccessDialog.title")}
className="publish-library-success"
small={true}
>
<p>
{t("publishSuccessDialog.content", {
authorName: publishLibSuccess!.authorName,
})}{" "}
<a
href={publishLibSuccess?.url}
target="_blank"
rel="noopener noreferrer"
>
{t("publishSuccessDialog.link")}
</a>
</p>
<ToolButton
type="button"
title={t("buttons.close")}
aria-label={t("buttons.close")}
label={t("buttons.close")}
onClick={() => setPublishLibSuccess(null)}
data-testid="publish-library-success-close"
className="publish-library-success-close"
/>
</Dialog>
);
}, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback(
(data) => {
setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice();
nextLibItems.forEach((libItem) => {
if (selectedItems.includes(libItem.id)) {
libItem.status = "published";
}
});
library.saveLibrary(nextLibItems);
setLibraryItems(nextLibItems);
},
[
setShowPublishLibraryDialog,
setPublishLibSuccess,
libraryItems,
selectedItems,
library,
],
);
return loadingState === "preloading" ? null : (
<Island padding={1} ref={ref} className="layer-ui__library">
{showPublishLibraryDialog && (
<PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(libraryItems, selectedItems)}
appState={appState}
onSuccess={onPublishLibSuccess}
onError={(error) => window.alert(error)}
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
{loadingState === "loading" ? (
<div className="layer-ui__library-message">
{t("labels.libraryLoadingMessage")}
</div>
) : (
<LibraryMenuItems
libraryItems={libraryItems}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id) => {
if (!selectedItems.includes(id)) {
setSelectedItems([...selectedItems, id]);
} else {
setSelectedItems(selectedItems.filter((_id) => _id !== id));
}
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
)}
</Island>
);
};
+102
View File
@@ -0,0 +1,102 @@
@import "open-color/open-color";
.excalidraw {
.library-menu-items-container {
.library-actions {
display: flex;
button .library-actions-counter {
position: absolute;
right: 2px;
bottom: 2px;
border-radius: 50%;
width: 1em;
height: 1em;
padding: 1px;
font-size: 0.7rem;
background: #fff;
}
&--remove {
background-color: $oc-red-7;
&:hover {
background-color: $oc-red-8;
}
&:active {
background-color: $oc-red-9;
}
svg {
color: $oc-white;
}
.library-actions-counter {
color: $oc-red-7;
}
}
&--export {
background-color: $oc-lime-5;
&:hover {
background-color: $oc-lime-7;
}
&:active {
background-color: $oc-lime-8;
}
svg {
color: $oc-white;
}
.library-actions-counter {
color: $oc-lime-5;
}
}
&--publish {
background-color: $oc-cyan-6;
&:hover {
background-color: $oc-cyan-7;
}
&:active {
background-color: $oc-cyan-9;
}
svg {
color: $oc-white;
}
label {
margin-left: -0.2em;
margin-right: 1.1em;
color: $oc-white;
font-size: 0.86em;
}
.library-actions-counter {
color: $oc-cyan-6;
}
}
&--load {
background-color: $oc-blue-6;
&:hover {
background-color: $oc-blue-7;
}
&:active {
background-color: $oc-blue-9;
}
svg {
color: $oc-white;
}
}
}
&__items {
max-height: 50vh;
overflow: auto;
margin-top: 0.5rem;
}
.separator {
font-weight: 500;
font-size: 0.9rem;
margin: 0.6em 0.2em;
color: var(--text-primary-color);
}
}
}
+322
View File
@@ -0,0 +1,322 @@
import { chunk } from "lodash";
import { useCallback, useState } from "react";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import Library from "../data/library";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n";
import {
AppState,
BinaryFiles,
ExcalidrawProps,
LibraryItem,
LibraryItems,
} from "../types";
import { muteFSAbortError } from "../utils";
import { useIsMobile } from "./App";
import ConfirmDialog from "./ConfirmDialog";
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip";
import "./LibraryMenuItems.scss";
const LibraryMenuItems = ({
libraryItems,
onRemoveFromLibrary,
onAddToLibrary,
onInsertShape,
pendingElements,
theme,
setAppState,
libraryReturnUrl,
library,
files,
id,
selectedItems,
onToggle,
onPublish,
resetLibrary,
}: {
libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"];
onRemoveFromLibrary: () => void;
onInsertShape: (elements: LibraryItem["elements"]) => void;
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library;
id: string;
selectedItems: LibraryItem["id"][];
onToggle: (id: LibraryItem["id"]) => void;
onPublish: () => void;
resetLibrary: () => void;
}) => {
const renderRemoveLibAlert = useCallback(() => {
const content = selectedItems.length
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
: t("alerts.resetLibrary");
const title = selectedItems.length
? t("confirmDialog.removeItemsFromLib")
: t("confirmDialog.resetLibrary");
return (
<ConfirmDialog
onConfirm={() => {
if (selectedItems.length) {
onRemoveFromLibrary();
} else {
resetLibrary();
}
setShowRemoveLibAlert(false);
}}
onCancel={() => {
setShowRemoveLibAlert(false);
}}
title={title}
>
<p>{content}</p>
</ConfirmDialog>
);
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
const isMobile = useIsMobile();
const renderLibraryActions = () => {
const itemsSelected = !!selectedItems.length;
const items = itemsSelected
? libraryItems.filter((item) => selectedItems.includes(item.id))
: libraryItems;
const resetLabel = itemsSelected
? t("buttons.remove")
: t("buttons.resetLibrary");
return (
<div className="library-actions">
{(!itemsSelected || !isMobile) && (
<ToolButton
key="import"
type="button"
title={t("buttons.load")}
aria-label={t("buttons.load")}
icon={load}
onClick={() => {
importLibraryFromJSON(library)
.then(() => {
// Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
})
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
className="library-actions--load"
/>
)}
{!!items.length && (
<>
<ToolButton
key="export"
type="button"
title={t("buttons.export")}
aria-label={t("buttons.export")}
icon={exportToFileIcon}
onClick={async () => {
const libraryItems = itemsSelected
? items
: await library.loadLibrary();
saveLibraryAsJSON(libraryItems)
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
className="library-actions--export"
>
{selectedItems.length > 0 && (
<span className="library-actions-counter">
{selectedItems.length}
</span>
)}
</ToolButton>
<ToolButton
key="reset"
type="button"
title={resetLabel}
aria-label={resetLabel}
icon={trash}
onClick={() => setShowRemoveLibAlert(true)}
className="library-actions--remove"
>
{selectedItems.length > 0 && (
<span className="library-actions-counter">
{selectedItems.length}
</span>
)}
</ToolButton>
</>
)}
{itemsSelected && !isPublished && (
<Tooltip label={t("hints.publishLibrary")}>
<ToolButton
type="button"
aria-label={t("buttons.publishLibrary")}
label={t("buttons.publishLibrary")}
icon={publishIcon}
className="library-actions--publish"
onClick={onPublish}
>
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
{selectedItems.length > 0 && (
<span className="library-actions-counter">
{selectedItems.length}
</span>
)}
</ToolButton>
</Tooltip>
)}
</div>
);
};
const CELLS_PER_ROW = isMobile ? 4 : 6;
const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname;
const isPublished = selectedItems.some(
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
);
const createLibraryItemCompo = (params: {
item:
| LibraryItem
| /* pending library item */ {
id: null;
elements: readonly NonDeleted<ExcalidrawElement>[];
}
| null;
onClick?: () => void;
key: string;
}) => {
return (
<Stack.Col key={params.key}>
<LibraryUnit
elements={params.item?.elements}
files={files}
isPending={!params.item?.id && !!params.item?.elements}
onClick={params.onClick || (() => {})}
id={params.item?.id || null}
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
onToggle={() => {
if (params.item?.id) {
onToggle(params.item.id);
}
}}
/>
</Stack.Col>
);
};
const renderLibrarySection = (
items: (
| LibraryItem
| /* pending library item */ {
id: null;
elements: readonly NonDeleted<ExcalidrawElement>[];
}
)[],
) => {
const _items = items.map((item) => {
if (item.id) {
return createLibraryItemCompo({
item,
onClick: () => onInsertShape(item.elements),
key: item.id,
});
}
return createLibraryItemCompo({
key: "__pending__item__",
item,
onClick: () => onAddToLibrary(pendingElements),
});
});
// ensure we render all empty cells if no items are present
let rows = chunk(_items, CELLS_PER_ROW);
if (!rows.length) {
rows = [[]];
}
return rows.map((rowItems, index, rows) => {
if (index === rows.length - 1) {
// pad row with empty cells
rowItems = rowItems.concat(
new Array(CELLS_PER_ROW - rowItems.length)
.fill(null)
.map((_, index) => {
return createLibraryItemCompo({
key: `empty_${index}`,
item: null,
});
}),
);
}
return (
<Stack.Row align="center" gap={1} key={index}>
{rowItems}
</Stack.Row>
);
});
};
const publishedItems = libraryItems.filter(
(item) => item.status === "published",
);
const unpublishedItems = [
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...libraryItems.filter((item) => item.status !== "published"),
];
return (
<div className="library-menu-items-container">
{showRemoveLibAlert && renderRemoveLibAlert()}
<div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()}
<a
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
</div>
<Stack.Col
className="library-menu-items-container__items"
align="start"
gap={1}
>
<>
<div className="separator">{t("labels.personalLib")}</div>
{renderLibrarySection(unpublishedItems)}
</>
<>
<div className="separator">{t("labels.excalidrawLib")} </div>
{renderLibrarySection(publishedItems)}
</>
</Stack.Col>
</div>
);
};
export default LibraryMenuItems;
+59 -15
View File
@@ -1,3 +1,5 @@
@import "../css/variables.module";
.excalidraw { .excalidraw {
.library-unit { .library-unit {
align-items: center; align-items: center;
@@ -7,6 +9,20 @@
position: relative; position: relative;
width: 63px; width: 63px;
height: 63px; // match width height: 63px; // match width
&--hover {
box-shadow: inset 0px 0px 0px 2px $oc-blue-5;
border-color: $oc-blue-5;
}
&--selected {
box-shadow: inset 0px 0px 0px 2px $oc-blue-8;
border-color: $oc-blue-8;
}
}
&.theme--dark .library-unit {
border-color: rgb(48, 48, 48);
} }
.library-unit__dragger { .library-unit__dragger {
@@ -22,9 +38,9 @@
max-width: 100%; max-width: 100%;
} }
.library-unit__removeFromLibrary, .library-unit__checkbox-container,
.library-unit__removeFromLibrary:hover, .library-unit__checkbox-container:hover,
.library-unit__removeFromLibrary:active { .library-unit__checkbox-container:active {
align-items: center; align-items: center;
background: none; background: none;
border: none; border: none;
@@ -32,10 +48,35 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
margin: 0; margin: 0;
padding: 0; padding: 0.5rem;
position: absolute; position: absolute;
right: 5px; left: 2rem;
top: 5px; bottom: 2rem;
cursor: pointer;
input {
cursor: pointer;
}
}
.library-unit__checkbox {
position: absolute;
left: 2.3rem;
bottom: 2.3rem;
.Checkbox-box {
width: 13px;
height: 13px;
border-radius: 2px;
margin: 0.5em 0.5em 0.2em 0.2em;
background-color: $oc-blue-1;
}
&.Checkbox:hover {
.Checkbox-box {
background-color: $oc-blue-2;
}
}
} }
.library-unit__removeFromLibrary > svg { .library-unit__removeFromLibrary > svg {
@@ -43,29 +84,32 @@
width: 16px; width: 16px;
} }
.library-unit__pulse { .library-unit__adder {
transform: scale(1); transform: scale(1);
animation: library-unit__pulse-animation 1s ease-in infinite; animation: library-unit__adder-animation 1s ease-in infinite;
} }
.library-unit__adder { .library-unit__adder {
position: absolute; position: absolute;
left: 50%; left: 40%;
top: 50%; top: 40%;
width: 20px; width: 2rem;
height: 20px; height: 2rem;
margin-left: -10px; margin-left: -10px;
margin-top: -10px; margin-top: -10px;
pointer-events: none; pointer-events: none;
} }
.library-unit--hover .library-unit__adder {
color: $oc-blue-7;
}
.library-unit__active { .library-unit__active {
cursor: pointer; cursor: pointer;
} }
@keyframes library-unit__pulse-animation { @keyframes library-unit__adder-animation {
0% { 0% {
transform: scale(0.95); transform: scale(0.85);
} }
50% { 50% {
@@ -73,7 +117,7 @@
} }
100% { 100% {
transform: scale(0.95); transform: scale(0.85);
} }
} }
} }
+25 -24
View File
@@ -1,13 +1,12 @@
import clsx from "clsx"; import clsx from "clsx";
import oc from "open-color"; import oc from "open-color";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { close } from "../components/icons";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import { useIsMobile } from "../components/App";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import { BinaryFiles, LibraryItem } from "../types"; import { BinaryFiles, LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem";
// fa-plus // fa-plus
const PLUS_ICON = ( const PLUS_ICON = (
@@ -20,17 +19,21 @@ const PLUS_ICON = (
); );
export const LibraryUnit = ({ export const LibraryUnit = ({
id,
elements, elements,
files, files,
pendingElements, isPending,
onRemoveFromLibrary,
onClick, onClick,
selected,
onToggle,
}: { }: {
elements?: LibraryItem; id: LibraryItem["id"] | /** for pending item */ null;
elements?: LibraryItem["elements"];
files: BinaryFiles; files: BinaryFiles;
pendingElements?: LibraryItem; isPending?: boolean;
onRemoveFromLibrary: () => void;
onClick: () => void; onClick: () => void;
selected: boolean;
onToggle: (id: string) => void;
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
@@ -40,12 +43,11 @@ export const LibraryUnit = ({
} }
(async () => { (async () => {
const elementsToRender = elements || pendingElements; if (!elements) {
if (!elementsToRender) {
return; return;
} }
const svg = await exportToSvg( const svg = await exportToSvg(
elementsToRender, elements,
{ {
exportBackground: false, exportBackground: false,
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
@@ -58,30 +60,31 @@ export const LibraryUnit = ({
return () => { return () => {
node.innerHTML = ""; node.innerHTML = "";
}; };
}, [elements, pendingElements, files]); }, [elements, files]);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const adder = isPending && (
const adder = (isHovered || isMobile) && pendingElements && (
<div className="library-unit__adder">{PLUS_ICON}</div> <div className="library-unit__adder">{PLUS_ICON}</div>
); );
return ( return (
<div <div
className={clsx("library-unit", { className={clsx("library-unit", {
"library-unit__active": elements || pendingElements, "library-unit__active": elements,
"library-unit--hover": elements && isHovered,
"library-unit--selected": selected,
})} })}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<div <div
className={clsx("library-unit__dragger", { className={clsx("library-unit__dragger", {
"library-unit__pulse": !!pendingElements, "library-unit__pulse": !!isPending,
})} })}
ref={ref} ref={ref}
draggable={!!elements} draggable={!!elements}
onClick={!!elements || !!pendingElements ? onClick : undefined} onClick={!!elements || !!isPending ? onClick : undefined}
onDragStart={(event) => { onDragStart={(event) => {
setIsHovered(false); setIsHovered(false);
event.dataTransfer.setData( event.dataTransfer.setData(
@@ -91,14 +94,12 @@ export const LibraryUnit = ({
}} }}
/> />
{adder} {adder}
{elements && (isHovered || isMobile) && ( {id && elements && (isHovered || isMobile || selected) && (
<button <CheckboxItem
className="library-unit__removeFromLibrary" checked={selected}
aria-label={t("labels.removeFromLibrary")} onChange={() => onToggle(id)}
onClick={onRemoveFromLibrary} className="library-unit__checkbox"
> />
{close}
</button>
)} )}
</div> </div>
); );
+1 -1
View File
@@ -92,7 +92,7 @@ export const MobileMenu = ({
</Stack.Col> </Stack.Col>
)} )}
</Section> </Section>
<HintViewer appState={appState} elements={elements} /> <HintViewer appState={appState} elements={elements} isMobile={true} />
</FixedSideContainer> </FixedSideContainer>
); );
}; };
+6 -2
View File
@@ -15,8 +15,9 @@ export const Modal = (props: {
onCloseRequest(): void; onCloseRequest(): void;
labelledBy: string; labelledBy: string;
theme?: AppState["theme"]; theme?: AppState["theme"];
closeOnClickOutside?: boolean;
}) => { }) => {
const { theme = THEME.LIGHT } = props; const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
const modalRoot = useBodyRoot(theme); const modalRoot = useBodyRoot(theme);
if (!modalRoot) { if (!modalRoot) {
@@ -39,7 +40,10 @@ export const Modal = (props: {
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
aria-labelledby={props.labelledBy} aria-labelledby={props.labelledBy}
> >
<div className="Modal__background" onClick={props.onCloseRequest}></div> <div
className="Modal__background"
onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
></div>
<div <div
className="Modal__content" className="Modal__content"
style={{ "--max-width": `${props.maxWidth}px` }} style={{ "--max-width": `${props.maxWidth}px` }}
+1 -1
View File
@@ -82,7 +82,7 @@ export const PasteChartDialog = ({
appState: AppState; appState: AppState;
onClose: () => void; onClose: () => void;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
onInsertChart: (elements: LibraryItem) => void; onInsertChart: (elements: LibraryItem["elements"]) => void;
}) => { }) => {
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
if (onClose) { if (onClose) {
+1
View File
@@ -42,6 +42,7 @@ export const ProjectName = (props: Props) => {
</label> </label>
{props.isNameEditable ? ( {props.isNameEditable ? (
<input <input
type="text"
className="TextInput" className="TextInput"
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
+92
View File
@@ -0,0 +1,92 @@
@import "../css/variables.module";
.excalidraw {
.publish-library {
&__fields {
display: flex;
flex-direction: column;
label {
padding: 1em;
display: flex;
justify-content: space-between;
align-items: center;
span {
font-weight: 500;
font-size: 1rem;
color: $oc-gray-6;
}
input,
textarea {
width: 70%;
padding: 0.6em;
font-family: var(--ui-font);
}
.required {
color: $oc-red-8;
margin: 0.2rem;
}
}
}
&__buttons {
display: flex;
padding: 0.2rem 0;
justify-content: flex-end;
.ToolIcon__icon {
min-width: 2.5rem;
width: auto;
font-size: 1rem;
}
.ToolIcon_type_button {
margin-left: 1rem;
padding: 0 0.5rem;
}
&--confirm.ToolIcon_type_button {
background-color: $oc-blue-6;
&:hover {
background-color: $oc-blue-8;
}
}
&--cancel.ToolIcon_type_button {
background-color: $oc-gray-5;
&:hover {
background-color: $oc-gray-6;
}
}
.ToolIcon__icon {
color: $oc-white;
.Spinner {
--spinner-color: #fff;
svg {
padding: 0.5rem;
}
}
}
}
.selected-library-items {
display: flex;
padding: 0 0.8rem;
flex-wrap: wrap;
.single-library-item-wrapper {
width: 9rem;
}
}
&-note {
padding: 1em;
font-style: italic;
font-size: 14px;
display: block;
}
}
}
+429
View File
@@ -0,0 +1,429 @@
import { ReactNode, useCallback, useEffect, useState } from "react";
import oc from "open-color";
import { Dialog } from "./Dialog";
import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
import { AppState, LibraryItems, LibraryItem } from "../types";
import { exportToBlob } from "../packages/utils";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE } from "../constants";
import { ExportedLibraryData } from "../data/types";
import "./PublishLibrary.scss";
import { ExcalidrawElement } from "../element/types";
import { newElement } from "../element";
import { mutateElement } from "../element/mutateElement";
import { getCommonBoundingBox } from "../element/bounds";
import SingleLibraryItem from "./SingleLibraryItem";
interface PublishLibraryDataParams {
authorName: string;
githubHandle: string;
name: string;
description: string;
twitterHandle: string;
website: string;
}
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
try {
localStorage.setItem(
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
JSON.stringify(data),
);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importPublishLibDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
if (data) {
return JSON.parse(data);
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const PublishLibrary = ({
onClose,
libraryItems,
appState,
onSuccess,
onError,
updateItemsInStorage,
onRemove,
}: {
onClose: () => void;
libraryItems: LibraryItems;
appState: AppState;
onSuccess: (data: {
url: string;
authorName: string;
items: LibraryItems;
}) => void;
onError: (error: Error) => void;
updateItemsInStorage: (items: LibraryItems) => void;
onRemove: (id: string) => void;
}) => {
const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
authorName: "",
githubHandle: "",
name: "",
description: "",
twitterHandle: "",
website: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const data = importPublishLibDataFromStorage();
if (data) {
setLibraryData(data);
}
}, []);
const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
libraryItems.slice(),
);
useEffect(() => {
setClonedLibItems(libraryItems.slice());
}, [libraryItems]);
const onInputChange = (event: any) => {
setLibraryData({
...libraryData,
[event.target.name]: event.target.value,
});
};
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
const erroredLibItems: LibraryItem[] = [];
let isError = false;
clonedLibItems.forEach((libItem) => {
let error = "";
if (!libItem.name) {
error = t("publishDialog.errors.required");
isError = true;
}
erroredLibItems.push({ ...libItem, error });
});
if (isError) {
setClonedLibItems(erroredLibItems);
setIsSubmitting(false);
return;
}
const elements: ExcalidrawElement[] = [];
const prevBoundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
clonedLibItems.forEach((libItem) => {
const boundingBox = getCommonBoundingBox(libItem.elements);
const width = boundingBox.maxX - boundingBox.minX + 30;
const height = boundingBox.maxY - boundingBox.minY + 30;
const offset = {
x: prevBoundingBox.maxX - boundingBox.minX,
y: prevBoundingBox.maxY - boundingBox.minY,
};
const itemsWithUpdatedCoords = libItem.elements.map((element) => {
element = mutateElement(element, {
x: element.x + offset.x + 15,
y: element.y + offset.y + 15,
});
return element;
});
const items = [
...itemsWithUpdatedCoords,
newElement({
type: "rectangle",
width,
height,
x: prevBoundingBox.maxX,
y: prevBoundingBox.maxY,
strokeColor: "#ced4da",
backgroundColor: "transparent",
strokeStyle: "solid",
opacity: 100,
roughness: 0,
strokeSharpness: "sharp",
fillStyle: "solid",
strokeWidth: 1,
}),
];
elements.push(...items);
prevBoundingBox.maxX = prevBoundingBox.maxX + width + 30;
});
const png = await exportToBlob({
elements,
mimeType: "image/png",
appState: {
...appState,
viewBackgroundColor: oc.white,
exportBackground: true,
},
files: null,
});
const libContent: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: 2,
source: EXPORT_SOURCE,
libraryItems: clonedLibItems,
};
const content = JSON.stringify(libContent, null, 2);
const lib = new Blob([content], { type: "application/json" });
const formData = new FormData();
formData.append("excalidrawLib", lib);
formData.append("excalidrawPng", png!);
formData.append("title", libraryData.name);
formData.append("authorName", libraryData.authorName);
formData.append("githubHandle", libraryData.githubHandle);
formData.append("name", libraryData.name);
formData.append("description", libraryData.description);
formData.append("twitterHandle", libraryData.twitterHandle);
formData.append("website", libraryData.website);
fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
method: "post",
body: formData,
})
.then(
(response) => {
if (response.ok) {
return response.json().then(({ url }) => {
// flush data from local storage
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
onSuccess({
url,
authorName: libraryData.authorName,
items: clonedLibItems,
});
});
}
return response
.json()
.catch(() => {
throw new Error(response.statusText || "something went wrong");
})
.then((error) => {
throw new Error(
error.message || response.statusText || "something went wrong",
);
});
},
(err) => {
console.error(err);
onError(err);
setIsSubmitting(false);
},
)
.catch((err) => {
console.error(err);
onError(err);
setIsSubmitting(false);
});
};
const renderLibraryItems = () => {
const items: ReactNode[] = [];
clonedLibItems.forEach((libItem, index) => {
items.push(
<div className="single-library-item-wrapper" key={index}>
<SingleLibraryItem
libItem={libItem}
appState={appState}
index={index}
onChange={(val, index) => {
const items = clonedLibItems.slice();
items[index].name = val;
setClonedLibItems(items);
}}
onRemove={onRemove}
/>
</div>,
);
});
return <div className="selected-library-items">{items}</div>;
};
const onDialogClose = useCallback(() => {
updateItemsInStorage(clonedLibItems);
savePublishLibDataToStorage(libraryData);
onClose();
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
const shouldRenderForm = !!libraryItems.length;
return (
<Dialog
onCloseRequest={onDialogClose}
title={t("publishDialog.title")}
className="publish-library"
>
{shouldRenderForm ? (
<form onSubmit={onSubmit}>
<div className="publish-library-note">
{t("publishDialog.noteDescription.pre")}
<a
href="https://libraries.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
>
{t("publishDialog.noteDescription.link")}
</a>{" "}
{t("publishDialog.noteDescription.post")}
</div>
<span className="publish-library-note">
{t("publishDialog.noteGuidelines.pre")}
<a
href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
target="_blank"
rel="noopener noreferrer"
>
{t("publishDialog.noteGuidelines.link")}
</a>
{t("publishDialog.noteGuidelines.post")}
</span>
<div className="publish-library-note">
{t("publishDialog.noteItems")}
</div>
{renderLibraryItems()}
<div className="publish-library__fields">
<label>
<div>
<span>{t("publishDialog.libraryName")}</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
name="name"
required
value={libraryData.name}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.libraryName")}
/>
</label>
<label style={{ alignItems: "flex-start" }}>
<div>
<span>{t("publishDialog.libraryDesc")}</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<textarea
name="description"
rows={4}
required
value={libraryData.description}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.libraryDesc")}
/>
</label>
<label>
<div>
<span>{t("publishDialog.authorName")}</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
name="authorName"
required
value={libraryData.authorName}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.authorName")}
/>
</label>
<label>
<span>{t("publishDialog.githubUsername")}</span>
<input
type="text"
name="githubHandle"
value={libraryData.githubHandle}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.githubHandle")}
/>
</label>
<label>
<span>{t("publishDialog.twitterUsername")}</span>
<input
type="text"
name="twitterHandle"
value={libraryData.twitterHandle}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.twitterHandle")}
/>
</label>
<label>
<span>{t("publishDialog.website")}</span>
<input
type="text"
name="website"
pattern="https?://.+"
title={t("publishDialog.errors.website")}
value={libraryData.website}
onChange={onInputChange}
placeholder={t("publishDialog.placeholder.website")}
/>
</label>
<span className="publish-library-note">
{t("publishDialog.noteLicense.pre")}
<a
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
>
{t("publishDialog.noteLicense.link")}
</a>
{t("publishDialog.noteLicense.post")}
</span>
</div>
<div className="publish-library__buttons">
<ToolButton
type="button"
title={t("buttons.cancel")}
aria-label={t("buttons.cancel")}
label={t("buttons.cancel")}
onClick={onDialogClose}
data-testid="cancel-clear-canvas-button"
className="publish-library__buttons--cancel"
/>
<ToolButton
type="submit"
title={t("buttons.submit")}
aria-label={t("buttons.submit")}
label={t("buttons.submit")}
className="publish-library__buttons--confirm"
isLoading={isSubmitting}
/>
</div>
</form>
) : (
<p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
{t("publishDialog.atleastOneLibItem")}
</p>
)}
</Dialog>
);
};
export default PublishLibrary;
+66
View File
@@ -0,0 +1,66 @@
@import "../css/variables.module";
.excalidraw {
.single-library-item {
position: relative;
&__svg {
width: 7.5rem;
height: 7.5rem;
border: 1px solid var(--button-gray-2);
margin: 0.3rem;
svg {
width: 100%;
height: 100%;
}
}
.ToolIcon__icon {
background-color: $oc-white;
width: auto;
height: auto;
margin: 0 0.5rem;
}
.ToolIcon,
.ToolIcon_type_button:hover {
background-color: white;
}
.required,
.error {
color: $oc-red-8;
font-weight: bold;
font-size: 1rem;
margin: 0.2rem;
}
.error {
font-weight: 500;
margin: 0;
padding: 0.3em 0;
}
&--remove {
position: absolute;
top: 0.2rem;
right: 1.3rem;
.ToolIcon__icon {
margin: 0;
}
.ToolIcon__icon {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-7;
}
&:active {
background-color: $oc-red-8;
}
}
svg {
color: $oc-white;
padding: 0.26rem;
border-radius: 0.3em;
width: 1rem;
height: 1rem;
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
import oc from "open-color";
import { useEffect, useRef } from "react";
import { t } from "../i18n";
import { exportToSvg } from "../packages/utils";
import { AppState, LibraryItem } from "../types";
import { close } from "./icons";
import "./SingleLibraryItem.scss";
import { ToolButton } from "./ToolButton";
const SingleLibraryItem = ({
libItem,
appState,
index,
onChange,
onRemove,
}: {
libItem: LibraryItem;
appState: AppState;
index: number;
onChange: (val: string, index: number) => void;
onRemove: (id: string) => void;
}) => {
const svgRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const node = svgRef.current;
if (!node) {
return;
}
(async () => {
const svg = await exportToSvg({
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: oc.white,
exportBackground: true,
},
files: null,
});
node.innerHTML = svg.outerHTML;
})();
}, [libItem.elements, appState]);
return (
<div className="single-library-item">
<div ref={svgRef} className="single-library-item__svg" />
<ToolButton
aria-label={t("buttons.remove")}
type="button"
icon={close}
className="single-library-item--remove"
onClick={onRemove.bind(null, libItem.id)}
title={t("buttons.remove")}
/>
<div
style={{
display: "flex",
margin: "0.8rem 0.3rem",
width: "100%",
fontSize: "14px",
fontWeight: 500,
flexDirection: "column",
}}
>
<label
style={{
display: "flex",
justifyContent: "space-between",
flexDirection: "column",
}}
>
<div style={{ padding: "0.5em 0" }}>
<span style={{ fontWeight: 500, color: oc.gray[6] }}>
{t("publishDialog.itemName")}
</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
ref={inputRef}
style={{ width: "80%", padding: "0.2rem" }}
defaultValue={libItem.name}
placeholder="Item name"
onChange={(event) => {
onChange(event.target.value, index);
}}
/>
</label>
<span className="error">{libItem.error}</span>
</div>
</div>
);
};
export default SingleLibraryItem;
-18
View File
@@ -2,24 +2,6 @@
.excalidraw { .excalidraw {
.TextInput { .TextInput {
color: var(--text-primary-color);
display: inline-block; display: inline-block;
border: 1.5px solid var(--button-gray-1);
line-height: 1;
padding: 0.75rem;
white-space: nowrap;
border-radius: var(--space-factor);
background-color: var(--input-bg-color);
&:not(:focus) {
&:hover {
background-color: var(--input-hover-bg-color);
}
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
} }
} }
+20 -4
View File
@@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
visible?: boolean; visible?: boolean;
selected?: boolean; selected?: boolean;
className?: string; className?: string;
isLoading?: boolean;
}; };
type ToolButtonProps = type ToolButtonProps =
@@ -33,6 +34,11 @@ type ToolButtonProps =
children?: React.ReactNode; children?: React.ReactNode;
onClick?(event: React.MouseEvent): void; onClick?(event: React.MouseEvent): void;
}) })
| (ToolButtonBaseProps & {
type: "submit";
children?: React.ReactNode;
onClick?(event: React.MouseEvent): void;
})
| (ToolButtonBaseProps & { | (ToolButtonBaseProps & {
type: "icon"; type: "icon";
children?: React.ReactNode; children?: React.ReactNode;
@@ -61,9 +67,11 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
try { try {
setIsLoading(true); setIsLoading(true);
await ret; await ret;
} catch (error) { } catch (error: any) {
if (!(error instanceof AbortError)) { if (!(error instanceof AbortError)) {
throw error; throw error;
} else {
console.warn(error);
} }
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
@@ -82,7 +90,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
const lastPointerTypeRef = useRef<PointerType | null>(null); const lastPointerTypeRef = useRef<PointerType | null>(null);
if (props.type === "button" || props.type === "icon") { if (
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return ( return (
<button <button
className={clsx( className={clsx(
@@ -102,10 +117,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
hidden={props.hidden} hidden={props.hidden}
title={props.title} title={props.title}
aria-label={props["aria-label"]} aria-label={props["aria-label"]}
type="button" type={type}
onClick={onClick} onClick={onClick}
ref={innerRef} ref={innerRef}
disabled={isLoading} disabled={isLoading || props.isLoading}
> >
{(props.icon || props.label) && ( {(props.icon || props.label) && (
<div className="ToolIcon__icon" aria-hidden="true"> <div className="ToolIcon__icon" aria-hidden="true">
@@ -115,6 +130,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
{props.keyBindingLabel} {props.keyBindingLabel}
</span> </span>
)} )}
{props.isLoading && <Spinner />}
</div> </div>
)} )}
{props.showAriaLabel && ( {props.showAriaLabel && (
-1
View File
@@ -6,7 +6,6 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
position: relative; position: relative;
font-family: Cascadia;
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
border-radius: var(--space-factor); border-radius: var(--space-factor);
+2 -4
View File
@@ -34,10 +34,8 @@ const updateTooltip = (
width: itemWidth, width: itemWidth,
} = item.getBoundingClientRect(); } = item.getBoundingClientRect();
const { const { width: labelWidth, height: labelHeight } =
width: labelWidth, tooltip.getBoundingClientRect();
height: labelHeight,
} = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
+3 -3
View File
@@ -27,7 +27,7 @@ export class TopErrorBoundary extends React.Component<
for (const [key, value] of Object.entries({ ...localStorage })) { for (const [key, value] of Object.entries({ ...localStorage })) {
try { try {
_localStorage[key] = JSON.parse(value); _localStorage[key] = JSON.parse(value);
} catch (error) { } catch (error: any) {
_localStorage[key] = value; _localStorage[key] = value;
} }
} }
@@ -60,7 +60,7 @@ export class TopErrorBoundary extends React.Component<
) )
).default; ).default;
body = encodeURIComponent(templateStrFn(this.state.sentryEventId)); body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
} }
@@ -86,7 +86,7 @@ export class TopErrorBoundary extends React.Component<
try { try {
localStorage.clear(); localStorage.clear();
window.location.reload(); window.location.reload();
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
} }
}} }}
+30 -2
View File
@@ -30,8 +30,12 @@ export const createIcon = (
d: string | React.ReactNode, d: string | React.ReactNode,
opts: number | Opts = 512, opts: number | Opts = 512,
) => { ) => {
const { width = 512, height = width, mirror, style } = const {
typeof opts === "number" ? ({ width: opts } as Opts) : opts; width = 512,
height = width,
mirror,
style,
} = typeof opts === "number" ? ({ width: opts } as Opts) : opts;
return ( return (
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -81,6 +85,7 @@ export const clipboard = createIcon(
export const trash = createIcon( export const trash = createIcon(
"M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z", "M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z",
{ width: 448, height: 512 }, { width: 448, height: 512 },
); );
@@ -752,6 +757,21 @@ export const ArrowheadBarIcon = React.memo(
), ),
); );
export const ArrowheadTriangleIcon = React.memo(
({ theme, flip = false }: { theme: Theme; flip?: boolean }) =>
createIcon(
<g
stroke={iconFillColor(theme)}
fill={iconFillColor(theme)}
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M32 10L6 10" strokeWidth={2} />
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" />
</g>,
{ width: 40, height: 20 },
),
);
export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) => export const FontSizeSmallIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon( createIcon(
<path <path
@@ -863,3 +883,11 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
{ width: 448, height: 512 }, { width: 448, height: 512 },
), ),
); );
export const publishIcon = createIcon(
<path
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
fill="currentColor"
/>,
{ width: 640, height: 512 },
);
+4 -1
View File
@@ -162,7 +162,8 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3]; export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_JPG = 10000;
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER = 1440;
export const ALLOWED_IMAGE_MIME_TYPES = [ export const ALLOWED_IMAGE_MIME_TYPES = [
MIME_TYPES.png, MIME_TYPES.png,
@@ -174,3 +175,5 @@ export const ALLOWED_IMAGE_MIME_TYPES = [
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024; export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS = "http://www.w3.org/2000/svg";
export const ENCRYPTION_KEY_BITS = 128;
+21
View File
@@ -517,6 +517,27 @@
} }
} }
input[type="text"],
textarea:not(.excalidraw-wysiwyg) {
color: var(--text-primary-color);
border: 1.5px solid var(--input-border-color);
padding: 0.75rem;
white-space: nowrap;
border-radius: var(--space-factor);
background-color: var(--input-bg-color);
&:not(:focus) {
&:hover {
background-color: var(--input-hover-bg-color);
}
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
}
@media print { @media print {
.App-bottom-bar, .App-bottom-bar,
.FixedSideContainer, .FixedSideContainer,
+2 -1
View File
@@ -16,7 +16,7 @@
--icon-green-fill-color: #{$oc-green-9}; --icon-green-fill-color: #{$oc-green-9};
--default-bg-color: #{$oc-white}; --default-bg-color: #{$oc-white};
--input-bg-color: #{$oc-white}; --input-bg-color: #{$oc-white};
--input-border-color: #{$oc-gray-3}; --input-border-color: #{$oc-gray-4};
--input-hover-bg-color: #{$oc-gray-1}; --input-hover-bg-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7}; --input-label-color: #{$oc-gray-7};
--island-bg-color: rgba(255, 255, 255, 0.96); --island-bg-color: rgba(255, 255, 255, 0.96);
@@ -64,6 +64,7 @@
--input-label-color: #{$oc-gray-2}; --input-label-color: #{$oc-gray-2};
--island-bg-color: rgba(30, 30, 30, 0.98); --island-bg-color: rgba(30, 30, 30, 0.98);
--keybinding-color: #{$oc-gray-6}; --keybinding-color: #{$oc-gray-6};
--link-color: #{$oc-blue-4};
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)}; --overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
--popup-bg-color: #2c2c2c; --popup-bg-color: #2c2c2c;
--popup-secondary-bg-color: #222; --popup-secondary-bg-color: #222;
+28 -19
View File
@@ -11,6 +11,7 @@ import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { AppState, DataURL } from "../types"; import { AppState, DataURL } from "../types";
import { bytesToHexString } from "../utils";
import { FileSystemHandle } from "./filesystem"; import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData } from "./json"; import { isValidExcalidrawData } from "./json";
import { restore } from "./restore"; import { restore } from "./restore";
@@ -24,7 +25,7 @@ const parseFileContents = async (blob: Blob | File) => {
return await ( return await (
await import(/* webpackChunkName: "image" */ "./image") await import(/* webpackChunkName: "image" */ "./image")
).decodePngMetadata(blob); ).decodePngMetadata(blob);
} catch (error) { } catch (error: any) {
if (error.message === "INVALID") { if (error.message === "INVALID") {
throw new DOMException( throw new DOMException(
t("alerts.imageDoesNotContainScene"), t("alerts.imageDoesNotContainScene"),
@@ -58,7 +59,7 @@ const parseFileContents = async (blob: Blob | File) => {
).decodeSvgMetadata({ ).decodeSvgMetadata({
svg: contents, svg: contents,
}); });
} catch (error) { } catch (error: any) {
if (error.message === "INVALID") { if (error.message === "INVALID") {
throw new DOMException( throw new DOMException(
t("alerts.imageDoesNotContainScene"), t("alerts.imageDoesNotContainScene"),
@@ -156,7 +157,7 @@ export const loadFromBlob = async (
); );
return result; return result;
} catch (error) { } catch (error: any) {
console.error(error.message); console.error(error.message);
throw new Error(t("alerts.couldNotLoadInvalidFile")); throw new Error(t("alerts.couldNotLoadInvalidFile"));
} }
@@ -187,7 +188,7 @@ export const canvasToBlob = async (
} }
resolve(blob); resolve(blob);
}); });
} catch (error) { } catch (error: any) {
reject(error); reject(error);
} }
}); });
@@ -195,26 +196,18 @@ export const canvasToBlob = async (
/** generates SHA-1 digest from supplied file (if not supported, falls back /** generates SHA-1 digest from supplied file (if not supported, falls back
to a 40-char base64 random id) */ to a 40-char base64 random id) */
export const generateIdFromFile = async (file: File) => { export const generateIdFromFile = async (file: File): Promise<FileId> => {
let id: FileId;
try { try {
const hashBuffer = await window.crypto.subtle.digest( const hashBuffer = await window.crypto.subtle.digest(
"SHA-1", "SHA-1",
await file.arrayBuffer(), await file.arrayBuffer(),
); );
id = return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
// convert buffer to byte array } catch (error: any) {
Array.from(new Uint8Array(hashBuffer))
// convert to hex string
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("") as FileId;
} catch (error) {
console.error(error); console.error(error);
// length 40 to align with the HEX length of SHA-1 (which is 160 bit) // length 40 to align with the HEX length of SHA-1 (which is 160 bit)
id = nanoid(40) as FileId; return nanoid(40) as FileId;
} }
return id;
}; };
export const getDataURL = async (file: Blob | File): Promise<DataURL> => { export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
@@ -244,7 +237,11 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
export const resizeImageFile = async ( export const resizeImageFile = async (
file: File, file: File,
maxWidthOrHeight: number, opts: {
/** undefined indicates auto */
outputType?: typeof MIME_TYPES["jpg"];
maxWidthOrHeight: number;
},
): Promise<File> => { ): Promise<File> => {
// SVG files shouldn't a can't be resized // SVG files shouldn't a can't be resized
if (file.type === MIME_TYPES.svg) { if (file.type === MIME_TYPES.svg) {
@@ -264,6 +261,16 @@ export const resizeImageFile = async (
pica: pica({ features: ["js", "wasm"] }), pica: pica({ features: ["js", "wasm"] }),
}); });
if (opts.outputType) {
const { outputType } = opts;
reduce._create_blob = function (env) {
return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => {
env.out_blob = blob;
return env;
});
};
}
const fileType = file.type; const fileType = file.type;
if (!isSupportedImageFile(file)) { if (!isSupportedImageFile(file)) {
@@ -271,9 +278,11 @@ export const resizeImageFile = async (
} }
return new File( return new File(
[await reduce.toBlob(file, { max: maxWidthOrHeight })], [await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
file.name, file.name,
{ type: fileType }, {
type: fileType,
},
); );
}; };
+2 -2
View File
@@ -85,7 +85,7 @@ export const encode = async ({
if (compress !== false) { if (compress !== false) {
try { try {
deflated = await toByteString(deflate(text)); deflated = await toByteString(deflate(text));
} catch (error) { } catch (error: any) {
console.error("encode: cannot deflate", error); console.error("encode: cannot deflate", error);
} }
} }
@@ -367,7 +367,7 @@ export const decompressData = async <T extends Record<string, any>>(
/** data can be anything so the caller must decode it */ /** data can be anything so the caller must decode it */
data: contentsBuffer, data: contentsBuffer,
}; };
} catch (error) { } catch (error: any) {
console.error( console.error(
`Error during decompressing and decrypting the file.`, `Error during decompressing and decrypting the file.`,
encodingMetadata, encodingMetadata,
+21 -8
View File
@@ -1,3 +1,5 @@
import { ENCRYPTION_KEY_BITS } from "../constants";
export const IV_LENGTH_BYTES = 12; export const IV_LENGTH_BYTES = 12;
export const createIV = () => { export const createIV = () => {
@@ -5,19 +7,27 @@ export const createIV = () => {
return window.crypto.getRandomValues(arr); return window.crypto.getRandomValues(arr);
}; };
export const generateEncryptionKey = async () => { export const generateEncryptionKey = async <
T extends "string" | "cryptoKey" = "string",
>(
returnAs?: T,
): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
const key = await window.crypto.subtle.generateKey( const key = await window.crypto.subtle.generateKey(
{ {
name: "AES-GCM", name: "AES-GCM",
length: 128, length: ENCRYPTION_KEY_BITS,
}, },
true, // extractable true, // extractable
["encrypt", "decrypt"], ["encrypt", "decrypt"],
); );
return (await window.crypto.subtle.exportKey("jwk", key)).k; return (
returnAs === "cryptoKey"
? key
: (await window.crypto.subtle.exportKey("jwk", key)).k
) as T extends "cryptoKey" ? CryptoKey : string;
}; };
export const getImportedKey = (key: string, usage: KeyUsage) => export const getCryptoKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey( window.crypto.subtle.importKey(
"jwk", "jwk",
{ {
@@ -29,17 +39,18 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
}, },
{ {
name: "AES-GCM", name: "AES-GCM",
length: 128, length: ENCRYPTION_KEY_BITS,
}, },
false, // extractable false, // extractable
[usage], [usage],
); );
export const encryptData = async ( export const encryptData = async (
key: string, key: string | CryptoKey,
data: Uint8Array | ArrayBuffer | Blob | File | string, data: Uint8Array | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => { ): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
const importedKey = await getImportedKey(key, "encrypt"); const importedKey =
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
const iv = createIV(); const iv = createIV();
const buffer: ArrayBuffer | Uint8Array = const buffer: ArrayBuffer | Uint8Array =
typeof data === "string" typeof data === "string"
@@ -50,6 +61,8 @@ export const encryptData = async (
? await data.arrayBuffer() ? await data.arrayBuffer()
: data; : data;
// We use symmetric encryption. AES-GCM is the recommended algorithm and
// includes checks that the ciphertext has not been modified by an attacker.
const encryptedBuffer = await window.crypto.subtle.encrypt( const encryptedBuffer = await window.crypto.subtle.encrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
@@ -67,7 +80,7 @@ export const decryptData = async (
encrypted: Uint8Array | ArrayBuffer, encrypted: Uint8Array | ArrayBuffer,
privateKey: string, privateKey: string,
): Promise<ArrayBuffer> => { ): Promise<ArrayBuffer> => {
const key = await getImportedKey(privateKey, "decrypt"); const key = await getCryptoKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt( return window.crypto.subtle.decrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
+3 -3
View File
@@ -4,7 +4,7 @@ import {
fileSave as _fileSave, fileSave as _fileSave,
FileSystemHandle, FileSystemHandle,
supported as nativeFileSystemSupported, supported as nativeFileSystemSupported,
} from "@dwelle/browser-fs-access"; } from "browser-fs-access";
import { EVENT, MIME_TYPES } from "../constants"; import { EVENT, MIME_TYPES } from "../constants";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
import { debounce } from "../utils"; import { debounce } from "../utils";
@@ -22,7 +22,7 @@ const INPUT_CHANGE_INTERVAL_MS = 500;
export const fileOpen = <M extends boolean | undefined = false>(opts: { export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[]; extensions?: FILE_EXTENSION[];
description?: string; description: string;
multiple?: M; multiple?: M;
}): Promise< }): Promise<
M extends false | undefined ? FileWithHandle : FileWithHandle[] M extends false | undefined ? FileWithHandle : FileWithHandle[]
@@ -94,7 +94,7 @@ export const fileSave = (
name: string; name: string;
/** file extension */ /** file extension */
extension: FILE_EXTENSION; extension: FILE_EXTENSION;
description?: string; description: string;
/** existing FileSystemHandle */ /** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemHandle | null;
}, },
+34 -5
View File
@@ -1,8 +1,11 @@
import decodePng from "png-chunks-extract"; import extractPngChunks from "png-chunks-extract";
import tEXt from "png-chunk-text"; import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode"; import encodePng from "png-chunks-encode";
import { stringToBase64, encode, decode, base64ToString } from "./encode"; import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { PngChunk } from "../types";
export { extractPngChunks };
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// PNG // PNG
@@ -28,7 +31,9 @@ const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
export const getTEXtChunk = async ( export const getTEXtChunk = async (
blob: Blob, blob: Blob,
): Promise<{ keyword: string; text: string } | null> => { ): Promise<{ keyword: string; text: string } | null> => {
const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); const chunks = extractPngChunks(
new Uint8Array(await blobToArrayBuffer(blob)),
);
const metadataChunk = chunks.find((chunk) => chunk.name === "tEXt"); const metadataChunk = chunks.find((chunk) => chunk.name === "tEXt");
if (metadataChunk) { if (metadataChunk) {
return tEXt.decode(metadataChunk.data); return tEXt.decode(metadataChunk.data);
@@ -36,6 +41,28 @@ export const getTEXtChunk = async (
return null; return null;
}; };
export const findPngChunk = (
chunks: PngChunk[],
name: PngChunk["name"],
/** this makes the search stop before IDAT chunk (before which most
* metadata chunks reside). This is a perf optim. */
breakBeforeIDAT = true,
) => {
let i = 0;
const len = chunks.length;
while (i <= len) {
const chunk = chunks[i];
if (chunk.name === name) {
return chunk;
}
if (breakBeforeIDAT && chunk.name === "IDAT") {
return null;
}
i++;
}
return null;
};
export const encodePngMetadata = async ({ export const encodePngMetadata = async ({
blob, blob,
metadata, metadata,
@@ -43,7 +70,9 @@ export const encodePngMetadata = async ({
blob: Blob; blob: Blob;
metadata: string; metadata: string;
}) => { }) => {
const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); const chunks = extractPngChunks(
new Uint8Array(await blobToArrayBuffer(blob)),
);
const metadataChunk = tEXt.encode( const metadataChunk = tEXt.encode(
MIME_TYPES.excalidraw, MIME_TYPES.excalidraw,
@@ -76,7 +105,7 @@ export const decodePngMetadata = async (blob: Blob) => {
throw new Error("FAILED"); throw new Error("FAILED");
} }
return await decode(encodedData); return await decode(encodedData);
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
throw new Error("FAILED"); throw new Error("FAILED");
} }
@@ -127,7 +156,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
throw new Error("FAILED"); throw new Error("FAILED");
} }
return await decode(encodedData); return await decode(encodedData);
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
throw new Error("FAILED"); throw new Error("FAILED");
} }
+3 -1
View File
@@ -54,6 +54,7 @@ export const exportCanvas = async (
return await fileSave( return await fileSave(
new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }), new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }),
{ {
description: "Export to SVG",
name, name,
extension: "svg", extension: "svg",
fileHandle, fileHandle,
@@ -86,6 +87,7 @@ export const exportCanvas = async (
} }
return await fileSave(blob, { return await fileSave(blob, {
description: "Export to PNG",
name, name,
extension: "png", extension: "png",
fileHandle, fileHandle,
@@ -93,7 +95,7 @@ export const exportCanvas = async (
} else if (type === "clipboard") { } else if (type === "clipboard") {
try { try {
await copyBlobToClipboardAsPng(blob); await copyBlobToClipboardAsPng(blob);
} catch (error) { } catch (error: any) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") { if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error; throw error;
} }
+5 -6
View File
@@ -3,7 +3,7 @@ import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants"; import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
import { clearElementsForDatabase, clearElementsForExport } from "../element"; import { clearElementsForDatabase, clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles, LibraryItems } from "../types";
import { isImageFileHandle, loadFromBlob } from "./blob"; import { isImageFileHandle, loadFromBlob } from "./blob";
import { import {
@@ -114,17 +114,16 @@ export const isValidLibrary = (json: any) => {
typeof json === "object" && typeof json === "object" &&
json && json &&
json.type === EXPORT_DATA_TYPES.excalidrawLibrary && json.type === EXPORT_DATA_TYPES.excalidrawLibrary &&
json.version === 1 (json.version === 1 || json.version === 2)
); );
}; };
export const saveLibraryAsJSON = async (library: Library) => { export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
const libraryItems = await library.loadLibrary();
const data: ExportedLibraryData = { const data: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary, type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: 1, version: 2,
source: EXPORT_SOURCE, source: EXPORT_SOURCE,
library: libraryItems, libraryItems,
}; };
const serialized = JSON.stringify(data, null, 2); const serialized = JSON.stringify(data, null, 2);
await fileSave( await fileSave(
+24 -17
View File
@@ -1,6 +1,6 @@
import { loadLibraryFromBlob } from "./blob"; import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types"; import { LibraryItems, LibraryItem } from "../types";
import { restoreElements } from "./restore"; import { restoreElements, restoreLibraryItems } from "./restore";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type App from "../components/App"; import type App from "../components/App";
@@ -18,14 +18,16 @@ class Library {
}; };
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => { restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
const elements = getNonDeletedElements(restoreElements(libraryItem, null)); const elements = getNonDeletedElements(
return elements.length ? elements : null; restoreElements(libraryItem.elements, null),
);
return elements.length ? { ...libraryItem, elements } : null;
}; };
/** imports library (currently merges, removing duplicates) */ /** imports library (currently merges, removing duplicates) */
async importLibrary(blob: Blob) { async importLibrary(blob: Blob, defaultStatus = "unpublished") {
const libraryFile = await loadLibraryFromBlob(blob); const libraryFile = await loadLibraryFromBlob(blob);
if (!libraryFile || !libraryFile.library) { if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
return; return;
} }
@@ -37,17 +39,17 @@ class Library {
targetLibraryItem: LibraryItem, targetLibraryItem: LibraryItem,
) => { ) => {
return !existingLibraryItems.find((libraryItem) => { return !existingLibraryItems.find((libraryItem) => {
if (libraryItem.length !== targetLibraryItem.length) { if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
return false; return false;
} }
// detect z-index difference by checking the excalidraw elements // detect z-index difference by checking the excalidraw elements
// are in order // are in order
return libraryItem.every((libItemExcalidrawItem, idx) => { return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
return ( return (
libItemExcalidrawItem.id === targetLibraryItem[idx].id && libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
libItemExcalidrawItem.versionNonce === libItemExcalidrawItem.versionNonce ===
targetLibraryItem[idx].versionNonce targetLibraryItem.elements[idx].versionNonce
); );
}); });
}); });
@@ -55,15 +57,20 @@ class Library {
const existingLibraryItems = await this.loadLibrary(); const existingLibraryItems = await this.loadLibrary();
const filtered = libraryFile.library!.reduce((acc, libraryItem) => { const library = libraryFile.libraryItems || libraryFile.library || [];
const restoredItem = this.restoreLibraryItem(libraryItem); const restoredLibItems = restoreLibraryItems(
library,
defaultStatus as "published" | "unpublished",
);
const filteredItems = [];
for (const item of restoredLibItems) {
const restoredItem = this.restoreLibraryItem(item as LibraryItem);
if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) { if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
acc.push(restoredItem); filteredItems.push(restoredItem);
} }
return acc; }
}, [] as Mutable<LibraryItems>);
await this.saveLibrary([...existingLibraryItems, ...filtered]); await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
} }
loadLibrary = (): Promise<LibraryItems> => { loadLibrary = (): Promise<LibraryItems> => {
@@ -90,7 +97,7 @@ class Library {
this.libraryCache = JSON.parse(JSON.stringify(items)); this.libraryCache = JSON.parse(JSON.stringify(items));
resolve(items); resolve(items);
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
resolve([]); resolve([]);
} }
@@ -105,7 +112,7 @@ class Library {
// immediately // immediately
this.libraryCache = JSON.parse(serializedItems); this.libraryCache = JSON.parse(serializedItems);
await this.app.props.onLibraryChange?.(items); await this.app.props.onLibraryChange?.(items);
} catch (error) { } catch (error: any) {
this.libraryCache = prevLibraryItems; this.libraryCache = prevLibraryItems;
throw error; throw error;
} }
+39 -8
View File
@@ -3,7 +3,12 @@ import {
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
FontFamilyValues, FontFamilyValues,
} from "../element/types"; } from "../element/types";
import { AppState, BinaryFiles, NormalizedZoomValue } from "../types"; import {
AppState,
BinaryFiles,
LibraryItem,
NormalizedZoomValue,
} from "../types";
import { ImportedDataState } from "./types"; import { ImportedDataState } from "./types";
import { import {
getElementMap, getElementMap,
@@ -59,7 +64,7 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
const restoreElementWithProperties = < const restoreElementWithProperties = <
T extends ExcalidrawElement, T extends ExcalidrawElement,
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>> K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
>( >(
element: Required<T>, element: Required<T>,
extra: Pick< extra: Pick<
@@ -98,11 +103,11 @@ const restoreElementWithProperties = <
boundElementIds: element.boundElementIds ?? [], boundElementIds: element.boundElementIds ?? [],
}; };
return ({ return {
...base, ...base,
...getNormalizedDimensions(base), ...getNormalizedDimensions(base),
...extra, ...extra,
} as unknown) as T; } as unknown as T;
}; };
const restoreElement = ( const restoreElement = (
@@ -113,10 +118,9 @@ const restoreElement = (
let fontSize = element.fontSize; let fontSize = element.fontSize;
let fontFamily = element.fontFamily; let fontFamily = element.fontFamily;
if ("font" in element) { if ("font" in element) {
const [fontPx, _fontFamily]: [ const [fontPx, _fontFamily]: [string, string] = (
string, element as any
string, ).font.split(" ");
] = (element as any).font.split(" ");
fontSize = parseInt(fontPx, 10); fontSize = parseInt(fontPx, 10);
fontFamily = getFontFamilyByName(_fontFamily); fontFamily = getFontFamilyByName(_fontFamily);
} }
@@ -274,3 +278,30 @@ export const restore = (
files: data?.files || {}, files: data?.files || {},
}; };
}; };
export const restoreLibraryItems = (
libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
defaultStatus: LibraryItem["status"],
) => {
const restoredItems: LibraryItem[] = [];
for (const item of libraryItems) {
// migrate older libraries
if (Array.isArray(item)) {
restoredItems.push({
status: defaultStatus,
elements: item,
id: randomId(),
created: Date.now(),
});
} else {
const _item = item as MarkOptional<LibraryItem, "id" | "status">;
restoredItems.push({
..._item,
id: _item.id || randomId(),
status: _item.status || defaultStatus,
created: _item.created || Date.now(),
});
}
}
return restoredItems;
};
+8 -5
View File
@@ -1,5 +1,5 @@
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles, LibraryItems } from "../types"; import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
import type { cleanAppStateForExport } from "../appState"; import type { cleanAppStateForExport } from "../appState";
export interface ExportedDataState { export interface ExportedDataState {
@@ -18,15 +18,18 @@ export interface ImportedDataState {
elements?: readonly ExcalidrawElement[] | null; elements?: readonly ExcalidrawElement[] | null;
appState?: Readonly<Partial<AppState>> | null; appState?: Readonly<Partial<AppState>> | null;
scrollToContent?: boolean; scrollToContent?: boolean;
libraryItems?: LibraryItems; libraryItems?: LibraryItems | LibraryItems_v1;
files?: BinaryFiles; files?: BinaryFiles;
} }
export interface ExportedLibraryData { export interface ExportedLibraryData {
type: string; type: string;
version: number; version: 2;
source: string; source: string;
library: LibraryItems; libraryItems: LibraryItems;
} }
export interface ImportedLibraryData extends Partial<ExportedLibraryData> {} export interface ImportedLibraryData extends Partial<ExportedLibraryData> {
/** @deprecated v1 */
library?: LibraryItems;
}
+28 -25
View File
@@ -137,14 +137,13 @@ export const bindOrUnbindSelectedElements = (
const maybeBindBindableElement = ( const maybeBindBindableElement = (
bindableElement: NonDeleted<ExcalidrawBindableElement>, bindableElement: NonDeleted<ExcalidrawBindableElement>,
): void => { ): void => {
getElligibleElementsForBindableElementAndWhere( getElligibleElementsForBindableElementAndWhere(bindableElement).forEach(
bindableElement, ([linearElement, where]) =>
).forEach(([linearElement, where]) => bindOrUnbindLinearElement(
bindOrUnbindLinearElement( linearElement,
linearElement, where === "end" ? "keep" : bindableElement,
where === "end" ? "keep" : bindableElement, where === "start" ? "keep" : bindableElement,
where === "start" ? "keep" : bindableElement, ),
),
); );
}; };
@@ -293,9 +292,11 @@ export const updateBoundElements = (
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated, simultaneouslyUpdated,
); );
(Scene.getScene(changedElement)!.getNonDeletedElements( (
boundElementIds, Scene.getScene(changedElement)!.getNonDeletedElements(
) as NonDeleted<ExcalidrawLinearElement>[]).forEach((linearElement) => { boundElementIds,
) as NonDeleted<ExcalidrawLinearElement>[]
).forEach((linearElement) => {
const bindableElement = changedElement as ExcalidrawBindableElement; const bindableElement = changedElement as ExcalidrawBindableElement;
// In case the boundElementIds are stale // In case the boundElementIds are stale
if (!doesNeedUpdate(linearElement, bindableElement)) { if (!doesNeedUpdate(linearElement, bindableElement)) {
@@ -580,9 +581,11 @@ export const fixBindingsAfterDuplication = (
}); });
// Update the linear elements // Update the linear elements
(sceneElements.filter(({ id }) => (
allBoundElementIds.has(id), sceneElements.filter(({ id }) =>
) as ExcalidrawLinearElement[]).forEach((element) => { allBoundElementIds.has(id),
) as ExcalidrawLinearElement[]
).forEach((element) => {
const { startBinding, endBinding } = element; const { startBinding, endBinding } = element;
mutateElement(element, { mutateElement(element, {
startBinding: newBindingAfterDuplication( startBinding: newBindingAfterDuplication(
@@ -642,17 +645,17 @@ export const fixBindingsAfterDeletion = (
}); });
} }
}); });
(sceneElements.filter(({ id }) => (
boundElementIds.has(id), sceneElements.filter(({ id }) =>
) as ExcalidrawLinearElement[]).forEach( boundElementIds.has(id),
(element: ExcalidrawLinearElement) => { ) as ExcalidrawLinearElement[]
const { startBinding, endBinding } = element; ).forEach((element: ExcalidrawLinearElement) => {
mutateElement(element, { const { startBinding, endBinding } = element;
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds), mutateElement(element, {
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds), startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
}); endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
}, });
); });
}; };
const newBindingAfterDeletion = ( const newBindingAfterDeletion = (
+19 -2
View File
@@ -3,6 +3,7 @@ import {
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
NonDeleted,
} from "./types"; } from "./types";
import { distance2d, rotate } from "../math"; import { distance2d, rotate } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
@@ -78,7 +79,7 @@ const getMinMaxXYFromCurvePathOps = (
// move, bcurveTo, lineTo, and curveTo // move, bcurveTo, lineTo, and curveTo
if (op === "move") { if (op === "move") {
// change starting point // change starting point
currentP = (data as unknown) as Point; currentP = data as unknown as Point;
// move operation does not draw anything; so, it always // move operation does not draw anything; so, it always
// returns false // returns false
} else if (op === "bcurveTo") { } else if (op === "bcurveTo") {
@@ -227,7 +228,7 @@ export const getArrowheadPoints = (
const prevOp = ops[index - 1]; const prevOp = ops[index - 1];
let p0: Point = [0, 0]; let p0: Point = [0, 0];
if (prevOp.op === "move") { if (prevOp.op === "move") {
p0 = (prevOp.data as unknown) as Point; p0 = prevOp.data as unknown as Point;
} else if (prevOp.op === "bcurveTo") { } else if (prevOp.op === "bcurveTo") {
p0 = [prevOp.data[4], prevOp.data[5]]; p0 = [prevOp.data[4], prevOp.data[5]];
} }
@@ -258,6 +259,7 @@ export const getArrowheadPoints = (
arrow: 30, arrow: 30,
bar: 15, bar: 15,
dot: 15, dot: 15,
triangle: 15,
}[arrowhead]; // pixels (will differ for each arrowhead) }[arrowhead]; // pixels (will differ for each arrowhead)
let length = 0; let length = 0;
@@ -294,6 +296,7 @@ export const getArrowheadPoints = (
const angle = { const angle = {
arrow: 20, arrow: 20,
bar: 90, bar: 90,
triangle: 25,
}[arrowhead]; // degrees }[arrowhead]; // degrees
// Return points // Return points
@@ -511,3 +514,17 @@ export const getClosestElementBounds = (
return getElementBounds(closestElement); return getElementBounds(closestElement);
}; };
export interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { minX, minY, maxX, maxY };
};
+1 -1
View File
@@ -866,7 +866,7 @@ const hitTestRoughShape = (
// move, bcurveTo, lineTo, and curveTo // move, bcurveTo, lineTo, and curveTo
if (op === "move") { if (op === "move") {
// change starting point // change starting point
currentP = (data as unknown) as Point; currentP = data as unknown as Point;
// move operation does not draw anything; so, it always // move operation does not draw anything; so, it always
// returns false // returns false
} else if (op === "bcurveTo") { } else if (op === "bcurveTo") {
+80 -1
View File
@@ -3,6 +3,7 @@
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
import { MIME_TYPES, SVG_NS } from "../constants"; import { MIME_TYPES, SVG_NS } from "../constants";
import { getDataURL } from "../data/blob";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppClassProperties, DataURL, BinaryFiles } from "../types"; import { AppClassProperties, DataURL, BinaryFiles } from "../types";
import { isInitializedImageElement } from "./typeChecks"; import { isInitializedImageElement } from "./typeChecks";
@@ -63,7 +64,7 @@ export const updateImageCache = async ({
const image = await imagePromise; const image = await imagePromise;
imageCache.set(fileId, { ...data, image }); imageCache.set(fileId, { ...data, image });
} catch (error) { } catch (error: any) {
erroredFiles.set(fileId, true); erroredFiles.set(fileId, true);
} }
})(), })(),
@@ -109,3 +110,81 @@ export const normalizeSVG = async (SVGString: string) => {
return svg.outerHTML; return svg.outerHTML;
} }
}; };
/**
* To improve perf, uses `createImageBitmap` is available. But there are
* quality issues across browsers, so don't use this API where quality matters.
*/
export const speedyImageToCanvas = async (imageFile: Blob | File) => {
let imageSrc: HTMLImageElement | ImageBitmap;
if (
typeof ImageBitmap !== "undefined" &&
ImageBitmap.prototype &&
ImageBitmap.prototype.close &&
window.createImageBitmap
) {
imageSrc = await window.createImageBitmap(imageFile);
} else {
imageSrc = await loadHTMLImageElement(await getDataURL(imageFile));
}
const { width, height } = imageSrc;
const canvas = document.createElement("canvas");
canvas.height = height;
canvas.width = width;
const context = canvas.getContext("2d")!;
context.drawImage(imageSrc, 0, 0, width, height);
if (typeof ImageBitmap !== "undefined" && imageSrc instanceof ImageBitmap) {
imageSrc.close();
}
return { canvas, context, width, height };
};
/**
* Does its best at figuring out if an image (PNG) has any (semi)transparent
* pixels. If not PNG, always returns false.
*/
export const hasTransparentPixels = async (imageFile: Blob | File) => {
if (imageFile.type !== MIME_TYPES.png) {
return false;
}
const { findPngChunk, extractPngChunks } = await import("../data/image");
const buffer = await imageFile.arrayBuffer();
const chunks = extractPngChunks(new Uint8Array(buffer));
// early exit if tRNS not found and IHDR states no support for alpha
// -----------------------------------------------------------------------
const IHDR = findPngChunk(chunks, "IHDR");
if (
IHDR &&
IHDR.data[9] !== 4 &&
IHDR.data[9] !== 6 &&
!findPngChunk(chunks, "tRNS")
) {
return false;
}
// otherwise loop through pixels to check if there's any actually
// (semi)transparent pixel
// -----------------------------------------------------------------------
const { width, height, context } = await speedyImageToCanvas(imageFile);
{
const { data } = context.getImageData(0, 0, width, height);
const len = data.byteLength;
let i = 3;
while (i <= len) {
if (data[i] !== 255) {
return true;
}
i += 4;
}
}
return false;
};
+4 -7
View File
@@ -150,9 +150,8 @@ export class LinearElementEditor {
) )
: null; : null;
binding = { binding = {
[activePointIndex === 0 [activePointIndex === 0 ? "startBindingElement" : "endBindingElement"]:
? "startBindingElement" bindingElement,
: "endBindingElement"]: bindingElement,
}; };
} }
return { return {
@@ -236,10 +235,8 @@ export class LinearElementEditor {
// from the end points of the `linearElement` - this is to allow disabling // from the end points of the `linearElement` - this is to allow disabling
// binding (which needs to happen at the point the user finishes moving // binding (which needs to happen at the point the user finishes moving
// the point). // the point).
const { const { startBindingElement, endBindingElement } =
startBindingElement, appState.editingLinearElement;
endBindingElement,
} = appState.editingLinearElement;
if (isBindingEnabled(appState) && isBindingElement(element)) { if (isBindingEnabled(appState) && isBindingElement(element)) {
bindOrUnbindLinearElement( bindOrUnbindLinearElement(
element, element,
+6 -10
View File
@@ -464,16 +464,12 @@ export const resizeSingleElement = (
} }
} }
const [ const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
newBoundsX1, getResizedElementAbsoluteCoords(
newBoundsY1, stateAtResizeStart,
newBoundsX2, eleNewWidth,
newBoundsY2, eleNewHeight,
] = getResizedElementAbsoluteCoords( );
stateAtResizeStart,
eleNewWidth,
eleNewHeight,
);
const newBoundsWidth = newBoundsX2 - newBoundsX1; const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1; const newBoundsHeight = newBoundsY2 - newBoundsY1;
+6 -10
View File
@@ -36,10 +36,8 @@ export const resizeTest = (
return false; return false;
} }
const { const { rotation: rotationTransformHandle, ...transformHandles } =
rotation: rotationTransformHandle, getTransformHandles(element, zoom, pointerType);
...transformHandles
} = getTransformHandles(element, zoom, pointerType);
if ( if (
rotationTransformHandle && rotationTransformHandle &&
@@ -49,9 +47,8 @@ export const resizeTest = (
} }
const filter = Object.keys(transformHandles).filter((key) => { const filter = Object.keys(transformHandles).filter((key) => {
const transformHandle = transformHandles[ const transformHandle =
key as Exclude<TransformHandleType, "rotation"> transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
]!;
if (!transformHandle) { if (!transformHandle) {
return false; return false;
} }
@@ -105,9 +102,8 @@ export const getTransformHandleTypeFromCoords = (
); );
const found = Object.keys(transformHandles).find((key) => { const found = Object.keys(transformHandles).find((key) => {
const transformHandle = transformHandles[ const transformHandle =
key as Exclude<TransformHandleType, "rotation"> transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
]!;
return ( return (
transformHandle && transformHandle &&
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY) isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
+1
View File
@@ -108,6 +108,7 @@ export const textWysiwyg = ({
editable.dataset.type = "wysiwyg"; editable.dataset.type = "wysiwyg";
// prevent line wrapping on Safari // prevent line wrapping on Safari
editable.wrap = "off"; editable.wrap = "off";
editable.classList.add("excalidraw-wysiwyg");
Object.assign(editable.style, { Object.assign(editable.style, {
position: "absolute", position: "absolute",
+3 -3
View File
@@ -17,9 +17,9 @@ export type TransformHandleDirection =
export type TransformHandleType = TransformHandleDirection | "rotation"; export type TransformHandleType = TransformHandleDirection | "rotation";
export type TransformHandle = [number, number, number, number]; export type TransformHandle = [number, number, number, number];
export type TransformHandles = Partial< export type TransformHandles = Partial<{
{ [T in TransformHandleType]: TransformHandle } [T in TransformHandleType]: TransformHandle;
>; }>;
export type MaybeTransformHandleType = TransformHandleType | false; export type MaybeTransformHandleType = TransformHandleType | false;
const transformHandleSizes: { [k in PointerType]: number } = { const transformHandleSizes: { [k in PointerType]: number } = {
+1 -1
View File
@@ -129,7 +129,7 @@ export type PointBinding = {
gap: number; gap: number;
}; };
export type Arrowhead = "arrow" | "bar" | "dot"; export type Arrowhead = "arrow" | "bar" | "dot" | "triangle";
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
+2
View File
@@ -23,3 +23,5 @@ export const FIREBASE_STORAGE_PREFIXES = {
shareLinkFiles: `/files/shareLinks`, shareLinkFiles: `/files/shareLinks`,
collabFiles: `/files/rooms`, collabFiles: `/files/rooms`,
}; };
export const ROOM_ID_BYTES = 10;
+67 -96
View File
@@ -8,10 +8,7 @@ import {
ExcalidrawElement, ExcalidrawElement,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
} from "../../element/types"; } from "../../element/types";
import { import { getSceneVersion } from "../../packages/excalidraw/index";
getElementMap,
getSceneVersion,
} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../types"; import { Collaborator, Gesture } from "../../types";
import { import {
preventUnload, preventUnload,
@@ -27,7 +24,6 @@ import {
SYNC_FULL_SCENE_INTERVAL_MS, SYNC_FULL_SCENE_INTERVAL_MS,
} from "../app_constants"; } from "../app_constants";
import { import {
decryptAESGEM,
generateCollaborationLinkData, generateCollaborationLinkData,
getCollaborationLink, getCollaborationLink,
SocketUpdateDataSource, SocketUpdateDataSource,
@@ -63,7 +59,12 @@ import {
isImageElement, isImageElement,
isInitializedImageElement, isInitializedImageElement,
} from "../../element/typeChecks"; } from "../../element/typeChecks";
import { mutateElement } from "../../element/mutateElement"; import { newElementWith } from "../../element/mutateElement";
import {
ReconciledElements,
reconcileElements as _reconcileElements,
} from "./reconciliation";
import { decryptData } from "../../data/encryption";
interface CollabState { interface CollabState {
modalIsShown: boolean; modalIsShown: boolean;
@@ -87,10 +88,6 @@ export interface CollabAPI {
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
} }
type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
};
interface Props { interface Props {
excalidrawAPI: ExcalidrawImperativeAPI; excalidrawAPI: ExcalidrawImperativeAPI;
onRoomClose?: () => void; onRoomClose?: () => void;
@@ -227,13 +224,13 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}); });
saveCollabRoomToFirebase = async ( saveCollabRoomToFirebase = async (
syncableElements: ExcalidrawElement[] = this.getSyncableElements( syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(), this.excalidrawAPI.getSceneElementsIncludingDeleted(),
), ),
) => { ) => {
try { try {
await saveToFirebase(this.portal, syncableElements); await saveToFirebase(this.portal, syncableElements);
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
} }
}; };
@@ -244,6 +241,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}; };
closePortal = () => { closePortal = () => {
this.queueBroadcastAllElements.cancel();
this.loadImageFiles.cancel();
this.saveCollabRoomToFirebase(); this.saveCollabRoomToFirebase();
if (window.confirm(t("alerts.collabStopOverridePrompt"))) { if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
window.history.pushState({}, APP_NAME, window.location.origin); window.history.pushState({}, APP_NAME, window.location.origin);
@@ -256,7 +256,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
.getSceneElementsIncludingDeleted() .getSceneElementsIncludingDeleted()
.map((element) => { .map((element) => {
if (isImageElement(element) && element.status === "saved") { if (isImageElement(element) && element.status === "saved") {
return mutateElement(element, { status: "pending" }, false); return newElementWith(element, { status: "pending" });
} }
return element; return element;
}); });
@@ -301,6 +301,27 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
return await this.fileManager.getFiles(unfetchedImages); return await this.fileManager.getFiles(unfetchedImages);
}; };
private decryptPayload = async (
iv: Uint8Array,
encryptedData: ArrayBuffer,
decryptionKey: string,
) => {
try {
const decrypted = await decryptData(iv, encryptedData, decryptionKey);
const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
);
return JSON.parse(decodedData);
} catch (error) {
window.alert(t("alerts.decryptFailed"));
console.error(error);
return {
type: "INVALID_RESPONSE",
};
}
};
private initializeSocketClient = async ( private initializeSocketClient = async (
existingRoomLinkData: null | { roomId: string; roomKey: string }, existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => { ): Promise<ImportedDataState | null> => {
@@ -347,18 +368,14 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
scrollToContent: true, scrollToContent: true,
}); });
} }
} catch (error) { } catch (error: any) {
// log the error and move on. other peers will sync us the scene. // log the error and move on. other peers will sync us the scene.
console.error(error); console.error(error);
} }
} else { } else {
const elements = this.excalidrawAPI.getSceneElements().map((element) => { const elements = this.excalidrawAPI.getSceneElements().map((element) => {
if (isImageElement(element) && element.status === "saved") { if (isImageElement(element) && element.status === "saved") {
return mutateElement( return newElementWith(element, { status: "pending" });
element,
{ status: "pending" },
/* informMutation */ false,
);
} }
return element; return element;
}); });
@@ -392,10 +409,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
if (!this.portal.roomKey) { if (!this.portal.roomKey) {
return; return;
} }
const decryptedData = await decryptAESGEM(
const decryptedData = await this.decryptPayload(
iv,
encryptedData, encryptedData,
this.portal.roomKey, this.portal.roomKey,
iv,
); );
switch (decryptedData.type) { switch (decryptedData.type) {
@@ -423,12 +441,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
); );
break; break;
case "MOUSE_LOCATION": { case "MOUSE_LOCATION": {
const { const { pointer, button, username, selectedElementIds } =
pointer, decryptedData.payload;
button,
username,
selectedElementIds,
} = decryptedData.payload;
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] = const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
decryptedData.payload.socketId || decryptedData.payload.socketId ||
// @ts-ignore legacy, see #2094 (#2097) // @ts-ignore legacy, see #2094 (#2097)
@@ -484,74 +498,33 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}; };
private reconcileElements = ( private reconcileElements = (
elements: readonly ExcalidrawElement[], remoteElements: readonly ExcalidrawElement[],
): ReconciledElements => { ): ReconciledElements => {
const currentElements = this.getSceneElementsIncludingDeleted(); const localElements = this.getSceneElementsIncludingDeleted();
// create a map of ids so we don't have to iterate
// over the array more than once.
const localElementMap = getElementMap(currentElements);
const appState = this.excalidrawAPI.getAppState(); const appState = this.excalidrawAPI.getAppState();
// Reconcile const reconciledElements = _reconcileElements(
const newElements: readonly ExcalidrawElement[] = elements localElements,
.reduce((elements, element) => { remoteElements,
// if the remote element references one that's currently appState,
// edited on local, skip it (it'll be added in the next step) );
if (
element.id === appState.editingElement?.id ||
element.id === appState.resizingElement?.id ||
element.id === appState.draggingElement?.id
) {
return elements;
}
if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version > element.version
) {
elements.push(localElementMap[element.id]);
delete localElementMap[element.id];
} else if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version === element.version &&
localElementMap[element.id].versionNonce !== element.versionNonce
) {
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
if (localElementMap[element.id].versionNonce < element.versionNonce) {
elements.push(localElementMap[element.id]);
} else {
// it should be highly unlikely that the two versionNonces are the same. if we are
// really worried about this, we can replace the versionNonce with the socket id.
elements.push(element);
}
delete localElementMap[element.id];
} else {
elements.push(element);
delete localElementMap[element.id];
}
return elements;
}, [] as Mutable<typeof elements>)
// add local elements that weren't deleted or on remote
.concat(...Object.values(localElementMap));
// Avoid broadcasting to the rest of the collaborators the scene // Avoid broadcasting to the rest of the collaborators the scene
// we just received! // we just received!
// Note: this needs to be set before updating the scene as it // Note: this needs to be set before updating the scene as it
// synchronously calls render. // synchronously calls render.
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements)); this.setLastBroadcastedOrReceivedSceneVersion(
getSceneVersion(reconciledElements),
);
return newElements as ReconciledElements; return reconciledElements;
}; };
private loadImageFiles = throttle(async () => { private loadImageFiles = throttle(async () => {
const { const { loadedFiles, erroredFiles } =
loadedFiles, await this.fetchImageFilesFromFirebase({
erroredFiles, elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
} = await this.fetchImageFilesFromFirebase({ });
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
});
this.excalidrawAPI.addFiles(loadedFiles); this.excalidrawAPI.addFiles(loadedFiles);
@@ -634,9 +607,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
setCollaborators(sockets: string[]) { setCollaborators(sockets: string[]) {
this.setState((state) => { this.setState((state) => {
const collaborators: InstanceType< const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] =
typeof CollabWrapper new Map();
>["collaborators"] = new Map();
for (const socketId of sockets) { for (const socketId of sockets) {
if (this.collaborators.has(socketId)) { if (this.collaborators.has(socketId)) {
collaborators.set(socketId, this.collaborators.get(socketId)!); collaborators.set(socketId, this.collaborators.get(socketId)!);
@@ -681,11 +653,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
getSceneVersion(elements) > getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion() this.getLastBroadcastedOrReceivedSceneVersion()
) { ) {
this.portal.broadcastScene( this.portal.broadcastScene(SCENE.UPDATE, elements, false);
SCENE.UPDATE,
this.getSyncableElements(elements),
false,
);
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
this.queueBroadcastAllElements(); this.queueBroadcastAllElements();
} }
@@ -694,9 +662,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
queueBroadcastAllElements = throttle(() => { queueBroadcastAllElements = throttle(() => {
this.portal.broadcastScene( this.portal.broadcastScene(
SCENE.UPDATE, SCENE.UPDATE,
this.getSyncableElements( this.excalidrawAPI.getSceneElementsIncludingDeleted(),
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
true, true,
); );
const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion(); const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
@@ -722,8 +688,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}); });
}; };
isSyncableElement = (element: ExcalidrawElement) => {
return element.isDeleted || !isInvisiblySmallElement(element);
};
getSyncableElements = (elements: readonly ExcalidrawElement[]) => getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
elements.filter((el) => el.isDeleted || !isInvisiblySmallElement(el)); elements.filter((element) => this.isSyncableElement(element));
/** PRIVATE. Use `this.getContextValue()` instead. */ /** PRIVATE. Use `this.getContextValue()` instead. */
private contextValue: CollabAPI | null = null; private contextValue: CollabAPI | null = null;
@@ -740,7 +710,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.contextValue.initializeSocketClient = this.initializeSocketClient; this.contextValue.initializeSocketClient = this.initializeSocketClient;
this.contextValue.onCollabButtonClick = this.onCollabButtonClick; this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
this.contextValue.broadcastElements = this.broadcastElements; this.contextValue.broadcastElements = this.broadcastElements;
this.contextValue.fetchImageFilesFromFirebase = this.fetchImageFilesFromFirebase; this.contextValue.fetchImageFilesFromFirebase =
this.fetchImageFilesFromFirebase;
return this.contextValue; return this.contextValue;
}; };
+45 -38
View File
@@ -1,8 +1,4 @@
import { import { SocketUpdateData, SocketUpdateDataSource } from "../data";
encryptAESGEM,
SocketUpdateData,
SocketUpdateDataSource,
} from "../data";
import CollabWrapper from "./CollabWrapper"; import CollabWrapper from "./CollabWrapper";
@@ -11,7 +7,9 @@ import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
import { UserIdleState } from "../../types"; import { UserIdleState } from "../../types";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { throttle } from "lodash"; import { throttle } from "lodash";
import { mutateElement } from "../../element/mutateElement"; import { newElementWith } from "../../element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../data/encryption";
class Portal { class Portal {
collab: CollabWrapper; collab: CollabWrapper;
@@ -40,9 +38,7 @@ class Portal {
this.socket.on("new-user", async (_socketId: string) => { this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene( this.broadcastScene(
SCENE.INIT, SCENE.INIT,
this.collab.getSyncableElements( this.collab.getSceneElementsIncludingDeleted(),
this.collab.getSceneElementsIncludingDeleted(),
),
/* syncAll */ true, /* syncAll */ true,
); );
}); });
@@ -55,6 +51,7 @@ class Portal {
if (!this.socket) { if (!this.socket) {
return; return;
} }
this.queueFileUpload.flush();
this.socket.close(); this.socket.close();
this.socket = null; this.socket = null;
this.roomId = null; this.roomId = null;
@@ -79,12 +76,13 @@ class Portal {
if (this.isOpen()) { if (this.isOpen()) {
const json = JSON.stringify(data); const json = JSON.stringify(data);
const encoded = new TextEncoder().encode(json); const encoded = new TextEncoder().encode(json);
const encrypted = await encryptAESGEM(encoded, this.roomKey!); const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
this.socket!.emit(
this.socket?.emit(
volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER, volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
this.roomId, this.roomId,
encrypted.data, encryptedBuffer,
encrypted.iv, iv,
); );
} }
} }
@@ -95,12 +93,14 @@ class Portal {
elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(), elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
files: this.collab.excalidrawAPI.getFiles(), files: this.collab.excalidrawAPI.getFiles(),
}); });
} catch (error) { } catch (error: any) {
this.collab.excalidrawAPI.updateScene({ if (error.name !== "AbortError") {
appState: { this.collab.excalidrawAPI.updateScene({
errorMessage: error.message, appState: {
}, errorMessage: error.message,
}); },
});
}
} }
this.collab.excalidrawAPI.updateScene({ this.collab.excalidrawAPI.updateScene({
@@ -111,11 +111,7 @@ class Portal {
// this will signal collaborators to pull image data from server // this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break // (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging) // in-progress dragging)
return mutateElement( return newElementWith(element, { status: "saved" });
element,
{ status: "saved" },
/* informMutation */ false,
);
} }
return element; return element;
}), }),
@@ -124,24 +120,35 @@ class Portal {
broadcastScene = async ( broadcastScene = async (
sceneType: SCENE.INIT | SCENE.UPDATE, sceneType: SCENE.INIT | SCENE.UPDATE,
syncableElements: ExcalidrawElement[], allElements: readonly ExcalidrawElement[],
syncAll: boolean, syncAll: boolean,
) => { ) => {
if (sceneType === SCENE.INIT && !syncAll) { if (sceneType === SCENE.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT"); throw new Error("syncAll must be true when sending SCENE.INIT");
} }
if (!syncAll) { // sync out only the elements we think we need to to save bandwidth.
// sync out only the elements we think we need to to save bandwidth. // periodically we'll resync the whole thing to make sure no one diverges
// periodically we'll resync the whole thing to make sure no one diverges // due to a dropped message (server goes down etc).
// due to a dropped message (server goes down etc). const syncableElements = allElements.reduce(
syncableElements = syncableElements.filter( (acc, element: BroadcastedExcalidrawElement, idx, elements) => {
(syncableElement) => if (
!this.broadcastedElementVersions.has(syncableElement.id) || (syncAll ||
syncableElement.version > !this.broadcastedElementVersions.has(element.id) ||
this.broadcastedElementVersions.get(syncableElement.id)!, element.version >
); this.broadcastedElementVersions.get(element.id)!) &&
} this.collab.isSyncableElement(element)
) {
acc.push({
...element,
// z-index info for the reconciler
parent: idx === 0 ? "^" : elements[idx - 1]?.id,
});
}
return acc;
},
[] as BroadcastedExcalidrawElement[],
);
const data: SocketUpdateDataSource[typeof sceneType] = { const data: SocketUpdateDataSource[typeof sceneType] = {
type: sceneType, type: sceneType,
@@ -201,8 +208,8 @@ class Portal {
socketId: this.socket.id, socketId: this.socket.id,
pointer: payload.pointer, pointer: payload.pointer,
button: payload.button || "up", button: payload.button || "up",
selectedElementIds: this.collab.excalidrawAPI.getAppState() selectedElementIds:
.selectedElementIds, this.collab.excalidrawAPI.getAppState().selectedElementIds,
username: this.collab.state.username, username: this.collab.state.username,
}, },
}; };
+1 -6
View File
@@ -6,7 +6,7 @@
margin: 1.5em 0; margin: 1.5em 0;
} }
.RoomDialog-link { input.RoomDialog-link {
color: var(--text-primary-color); color: var(--text-primary-color);
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
@@ -14,8 +14,6 @@
display: inline-block; display: inline-block;
cursor: pointer; cursor: pointer;
border: none; border: none;
height: 2.5rem;
line-height: 2.5rem;
padding: 0 0.5rem; padding: 0 0.5rem;
white-space: nowrap; white-space: nowrap;
border-radius: var(--space-factor); border-radius: var(--space-factor);
@@ -55,10 +53,7 @@
margin-top: 0.5em; margin-top: 0.5em;
margin-inline-start: 0; margin-inline-start: 0;
} }
height: 2.5rem;
font-size: 1em; font-size: 1em;
line-height: 1.5;
padding: 0 0.5rem;
} }
.RoomDialog-sessionStartButtonContainer { .RoomDialog-sessionStartButtonContainer {
+4 -2
View File
@@ -53,7 +53,7 @@ const RoomDialog = ({
const copyRoomLink = async () => { const copyRoomLink = async () => {
try { try {
await copyTextToSystemClipboard(activeRoomLink); await copyTextToSystemClipboard(activeRoomLink);
} catch (error) { } catch (error: any) {
setErrorMessage(error.message); setErrorMessage(error.message);
} }
if (roomLinkInput.current) { if (roomLinkInput.current) {
@@ -68,7 +68,7 @@ const RoomDialog = ({
text: t("roomDialog.shareTitle"), text: t("roomDialog.shareTitle"),
url: activeRoomLink, url: activeRoomLink,
}); });
} catch (error) { } catch (error: any) {
// Just ignore. // Just ignore.
} }
}; };
@@ -124,6 +124,7 @@ const RoomDialog = ({
/> />
</Stack.Row> </Stack.Row>
<input <input
type="text"
value={activeRoomLink} value={activeRoomLink}
readOnly={true} readOnly={true}
className="RoomDialog-link" className="RoomDialog-link"
@@ -136,6 +137,7 @@ const RoomDialog = ({
{t("labels.yourName")} {t("labels.yourName")}
</label> </label>
<input <input
type="text"
id="username" id="username"
value={username || ""} value={username || ""}
className="RoomDialog-username TextInput" className="RoomDialog-username TextInput"
+161
View File
@@ -0,0 +1,161 @@
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
};
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
parent?: string;
};
const shouldDiscardRemoteElement = (
localAppState: AppState,
local: ExcalidrawElement | undefined,
remote: BroadcastedExcalidrawElement,
): boolean => {
if (
local &&
// local element is being edited
(local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id ||
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with
// the lowest versionNonce
(local.version === remote.version &&
local.versionNonce < remote.versionNonce))
) {
return true;
}
return false;
};
const getElementsMapWithIndex = <T extends ExcalidrawElement>(
elements: readonly T[],
) =>
elements.reduce(
(
acc: {
[key: string]: [element: T, index: number] | undefined;
},
element: T,
idx,
) => {
acc[element.id] = [element, idx];
return acc;
},
{},
);
export const reconcileElements = (
localElements: readonly ExcalidrawElement[],
remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState,
): ReconciledElements => {
const localElementsData =
getElementsMapWithIndex<ExcalidrawElement>(localElements);
const reconciledElements: ExcalidrawElement[] = localElements.slice();
const duplicates = new WeakMap<ExcalidrawElement, true>();
let cursor = 0;
let offset = 0;
let remoteElementIdx = -1;
for (const remoteElement of remoteElements) {
remoteElementIdx++;
const local = localElementsData[remoteElement.id];
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
if (remoteElement.parent) {
delete remoteElement.parent;
}
continue;
}
if (local) {
// mark for removal since it'll be replaced with the remote element
duplicates.set(local[0], true);
}
// parent may not be defined in case the remote client is running an older
// excalidraw version
const parent =
remoteElement.parent || remoteElements[remoteElementIdx - 1]?.id || null;
if (parent != null) {
delete remoteElement.parent;
// ^ indicates the element is the first in elements array
if (parent === "^") {
offset++;
if (cursor === 0) {
reconciledElements.unshift(remoteElement);
localElementsData[remoteElement.id] = [
remoteElement,
cursor - offset,
];
} else {
reconciledElements.splice(cursor + 1, 0, remoteElement);
localElementsData[remoteElement.id] = [
remoteElement,
cursor + 1 - offset,
];
cursor++;
}
} else {
let idx = localElementsData[parent]
? localElementsData[parent]![1]
: null;
if (idx != null) {
idx += offset;
}
if (idx != null && idx >= cursor) {
reconciledElements.splice(idx + 1, 0, remoteElement);
offset++;
localElementsData[remoteElement.id] = [
remoteElement,
idx + 1 - offset,
];
cursor = idx + 1;
} else if (idx != null) {
reconciledElements.splice(cursor + 1, 0, remoteElement);
offset++;
localElementsData[remoteElement.id] = [
remoteElement,
cursor + 1 - offset,
];
cursor++;
} else {
reconciledElements.push(remoteElement);
localElementsData[remoteElement.id] = [
remoteElement,
reconciledElements.length - 1 - offset,
];
}
}
// no parent z-index information, local element exists → replace in place
} else if (local) {
reconciledElements[local[1]] = remoteElement;
localElementsData[remoteElement.id] = [remoteElement, local[1]];
// otherwise push to the end
} else {
reconciledElements.push(remoteElement);
localElementsData[remoteElement.id] = [
remoteElement,
reconciledElements.length - 1 - offset,
];
}
}
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
(element) => !duplicates.has(element),
);
return ret as ReconciledElements;
};
@@ -93,9 +93,11 @@ export const ExportToExcalidrawPlus: React.FC<{
onClick={async () => { onClick={async () => {
try { try {
await exportToExcalidrawPlus(elements, appState, files); await exportToExcalidrawPlus(elements, appState, files);
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
onError(new Error(t("exportDialog.excalidrawplus_exportError"))); if (error.name !== "AbortError") {
onError(new Error(t("exportDialog.excalidrawplus_exportError")));
}
} }
}} }}
/> />
@@ -14,9 +14,8 @@ export const GitHubCorner = React.memo(
className="rtl-mirror" className="rtl-mirror"
style={{ style={{
marginTop: "calc(var(--space-factor) * -1)", marginTop: "calc(var(--space-factor) * -1)",
[dir === "rtl" [dir === "rtl" ? "marginLeft" : "marginRight"]:
? "marginLeft" "calc(var(--space-factor) * -1)",
: "marginRight"]: "calc(var(--space-factor) * -1)",
}} }}
> >
<a <a
+7 -19
View File
@@ -1,5 +1,5 @@
import { compressData } from "../../data/encode"; import { compressData } from "../../data/encode";
import { mutateElement } from "../../element/mutateElement"; import { newElementWith } from "../../element/mutateElement";
import { isInitializedImageElement } from "../../element/typeChecks"; import { isInitializedImageElement } from "../../element/typeChecks";
import { import {
ExcalidrawElement, ExcalidrawElement,
@@ -31,15 +31,11 @@ export class FileManager {
getFiles, getFiles,
saveFiles, saveFiles,
}: { }: {
getFiles: ( getFiles: (fileIds: FileId[]) => Promise<{
fileIds: FileId[],
) => Promise<{
loadedFiles: BinaryFileData[]; loadedFiles: BinaryFileData[];
erroredFiles: Map<FileId, true>; erroredFiles: Map<FileId, true>;
}>; }>;
saveFiles: (data: { saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
addedFiles: Map<FileId, BinaryFileData>;
}) => Promise<{
savedFiles: Map<FileId, true>; savedFiles: Map<FileId, true>;
erroredFiles: Map<FileId, true>; erroredFiles: Map<FileId, true>;
}>; }>;
@@ -203,11 +199,7 @@ export const encodeFilesForUpload = async ({
}); });
if (buffer.byteLength > maxBytes) { if (buffer.byteLength > maxBytes) {
throw new Error( throw new Error(t("errors.fileTooBig"));
t("errors.fileTooBig", {
maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`,
}),
);
} }
processedFiles.push({ processedFiles.push({
@@ -235,13 +227,9 @@ export const updateStaleImageStatuses = (params: {
isInitializedImageElement(element) && isInitializedImageElement(element) &&
params.erroredFiles.has(element.fileId) params.erroredFiles.has(element.fileId)
) { ) {
return mutateElement( return newElementWith(element, {
element, status: "error",
{ });
status: "error",
},
false,
);
} }
return element; return element;
}), }),
+11 -28
View File
@@ -5,7 +5,7 @@ import { restoreElements } from "../../data/restore";
import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types"; import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants"; import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "../../data/encode"; import { decompressData } from "../../data/encode";
import { getImportedKey, createIV } from "../../data/encryption"; import { encryptData, decryptData } from "../../data/encryption";
import { MIME_TYPES } from "../../constants"; import { MIME_TYPES } from "../../constants";
// private // private
@@ -13,9 +13,8 @@ import { MIME_TYPES } from "../../constants";
const FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG); const FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
let firebasePromise: Promise< let firebasePromise: Promise<typeof import("firebase/app").default> | null =
typeof import("firebase/app").default null;
> | null = null;
let firestorePromise: Promise<any> | null | true = null; let firestorePromise: Promise<any> | null | true = null;
let firebaseStoragePromise: Promise<any> | null | true = null; let firebaseStoragePromise: Promise<any> | null | true = null;
@@ -29,7 +28,7 @@ const _loadFirebase = async () => {
if (!isFirebaseInitialized) { if (!isFirebaseInitialized) {
try { try {
firebase.initializeApp(FIREBASE_CONFIG); firebase.initializeApp(FIREBASE_CONFIG);
} catch (error) { } catch (error: any) {
// trying initialize again throws. Usually this is harmless, and happens // trying initialize again throws. Usually this is harmless, and happens
// mainly in dev (HMR) // mainly in dev (HMR)
if (error.code === "app/duplicate-app") { if (error.code === "app/duplicate-app") {
@@ -93,20 +92,11 @@ const encryptElements = async (
key: string, key: string,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => { ): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
const importedKey = await getImportedKey(key, "encrypt");
const iv = createIV();
const json = JSON.stringify(elements); const json = JSON.stringify(elements);
const encoded = new TextEncoder().encode(json); const encoded = new TextEncoder().encode(json);
const ciphertext = await window.crypto.subtle.encrypt( const { encryptedBuffer, iv } = await encryptData(key, encoded);
{
name: "AES-GCM",
iv,
},
importedKey,
encoded,
);
return { ciphertext, iv }; return { ciphertext: encryptedBuffer, iv };
}; };
const decryptElements = async ( const decryptElements = async (
@@ -114,16 +104,7 @@ const decryptElements = async (
iv: Uint8Array, iv: Uint8Array,
ciphertext: ArrayBuffer | Uint8Array, ciphertext: ArrayBuffer | Uint8Array,
): Promise<readonly ExcalidrawElement[]> => { ): Promise<readonly ExcalidrawElement[]> => {
const importedKey = await getImportedKey(key, "decrypt"); const decrypted = await decryptData(iv, ciphertext, key);
const decrypted = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
ciphertext,
);
const decodedData = new TextDecoder("utf-8").decode( const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted), new Uint8Array(decrypted),
); );
@@ -173,7 +154,7 @@ export const saveFilesToFirebase = async ({
}, },
); );
savedFiles.set(id, true); savedFiles.set(id, true);
} catch (error) { } catch (error: any) {
erroredFiles.set(id, true); erroredFiles.set(id, true);
} }
}), }),
@@ -294,8 +275,10 @@ export const loadFilesFromFirebase = async (
dataURL, dataURL,
created: metadata?.created || Date.now(), created: metadata?.created || Date.now(),
}); });
} else {
erroredFiles.set(id, true);
} }
} catch (error) { } catch (error: any) {
erroredFiles.set(id, true); erroredFiles.set(id, true);
console.error(error); console.error(error);
} }
+38 -128
View File
@@ -1,7 +1,7 @@
import { import {
createIV, decryptData,
encryptData,
generateEncryptionKey, generateEncryptionKey,
getImportedKey,
IV_LENGTH_BYTES, IV_LENGTH_BYTES,
} from "../../data/encryption"; } from "../../data/encryption";
import { serializeAsJSON } from "../../data/json"; import { serializeAsJSON } from "../../data/json";
@@ -16,20 +16,18 @@ import {
BinaryFiles, BinaryFiles,
UserIdleState, UserIdleState,
} from "../../types"; } from "../../types";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants"; import { bytesToHexString } from "../../utils";
import { FILE_UPLOAD_MAX_BYTES, ROOM_ID_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "./FileManager"; import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase"; import { saveFilesToFirebase } from "./firebase";
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
const BACKEND_GET = process.env.REACT_APP_BACKEND_V1_GET_URL;
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL; const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL; const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
const generateRandomID = async () => { const generateRoomId = async () => {
const arr = new Uint8Array(10); const buffer = new Uint8Array(ROOM_ID_BYTES);
window.crypto.getRandomValues(arr); window.crypto.getRandomValues(buffer);
return Array.from(arr, byteToHex).join(""); return bytesToHexString(buffer);
}; };
export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL; export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
@@ -78,57 +76,10 @@ export type SocketUpdateDataIncoming =
type: "INVALID_RESPONSE"; type: "INVALID_RESPONSE";
}; };
export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & { export type SocketUpdateData =
_brand: "socketUpdateData"; SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
}; _brand: "socketUpdateData";
export const encryptAESGEM = async (
data: Uint8Array,
key: string,
): Promise<EncryptedData> => {
const importedKey = await getImportedKey(key, "encrypt");
const iv = createIV();
return {
data: await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
data,
),
iv,
}; };
};
export const decryptAESGEM = async (
data: ArrayBuffer,
key: string,
iv: Uint8Array,
): Promise<SocketUpdateDataIncoming> => {
try {
const importedKey = await getImportedKey(key, "decrypt");
const decrypted = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
data,
);
const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
);
return JSON.parse(decodedData);
} catch (error) {
window.alert(t("alerts.decryptFailed"));
console.error(error);
}
return {
type: "INVALID_RESPONSE",
};
};
export const getCollaborationLinkData = (link: string) => { export const getCollaborationLinkData = (link: string) => {
const hash = new URL(link).hash; const hash = new URL(link).hash;
@@ -141,7 +92,7 @@ export const getCollaborationLinkData = (link: string) => {
}; };
export const generateCollaborationLinkData = async () => { export const generateCollaborationLinkData = async () => {
const roomId = await generateRandomID(); const roomId = await generateRoomId();
const roomKey = await generateEncryptionKey(); const roomKey = await generateEncryptionKey();
if (!roomKey) { if (!roomKey) {
@@ -158,66 +109,42 @@ export const getCollaborationLink = (data: {
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`; return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
}; };
export const decryptImported = async (
iv: ArrayBuffer | Uint8Array,
encrypted: ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getImportedKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
encrypted,
);
};
const importFromBackend = async ( const importFromBackend = async (
id: string | null, id: string,
privateKey?: string | null, privateKey: string,
): Promise<ImportedDataState> => { ): Promise<ImportedDataState> => {
try { try {
const response = await fetch( const response = await fetch(`${BACKEND_V2_GET}${id}`);
privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
);
if (!response.ok) { if (!response.ok) {
window.alert(t("alerts.importBackendFailed")); window.alert(t("alerts.importBackendFailed"));
return {}; return {};
} }
let data: ImportedDataState; const buffer = await response.arrayBuffer();
if (privateKey) {
const buffer = await response.arrayBuffer();
let decrypted: ArrayBuffer; let decrypted: ArrayBuffer;
try { try {
// Buffer should contain both the IV (fixed length) and encrypted data // Buffer should contain both the IV (fixed length) and encrypted data
const iv = buffer.slice(0, IV_LENGTH_BYTES); const iv = buffer.slice(0, IV_LENGTH_BYTES);
const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength); const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
decrypted = await decryptImported(iv, encrypted, privateKey); decrypted = await decryptData(new Uint8Array(iv), encrypted, privateKey);
} catch (error) { } catch (error: any) {
// Fixed IV (old format, backward compatibility) // Fixed IV (old format, backward compatibility)
const fixedIv = new Uint8Array(IV_LENGTH_BYTES); const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
decrypted = await decryptImported(fixedIv, buffer, privateKey); decrypted = await decryptData(fixedIv, buffer, privateKey);
}
// We need to convert the decrypted array buffer to a string
const string = new window.TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
);
data = JSON.parse(string);
} else {
// Legacy format
data = await response.json();
} }
// We need to convert the decrypted array buffer to a string
const string = new window.TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
);
const data: ImportedDataState = JSON.parse(string);
return { return {
elements: data.elements || null, elements: data.elements || null,
appState: data.appState || null, appState: data.appState || null,
}; };
} catch (error) { } catch (error: any) {
window.alert(t("alerts.importBackendFailed")); window.alert(t("alerts.importBackendFailed"));
console.error(error); console.error(error);
return {}; return {};
@@ -233,7 +160,7 @@ export const loadScene = async (
localDataState: ImportedDataState | undefined | null, localDataState: ImportedDataState | undefined | null,
) => { ) => {
let data; let data;
if (id != null) { if (id != null && privateKey != null) {
// the private key is used to decrypt the content from the server, take // the private key is used to decrypt the content from the server, take
// extra care not to leak it // extra care not to leak it
data = restore( data = restore(
@@ -264,29 +191,12 @@ export const exportToBackend = async (
const json = serializeAsJSON(elements, appState, files, "database"); const json = serializeAsJSON(elements, appState, files, "database");
const encoded = new TextEncoder().encode(json); const encoded = new TextEncoder().encode(json);
const cryptoKey = await window.crypto.subtle.generateKey( const cryptoKey = await generateEncryptionKey("cryptoKey");
{
name: "AES-GCM",
length: 128,
},
true, // extractable
["encrypt", "decrypt"],
);
const iv = createIV(); const { encryptedBuffer, iv } = await encryptData(cryptoKey, encoded);
// We use symmetric encryption. AES-GCM is the recommended algorithm and
// includes checks that the ciphertext has not been modified by an attacker.
const encrypted = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
cryptoKey,
encoded,
);
// Concatenate IV with encrypted data (IV does not have to be secret). // Concatenate IV with encrypted data (IV does not have to be secret).
const payloadBlob = new Blob([iv.buffer, encrypted]); const payloadBlob = new Blob([iv.buffer, encryptedBuffer]);
const payload = await new Response(payloadBlob).arrayBuffer(); const payload = await new Response(payloadBlob).arrayBuffer();
// We use jwk encoding to be able to extract just the base64 encoded key. // We use jwk encoding to be able to extract just the base64 encoded key.
@@ -332,7 +242,7 @@ export const exportToBackend = async (
} else { } else {
window.alert(t("alerts.couldNotCreateShareableLink")); window.alert(t("alerts.couldNotCreateShareableLink"));
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
window.alert(t("alerts.couldNotCreateShareableLink")); window.alert(t("alerts.couldNotCreateShareableLink"));
} }
+8 -8
View File
@@ -20,7 +20,7 @@ export const saveUsernameToLocalStorage = (username: string) => {
STORAGE_KEYS.LOCAL_STORAGE_COLLAB, STORAGE_KEYS.LOCAL_STORAGE_COLLAB,
JSON.stringify({ username }), JSON.stringify({ username }),
); );
} catch (error) { } catch (error: any) {
// Unable to access window.localStorage // Unable to access window.localStorage
console.error(error); console.error(error);
} }
@@ -32,7 +32,7 @@ export const importUsernameFromLocalStorage = (): string | null => {
if (data) { if (data) {
return JSON.parse(data).username; return JSON.parse(data).username;
} }
} catch (error) { } catch (error: any) {
// Unable to access localStorage // Unable to access localStorage
console.error(error); console.error(error);
} }
@@ -53,7 +53,7 @@ export const saveToLocalStorage = (
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)), JSON.stringify(clearAppStateForLocalStorage(appState)),
); );
} catch (error) { } catch (error: any) {
// Unable to access window.localStorage // Unable to access window.localStorage
console.error(error); console.error(error);
} }
@@ -66,7 +66,7 @@ export const importFromLocalStorage = () => {
try { try {
savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS); savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
} catch (error) { } catch (error: any) {
// Unable to access localStorage // Unable to access localStorage
console.error(error); console.error(error);
} }
@@ -75,7 +75,7 @@ export const importFromLocalStorage = () => {
if (savedElements) { if (savedElements) {
try { try {
elements = clearElementsForLocalStorage(JSON.parse(savedElements)); elements = clearElementsForLocalStorage(JSON.parse(savedElements));
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
// Do nothing because elements array is already empty // Do nothing because elements array is already empty
} }
@@ -90,7 +90,7 @@ export const importFromLocalStorage = () => {
JSON.parse(savedState) as Partial<AppState>, JSON.parse(savedState) as Partial<AppState>,
), ),
}; };
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
// Do nothing because appState is already null // Do nothing because appState is already null
} }
@@ -103,7 +103,7 @@ export const getElementsStorageSize = () => {
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS); const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
const elementsSize = elements?.length || 0; const elementsSize = elements?.length || 0;
return elementsSize; return elementsSize;
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
return 0; return 0;
} }
@@ -122,7 +122,7 @@ export const getTotalStorageSize = () => {
const librarySize = library?.length || 0; const librarySize = library?.length || 0;
return appStateSize + collabSize + librarySize + getElementsStorageSize(); return appStateSize + collabSize + librarySize + getElementsStorageSize();
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
return 0; return 0;
} }
+25 -22
View File
@@ -64,7 +64,7 @@ import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
import { getMany, set, del, keys, createStore } from "idb-keyval"; import { getMany, set, del, keys, createStore } from "idb-keyval";
import { FileManager, updateStaleImageStatuses } from "./data/FileManager"; import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
import { mutateElement } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks"; import { isInitializedImageElement } from "../element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase"; import { loadFilesFromFirebase } from "./data/firebase";
@@ -109,7 +109,7 @@ const localFileStorage = new FileManager({
try { try {
await set(id, fileData, filesStore); await set(id, fileData, filesStore);
savedFiles.set(id, true); savedFiles.set(id, true);
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
erroredFiles.set(id, true); erroredFiles.set(id, true);
} }
@@ -163,7 +163,7 @@ const initializeScene = async (opts: {
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id"); const id = searchParams.get("id");
const jsonBackendMatch = window.location.hash.match( const jsonBackendMatch = window.location.hash.match(
/^#json=([0-9]+),([a-zA-Z0-9_-]+)$/, /^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
); );
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/); const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
@@ -184,10 +184,7 @@ const initializeScene = async (opts: {
// otherwise, prompt whether user wants to override current scene // otherwise, prompt whether user wants to override current scene
window.confirm(t("alerts.loadSceneOverridePrompt")) window.confirm(t("alerts.loadSceneOverridePrompt"))
) { ) {
// Backwards compatibility with legacy url format if (jsonBackendMatch) {
if (id) {
scene = await loadScene(id, null, localDataState);
} else if (jsonBackendMatch) {
scene = await loadScene( scene = await loadScene(
jsonBackendMatch[1], jsonBackendMatch[1],
jsonBackendMatch[2], jsonBackendMatch[2],
@@ -228,7 +225,7 @@ const initializeScene = async (opts: {
) { ) {
return { scene: data, isExternalScene }; return { scene: data, isExternalScene };
} }
} catch (error) { } catch (error: any) {
return { return {
scene: { scene: {
appState: { appState: {
@@ -276,7 +273,10 @@ const PlusLinkJSX = (
const ExcalidrawWrapper = () => { const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const currentLangCode = languageDetector.detect() || defaultLang.code; let currentLangCode = languageDetector.detect() || defaultLang.code;
if (Array.isArray(currentLangCode)) {
currentLangCode = currentLangCode[0];
}
const [langCode, setLangCode] = useState(currentLangCode); const [langCode, setLangCode] = useState(currentLangCode);
// initial state // initial state
@@ -286,7 +286,8 @@ const ExcalidrawWrapper = () => {
promise: ResolvablePromise<ImportedDataState | null>; promise: ResolvablePromise<ImportedDataState | null>;
}>({ promise: null! }); }>({ promise: null! });
if (!initialStatePromiseRef.current.promise) { if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>(); initialStatePromiseRef.current.promise =
resolvablePromise<ImportedDataState | null>();
} }
useEffect(() => { useEffect(() => {
@@ -296,10 +297,8 @@ const ExcalidrawWrapper = () => {
}, VERSION_TIMEOUT); }, VERSION_TIMEOUT);
}, []); }, []);
const [ const [excalidrawAPI, excalidrawRefCallback] =
excalidrawAPI, useCallbackRefState<ExcalidrawImperativeAPI>();
excalidrawRefCallback,
] = useCallbackRefState<ExcalidrawImperativeAPI>();
const collabAPI = useContext(CollabContext)?.api; const collabAPI = useContext(CollabContext)?.api;
@@ -378,8 +377,8 @@ const ExcalidrawWrapper = () => {
JSON.parse( JSON.parse(
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string, localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
) || []; ) || [];
} catch (e) { } catch (error: any) {
console.error(e); console.error(error);
} }
}; };
@@ -460,16 +459,17 @@ const ExcalidrawWrapper = () => {
if (excalidrawAPI) { if (excalidrawAPI) {
let didChange = false; let didChange = false;
let pendingImageElement = appState.pendingImageElement;
const elements = excalidrawAPI const elements = excalidrawAPI
.getSceneElementsIncludingDeleted() .getSceneElementsIncludingDeleted()
.map((element) => { .map((element) => {
if (localFileStorage.shouldUpdateImageElementStatus(element)) { if (localFileStorage.shouldUpdateImageElementStatus(element)) {
didChange = true; didChange = true;
return mutateElement( const newEl = newElementWith(element, { status: "saved" });
element, if (pendingImageElement === element) {
{ status: "saved" }, pendingImageElement = newEl;
/* informMutation */ false, }
); return newEl;
} }
return element; return element;
}); });
@@ -477,6 +477,9 @@ const ExcalidrawWrapper = () => {
if (didChange) { if (didChange) {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements, elements,
appState: {
pendingImageElement,
},
}); });
} }
} }
@@ -505,7 +508,7 @@ const ExcalidrawWrapper = () => {
}, },
files, files,
); );
} catch (error) { } catch (error: any) {
if (error.name !== "AbortError") { if (error.name !== "AbortError") {
const { width, height } = canvas; const { width, height } = canvas;
console.error(error, { width, height }); console.error(error, { width, height });
+1 -1
View File
@@ -67,7 +67,7 @@ export const nvector = (value: number = 0, index: number = 0): NVector => {
if (value !== 0) { if (value !== 0) {
result[index] = value; result[index] = value;
} }
return (result as unknown) as NVector; return result as unknown as NVector;
}; };
const STRING_EPSILON = 0.000001; const STRING_EPSILON = 0.000001;
+1 -1
View File
@@ -36,7 +36,7 @@ export const orthogonalThrough = (against: Point, intersection: Point): Line =>
export const parallel = (line: Line, distance: number): Line => { export const parallel = (line: Line, distance: number): Line => {
const result = line.slice(); const result = line.slice();
result[1] -= distance; result[1] -= distance;
return (result as unknown) as Line; return result as unknown as Line;
}; };
export const parallelThrough = (line: Line, point: Point): Line => export const parallelThrough = (line: Line, point: Point): Line =>
+17 -10
View File
@@ -1,3 +1,5 @@
// import type {PngChunk} from "./types";
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Document { interface Document {
fonts?: { fonts?: {
@@ -19,7 +21,6 @@ interface Window {
// https://github.com/facebook/create-react-app/blob/ddcb7d5/packages/react-scripts/lib/react-app.d.ts // https://github.com/facebook/create-react-app/blob/ddcb7d5/packages/react-scripts/lib/react-app.d.ts
declare namespace NodeJS { declare namespace NodeJS {
interface ProcessEnv { interface ProcessEnv {
readonly REACT_APP_BACKEND_V1_GET_URL: string;
readonly REACT_APP_BACKEND_V2_GET_URL: string; readonly REACT_APP_BACKEND_V2_GET_URL: string;
readonly REACT_APP_BACKEND_V2_POST_URL: string; readonly REACT_APP_BACKEND_V2_POST_URL: string;
readonly REACT_APP_SOCKET_SERVER_URL: string; readonly REACT_APP_SOCKET_SERVER_URL: string;
@@ -49,13 +50,12 @@ type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
type MarkNonNullable<T, K extends keyof T> = { type MarkNonNullable<T, K extends keyof T> = {
[P in K]-?: P extends K ? NonNullable<T[P]> : T[P]; [P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
} & } & { [P in keyof T]: T[P] };
{ [P in keyof T]: T[P] };
type NonOptional<T> = Exclude<T, undefined>;
// PNG encoding/decoding // PNG encoding/decoding
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
type TEXtChunk = { name: "tEXt"; data: Uint8Array };
declare module "png-chunk-text" { declare module "png-chunk-text" {
function encode( function encode(
name: string, name: string,
@@ -64,11 +64,11 @@ declare module "png-chunk-text" {
function decode(data: Uint8Array): { keyword: string; text: string }; function decode(data: Uint8Array): { keyword: string; text: string };
} }
declare module "png-chunks-encode" { declare module "png-chunks-encode" {
function encode(chunks: TEXtChunk[]): Uint8Array; function encode(chunks: import("./types").PngChunk[]): Uint8Array;
export = encode; export = encode;
} }
declare module "png-chunks-extract" { declare module "png-chunks-extract" {
function extract(buffer: Uint8Array): TEXtChunk[]; function extract(buffer: Uint8Array): import("./types").PngChunk[];
export = extract; export = extract;
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -102,19 +102,26 @@ declare module "*.scss";
// (due to TS structural typing) // (due to TS structural typing)
// https://github.com/microsoft/TypeScript/issues/31311#issuecomment-490690695 // https://github.com/microsoft/TypeScript/issues/31311#issuecomment-490690695
interface ArrayBuffer { interface ArrayBuffer {
private _brand?: "ArrayBuffer"; _brand?: "ArrayBuffer";
} }
interface Uint8Array { interface Uint8Array {
private _brand?: "Uint8Array"; _brand?: "Uint8Array";
} }
// --------------------------------------------------------------------------— // --------------------------------------------------------------------------—
// https://github.com/nodeca/image-blob-reduce/issues/23#issuecomment-783271848 // https://github.com/nodeca/image-blob-reduce/issues/23#issuecomment-783271848
declare module "image-blob-reduce" { declare module "image-blob-reduce" {
import { PicaResizeOptions } from "pica"; import { PicaResizeOptions, Pica } from "pica";
namespace ImageBlobReduce { namespace ImageBlobReduce {
interface ImageBlobReduce { interface ImageBlobReduce {
toBlob(file: File, options: ImageBlobReduceOptions): Promise<Blob>; toBlob(file: File, options: ImageBlobReduceOptions): Promise<Blob>;
_create_blob(
this: { pica: Pica },
env: {
out_canvas: HTMLCanvasElement;
out_blob: Blob;
},
): Promise<any>;
} }
interface ImageBlobReduceStatic { interface ImageBlobReduceStatic {
+5 -2
View File
@@ -105,7 +105,10 @@ const findPartsForData = (data: any, parts: string[]) => {
return data; return data;
}; };
export const t = (path: string, replacement?: { [key: string]: string }) => { export const t = (
path: string,
replacement?: { [key: string]: string | number },
) => {
if (currentLang.code.startsWith(TEST_LANG_CODE)) { if (currentLang.code.startsWith(TEST_LANG_CODE)) {
const name = replacement const name = replacement
? `${path}(${JSON.stringify(replacement).slice(1, -1)})` ? `${path}(${JSON.stringify(replacement).slice(1, -1)})`
@@ -123,7 +126,7 @@ export const t = (path: string, replacement?: { [key: string]: string }) => {
if (replacement) { if (replacement) {
for (const key in replacement) { for (const key in replacement) {
translation = translation.replace(`{{${key}}}`, replacement[key]); translation = translation.replace(`{{${key}}}`, String(replacement[key]));
} }
} }
return translation; return translation;
+20 -7
View File
@@ -20,10 +20,6 @@
"background": "الخلفية", "background": "الخلفية",
"fill": "التعبئة", "fill": "التعبئة",
"strokeWidth": "سُمك الخط", "strokeWidth": "سُمك الخط",
"strokeShape": "شكل الخط",
"strokeShape_gel": "قلم جل",
"strokeShape_fountain": "قلم رش",
"strokeShape_brush": "فرشاه",
"strokeStyle": "نمط الخط", "strokeStyle": "نمط الخط",
"strokeStyle_solid": "كامل", "strokeStyle_solid": "كامل",
"strokeStyle_dashed": "متقطع", "strokeStyle_dashed": "متقطع",
@@ -39,6 +35,7 @@
"arrowhead_arrow": "سهم", "arrowhead_arrow": "سهم",
"arrowhead_bar": "شريط", "arrowhead_bar": "شريط",
"arrowhead_dot": "نقطة", "arrowhead_dot": "نقطة",
"arrowhead_triangle": "مثلث",
"fontSize": "حجم الخط", "fontSize": "حجم الخط",
"fontFamily": "نوع الخط", "fontFamily": "نوع الخط",
"onlySelected": "المحدد فقط", "onlySelected": "المحدد فقط",
@@ -136,7 +133,9 @@
"darkMode": "الوضع المظلم", "darkMode": "الوضع المظلم",
"lightMode": "الوضع المضيء", "lightMode": "الوضع المضيء",
"zenMode": "وضع التأمل", "zenMode": "وضع التأمل",
"exitZenMode": "إلغاء الوضع الليلى" "exitZenMode": "إلغاء الوضع الليلى",
"cancel": "إلغاء",
"clear": "مسح"
}, },
"alerts": { "alerts": {
"clearReset": "هذا سيُزيل كامل اللوحة. هل أنت متأكد؟", "clearReset": "هذا سيُزيل كامل اللوحة. هل أنت متأكد؟",
@@ -154,14 +153,22 @@
"errorAddingToLibrary": "تعذر إضافة العنصر للمكتبة", "errorAddingToLibrary": "تعذر إضافة العنصر للمكتبة",
"errorRemovingFromLibrary": "تعذر إزالة العنصر من المكتبة", "errorRemovingFromLibrary": "تعذر إزالة العنصر من المكتبة",
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟", "confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟", "imageDoesNotContainScene": "",
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة", "cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.", "invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟", "resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل." "invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
}, },
"errors": {
"unsupportedFileType": "نوع الملف غير مدعوم.",
"imageInsertError": "تعذر إدراج الصورة. حاول مرة أخرى لاحقاً...",
"fileTooBig": "الملف كبير جداً. الحد الأقصى المسموح به للحجم هو {{maxSize}}.",
"svgImageInsertError": "تعذر إدراج صورة SVG. يبدو أن ترميز SVG غير صحيح.",
"invalidSVGString": ""
},
"toolBar": { "toolBar": {
"selection": "تحديد", "selection": "تحديد",
"image": "إدراج صورة",
"rectangle": "مستطيل", "rectangle": "مستطيل",
"diamond": "مضلع", "diamond": "مضلع",
"ellipse": "دائرة", "ellipse": "دائرة",
@@ -178,6 +185,7 @@
"shapes": "الأشكال" "shapes": "الأشكال"
}, },
"hints": { "hints": {
"canvasPanning": "",
"linearElement": "انقر لبدء نقاط متعددة، اسحب لخط واحد", "linearElement": "انقر لبدء نقاط متعددة، اسحب لخط واحد",
"freeDraw": "انقر واسحب، افرج عند الانتهاء", "freeDraw": "انقر واسحب، افرج عند الانتهاء",
"text": "نصيحة: يمكنك أيضًا إضافة نص بالنقر المزدوج في أي مكان بأداة الاختيار", "text": "نصيحة: يمكنك أيضًا إضافة نص بالنقر المزدوج في أي مكان بأداة الاختيار",
@@ -186,10 +194,12 @@
"linearElementMulti": "انقر فوق النقطة الأخيرة أو اضغط على Esc أو Enter للإنهاء", "linearElementMulti": "انقر فوق النقطة الأخيرة أو اضغط على Esc أو Enter للإنهاء",
"lockAngle": "يمكنك تقييد الزاوية بالضغط على SHIFT", "lockAngle": "يمكنك تقييد الزاوية بالضغط على SHIFT",
"resize": "يمكنك تقييد النسب بالضغط على SHIFT أثناء تغيير الحجم،\nاضغط على ALT لتغيير الحجم من المركز", "resize": "يمكنك تقييد النسب بالضغط على SHIFT أثناء تغيير الحجم،\nاضغط على ALT لتغيير الحجم من المركز",
"resizeImage": "يمكنك تغيير الحجم بحرية بالضغط بأستمرار على SHIFT،\nاضغط بأستمرار على ALT أيضا لتغيير الحجم من المركز",
"rotate": "يمكنك تقييد الزوايا من خلال الضغط على SHIFT أثناء الدوران", "rotate": "يمكنك تقييد الزوايا من خلال الضغط على SHIFT أثناء الدوران",
"lineEditor_info": "انقر نقراً مزدوجاً أو اضغط Enter لتعديل النقاط", "lineEditor_info": "انقر نقراً مزدوجاً أو اضغط Enter لتعديل النقاط",
"lineEditor_pointSelected": "اضغط على حذف لإزالة النقطة، Ctrl Or Cmd+D للتكرار، أو اسحب للانتقال", "lineEditor_pointSelected": "اضغط على حذف لإزالة النقطة، Ctrl Or Cmd+D للتكرار، أو اسحب للانتقال",
"lineEditor_nothingSelected": "حدد نقطة لتحريك أو إزالتها، أو اضغط Alt ثم انقر لإضافة نقاط جديدة" "lineEditor_nothingSelected": "حدد نقطة لتحريك أو إزالتها، أو اضغط Alt ثم انقر لإضافة نقاط جديدة",
"placeImage": ""
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "تعذر عرض المعاينة", "cannotShowPreview": "تعذر عرض المعاينة",
@@ -256,6 +266,9 @@
"zoomToFit": "تكبير للملائمة", "zoomToFit": "تكبير للملائمة",
"zoomToSelection": "تكبير للعنصر المحدد" "zoomToSelection": "تكبير للعنصر المحدد"
}, },
"clearCanvasDialog": {
"title": ""
},
"encrypted": { "encrypted": {
"tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا.", "tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا.",
"link": "مشاركة المدونة في التشفير من النهاية إلى النهاية في Excalidraw" "link": "مشاركة المدونة في التشفير من النهاية إلى النهاية في Excalidraw"
+20 -7
View File
@@ -20,10 +20,6 @@
"background": "Фон", "background": "Фон",
"fill": "Наситеност", "fill": "Наситеност",
"strokeWidth": "Ширина на щриха", "strokeWidth": "Ширина на щриха",
"strokeShape": "",
"strokeShape_gel": "",
"strokeShape_fountain": "",
"strokeShape_brush": "",
"strokeStyle": "Стил на линия", "strokeStyle": "Стил на линия",
"strokeStyle_solid": "Плътен", "strokeStyle_solid": "Плътен",
"strokeStyle_dashed": "Пунктир", "strokeStyle_dashed": "Пунктир",
@@ -39,6 +35,7 @@
"arrowhead_arrow": "Стрелка", "arrowhead_arrow": "Стрелка",
"arrowhead_bar": "Връх на стрелката", "arrowhead_bar": "Връх на стрелката",
"arrowhead_dot": "Точка", "arrowhead_dot": "Точка",
"arrowhead_triangle": "",
"fontSize": "Размер на шрифта", "fontSize": "Размер на шрифта",
"fontFamily": "Семейство шрифтове", "fontFamily": "Семейство шрифтове",
"onlySelected": "Само избраното", "onlySelected": "Само избраното",
@@ -136,7 +133,9 @@
"darkMode": "Тъмен режим", "darkMode": "Тъмен режим",
"lightMode": "Светъл режим", "lightMode": "Светъл режим",
"zenMode": "Режим Zen", "zenMode": "Режим Zen",
"exitZenMode": "Спиране на Zen режим" "exitZenMode": "Спиране на Zen режим",
"cancel": "",
"clear": ""
}, },
"alerts": { "alerts": {
"clearReset": "Това ще изчисти цялото платно. Сигурни ли сте?", "clearReset": "Това ще изчисти цялото платно. Сигурни ли сте?",
@@ -154,14 +153,22 @@
"errorAddingToLibrary": "", "errorAddingToLibrary": "",
"errorRemovingFromLibrary": "", "errorRemovingFromLibrary": "",
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?", "confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
"imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?", "imageDoesNotContainScene": "",
"cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл", "cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл",
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": ""
}, },
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": { "toolBar": {
"selection": "Селекция", "selection": "Селекция",
"image": "",
"rectangle": "Правоъгълник", "rectangle": "Правоъгълник",
"diamond": "Диамант", "diamond": "Диамант",
"ellipse": "Елипс", "ellipse": "Елипс",
@@ -178,6 +185,7 @@
"shapes": "Фигури" "shapes": "Фигури"
}, },
"hints": { "hints": {
"canvasPanning": "",
"linearElement": "Кликнете, за да стартирате няколко точки, плъзнете за една линия", "linearElement": "Кликнете, за да стартирате няколко точки, плъзнете за една линия",
"freeDraw": "Натиснете и влачете, пуснете като сте готови", "freeDraw": "Натиснете и влачете, пуснете като сте готови",
"text": "Подсказка: Можете също да добавите текст като натиснете някъде два път с инструмента за селекция", "text": "Подсказка: Можете също да добавите текст като натиснете някъде два път с инструмента за селекция",
@@ -186,10 +194,12 @@
"linearElementMulti": "Кликнете върху последната точка или натиснете Escape или Enter, за да завършите", "linearElementMulti": "Кликнете върху последната точка или натиснете Escape или Enter, за да завършите",
"lockAngle": "Можете да ограничите ъгъла, като задържите SHIFT", "lockAngle": "Можете да ограничите ъгъла, като задържите SHIFT",
"resize": "Може да ограничите при преоразмеряване като задържите SHIFT,\nзадръжте ALT за преоразмерите през центъра", "resize": "Може да ограничите при преоразмеряване като задържите SHIFT,\nзадръжте ALT за преоразмерите през центъра",
"resizeImage": "",
"rotate": "Можете да ограничите ъглите, като държите SHIFT, докато се въртите", "rotate": "Можете да ограничите ъглите, като държите SHIFT, докато се въртите",
"lineEditor_info": "Кликнете два пъти или натиснете Enter за да промените точките", "lineEditor_info": "Кликнете два пъти или натиснете Enter за да промените точките",
"lineEditor_pointSelected": "Натиснете Delete за да изтриете точка, CtrlOrCmd+D за дуплициране, или извлачете за да преместите", "lineEditor_pointSelected": "Натиснете Delete за да изтриете точка, CtrlOrCmd+D за дуплициране, или извлачете за да преместите",
"lineEditor_nothingSelected": "Изберете точка за местене или изтриване, или пък задръжте Alt и натиснете за да добавите нови точки" "lineEditor_nothingSelected": "Изберете точка за местене или изтриване, или пък задръжте Alt и натиснете за да добавите нови точки",
"placeImage": ""
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "Невъзможност за показване на preview", "cannotShowPreview": "Невъзможност за показване на preview",
@@ -256,6 +266,9 @@
"zoomToFit": "Приближи докато се виждат всички елементи", "zoomToFit": "Приближи докато се виждат всички елементи",
"zoomToSelection": "Приближи селекцията" "zoomToSelection": "Приближи селекцията"
}, },
"clearCanvasDialog": {
"title": ""
},
"encrypted": { "encrypted": {
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат.", "tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат.",
"link": "" "link": ""
+347
View File
@@ -0,0 +1,347 @@
{
"labels": {
"paste": "",
"pasteCharts": "",
"selectAll": "",
"multiSelect": "",
"moveCanvas": "",
"cut": "",
"copy": "",
"copyAsPng": "",
"copyAsSvg": "",
"bringForward": "",
"sendToBack": "",
"bringToFront": "",
"sendBackward": "",
"delete": "",
"copyStyles": "",
"pasteStyles": "",
"stroke": "",
"background": "",
"fill": "",
"strokeWidth": "",
"strokeStyle": "",
"strokeStyle_solid": "",
"strokeStyle_dashed": "",
"strokeStyle_dotted": "",
"sloppiness": "",
"opacity": "",
"textAlign": "",
"edges": "",
"sharp": "",
"round": "",
"arrowheads": "",
"arrowhead_none": "",
"arrowhead_arrow": "",
"arrowhead_bar": "",
"arrowhead_dot": "",
"arrowhead_triangle": "",
"fontSize": "",
"fontFamily": "",
"onlySelected": "",
"withBackground": "",
"exportEmbedScene": "",
"exportEmbedScene_details": "",
"addWatermark": "",
"handDrawn": "",
"normal": "",
"code": "",
"small": "",
"medium": "",
"large": "",
"veryLarge": "",
"solid": "",
"hachure": "",
"crossHatch": "",
"thin": "",
"bold": "",
"left": "",
"center": "",
"right": "",
"extraBold": "",
"architect": "",
"artist": "",
"cartoonist": "",
"fileTitle": "",
"colorPicker": "",
"canvasBackground": "",
"drawingCanvas": "",
"layers": "",
"actions": "",
"language": "",
"liveCollaboration": "",
"duplicateSelection": "",
"untitled": "",
"name": "",
"yourName": "",
"madeWithExcalidraw": "",
"group": "",
"ungroup": "",
"collaborators": "",
"showGrid": "",
"addToLibrary": "",
"removeFromLibrary": "",
"libraryLoadingMessage": "",
"libraries": "",
"loadingScene": "",
"align": "",
"alignTop": "",
"alignBottom": "",
"alignLeft": "",
"alignRight": "",
"centerVertically": "",
"centerHorizontally": "",
"distributeHorizontally": "",
"distributeVertically": "",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "",
"toggleExportColorScheme": "",
"share": "",
"showStroke": "",
"showBackground": "",
"toggleTheme": ""
},
"buttons": {
"clearReset": "",
"exportJSON": "",
"exportImage": "",
"export": "",
"exportToPng": "",
"exportToSvg": "",
"copyToClipboard": "",
"copyPngToClipboard": "",
"scale": "",
"save": "",
"saveAs": "",
"load": "",
"getShareableLink": "",
"close": "",
"selectLanguage": "",
"scrollBackToContent": "",
"zoomIn": "",
"zoomOut": "",
"resetZoom": "",
"menu": "",
"done": "",
"edit": "",
"undo": "",
"redo": "",
"resetLibrary": "",
"createNewRoom": "",
"fullScreen": "",
"darkMode": "",
"lightMode": "",
"zenMode": "",
"exitZenMode": "",
"cancel": "",
"clear": ""
},
"alerts": {
"clearReset": "",
"couldNotCreateShareableLink": "",
"couldNotCreateShareableLinkTooBig": "",
"couldNotLoadInvalidFile": "",
"importBackendFailed": "",
"cannotExportEmptyCanvas": "",
"couldNotCopyToClipboard": "",
"decryptFailed": "",
"uploadedSecurly": "",
"loadSceneOverridePrompt": "",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "",
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "",
"imageDoesNotContainScene": "",
"cannotRestoreFromImage": "",
"invalidSceneUrl": "",
"resetLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": {
"selection": "",
"image": "",
"rectangle": "",
"diamond": "",
"ellipse": "",
"arrow": "",
"line": "",
"freedraw": "",
"text": "",
"library": "",
"lock": ""
},
"headings": {
"canvasActions": "",
"selectedShapeActions": "",
"shapes": ""
},
"hints": {
"canvasPanning": "",
"linearElement": "",
"freeDraw": "",
"text": "",
"text_selected": "",
"text_editing": "",
"linearElementMulti": "",
"lockAngle": "",
"resize": "",
"resizeImage": "",
"rotate": "",
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "",
"canvasTooBig": "",
"canvasTooBigTip": ""
},
"errorSplash": {
"headingMain_pre": "",
"headingMain_button": "",
"clearCanvasMessage": "",
"clearCanvasMessage_button": "",
"clearCanvasCaveat": "",
"trackedToSentry_pre": "",
"trackedToSentry_post": "",
"openIssueMessage_pre": "",
"openIssueMessage_button": "",
"openIssueMessage_post": "",
"sceneContent": ""
},
"roomDialog": {
"desc_intro": "",
"desc_privacy": "",
"button_startSession": "",
"button_stopSession": "",
"desc_inProgressIntro": "",
"desc_shareLink": "",
"desc_exitSession": "",
"shareTitle": ""
},
"errorDialog": {
"title": ""
},
"exportDialog": {
"disk_title": "",
"disk_details": "",
"disk_button": "",
"link_title": "",
"link_details": "",
"link_button": "",
"excalidrawplus_description": "",
"excalidrawplus_button": "",
"excalidrawplus_exportError": ""
},
"helpDialog": {
"blog": "",
"click": "",
"curvedArrow": "",
"curvedLine": "",
"documentation": "",
"doubleClick": "",
"drag": "",
"editor": "",
"editSelectedShape": "",
"github": "",
"howto": "",
"or": "",
"preventBinding": "",
"shapes": "",
"shortcuts": "",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": ""
},
"clearCanvasDialog": {
"title": ""
},
"encrypted": {
"tooltip": "",
"link": ""
},
"stats": {
"angle": "",
"element": "",
"elements": "",
"height": "",
"scene": "",
"selected": "",
"storage": "",
"title": "",
"total": "",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": ""
},
"toast": {
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "",
"fileSaved": "",
"fileSavedToFilename": "",
"canvas": "",
"selection": ""
},
"colors": {
"ffffff": "",
"f8f9fa": "",
"f1f3f5": "",
"fff5f5": "",
"fff0f6": "",
"f8f0fc": "",
"f3f0ff": "",
"edf2ff": "",
"e7f5ff": "",
"e3fafc": "",
"e6fcf5": "",
"ebfbee": "",
"f4fce3": "",
"fff9db": "",
"fff4e6": "",
"transparent": "",
"ced4da": "",
"868e96": "",
"fa5252": "",
"e64980": "",
"be4bdb": "",
"7950f2": "",
"4c6ef5": "",
"228be6": "",
"15aabf": "",
"12b886": "",
"40c057": "",
"82c91e": "",
"fab005": "",
"fd7e14": "",
"000000": "",
"343a40": "",
"495057": "",
"c92a2a": "",
"a61e4d": "",
"862e9c": "",
"5f3dc4": "",
"364fc7": "",
"1864ab": "",
"0b7285": "",
"087f5b": "",
"2b8a3e": "",
"5c940d": "",
"e67700": "",
"d9480f": ""
}
}
+46 -33
View File
@@ -20,10 +20,6 @@
"background": "Color del fons", "background": "Color del fons",
"fill": "Estil del fons", "fill": "Estil del fons",
"strokeWidth": "Amplada del traç", "strokeWidth": "Amplada del traç",
"strokeShape": "Estil del traç",
"strokeShape_gel": "Bolígraf de gel",
"strokeShape_fountain": "Bolígraf de font",
"strokeShape_brush": "Bolígraf de raspall",
"strokeStyle": "Estil del traç", "strokeStyle": "Estil del traç",
"strokeStyle_solid": "Sòlid", "strokeStyle_solid": "Sòlid",
"strokeStyle_dashed": "Guions", "strokeStyle_dashed": "Guions",
@@ -39,6 +35,7 @@
"arrowhead_arrow": "Fletxa", "arrowhead_arrow": "Fletxa",
"arrowhead_bar": "Barra", "arrowhead_bar": "Barra",
"arrowhead_dot": "Punt", "arrowhead_dot": "Punt",
"arrowhead_triangle": "",
"fontSize": "Mida de lletra", "fontSize": "Mida de lletra",
"fontFamily": "Tipus de lletra", "fontFamily": "Tipus de lletra",
"onlySelected": "Només seleccionats", "onlySelected": "Només seleccionats",
@@ -136,7 +133,9 @@
"darkMode": "Mode fosc", "darkMode": "Mode fosc",
"lightMode": "Mode clar", "lightMode": "Mode clar",
"zenMode": "Mode zen", "zenMode": "Mode zen",
"exitZenMode": "Surt de mode zen" "exitZenMode": "Surt de mode zen",
"cancel": "",
"clear": ""
}, },
"alerts": { "alerts": {
"clearReset": "S'esborrarà tot el llenç. N'esteu segur?", "clearReset": "S'esborrarà tot el llenç. N'esteu segur?",
@@ -154,14 +153,22 @@
"errorAddingToLibrary": "No s'ha pogut afegir l'element a la biblioteca", "errorAddingToLibrary": "No s'ha pogut afegir l'element a la biblioteca",
"errorRemovingFromLibrary": "No s'ha pogut eliminar l'element de la biblioteca", "errorRemovingFromLibrary": "No s'ha pogut eliminar l'element de la biblioteca",
"confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?", "confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?",
"imageDoesNotContainScene": "En aquest moment no sadmet la importació dimatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada descena. Ho has activat durant l'exportació?", "imageDoesNotContainScene": "",
"cannotRestoreFromImage": "Lescena no sha pogut restaurar des daquest fitxer dimatge", "cannotRestoreFromImage": "Lescena no sha pogut restaurar des daquest fitxer dimatge",
"invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.", "invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.",
"resetLibrary": "Això buidarà la biblioteca. N'esteu segur?", "resetLibrary": "Això buidarà la biblioteca. N'esteu segur?",
"invalidEncryptionKey": "" "invalidEncryptionKey": ""
}, },
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": { "toolBar": {
"selection": "Selecció", "selection": "Selecció",
"image": "",
"rectangle": "Rectangle", "rectangle": "Rectangle",
"diamond": "Rombe", "diamond": "Rombe",
"ellipse": "El·lipse", "ellipse": "El·lipse",
@@ -178,6 +185,7 @@
"shapes": "Formes" "shapes": "Formes"
}, },
"hints": { "hints": {
"canvasPanning": "",
"linearElement": "Feu clic per a dibuixar múltiples punts; arrossegueu per a una sola línia", "linearElement": "Feu clic per a dibuixar múltiples punts; arrossegueu per a una sola línia",
"freeDraw": "Feu clic i arrossegueu, deixeu anar per a finalitzar", "freeDraw": "Feu clic i arrossegueu, deixeu anar per a finalitzar",
"text": "Consell: també podeu afegir text fent doble clic en qualsevol lloc amb l'eina de selecció", "text": "Consell: també podeu afegir text fent doble clic en qualsevol lloc amb l'eina de selecció",
@@ -186,10 +194,12 @@
"linearElementMulti": "Feu clic a l'ultim punt, o pitgeu Esc o Retorn per a finalitzar", "linearElementMulti": "Feu clic a l'ultim punt, o pitgeu Esc o Retorn per a finalitzar",
"lockAngle": "Per restringir els angles, mantenir premut el majúscul (SHIFT)", "lockAngle": "Per restringir els angles, mantenir premut el majúscul (SHIFT)",
"resize": "Per restringir les proporcions mentres es canvia la mida, mantenir premut el majúscul (SHIFT); per canviar la mida des del centre, mantenir premut ALT", "resize": "Per restringir les proporcions mentres es canvia la mida, mantenir premut el majúscul (SHIFT); per canviar la mida des del centre, mantenir premut ALT",
"resizeImage": "",
"rotate": "Per restringir els angles mentre gira, mantenir premut el majúscul (SHIFT)", "rotate": "Per restringir els angles mentre gira, mantenir premut el majúscul (SHIFT)",
"lineEditor_info": "Fes doble clic o premi Enter per editar punts", "lineEditor_info": "Fes doble clic o premi Enter per editar punts",
"lineEditor_pointSelected": "Premeu Suprimir per a eliminar el punt, CtrlOrCmd+D per a duplicar-lo, o arrossegueu-lo per a moure'l", "lineEditor_pointSelected": "Premeu Suprimir per a eliminar el punt, CtrlOrCmd+D per a duplicar-lo, o arrossegueu-lo per a moure'l",
"lineEditor_nothingSelected": "Selecciona un punt per moure o eliminar, o manté premut Alt i fes clic per afegir punts nous" "lineEditor_nothingSelected": "Selecciona un punt per moure o eliminar, o manté premut Alt i fes clic per afegir punts nous",
"placeImage": ""
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "No es pot mostrar la previsualització", "cannotShowPreview": "No es pot mostrar la previsualització",
@@ -256,6 +266,9 @@
"zoomToFit": "Zoom per veure tots els elements", "zoomToFit": "Zoom per veure tots els elements",
"zoomToSelection": "Zoom per veure la selecció" "zoomToSelection": "Zoom per veure la selecció"
}, },
"clearCanvasDialog": {
"title": ""
},
"encrypted": { "encrypted": {
"tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors dExcalidraw no els veuran mai.", "tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors dExcalidraw no els veuran mai.",
"link": "Article del blog sobre encriptació d'extrem a extrem a Excalidraw" "link": "Article del blog sobre encriptació d'extrem a extrem a Excalidraw"
@@ -285,50 +298,50 @@
"selection": "la selecció" "selection": "la selecció"
}, },
"colors": { "colors": {
"ffffff": "", "ffffff": "Blanc",
"f8f9fa": "", "f8f9fa": "Gris 0",
"f1f3f5": "", "f1f3f5": "Gris 1",
"fff5f5": "", "fff5f5": "Vermell 0",
"fff0f6": "", "fff0f6": "Rosa 0",
"f8f0fc": "", "f8f0fc": "",
"f3f0ff": "", "f3f0ff": "",
"edf2ff": "", "edf2ff": "",
"e7f5ff": "", "e7f5ff": "Blau 0",
"e3fafc": "", "e3fafc": "",
"e6fcf5": "", "e6fcf5": "",
"ebfbee": "", "ebfbee": "Verd 0",
"f4fce3": "", "f4fce3": "",
"fff9db": "", "fff9db": "Groc 0",
"fff4e6": "", "fff4e6": "",
"transparent": "", "transparent": "Transparent",
"ced4da": "", "ced4da": "Gris 4",
"868e96": "", "868e96": "Gris 6",
"fa5252": "", "fa5252": "Vermell 6",
"e64980": "", "e64980": "Rosa 6",
"be4bdb": "", "be4bdb": "",
"7950f2": "", "7950f2": "",
"4c6ef5": "", "4c6ef5": "",
"228be6": "", "228be6": "Blau 6",
"15aabf": "", "15aabf": "",
"12b886": "", "12b886": "",
"40c057": "", "40c057": "Verd 6",
"82c91e": "", "82c91e": "",
"fab005": "", "fab005": "Groc 6",
"fd7e14": "", "fd7e14": "Taronja 6",
"000000": "", "000000": "Negre",
"343a40": "", "343a40": "Gris 8",
"495057": "", "495057": "Gris 7",
"c92a2a": "", "c92a2a": "Vermell 9",
"a61e4d": "", "a61e4d": "Rosa 9",
"862e9c": "", "862e9c": "",
"5f3dc4": "", "5f3dc4": "",
"364fc7": "", "364fc7": "",
"1864ab": "", "1864ab": "Blau 9",
"0b7285": "", "0b7285": "",
"087f5b": "", "087f5b": "",
"2b8a3e": "", "2b8a3e": "Verd 9",
"5c940d": "", "5c940d": "",
"e67700": "", "e67700": "Groc 9",
"d9480f": "" "d9480f": "Taronja 9"
} }
} }
+19 -6
View File
@@ -20,10 +20,6 @@
"background": "Pozadí", "background": "Pozadí",
"fill": "Výplň", "fill": "Výplň",
"strokeWidth": "Šířka obrysu", "strokeWidth": "Šířka obrysu",
"strokeShape": "Tvar tahu",
"strokeShape_gel": "Gelové pero",
"strokeShape_fountain": "Plnicí pero",
"strokeShape_brush": "Fixa",
"strokeStyle": "Styl tahu", "strokeStyle": "Styl tahu",
"strokeStyle_solid": "Plný", "strokeStyle_solid": "Plný",
"strokeStyle_dashed": "Čárkovaný", "strokeStyle_dashed": "Čárkovaný",
@@ -39,6 +35,7 @@
"arrowhead_arrow": "Šipka", "arrowhead_arrow": "Šipka",
"arrowhead_bar": "Kóta", "arrowhead_bar": "Kóta",
"arrowhead_dot": "Tečka", "arrowhead_dot": "Tečka",
"arrowhead_triangle": "",
"fontSize": "Velikost písma", "fontSize": "Velikost písma",
"fontFamily": "Písmo", "fontFamily": "Písmo",
"onlySelected": "Pouze vybrané", "onlySelected": "Pouze vybrané",
@@ -136,7 +133,9 @@
"darkMode": "Tmavý režim", "darkMode": "Tmavý režim",
"lightMode": "Světlý režim", "lightMode": "Světlý režim",
"zenMode": "Zen mód", "zenMode": "Zen mód",
"exitZenMode": "Opustit zen mód" "exitZenMode": "Opustit zen mód",
"cancel": "",
"clear": ""
}, },
"alerts": { "alerts": {
"clearReset": "", "clearReset": "",
@@ -160,8 +159,16 @@
"resetLibrary": "", "resetLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": ""
}, },
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": { "toolBar": {
"selection": "Výběr", "selection": "Výběr",
"image": "",
"rectangle": "Obdélník", "rectangle": "Obdélník",
"diamond": "Diamant", "diamond": "Diamant",
"ellipse": "Elipsa", "ellipse": "Elipsa",
@@ -178,6 +185,7 @@
"shapes": "Tvary" "shapes": "Tvary"
}, },
"hints": { "hints": {
"canvasPanning": "",
"linearElement": "", "linearElement": "",
"freeDraw": "", "freeDraw": "",
"text": "", "text": "",
@@ -186,10 +194,12 @@
"linearElementMulti": "", "linearElementMulti": "",
"lockAngle": "", "lockAngle": "",
"resize": "", "resize": "",
"resizeImage": "",
"rotate": "", "rotate": "",
"lineEditor_info": "", "lineEditor_info": "",
"lineEditor_pointSelected": "", "lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "" "lineEditor_nothingSelected": "",
"placeImage": ""
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "", "cannotShowPreview": "",
@@ -256,6 +266,9 @@
"zoomToFit": "", "zoomToFit": "",
"zoomToSelection": "" "zoomToSelection": ""
}, },
"clearCanvasDialog": {
"title": ""
},
"encrypted": { "encrypted": {
"tooltip": "", "tooltip": "",
"link": "" "link": ""
+19 -6
View File
@@ -20,10 +20,6 @@
"background": "Baggrund", "background": "Baggrund",
"fill": "", "fill": "",
"strokeWidth": "Linjebredde", "strokeWidth": "Linjebredde",
"strokeShape": "Linjeform",
"strokeShape_gel": "",
"strokeShape_fountain": "",
"strokeShape_brush": "",
"strokeStyle": "", "strokeStyle": "",
"strokeStyle_solid": "", "strokeStyle_solid": "",
"strokeStyle_dashed": "", "strokeStyle_dashed": "",
@@ -39,6 +35,7 @@
"arrowhead_arrow": "Pil", "arrowhead_arrow": "Pil",
"arrowhead_bar": "", "arrowhead_bar": "",
"arrowhead_dot": "", "arrowhead_dot": "",
"arrowhead_triangle": "",
"fontSize": "", "fontSize": "",
"fontFamily": "", "fontFamily": "",
"onlySelected": "", "onlySelected": "",
@@ -136,7 +133,9 @@
"darkMode": "Mørk tilstand", "darkMode": "Mørk tilstand",
"lightMode": "Lys baggrund", "lightMode": "Lys baggrund",
"zenMode": "", "zenMode": "",
"exitZenMode": "" "exitZenMode": "",
"cancel": "",
"clear": ""
}, },
"alerts": { "alerts": {
"clearReset": "", "clearReset": "",
@@ -160,8 +159,16 @@
"resetLibrary": "", "resetLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": ""
}, },
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": ""
},
"toolBar": { "toolBar": {
"selection": "", "selection": "",
"image": "",
"rectangle": "", "rectangle": "",
"diamond": "", "diamond": "",
"ellipse": "", "ellipse": "",
@@ -178,6 +185,7 @@
"shapes": "" "shapes": ""
}, },
"hints": { "hints": {
"canvasPanning": "",
"linearElement": "", "linearElement": "",
"freeDraw": "Klik og træk, slip når du er færdig", "freeDraw": "Klik og træk, slip når du er færdig",
"text": "", "text": "",
@@ -186,10 +194,12 @@
"linearElementMulti": "", "linearElementMulti": "",
"lockAngle": "", "lockAngle": "",
"resize": "", "resize": "",
"resizeImage": "",
"rotate": "", "rotate": "",
"lineEditor_info": "", "lineEditor_info": "",
"lineEditor_pointSelected": "", "lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "" "lineEditor_nothingSelected": "",
"placeImage": ""
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "", "cannotShowPreview": "",
@@ -256,6 +266,9 @@
"zoomToFit": "", "zoomToFit": "",
"zoomToSelection": "" "zoomToSelection": ""
}, },
"clearCanvasDialog": {
"title": ""
},
"encrypted": { "encrypted": {
"tooltip": "", "tooltip": "",
"link": "" "link": ""

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