Compare commits

..

251 Commits

Author SHA1 Message Date
Daniel J. Geiger
c93e2fa9ce Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-10-06 19:09:35 -05:00
David Luzar
47ee8a0094 refactor: point() -> pointFrom() to fix compiler issue (#8578) 2024-10-01 21:27:17 +02:00
Subhadeep Sengupta
a977dd1bf5 feat: Added reddit links as embeddable (#8099)
feat: #8063 Added reddit links as embeddable

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2024-09-28 11:49:18 +05:30
Aakansha Doshi
3fe1883f3f feat: prefer user defined coords and dimensions over calculated for for frame (#8517)
* feat: prefer user defined coords and dimensions over calculated for frame

* update changelog

* lint

* show the info only in dev mode and when children present
2024-09-24 21:09:15 +05:30
Marcel Mraz
a80cb5896a feat: self-hosting existing google fonts (#8540) 2024-09-24 17:30:21 +02:00
David Luzar
6dfa18414a test: decrease min coverage thresholds (#8541) 2024-09-24 12:01:28 +00:00
David Luzar
8ca4cf3260 feat: flip arrowheads if only arrow(s) selected (#8525)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2024-09-19 15:46:36 +02:00
Márk Tolmács
f3f0ab7c83 fix: Elbow arrow fixedpoint flipping now properly flips on inverted resize and flip action (#8324)
* Flipping action now properly mirrors selections with elbow arrows
* Flipping action now re-centers the selection to the original center to avoid "walking" selections on repeated flipping
2024-09-19 08:47:23 +02:00
David Luzar
44a1c8d857 fix: svg and png frame clipping cases (#8515) 2024-09-18 00:20:22 +02:00
Márk Tolmács
e0a22edfbd fix: Re-route elbow arrows when pasted (#8448)
Re-route elbow arrows when pasted
2024-09-17 12:20:40 +02:00
Márk Tolmács
c07f5a0c80 feat: Common elbow mid segments (#8440)
Common start or end segment length for elbow arrows regardless of arrowhead is present
2024-09-17 10:11:07 +02:00
David Luzar
508f16dc04 refactor: rename example App.tsx -> ExampleApp.tsx (#8501) 2024-09-13 16:56:32 +02:00
zsviczian
c1b310c56b fix: Buffer dependency (#8474)
* fix Buffer dependency

* moved to encode.ts

* move base64 parsing out

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-12 15:48:47 +02:00
zsviczian
d4900e8f19 fix: Linear element complete button disabled (#8492) 2024-09-12 14:59:38 +02:00
zsviczian
caf2db934c fix: aspect ratio of distorted images are not preserved in SVG exports (#8061) 2024-09-12 14:11:08 +02:00
zsviczian
60e3801691 fix: WYSIWYG editor padding is not normalized with zoom.value (#8481) 2024-09-12 13:42:39 +02:00
David Luzar
80f3b75d42 chore: revert vite 5.4.2 -> 5.0.12 (#8499) 2024-09-12 12:56:45 +02:00
hocino
dc812bee19 docs: replace dead link (#8494)
* docs update dead link on main-menu page

* doc: fix dead link
2024-09-12 13:20:18 +05:30
David Luzar
01e83cc9a5 fix: default sidebar triggers & behavior (#8498) 2024-09-11 20:19:14 +02:00
David Luzar
813f9b702e feat: merge search sidebar back to default sidebar (#8497) 2024-09-11 19:26:01 +02:00
David Luzar
fd39712ba6 fix: improve canvas search scroll behavior further (#8491) 2024-09-11 18:01:18 +02:00
zsviczian
b46ca0192b fix: addFiles clears the whole image cache when each file is added - regression from #8471 (#8490)
Update App.tsx
2024-09-11 07:57:41 +02:00
David Luzar
72b7c937b1 feat: smarter zooming when scrolling to match & only match on search/switch (#8488) 2024-09-10 21:26:01 +02:00
David Luzar
d107215564 fix: select instead of focus search input (#8483) 2024-09-09 19:57:22 +02:00
Ryan Di
6959a363f0 feat: canvas search (#8438)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-09 17:12:07 +02:00
Abhishek Mehandiratta
5a11c70714 fix: image rendering issue when passed in initialData (#8471) 2024-09-08 23:56:00 +02:00
Aakansha Doshi
6ff56c36e3 fix: add partial mocking (#8473)
* fix: add partial mocking

* lint

* Update packages/utils/export.test.ts
2024-09-06 16:41:37 +05:30
Aakansha Doshi
51ea184938 build: upgrade vite to 5.4.x, vitest to 2.x and related vite packages (#8459)
* build: upgrade vite to 5.x, vitest to 2.x and related vite packages

* upgrade vitest-ui and coverage

* pass empty set to fix type error and update snap

* set ignoreEmptyLines to false

* update threshold

* update coverage threshold

* downgrade vite-plugin-pwa as its better to push separately with testing

* add package resolutions for strip-ansi, string-width and wrap-ansi

* disable pwa

* only add resolution for strip-ansi
2024-09-05 18:35:36 +05:30
dependabot[bot]
21fff26d31 build(deps): bump micromatch from 4.0.5 to 4.0.8 in /dev-docs (#8458)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-04 15:46:37 +05:30
Márk Tolmács
f4dd23fc31 chore: Unify math types, utils and functions (#8389)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-02 22:23:38 +00:00
dependabot[bot]
e3d1dee9d0 build(deps): bump micromatch from 4.0.7 to 4.0.8 (#8450)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-02 00:37:49 +02:00
hocino
e3f31df747 docs : update dead link on main-menu page (#8454) 2024-09-01 22:31:13 +00:00
David Luzar
60e75406e0 refactor: remove unused env variable (#8457) 2024-09-02 00:25:12 +02:00
zsviczian
b396e07b90 fix: PropertiesPopover maxWidth changing fixed units to relative units (#8456) 2024-09-01 18:18:37 +02:00
zsviczian
2d1d84a47b fix: View mode wheel zooming does not work (#8452)
* Update App.tsx

* fix: prevent zooming browser over DOM

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-31 12:06:38 +02:00
Marcel Mraz
ee30225062 feat: subset font glyphs for SVG export (#8384)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-30 22:26:06 +02:00
David Luzar
16cae4fc07 feat: reset copyStatus on export dialog settings change (#8443) 2024-08-29 15:10:08 +02:00
David Luzar
576bc0dbe5 feat: tweak copy button success animation (#8441) 2024-08-29 00:43:01 +02:00
David Luzar
00af35c692 feat: enable panning/zoom while in wysiwyg (#8437) 2024-08-29 00:42:46 +02:00
Márk Tolmács
ea7c702cfc feat: Visual debugger (#8344)
Add visual debugger to the Excalidraw app (only).
2024-08-27 19:46:00 +02:00
spc-28
26d2296578 fix: fixed copy to clipboard button (#8426)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-27 00:27:44 +02:00
zsviczian
afb68a6467 feat: improve elbow arrow keyboard move (#8392) 2024-08-26 15:58:54 +02:00
zsviczian
b459e5cfd2 fix: context menu does not work after after dragging on StatsDragInput (#8386)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-26 15:58:46 +02:00
David Luzar
5facc0d6da fix: perf regression in getCommonBounds (#8429) 2024-08-26 15:57:28 +02:00
Paul Tune
824ad603e1 docs: Update function call in @excalidraw/mermaid-to-excalidraw (#8420) 2024-08-26 13:20:29 +02:00
Ryan Di
5e1ff7cafe perf: improve new element drawing (#8340)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-23 20:27:57 +02:00
David Luzar
b5d7f5b4ba feat: rewrite d2c to not require token (#8269) 2024-08-20 18:06:22 +02:00
David Luzar
fb4bb29aa5 fix: object snapping not working (#8381) 2024-08-15 18:48:25 +02:00
David Luzar
3cfcc7b489 feat: split gridSize from enabled state & support custom gridStep (#8364) 2024-08-14 14:59:14 +02:00
David Luzar
4320a3cf41 feat: improve zoom-to-content when creating flowchart (#8368) 2024-08-12 20:42:08 +02:00
Márk Tolmács
8420e1aa13 fix: Reimplement rectangle intersection (#8367) 2024-08-12 19:19:16 +02:00
Márk Tolmács
5daf1a1b4e fix: Round coordinates and sizes for rectangle intersection (#8366)
Round coordinates and sizes for rectangle intersection
2024-08-12 11:07:58 +02:00
David Luzar
97981804d7 feat: Stats popup style tweaks (#8361) 2024-08-11 19:33:44 +02:00
Clarence Chan
f7b3befd0a fix: text content with tab characters act different in view/edit (#8336)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-09 20:20:36 +00:00
DDDDD12138
7b2bee9746 chore: remove unused parameter (#8355) 2024-08-09 21:39:40 +02:00
David Luzar
88014ace4a fix: drawing from 0-dimension canvas (#8356) 2024-08-09 21:36:04 +02:00
David Luzar
87a9430809 fix: disable flowchart keybindings inside inputs (#8353) 2024-08-09 18:44:17 +02:00
Márk Tolmács
99b91c46f7 fix: Yet more patching of intersect code (#8352)
* Yet more patching of intersect code
2024-08-09 17:33:12 +02:00
David Luzar
1ea5b26f25 fix: missing act() in flowchart tests (#8354) 2024-08-09 17:27:02 +02:00
Clarence Chan
d5f4ee7b3f fix: z-index change by one causes app to freeze (#8314) 2024-08-09 15:26:08 +02:00
Márk Tolmács
261304c1a4 fix: Patch over intersection calculation issue (#8350)
* Patch over intersection calculation issue
2024-08-09 14:40:57 +02:00
Márk Tolmács
84398a7e5c fix: Point duplication in LEE on ALT+click (#8347) 2024-08-09 12:01:32 +02:00
Ryan Di
54491d13d4 feat: create flowcharts from a generic element using elbow arrows (#8329)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-08 21:43:15 +02:00
Márk Tolmács
dd1370381d chore: Refactor and remove scene from elbow arrow generation (#8342)
* Refactor and remove scene from elbow arrow generation
2024-08-08 14:06:26 +02:00
Márk Tolmács
72d6ee48fc fix: Do not allow resizing unbound elbow arrows either (#8333)
* Fix resizing of unbound elbow arrows
2024-08-06 17:33:34 +02:00
David Luzar
232242d2e9 test: skip test.yml in PRs (#8330) 2024-08-06 16:04:04 +02:00
David Luzar
f19ce30dfe chore: bump @testing-library/react 12.1.5 -> 16.0.0 (#8322) 2024-08-06 15:17:42 +02:00
Ryan Di
3cf14c73a3 refactor: rename draggingElement -> newElement (#8294)
* add newElement to appState

* freedraw should not be an editing element

* do not set editing element for freedraw and generic

* remove ununsed `appState.draggingElement`

* remove setting dragged for new linear element

* decouple selection element from new element

* fix hint for text bindables

* update snapshot

* fixes

* fix frame regressions

* add comments to types

* document `editingElement`

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-06 19:26:06 +08:00
Márk Tolmács
8d530cf102 fix: Docker build in CI (#8312)
* Fix Docker build CI
* Bump nginx-alpine version to 1.27
2024-08-06 13:21:20 +02:00
David Luzar
b87925d253 build: add example apps public and vite dev-dist to eslintignore (#8326) 2024-08-05 23:43:48 +02:00
b1sar
a6684b09f2 docs: fix typos in props.mdx (#8325) 2024-08-05 21:41:19 +00:00
David Luzar
b427617f66 build: add rm:build, rm:node_modules & clean-install scripts (#8323) 2024-08-05 23:37:33 +02:00
dependabot[bot]
2907fbe34b build(deps): bump @excalidraw/excalidraw from 0.17.0 to 0.17.6 in /dev-docs (#7908)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 09:49:57 +02:00
zsviczian
c67815f7b0 fix: Duplicating arrow without bound elements throws error (#8316)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2024-08-03 16:37:36 +00:00
Márk Tolmács
c641860cb1 fix: CVE-2023-45133 (#7988)
* Upgrade @babel/* versions to 7.24 to ensure non-vulnerable Babel versions
* Pinning React version to 18.2.0 exactly, avoiding test-utils type version clashes
* Fix warning message on yarn start
* Moving react to peer dependencies
* Moving app dependencies from workspace into app
* Bump vitest to 1.6.0 to fix history.test.tsx breaking

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-08-02 14:44:38 +02:00
Marcel Mraz
84d89b9a8a fix: throttle fractional indices validation (#8306) 2024-08-02 11:55:15 +02:00
David Luzar
e63dd025c9 fix: allow binding elbow arrows to frame children (#8309) 2024-08-01 19:40:54 +02:00
Márk Tolmács
15e019706d feat: Orthogonal (elbow) arrows for diagramming (#8299)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-01 18:39:03 +02:00
Aakansha Doshi
a133a70e87 build: update release script to build esm (#8308) 2024-08-01 20:19:37 +05:30
Marcel Mraz
80ea7ca23f fix: skip registering font faces for local fonts (#8303) 2024-08-01 11:32:16 +02:00
David Luzar
e844580b14 feat: remove automatic frame naming (#8302) 2024-07-31 13:56:11 +02:00
Marcel Mraz
5a0771ad9c fix: load fonts for exportToCanvas (#8298) 2024-07-30 17:23:35 +02:00
Marcel Mraz
adcdbe2907 fix: re-add Cascadia Code with ligatures (#8291) 2024-07-30 11:15:11 +02:00
Marcel Mraz
230d0edc44 feat: multiple fonts fallbacks (#8286) 2024-07-30 10:34:40 +02:00
Marcel Mraz
d0a380758e feat: ability to debug the state of fractional indices (#8235) 2024-07-30 10:03:27 +02:00
Ryan Di
7b36de0476 fix: linear elements not selected on pointer up from hitting its bound text (#8285)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-07-27 13:02:00 +00:00
David Luzar
2427e622b0 feat: improve mermaid detection on paste (#8287) 2024-07-27 12:36:54 +02:00
Daniel J. Geiger
039562cd61 Fix test failures from May merge. 2024-07-26 20:03:02 -05:00
Daniel J. Geiger
629cd307fd Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-07-26 20:02:06 -05:00
Marcel Mraz
62228e0bbb feat: introduce font picker (#8012)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-07-25 18:55:55 +02:00
DDDDD12138
4c5408263c chore: Correct Typos in Code Comments (#8268)
chore: correct typos

Co-authored-by: wuzhiqing <wuzhiqing@linklogis.com>
2024-07-23 14:26:55 +05:30
Aakansha Doshi
bd7b778f41 perf: cache the temp canvas created for labeled arrows (#8267)
* perf: cache the temp canvas created for labeled arrows

* use allEleemntsMap so bound text element can be retrieved when editing

* remove logs

* fix rotation

* pass isRotating

* feat: cache `element.angle` instead of relying on `appState.isRotating`

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-07-23 11:17:32 +05:30
David Luzar
43b2476dfe fix: revert default element canvas padding change (#8266) 2024-07-22 11:47:16 +02:00
BlueGreenMagick
df8875a497 fix: freedraw jittering (#8238) 2024-07-14 08:44:47 +00:00
David Luzar
6fbc44fd1f fix: messed up env variable (#8231) 2024-07-11 14:33:35 +02:00
Aakansha Doshi
d25a7d365b feat: upgrade mermaid-to-excalidraw to v1.1.0 (#8226)
* feat: upgrade mermaid-to-excalidraw to v1.1.0

* fixes

* upgrade and remove config as its redundant

* lint

* upgrade to v1.1.0
2024-07-10 20:57:43 +05:30
David Luzar
e52c2cd0b6 fix: log allowed events (#8224) 2024-07-09 12:16:14 +02:00
David Luzar
96eeec5119 feat: bump max file size (#8220) 2024-07-08 18:35:13 +02:00
Hamir Mahal
f5221d521b ci: upgrade gh actions checkout and setup-node to v4 (#8168)
fix: usage of `node12 which is deprecated`
2024-07-08 14:26:25 +05:30
Alexandre Lemoine
db2c235cd4 Fix : exportToCanvas() doc example (#8127) 2024-07-08 08:52:05 +00:00
David Luzar
148b895f46 feat: smarter preferred lang detection (#8205) 2024-07-04 17:55:35 +02:00
DDDDD12138
d9258a736b chore: Consolidate i18n import in LanguageList component (#8201) 2024-07-04 17:34:16 +02:00
zsviczian
2e1f08c796 fix: memory leak - scene.destroy() and window.launchQueue (#8198) 2024-07-02 22:08:02 +02:00
David Luzar
1d5b41dabb fix: stop updating text versions on init (#8191) 2024-07-01 14:04:58 +02:00
Márk Tolmács
66a2f24296 fix: Add binding update to manual stat changes (#8183)
Manual stats changes now respect previous element bindings.
2024-07-01 09:45:31 +02:00
Márk Tolmács
04668d8263 fix: Binding after duplicating is now applied for both the old and duplicate shapes (#8185)
Using ALT/OPT + drag to clone does not transfer the bindings (or leaves the duplicates in place of the old one , which are also not bound).

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-06-28 15:28:48 +02:00
David Luzar
abbeed3d5f feat: support Stats bound text fontSize editing (#8187) 2024-06-28 13:52:29 +02:00
Márk Tolmács
ba8c09d529 fix: Incorrect point offsetting in LinearElementEditor.movePoints() (#8145)
The LinearElementEditor.movePoints() function incorrectly calculates the offset for local linear element points when multiple targetPoints are provided, one of those target points is index === 0 AND the other points are moved in the negative direction, and ending up with negative local coordinates.

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-06-28 12:23:10 +02:00
David Luzar
744b3e5d09 fix: stats state leaking & race conds (#8177) 2024-06-26 23:31:08 +02:00
Esteban Romo
6ba9bd60e8 feat: allow props.initialData to be a function (#8135) 2024-06-24 11:36:49 +02:00
zsviczian
a1ffa064df fix: only bind arrow (#8152)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-06-19 10:55:35 +02:00
David Luzar
4dc4590f24 fix: repair invalid binding on restore & fix type check (#8133) 2024-06-13 19:42:08 +02:00
Ryan Di
d2f67e619f feat: editable element stats (#6382)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-06-12 19:49:46 +02:00
David Luzar
22b39277f5 feat: paste as mermaid if applicable (#8116) 2024-06-11 19:19:22 +02:00
Sunil
63dee03ef0 docs: remove extra braces in callback JSX (#8087)
Fix: Syantax error
2024-05-31 10:48:31 +00:00
David Luzar
08b13f971d fix: wysiwyg blur-submit on mobile (#8075) 2024-05-28 16:18:02 +02:00
David Luzar
69f4cc70cb feat: stop autoselecting text on text edit on mobile (#8076) 2024-05-28 16:17:52 +02:00
Ryan Di
860308eb27 feat: create new text with width (#8038)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-05-28 15:53:52 +02:00
David Luzar
4eb9463f26 fix: restore linear dimensions from points (#8062) 2024-05-27 10:36:58 +02:00
Aakansha Doshi
6ed6131169 build: run tests on master branch (#8072)
* build: run tests on master branch

* lint
2024-05-27 12:44:20 +05:30
David Luzar
1ed98f9c93 fix: lp plus url (#8056) 2024-05-24 09:10:14 +00:00
David Luzar
a71bb63d1f fix: fix twitter og image (#8050) 2024-05-23 11:52:37 +02:00
Marcel Mraz
661d6a4a75 fix: flaky snapshot tests with floating point precision issues (#8049) 2024-05-23 11:51:01 +02:00
David Luzar
defd34923a docs: fix updateScene storeAction default tsdoc & document types (#8048) 2024-05-22 13:40:23 +02:00
Ryan Di
c540bd68aa feat: wrap long text when pasting (#8026)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-05-21 16:56:09 +02:00
Marcel Mraz
eddbe55f50 fix: always re-generate index of defined moved elements (#8040) 2024-05-20 23:23:42 +02:00
Aakansha Doshi
2f9526da24 feat: upgrade to mermaid-to-excalidraw v1 🚀 (#8022)
* feat: upgrade to mermaid-to-excalidraw v1 🚀

* upgrade to v1
2024-05-20 11:19:38 +05:30
David Luzar
1b6e3fe05b feat: rerender canvas on focus (#8035) 2024-05-19 22:20:40 +02:00
VatsalSoni_13
afe52c89a7 fix: undo/redo when exiting view mode (#8024)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-05-19 15:54:52 +02:00
zsviczian
be4e127f6c fix: Two finger panning is slow (#7849)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-05-19 14:23:43 +02:00
Karthik Nishanth
ff0b4394b1 feat: add missing type="button" (#8030) 2024-05-18 08:36:08 +00:00
Hey
7d8b7fc14d fix: compatible safari layers button svg (#8020)
Co-authored-by: ysen <ysen.ge@hairobotics.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-05-15 15:22:05 +02:00
Ryan Di
971b4d4ae6 feat: text wrapping (#7999)
* resize single elements from the side

* fix lint

* do not resize texts from the sides (for we want to wrap/unwrap)

* omit side handles for frames too

* upgrade types

* enable resizing from the sides for multiple elements as well

* fix lint

* maintain aspect ratio when elements are not of the same angle

* lint

* always resize proportionally for multiple elements

* increase side resizing padding

* code cleanup

* adaptive handles

* do not resize for linear elements with only two points

* prioritize point dragging over edge resizing

* lint

* allow free resizing for multiple elements at degree 0

* always resize from the sides

* reduce hit threshold

* make small multiple elements movable

* lint

* show side handles on touch screen and mobile devices

* differentiate touchscreens

* keep proportional with text in multi-element resizing

* update snapshot

* update multi elements resizing logic

* lint

* reduce side resizing padding

* bound texts do not scale in normal cases

* lint

* test sides for texts

* wrap text

* do not update text size when changing its alignment

* keep text wrapped/unwrapped when editing

* change wrapped size to auto size from context menu

* fix test

* lint

* increase min width for wrapped texts

* wrap wrapped text in container

* unwrap when binding text to container

* rename `wrapped` to `autoResize`

* fix lint

* revert: use `center` align when wrapping text in container

* update snaps

* fix lint

* simplify logic on autoResize

* lint and test

* snapshots

* remove unnecessary code

* snapshots

* fix: defaults not set correctly

* tests for wrapping texts when resized

* tests for text wrapping when edited

* fix autoResize refactor

* include autoResize flag check

* refactor

* feat: rename action label & change contextmenu position

* fix: update version on `autoResize` action

* fix infinite loop when editing text in a container

* simplify

* always maintain `width` if `!autoResize`

* maintain `x` if `!autoResize`

* maintain `y` pos after fontSize change if `!autoResize`

* refactor

* when editing, do not wrap text in textWysiwyg

* simplify text editor

* make test more readable

* comment

* rename action to match file name

* revert function signature change

* only update  in app

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-05-15 21:04:53 +08:00
David Luzar
cc4c51996c build: specify packageManager field (#8010) 2024-05-14 10:45:27 +02:00
Guillaume Grossetie
79257a1923 fix: correctly resolve the package version (#8016)
The property name is `VITE_PKG_VERSION` (not `PKG_VERSION`)

Resolves #7984
2024-05-14 13:31:02 +05:30
Marcel Mraz
dc66261c19 fix: re-introduce wysiwyg width offset (#8014) 2024-05-13 17:38:21 +02:00
David Luzar
273ba803d9 fix: font not rendered correctly on init (#8002) 2024-05-10 16:37:46 +02:00
Daniel J. Geiger
81e3dd5406 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-05-08 18:41:06 -05:00
David Luzar
301e83805d feat: add install-PWA to command palette (#7935) 2024-05-08 22:02:28 +02:00
David Luzar
ed5ce8d3de fix: command palette filter (#7981) 2024-05-08 17:56:05 +02:00
Aakansha Doshi
1ed53b153c build: enable consistent type imports eslint rule (#7992)
* build: enable consistent type imports eslint rule

* change to warn

* fix the warning in example and excalidraw-app

* fix packages

* enable type annotations and throw error for the rule
2024-05-08 14:21:50 +05:30
Aakansha Doshi
c1926f33bb fix: remove unused param from drawImagePlaceholder (#7991) 2024-05-07 20:22:51 +05:30
Andreas Gebhardt
6539029d2a fix: docker build of Excalidraw app (#7430)
* fix: docker build of Excalidraw app

Fixes #7403.

* deps: update (container) Nginx to 1.24

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2024-05-07 18:00:58 +05:30
David Luzar
d1f37cc64f feat: tweak a few icons & add line editor button to side panel (#7990) 2024-05-07 13:18:39 +02:00
Márk Tolmács
f0d25e34c3 chore: Add lcov coverage output to vitest (#7987)
chore: Add lcov coverage output to vitest so VSCode coverage gutters extension works

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-05-06 17:35:23 +02:00
Márk Tolmács
d9bbf1eda6 feat: Allow binding only via linear element ends (#7946)
Arrows now only bind to new shapes if their start or end point is dragged close to them. Arrows previously bound to shapes remain bound on move and drag if at the end of the drag/move the points remain in the original shapes' binding area.

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Sammy Lee <sammy.joe.lee@gmail.com>
2024-05-02 08:32:12 +02:00
Márk Tolmács
f79fb9aae2 chore: Bump vitest to 1.5.3 to support VSCode vitest Extension (#7968)
Bump vitest to 1.5.3 to support VSCode vitest Extension

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-05-01 20:47:52 +02:00
Mritunjay Goutam
275f6fbe24 fix: typo in doc api (#7466) 2024-04-30 16:52:42 +00:00
Ryan Di
88812e0386 feat: resize elements from the sides (#7855)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-04-30 18:05:00 +02:00
Marcel Mraz
6e5aeb112d feat: record freedraw tool selection to history (#7949) 2024-04-25 17:24:05 +00:00
Marcel Mraz
4d83d1c91e fix: use Reflect API instead of Object.hasOwn (#7958) 2024-04-25 15:36:26 +02:00
Márk Tolmács
a04676d423 fix: CTRL/CMD & arrow point drag unbinds both sides (#6459) (#7877) 2024-04-23 00:01:05 +02:00
Milos Vetesnik
c851aaaf7b fix: z-index for laser pointer to be able to draw on embeds and such (#7918) 2024-04-22 23:53:55 +02:00
Marcel Mraz
1bd2b1fe55 feat: export reconciliation (#7917) 2024-04-22 17:27:57 +02:00
Marcel Mraz
015b46ab23 feat: expose StoreAction in relation to multiplayer history (#7898)
Improved Store API and improved handling of actions to eliminate potential concurrency issues
2024-04-22 09:22:25 +00:00
Daniel J. Geiger
704bbd6e0f fix: Missed in merging 2024-02-18 18:53:25 -06:00
Daniel J. Geiger
bff220e0f5 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-02-18 18:52:00 -06:00
Daniel J. Geiger
ce595ff18c chore: Clean up src/i18n.ts changes 2023-12-02 19:07:48 -06:00
Daniel J. Geiger
1dfadb4d26 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-12-02 14:50:21 -06:00
Daniel J. Geiger
3fb902f1d8 Resolve merge conflicts 2023-11-22 16:18:53 -06:00
DanielJGeiger
63a91a883f feat: Expose ActionManager.registerAction through ExcalidrawImperativeAPI (#6995)
* feat: Expose `ActionManager` through `ExcalidrawImperativeAPI`

* Only expose `registerAction` instead of `ActionManager`
2023-11-22 16:02:06 -06:00
Daniel J. Geiger
5164bdb782 refactor: Remove some hard-coding in the MathJax subtype. 2023-11-22 15:50:07 -06:00
Daniel J. Geiger
9642a6e756 fix: Use a narrower type for custom shortcut names and reduce hard-coding. 2023-11-22 14:47:53 -06:00
Daniel J. Geiger
f71ded4bf9 fix: An empty island would render if no subtypes were registered. 2023-11-22 11:29:43 -06:00
Daniel J. Geiger
3aa1365acb fix: Distinguish subtype toggles from regular tools. 2023-11-22 11:19:34 -06:00
Daniel J. Geiger
00691631d8 fix: Narrow the type of Action.name while still allowing custom names 2023-11-18 10:56:17 -06:00
Daniel J. Geiger
cbb349e34b Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-11-18 09:36:19 -06:00
Daniel J. Geiger
453757756d Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-11-18 09:35:29 -06:00
Daniel J. Geiger
c456c1e713 fix: Only expose registerAction instead of ActionManager in the API 2023-11-03 19:12:46 -05:00
Daniel J. Geiger
daf305af34 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-11-03 19:11:26 -05:00
Daniel J. Geiger
6966a1022c fix: Don't filter element.subtype in restoreElementWithProperties.
Subtypes are dynamically registered and may vary across deployments.
2023-10-14 14:24:05 -05:00
Daniel J. Geiger
fc7ea757b2 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-10-14 13:05:07 -05:00
Daniel J. Geiger
e5934f23c0 Re-add the MathJax patch for Vite. 2023-10-14 12:27:23 -05:00
Daniel J. Geiger
1cad91ca5f Temporarily drop the MathJax patch. 2023-10-14 12:23:23 -05:00
Daniel J. Geiger
6b2e5516ca Refactor the MathJax patch for Vite. 2023-10-04 18:59:50 -05:00
Daniel J. Geiger
dd4bf91128 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-10-04 18:38:51 -05:00
Daniel J. Geiger
ef0fcc1537 refactor: Replace the useSubtypes selection hook with a generic useSubtype hook 2023-09-23 15:54:27 -05:00
Daniel J. Geiger
ec26aeead2 refactor: Refactor and add a test 2023-09-22 17:33:34 -05:00
Daniel J. Geiger
62f5475c4a Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-22 15:19:21 -05:00
Daniel J. Geiger
7225915b82 fix: 4d6d6cf1 had a line-height regression for sufficiently short math symbols 2023-09-22 14:34:44 -05:00
Daniel J. Geiger
8eb3191b3f refactor: Move MathJax into src/element/subtypes for the
`excalidraw-app` separation, maintaining lazy-loading of MathJax.
2023-09-22 14:25:15 -05:00
Daniel J. Geiger
4d6d6cf129 fix: Text-only measurements off by a pixel 2023-09-22 10:17:51 -05:00
Daniel J. Geiger
208285b7ba Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-17 15:40:45 -05:00
Daniel J. Geiger
372a4868da chore: Only use transform-origin in the text editor if rendered
dimensions don't match the editor dimensions.
2023-09-15 13:40:46 -05:00
Daniel J. Geiger
05800d8599 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-15 10:52:15 -05:00
Daniel J. Geiger
1f496d9f64 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-11 19:22:29 -05:00
Daniel J. Geiger
e0221ddf20 fix: Inform scenes of mutations when a subtype finishes loading. 2023-09-10 16:49:06 -05:00
Daniel J. Geiger
1bd86942f3 refactor: Simplify a file. 2023-09-10 16:47:29 -05:00
Daniel J. Geiger
fd9a172da9 refactor: Relocate a type definition. 2023-09-08 13:12:50 -05:00
Daniel J. Geiger
1f9847ed98 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-08 10:31:19 -05:00
Daniel J. Geiger
4e4802b19e chore: Don't bundle #6050 or #5511. 2023-09-01 14:30:52 -05:00
Daniel J. Geiger
23eb08088e chore: Drop @excalidraw/extensions and move the MathJax subtype into
`src/excalidraw-app/subtypes` to leave `@excalidraw/excalidraw` untouched.

`@excalidraw/extensions` mostly contained boilerplate and obscured the
main new features here: `ExcalidrawElement` subtypes and MathJax support.
2023-09-01 13:40:27 -05:00
Daniel J. Geiger
e8a6053251 Revert "Add a semicolon."
This reverts commit 456433e8f0.
2023-08-24 11:11:11 -05:00
Daniel J. Geiger
456433e8f0 Add a semicolon. 2023-08-24 10:35:12 -05:00
Daniel J. Geiger
38e3a4e8e1 fix: Further patch AsciiMath to work with Vite in production mode also. 2023-08-24 10:16:04 -05:00
Daniel J. Geiger
27a8cda8fd fix: Patch AsciiMath to work with Vite.
Incorporates PR mathjax/MathJax-src#854 by @masx200.
2023-08-23 11:27:12 -05:00
Daniel J. Geiger
dd5053149a @excalidraw/extensions: Fixes for Vite. 2023-08-22 16:18:28 -05:00
Daniel J. Geiger
40ec02b280 chore: Update @excalidraw/extensions configs. 2023-08-22 09:54:13 -05:00
Daniel J. Geiger
b81aa19ff9 fix: Migrate @excalidraw/extensions environment variable names to Vite. 2023-08-22 08:51:01 -05:00
Daniel J. Geiger
e4ddd08bb1 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-08-21 16:09:37 -05:00
Daniel J. Geiger
795176b256 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-06-15 14:36:09 -05:00
Daniel J. Geiger
be057bde39 MathJax: Use $ as LaTeX delimiters. Fall back to \( and \) if detected. Interpret \$ as a text literal "$" sign. 2023-06-15 13:56:33 -05:00
Daniel J. Geiger
94f4b727bb Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-05-17 14:36:53 -05:00
Daniel J. Geiger
63698572db Subtypes: add another test. 2023-04-28 13:03:03 -05:00
Daniel J. Geiger
ab3467973f fix: No more debounced refresh() for subtypes. 2023-04-28 09:47:03 -05:00
Daniel J. Geiger
91fe07d9c5 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-26 16:43:42 -05:00
Daniel J. Geiger
28cc821047 Fix a merge lint issue 2023-04-24 15:29:55 -05:00
Daniel J. Geiger
7dc728a459 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-24 13:08:44 -05:00
Daniel J. Geiger
12c651af6d Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-20 18:52:45 -05:00
Daniel J. Geiger
9d0cafe10b Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-14 18:34:08 -05:00
Daniel J. Geiger
fb24221587 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-10 20:24:03 -05:00
Daniel J. Geiger
ef347cc685 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-08 09:52:03 -05:00
Daniel J. Geiger
2d3b9e0c66 fix: Properly avoid concurrent invocations of loadMathJax(). 2023-03-18 09:52:44 -05:00
Daniel J. Geiger
bdb0dd064b Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-17 11:19:18 -05:00
Daniel J. Geiger
b17ed4dc29 fix: Don't cache wrapped text before MathJax finishes loading. 2023-03-13 13:01:52 -05:00
Daniel J. Geiger
b988f67759 fix: Better legibility when editing some math elements. 2023-03-13 12:57:33 -05:00
Daniel J. Geiger
089aaa8792 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-13 11:33:26 -05:00
Daniel J. Geiger
28261c4b29 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-06 09:06:55 -06:00
Daniel J. Geiger
3fbed86d3e Fixes for math element dimensions before/upon loading MathJax. 2023-02-27 15:32:36 -06:00
Daniel J. Geiger
38b3d90fa6 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-27 15:32:15 -06:00
Daniel J. Geiger
82b597ab8b fix: Catch MathML errors and render the "ERR" block instead. 2023-02-27 14:19:06 -06:00
Daniel J. Geiger
4c939cefad Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-27 14:18:41 -06:00
Daniel J. Geiger
8f0d9f5230 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-19 16:02:22 -06:00
Daniel J. Geiger
fcde0ac3de Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-02-07 21:10:26 -06:00
Daniel J. Geiger
b07dfba4b8 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-03 17:49:32 -06:00
Daniel J. Geiger
1089cdb278 Refactor: Modify fewer components. 2023-02-01 21:25:04 -06:00
Daniel J. Geiger
7246a6b17a Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-01 17:34:12 -06:00
Daniel J. Geiger
04a96caf78 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-31 15:26:03 -06:00
Daniel J. Geiger
14c6ea938a Refactor: Drop isActionName and convert getCustomActions to
`filterActions`.
2023-01-28 21:27:25 -06:00
Daniel J. Geiger
87aba3f619 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-28 18:44:03 -06:00
Daniel J. Geiger
c8d4e8c421 Simplify custom Actions: universal Action predicates instead of
action-specific guards.
2023-01-27 13:23:40 -06:00
Daniel J. Geiger
512e506798 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-26 17:38:48 -06:00
Daniel J. Geiger
b4e742bda0 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-25 16:54:14 -06:00
Daniel J. Geiger
5a3f4fd08f Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-24 19:27:05 -06:00
Daniel J. Geiger
34515f2952 Fixes. 2023-01-24 19:09:07 -06:00
Daniel J. Geiger
08f430b3ac Fix tests. 2023-01-23 20:23:51 -06:00
Daniel J. Geiger
59e74f94e6 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-17 15:12:39 -06:00
Daniel J. Geiger
ddc393bd9d Make filtering of custom actions optional. 2023-01-08 19:30:01 -06:00
Daniel J. Geiger
9e5948ac28 Filter all context menu items (standard and custom) through
`isActionEnabled`.
2023-01-08 17:37:32 -06:00
Daniel J. Geiger
f86d0f9102 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-08 17:06:23 -06:00
Daniel J. Geiger
ace031e992 Update to latest Action changes. 2023-01-07 15:47:19 -06:00
Daniel J. Geiger
45faf7d58f Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-07 11:58:15 -06:00
Daniel J. Geiger
8c558a0f33 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-05 11:36:23 -06:00
Daniel J. Geiger
65059cb166 Fix tests introduced by the arrow labels feature. 2023-01-02 13:30:11 -06:00
Daniel J. Geiger
9158e2d989 fix: Remove leftovers from a merge. 2023-01-02 12:48:53 -06:00
Daniel J. Geiger
12da1862a0 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-02 12:45:33 -06:00
Daniel J. Geiger
67fb3210ab fix: Correct existing subtypes test coverage; add test coverage for
subtype actions; and a subtype action fix.
2023-01-02 12:43:19 -06:00
Daniel J. Geiger
13d69d8cef Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2022-12-31 16:00:26 -06:00
Daniel J. Geiger
0f6ad916c0 fix: Cache SVGs separately for mixed-text and math-only modes. 2022-12-30 10:48:47 -06:00
Daniel J. Geiger
9ee2bf36cf Render LaTeX matrices correctly in math-only mode. 2022-12-30 10:26:46 -06:00
Daniel J. Geiger
86f5c2ebcf feat: Support LaTeX and AsciiMath via MathJax on stem.excalidraw.com 2022-12-27 15:11:52 -06:00
512 changed files with 43908 additions and 16882 deletions

View File

@@ -4,8 +4,16 @@
!.eslintrc.json
!.npmrc
!.prettierrc
!excalidraw-app/
!package.json
!public/
!packages/
!scripts/
!tsconfig.json
!yarn.lock
# keep (sub)sub directories at the end to exclude from explicit included
# e.g. ./packages/excalidraw/{dist,node_modules}
**/build
**/dist
**/node_modules

View File

@@ -17,12 +17,10 @@ VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","a
# put these in your .env.local, or make sure you don't commit!
# must be lowercase `true` when turned on
#
# whether to enable Service Workers in development
VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
VITE_APP_DISABLE_TRACKING=true
VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false

View File

@@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_DISABLE_TRACKING=
VITE_APP_ENABLE_TRACKING=false

View File

@@ -6,3 +6,6 @@ firebase/
dist/
public/workbox
packages/excalidraw/types
examples/**/public
dev-dist
coverage

View File

@@ -2,6 +2,7 @@
"extends": ["@excalidraw/eslint-config", "react-app"],
"rules": {
"import/no-anonymous-default-export": "off",
"no-restricted-globals": "off"
"no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
}
}

View File

@@ -1,14 +1,16 @@
name: Tests
on: pull_request
on:
push:
branches: master
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: 18.x
- name: Install and test

View File

@@ -2,16 +2,18 @@ FROM node:18 AS build
WORKDIR /opt/node_app
COPY package.json yarn.lock ./
RUN yarn --ignore-optional --network-timeout 600000
COPY . .
# do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN yarn --network-timeout 600000
ARG NODE_ENV=production
COPY . .
RUN yarn build:app:docker
FROM nginx:1.21-alpine
FROM nginx:1.27-alpine
COPY --from=build /opt/node_app/build /usr/share/nginx/html
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1

View File

@@ -133,7 +133,7 @@ function App() {
}
```
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/mainMenu/DefaultItems.tsx) of the default items.
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/main-menu/DefaultItems.tsx) of the default items.
### MainMenu.Group

View File

@@ -8,15 +8,15 @@
import { FONT_FAMILY } from "@excalidraw/excalidraw";
```
`FONT_FAMILY` contains all the font families used in `Excalidraw`. The default families are the following:
`FONT_FAMILY` contains all the font families used in `Excalidraw` as explained below
| Font Family | Description |
| ----------- | ---------------------- |
| `Excalifont` | The `Hand-drawn` font |
| `Nunito` | The `Normal` Font |
| `Comic Shanns` | The `Code` Font |
| `Virgil` | The `handwritten` font |
| `Helvetica` | The `Normal` Font |
| `Cascadia` | The `Code` Font |
Pre-selected family is `FONT_FAMILY.Excalifont`, unless it's overriden with `initialData.appState.currentItemFontFamily`.
Defaults to `FONT_FAMILY.Virgil` unless passed in `initialData.appState.currentItemFontFamily`.
### THEME

View File

@@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
```jsx showLineNumbers
export default function App() {
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />;
return <Excalidraw excalidrawAPI={(api)=> setExcalidrawAPI(api)} />;
}
```
@@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
| `storeAction` | [`StoreAction`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/store.ts#L40) | Parameter to control which updates should be captured by the `Store`. Captured updates are emmitted as increments and listened to by other components, such as `History` for undo / redo purposes. |
| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
```jsx live
function App() {
@@ -105,7 +105,6 @@ function App() {
appState: {
viewBackgroundColor: "#edf2ff",
},
storeAction: StoreAction.CAPTURE,
};
excalidrawAPI.updateScene(sceneData);
};
@@ -116,25 +115,12 @@ function App() {
<button className="custom-button" onClick={updateScene}>
Update Scene
</button>
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
</div>
);
}
```
#### storeAction
You can use the `storeAction` to influence undo / redo behaviour.
> **NOTE**: Some updates are not observed by the store / history - i.e. updates to `collaborators` object or parts of `AppState` which are not observed (not `ObservedAppState`). Such updates will never make it to the undo / redo stacks, regardless of the passed `storeAction` value.
| | `storeAction` value | Notes |
| --- | --- | --- |
| _Immediately undoable_ | `StoreAction.CAPTURE` | Use for updates which should be captured. Should be used for most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
| _Eventually undoable_ | `StoreAction.NONE` | Use for updates which should not be captured immediately - likely exceptions which are part of some async multi-step process. Otherwise, all such updates would end up being captured with the next `StoreAction.CAPTURE` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
| _Never undoable_ | `StoreAction.UPDATE` | Use for updates which should never be recorded, such as remote updates or scene initialization. These updates will _never_ make it to the local undo / redo stacks. |
### updateLibrary
<pre>
@@ -202,7 +188,7 @@ function App() {
Update Library
</button>
<Excalidraw
ref={(api) => setExcalidrawAPI(api)}
excalidrawAPI={(api) => setExcalidrawAPI(api)}
// initial data retrieved from https://github.com/excalidraw/excalidraw/blob/master/dev-docs/packages/excalidraw/initialData.js
initialData={{
libraryItems: initialData.libraryItems,

View File

@@ -9,9 +9,9 @@ All `props` are _optional_.
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down evenets |
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events |
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when the something is pasted in to the scene |
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
@@ -26,7 +26,7 @@ All `props` are _optional_.
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |

View File

@@ -20,7 +20,7 @@ exportToCanvas(&#123;<br/>&nbsp;
getDimensions,<br/>&nbsp;
files,<br/>&nbsp;
exportPadding?: number;<br/>
&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
</pre>
| Name | Type | Default | Description |
@@ -90,7 +90,7 @@ function App() {
<img src={canvasUrl} alt="" />
</div>
<div style={{ height: "400px" }}>
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
/>
</div>
</>

View File

@@ -31,7 +31,7 @@ You can pass `null` / `undefined` if not applicable.
restoreElements(
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>&nbsp;
opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean, normalizeIndices?: boolean }<br/>
opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/>
)
</pre>
@@ -51,9 +51,8 @@ The extra optional parameter to configure restored elements. It has the followin
| Prop | Type | Description|
| --- | --- | ------|
| `refreshDimensions` | `boolean` | Indicates whether we should also _recalculate_ text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
| `repairBindings` |`boolean` | Indicates whether the _bindings_ for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
| `normalizeIndices` |`boolean` | Indicates whether _fractional indices_ for the elements should be normalized. This is to prevent possible issues caused by using stale (too old, too long) indices. |
| `refreshDimensions` | `boolean` | Indicates whether we should also `recalculate` text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
| `repairBindings` |`boolean` | Indicates whether the `bindings` for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
**_How to use_**
@@ -74,7 +73,7 @@ restore(
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState</a>,<br/>&nbsp;
localAppState: Partial&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a>> | null | undefined,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L4">DataState</a><br/>
opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean, normalizeIndices?: boolean }<br/>
opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/>
)
</pre>

View File

@@ -24,7 +24,7 @@ yarn add react react-dom @excalidraw/excalidraw
Excalidraw depends on assets such as localization files (if you opt to use them), fonts, and others.
By default these assets are loaded from a public CDN [`https://unpkg.com/@excalidraw/excalidraw/dist/prod/`](https://unpkg.com/@excalidraw/excalidraw/dist/prod/), so you don't need to do anything on your end.
By default these assets are loaded from a public CDN [`https://unpkg.com/@excalidraw/excalidraw/dist/`](https://unpkg.com/@excalidraw/excalidraw/dist), so you don't need to do anything on your end.
However, if you want to host these files yourself, you can find them in your `node_modules/@excalidraw/excalidraw/dist` directory, in folders `excalidraw-assets` (for production) and `excalidraw-assets-dev` (for development).
@@ -34,26 +34,6 @@ Copy these folders to your static assets directory, and add a `window.EXCALIDRAW
window.EXCALIDRAW_ASSET_PATH = "/";
```
or, if you serve your assets from the root of your CDN, you would do:
```js
// Vanilla
<head>
<script>
window.EXCALIDRAW_ASSET_PATH = "https://my.cdn.com/assets/";
</script>
</head>
```
or, if you prefer the path to be dynamicly set based on the `location.origin`, you could do the following:
```jsx
// Next.js
<Script id="load-env-variables" strategy="beforeInteractive" >
{ `window["EXCALIDRAW_ASSET_PATH"] = location.origin;` } // or use just "/"!
</Script>
```
### Dimensions of Excalidraw
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.

View File

@@ -14,7 +14,7 @@ This API receives the mermaid syntax as the input, and resolves to skeleton Exca
import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
import { convertToExcalidrawElements} from "@excalidraw/excalidraw"
try {
const { elements, files } = await parseMermaid(mermaidSyntax: string, {
const { elements, files } = await parseMermaidToExcalidraw(mermaidSyntax: string, {
fontSize: number,
});
const excalidrawElements = convertToExcalidrawElements(elements);

View File

@@ -43,7 +43,7 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
// editor state (canvas config, preferences, ...)
"appState": {
"gridSize": null,
"gridSize": 20,
"viewBackgroundColor": "#ffffff"
},

View File

@@ -18,13 +18,13 @@
"@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.17.0",
"@excalidraw/excalidraw": "0.17.6",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3",
"prism-react-renderer": "^1.3.5",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"sass": "1.57.1"
},
"devDependencies": {

View File

@@ -59,7 +59,7 @@ pre a {
padding: 5px;
background: #70b1ec;
color: white;
font-weight: bold;
font-weight: 700;
border: none;
}

View File

@@ -1547,7 +1547,7 @@
"@docusaurus/theme-search-algolia" "2.2.0"
"@docusaurus/types" "2.2.0"
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2":
"@docusaurus/react-loadable@5.5.2":
version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
@@ -1718,10 +1718,10 @@
url-loader "^4.1.1"
webpack "^5.73.0"
"@excalidraw/excalidraw@0.17.0":
version "0.17.0"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725"
integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==
"@excalidraw/excalidraw@0.17.6":
version "0.17.6"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
"@hapi/hoek@^9.0.0":
version "9.3.0"
@@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0"
concat-map "0.0.1"
braces@^3.0.2, braces@~3.0.2:
braces@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
dependencies:
fill-range "^7.1.1"
braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@@ -4011,6 +4018,13 @@ fill-range@^7.0.1:
dependencies:
to-regex-range "^5.0.1"
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
dependencies:
to-regex-range "^5.0.1"
finalhandler@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
@@ -5207,11 +5221,11 @@ methods@~1.1.2:
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
dependencies:
braces "^3.0.2"
braces "^3.0.3"
picomatch "^2.3.1"
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
@@ -6190,14 +6204,13 @@ react-dev-utils@^12.0.1:
strip-ansi "^6.0.1"
text-table "^0.2.0"
react-dom@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
react-dom@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scheduler "^0.20.2"
scheduler "^0.23.0"
react-error-overlay@^6.0.11:
version "6.0.11"
@@ -6260,6 +6273,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
dependencies:
"@babel/runtime" "^7.10.3"
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
dependencies:
"@types/react" "*"
prop-types "^15.6.2"
react-router-config@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
@@ -6310,13 +6331,12 @@ react-textarea-autosize@^8.3.2:
use-composed-ref "^1.3.0"
use-latest "^1.2.1"
react@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
react@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
readable-stream@^2.0.1:
version "2.3.7"
@@ -6664,13 +6684,12 @@ sax@^1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
scheduler@^0.20.2:
version "0.20.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
scheduler@^0.23.0:
version "0.23.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
schema-utils@2.7.0:
version "2.7.0"

View File

@@ -12,9 +12,9 @@ import type * as TExcalidraw from "@excalidraw/excalidraw";
import { nanoid } from "nanoid";
import type { ResolvablePromise } from "../utils";
import {
resolvablePromise,
ResolvablePromise,
distance2d,
fileOpen,
withBatchedUpdates,
@@ -40,7 +40,7 @@ import type {
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
import "./App.scss";
import "./ExampleApp.scss";
type Comment = {
x: number;
@@ -73,7 +73,7 @@ export interface AppProps {
excalidrawLib: typeof TExcalidraw;
}
export default function App({
export default function ExampleApp({
appTitle,
useCustom,
customArgs,
@@ -872,7 +872,7 @@ export default function App({
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil";
ctx.font = "30px Excalifont";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
}}
@@ -893,7 +893,7 @@ export default function App({
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil";
ctx.font = "30px Excalifont";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
}}

View File

@@ -1,4 +1,4 @@
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
import CustomFooter from "./CustomFooter";
import type * as TExcalidraw from "@excalidraw/excalidraw";

View File

@@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
];
export default {
elements,
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
scrollToContent: true,
libraryItems: [
[

View File

@@ -1,6 +1,6 @@
import { unstable_batchedUpdates } from "react-dom";
import { fileOpen as _fileOpen } from "browser-fs-access";
import type { MIME_TYPES } from "@excalidraw/excalidraw";
import { MIME_TYPES } from "@excalidraw/excalidraw";
import { AbortError } from "../../packages/excalidraw/errors";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;

View File

@@ -34,3 +34,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# copied assets
public/*.woff2

View File

@@ -3,7 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",
"start": "next start -p 3006",
@@ -12,13 +13,13 @@
"dependencies": {
"@excalidraw/excalidraw": "*",
"next": "14.1",
"react": "^18",
"react-dom": "^18"
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"path2d-polyfill": "2.0.1",
"typescript": "^5"
}

View File

@@ -1,4 +1,5 @@
import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
@@ -15,7 +16,9 @@ export default function Page() {
<>
<a href="/excalidraw-in-pages">Switch to Pages router</a>
<h1 className="page-title">App Router</h1>
<Script id="load-env-variables" strategy="beforeInteractive">
{`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
</Script>
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
<ExcalidrawWithClientOnly />
</>

View File

@@ -7,7 +7,7 @@ a {
color: #1c7ed6;
font-size: 20px;
text-decoration: none;
font-weight: 550;
font-weight: 500;
}
.page-title {

View File

@@ -1,7 +1,7 @@
"use client";
import * as excalidrawLib from "@excalidraw/excalidraw";
import { Excalidraw } from "@excalidraw/excalidraw";
import App from "../../components/App";
import App from "../../components/ExampleApp";
import "@excalidraw/excalidraw/index.css";

View File

@@ -0,0 +1,2 @@
# copied assets
public/*.woff2

View File

@@ -11,6 +11,7 @@
<title>React App</title>
<script>
window.name = "codesandbox";
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
</head>

View File

@@ -1,4 +1,4 @@
import App from "../components/App";
import App from "../components/ExampleApp";
import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";

View File

@@ -12,8 +12,10 @@
"typescript": "^5"
},
"scripts": {
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build",
"build:preview": "yarn build && vite preview --port 5002"
}
}

View File

@@ -1,10 +1,10 @@
import polyfill from "../packages/excalidraw/polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState";
import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
import { TopErrorBoundary } from "./components/TopErrorBoundary";
import { useMathSubtype } from "../packages/excalidraw/element/subtypes/mathjax";
import {
APP_NAME,
EVENT,
@@ -13,7 +13,7 @@ import {
VERSION_TIMEOUT,
} from "../packages/excalidraw/constants";
import { loadFromBlob } from "../packages/excalidraw/data/blob";
import {
import type {
FileId,
NonDeletedExcalidrawElement,
OrderedExcalidrawElement,
@@ -22,25 +22,25 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
import { t } from "../packages/excalidraw/i18n";
import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
} from "../packages/excalidraw/index";
import {
StoreAction,
reconcileElements,
} from "../packages/excalidraw";
import type {
AppState,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "../packages/excalidraw/types";
import type { ResolvablePromise } from "../packages/excalidraw/utils";
import {
debounce,
getVersion,
getFrame,
isTestEnv,
preventUnload,
ResolvablePromise,
resolvablePromise,
isRunningInIframe,
} from "../packages/excalidraw/utils";
@@ -50,8 +50,8 @@ import {
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import type { CollabAPI } from "./collab/Collab";
import Collab, {
CollabAPI,
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
@@ -67,11 +67,8 @@ import {
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import {
restore,
restoreAppState,
RestoredDataState,
} from "../packages/excalidraw/data/restore";
import type { RestoredDataState } from "../packages/excalidraw/data/restore";
import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
@@ -94,22 +91,19 @@ import {
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai";
import "./index.scss";
import { ResolutionType } from "../packages/excalidraw/utility-types";
import type { ResolutionType } from "../packages/excalidraw/utility-types";
import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../packages/excalidraw/components/Trans";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import {
RemoteExcalidrawElement,
reconcileElements,
} from "../packages/excalidraw/data/reconcile";
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
import {
CommandPalette,
DEFAULT_CATEGORIES,
@@ -125,11 +119,51 @@ import {
youtubeIcon,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
import DebugCanvas, {
debugRenderer,
isVisualDebuggerEnabled,
loadSavedDebugState,
} from "./components/DebugCanvas";
import { AIComponents } from "./components/AI";
polyfill();
window.EXCALIDRAW_THROTTLE_RENDER = true;
declare global {
interface BeforeInstallPromptEventChoiceResult {
outcome: "accepted" | "dismissed";
}
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
}
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
}
}
let pwaEvent: BeforeInstallPromptEvent | null = null;
// Adding a listener outside of the component as it may (?) need to be
// subscribed early to catch the event.
//
// Also note that it will fire only if certain heuristics are met (user has
// used the app for some time, etc.)
window.addEventListener(
"beforeinstallprompt",
(event: BeforeInstallPromptEvent) => {
// prevent Chrome <= 67 from automatically showing the prompt
event.preventDefault();
// cache for later use
pwaEvent = event;
},
);
let isSelfEmbedding = false;
if (window.self !== window.top) {
@@ -144,11 +178,6 @@ if (window.self !== window.top) {
}
}
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
@@ -294,19 +323,15 @@ const initializeScene = async (opts: {
return { scene: null, isExternalScene: false };
};
const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
);
const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode();
// initial state
// ---------------------------------------------------------------------------
@@ -318,6 +343,8 @@ const ExcalidrawWrapper = () => {
resolvablePromise<ExcalidrawInitialDataState | null>();
}
const debugCanvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
trackEvent("load", "frame", getFrame());
// Delayed so that the app has a time to load the latest SW
@@ -329,6 +356,8 @@ const ExcalidrawWrapper = () => {
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
useMathSubtype(excalidrawAPI);
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -343,6 +372,23 @@ const ExcalidrawWrapper = () => {
migrationAdapter: LibraryLocalStorageMigrationAdapter,
});
const [, forceRefresh] = useState(false);
useEffect(() => {
if (import.meta.env.DEV) {
const debugState = loadSavedDebugState();
if (debugState.enabled && !window.visualDebug) {
window.visualDebug = {
data: [],
};
} else {
delete window.visualDebug;
}
forceRefresh((prev) => !prev);
}
}, [excalidrawAPI]);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
@@ -438,7 +484,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
commitToStore: true,
storeAction: StoreAction.CAPTURE,
});
}
});
@@ -462,13 +508,10 @@ const ExcalidrawWrapper = () => {
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage();
let langCode = languageDetector.detect() || defaultLang.code;
if (Array.isArray(langCode)) {
langCode = langCode[0];
}
setLangCode(langCode);
setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({
...localDataState,
storeAction: StoreAction.UPDATE,
});
LibraryIndexedDBAdapter.load().then((data) => {
if (data) {
@@ -566,10 +609,6 @@ const ExcalidrawWrapper = () => {
};
}, [excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
const onChange = (
elements: readonly OrderedExcalidrawElement[],
appState: AppState,
@@ -604,11 +643,22 @@ const ExcalidrawWrapper = () => {
if (didChange) {
excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});
}
}
});
}
// Render the debug scene if the debug canvas is available
if (debugCanvasRef.current && excalidrawAPI) {
debugRenderer(
debugCanvasRef.current,
appState,
window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
);
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
@@ -807,6 +857,7 @@ const ExcalidrawWrapper = () => {
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
@@ -832,64 +883,9 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter />
<TTDDialog
onTextSubmit={async (input) => {
try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
<TTDDialogTrigger />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
@@ -1102,8 +1098,30 @@ const ExcalidrawWrapper = () => {
);
},
},
{
label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app,
predicate: () => !!pwaEvent,
perform: () => {
if (pwaEvent) {
pwaEvent.prompt();
pwaEvent.userChoice.then(() => {
// event cannot be reused, but we'll hopefully
// grab new one as the event should be fired again
pwaEvent = null;
});
}
},
},
]}
/>
{isVisualDebuggerEnabled() && excalidrawAPI && (
<DebugCanvas
appState={excalidrawAPI.getAppState()}
scale={window.devicePixelRatio}
ref={debugCanvasRef}
/>
)}
</Excalidraw>
</div>
);

View File

@@ -7,8 +7,9 @@ import {
import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
import { t } from "../packages/excalidraw/i18n";
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import { UIAppState } from "../packages/excalidraw/types";
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import type { UIAppState } from "../packages/excalidraw/types";
import { Stats } from "../packages/excalidraw";
type StorageSizes = { scene: number; total: number };
@@ -51,39 +52,33 @@ const CustomStats = (props: Props) => {
}
return (
<>
<tr>
<th colSpan={2}>{t("stats.storage")}</th>
</tr>
<tr>
<td>{t("stats.scene")}</td>
<td>{nFormatter(storageSizes.scene, 1)}</td>
</tr>
<tr>
<td>{t("stats.total")}</td>
<td>{nFormatter(storageSizes.total, 1)}</td>
</tr>
<tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setToast(t("toast.copyToClipboard"));
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</td>
</tr>
</>
<Stats.StatsRows order={-1}>
<Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
<Stats.StatsRow
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setToast(t("toast.copyToClipboard"));
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</Stats.StatsRow>
<Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
<Stats.StatsRow columns={2}>
<div>{t("stats.scene")}</div>
<div>{nFormatter(storageSizes.scene, 1)}</div>
</Stats.StatsRow>
<Stats.StatsRow columns={2}>
<div>{t("stats.total")}</div>
<div>{nFormatter(storageSizes.total, 1)}</div>
</Stats.StatsRow>
</Stats.StatsRows>
);
};

View File

@@ -1,8 +1,7 @@
import { useSetAtom } from "jotai";
import React from "react";
import { appLangCodeAtom } from "../App";
import { useI18n } from "../../packages/excalidraw/i18n";
import { languages } from "../../packages/excalidraw/i18n";
import { useI18n, languages } from "../../packages/excalidraw/i18n";
import { appLangCodeAtom } from "./language-state";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n();

View File

@@ -0,0 +1,25 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "../../packages/excalidraw";
export const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
export const getPreferredLanguage = () => {
const detectedLanguages = languageDetector.detect();
const detectedLanguage = Array.isArray(detectedLanguages)
? detectedLanguages[0]
: detectedLanguages;
const initialLanguage =
(detectedLanguage
? // region code may not be defined if user uses generic preferred language
// (e.g. chinese vs instead of chinese-simplified)
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
: null) || defaultLang.code;
return initialLanguage;
};

View File

@@ -0,0 +1,15 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());
export const useAppLangCode = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
return [langCode, setLangCode] as const;
};

View File

@@ -40,6 +40,7 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_THEME: "excalidraw-theme",
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files",

View File

@@ -1,23 +1,25 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import {
import type {
ExcalidrawImperativeAPI,
SocketId,
} from "../../packages/excalidraw/types";
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
import {
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type {
ExcalidrawElement,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import {
StoreAction,
getSceneVersion,
restoreElements,
zoomToFitBounds,
} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
reconcileElements,
} from "../../packages/excalidraw";
import type { Collaborator, Gesture } from "../../packages/excalidraw/types";
import {
assertNever,
preventUnload,
@@ -34,12 +36,14 @@ import {
SYNC_FULL_SCENE_INTERVAL_MS,
WS_EVENTS,
} from "../app_constants";
import type {
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import {
generateCollaborationLinkData,
getCollaborationLink,
getSyncableElements,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import {
isSavedToFirebase,
@@ -75,14 +79,13 @@ import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { atom } from "jotai";
import { appJotaiStore } from "../app-jotai";
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
import { collabErrorIndicatorAtom } from "./CollabError";
import {
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
reconcileElements,
} from "../../packages/excalidraw/data/reconcile";
export const collabAPIAtom = atom<CollabAPI | null>(null);
@@ -356,6 +359,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});
}
};
@@ -506,6 +510,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// to database even if deleted before creating the room.
this.excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});
this.saveCollabRoomToFirebase(getSyncableElements(elements));
@@ -743,6 +748,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
) => {
this.excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});
this.loadImageFiles();

View File

@@ -1,15 +1,15 @@
import {
isSyncableElement,
import type {
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import { isSyncableElement } from "../data";
import { TCollabClass } from "./Collab";
import type { TCollabClass } from "./Collab";
import { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
import type { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import {
import type {
OnUserFollowedPayload,
SocketId,
UserIdleState,
@@ -19,6 +19,7 @@ import throttle from "lodash.throttle";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { encryptData } from "../../packages/excalidraw/data/encryption";
import type { Socket } from "socket.io-client";
import { StoreAction } from "../../packages/excalidraw";
class Portal {
collab: TCollabClass;
@@ -115,19 +116,26 @@ class Portal {
}
}
this.collab.excalidrawAPI.updateScene({
elements: this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
// this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging)
return newElementWith(element, { status: "saved" });
}
return element;
}),
});
let isChanged = false;
const newElements = this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
isChanged = true;
// this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging)
return newElementWith(element, { status: "saved" });
}
return element;
});
if (isChanged) {
this.collab.excalidrawAPI.updateScene({
elements: newElements,
storeAction: StoreAction.UPDATE,
});
}
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (

View File

@@ -1,218 +0,0 @@
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
import { trackEvent } from "../../packages/excalidraw/analytics";
import { getFrame } from "../../packages/excalidraw/utils";
import { useI18n } from "../../packages/excalidraw/i18n";
import { KEYS } from "../../packages/excalidraw/keys";
import { Dialog } from "../../packages/excalidraw/components/Dialog";
import {
copyIcon,
playerPlayIcon,
playerStopFilledIcon,
share,
shareIOS,
shareWindows,
tablerCheckIcon,
} from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
import "./RoomDialog.scss";
const getShareIcon = () => {
const navigator = window.navigator as any;
const isAppleBrowser = /Apple/.test(navigator.vendor);
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
if (isAppleBrowser) {
return shareIOS;
} else if (isWindowsBrowser) {
return shareWindows;
}
return share;
};
export type RoomModalProps = {
handleClose: () => void;
activeRoomLink: string;
username: string;
onUsernameChange: (username: string) => void;
onRoomCreate: () => void;
onRoomDestroy: () => void;
setErrorMessage: (message: string) => void;
};
export const RoomModal = ({
activeRoomLink,
onRoomCreate,
onRoomDestroy,
setErrorMessage,
username,
onUsernameChange,
handleClose,
}: RoomModalProps) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const isShareSupported = "share" in navigator;
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
} catch (e) {
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};
const shareRoomLink = async () => {
try {
await navigator.share({
title: t("roomDialog.shareTitle"),
text: t("roomDialog.shareTitle"),
url: activeRoomLink,
});
} catch (error: any) {
// Just ignore.
}
};
if (activeRoomLink) {
return (
<>
<h3 className="RoomDialog__active__header">
{t("labels.liveCollaboration")}
</h3>
<TextField
value={username}
placeholder="Your name"
label="Your name"
onChange={onUsernameChange}
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
/>
<div className="RoomDialog__active__linkRow">
<TextField
ref={ref}
label="Link"
readonly
fullWidth
value={activeRoomLink}
/>
{isShareSupported && (
<FilledButton
size="large"
variant="icon"
label="Share"
icon={getShareIcon()}
className="RoomDialog__active__share"
onClick={shareRoomLink}
/>
)}
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton
size="large"
label="Copy link"
icon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="RoomDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div>
<div className="RoomDialog__active__description">
<p>
<span
role="img"
aria-hidden="true"
className="RoomDialog__active__description__emoji"
>
🔒{" "}
</span>
{t("roomDialog.desc_privacy")}
</p>
<p>{t("roomDialog.desc_exitSession")}</p>
</div>
<div className="RoomDialog__active__actions">
<FilledButton
size="large"
variant="outlined"
color="danger"
label={t("roomDialog.button_stopSession")}
icon={playerStopFilledIcon}
onClick={() => {
trackEvent("share", "room closed");
onRoomDestroy();
}}
/>
</div>
</>
);
}
return (
<>
<div className="RoomDialog__inactive__illustration">
<CollabImage />
</div>
<div className="RoomDialog__inactive__header">
{t("labels.liveCollaboration")}
</div>
<div className="RoomDialog__inactive__description">
<strong>{t("roomDialog.desc_intro")}</strong>
{t("roomDialog.desc_privacy")}
</div>
<div className="RoomDialog__inactive__start_session">
<FilledButton
size="large"
label={t("roomDialog.button_startSession")}
icon={playerPlayIcon}
onClick={() => {
trackEvent("share", "room creation", `ui (${getFrame()})`);
onRoomCreate();
}}
/>
</div>
</>
);
};
const RoomDialog = (props: RoomModalProps) => {
return (
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
<div className="RoomDialog">
<RoomModal {...props} />
</div>
</Dialog>
);
};
export default RoomDialog;

View File

@@ -0,0 +1,159 @@
import type { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
import {
DiagramToCodePlugin,
exportToBlob,
getTextFromElements,
MIME_TYPES,
TTDDialog,
} from "../../packages/excalidraw";
import { getDataURL } from "../../packages/excalidraw/data/blob";
import { safelyParseJSON } from "../../packages/excalidraw/utils";
export const AIComponents = ({
excalidrawAPI,
}: {
excalidrawAPI: ExcalidrawImperativeAPI;
}) => {
return (
<>
<DiagramToCodePlugin
generate={async ({ frame, children }) => {
const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({
elements: children,
appState: {
...appState,
exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
});
const dataURL = await getDataURL(blob);
const textFromFrameChildren = getTextFromElements(children);
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/diagram-to-code/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
texts: textFromFrameChildren,
image: dataURL,
theme: appState.theme,
}),
},
);
if (!response.ok) {
const text = await response.text();
const errorJSON = safelyParseJSON(text);
if (!errorJSON) {
throw new Error(text);
}
if (errorJSON.statusCode === 429) {
return {
html: `<html>
<body style="margin: 0; text-align: center">
<div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
<div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
</br>
</br>
<div>You can also try <a href="${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
</div>
</body>
</html>`,
};
}
throw new Error(errorJSON.message || text);
}
try {
const { html } = await response.json();
if (!html) {
throw new Error("Generation failed (invalid response)");
}
return {
html,
};
} catch (error: any) {
throw new Error("Generation failed (invalid response)");
}
}}
/>
<TTDDialog
onTextSubmit={async (input) => {
try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
</>
);
};

View File

@@ -3,23 +3,27 @@ import { Footer } from "../../packages/excalidraw/index";
import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
export const AppFooter = React.memo(() => {
return (
<Footer>
<div
style={{
display: "flex",
gap: ".5rem",
alignItems: "center",
}}
>
{isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
</div>
</Footer>
);
});
export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => {
return (
<Footer>
<div
style={{
display: "flex",
gap: ".5rem",
alignItems: "center",
}}
>
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
</div>
</Footer>
);
},
);

View File

@@ -1,12 +1,14 @@
import React from "react";
import {
arrowBarToLeftIcon,
loginIcon,
ExcalLogo,
eyeIcon,
} from "../../packages/excalidraw/components/icons";
import { Theme } from "../../packages/excalidraw/element/types";
import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "./LanguageList";
import { LanguageList } from "../app-language/LanguageList";
import { saveDebugState } from "./DebugCanvas";
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
@@ -14,6 +16,7 @@ export const AppMainMenu: React.FC<{
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
refresh: () => void;
}> = React.memo((props) => {
return (
<MainMenu>
@@ -28,13 +31,14 @@ export const AppMainMenu: React.FC<{
/>
)}
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.ItemLink
icon={ExcalLogo}
href={`${
import.meta.env.VITE_APP_PLUS_APP
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
className=""
>
@@ -42,7 +46,7 @@ export const AppMainMenu: React.FC<{
</MainMenu.ItemLink>
<MainMenu.DefaultItems.Socials />
<MainMenu.ItemLink
icon={arrowBarToLeftIcon}
icon={loginIcon}
href={`${import.meta.env.VITE_APP_PLUS_APP}${
isExcalidrawPlusSignedUser ? "" : "/sign-up"
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
@@ -50,6 +54,23 @@ export const AppMainMenu: React.FC<{
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink>
{import.meta.env.DEV && (
<MainMenu.Item
icon={eyeIcon}
onClick={() => {
if (window.visualDebug) {
delete window.visualDebug;
saveDebugState({ enabled: false });
} else {
window.visualDebug = { data: [] };
saveDebugState({ enabled: true });
}
props?.refresh();
}}
>
Visual Debug
</MainMenu.Item>
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme

View File

@@ -1,5 +1,5 @@
import React from "react";
import { arrowBarToLeftIcon } from "../../packages/excalidraw/components/icons";
import { loginIcon } from "../../packages/excalidraw/components/icons";
import { useI18n } from "../../packages/excalidraw/i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
@@ -61,7 +61,7 @@ export const AppWelcomeScreen: React.FC<{
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
shortcut={null}
icon={arrowBarToLeftIcon}
icon={loginIcon}
>
Sign up
</WelcomeScreen.Center.MenuItemLink>

View File

@@ -0,0 +1,311 @@
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { type AppState } from "../../packages/excalidraw/types";
import { throttleRAF } from "../../packages/excalidraw/utils";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
} from "../../packages/excalidraw/renderer/helpers";
import type { DebugElement } from "../../packages/excalidraw/visualdebug";
import {
ArrowheadArrowIcon,
CloseIcon,
TrashIcon,
} from "../../packages/excalidraw/components/icons";
import { STORAGE_KEYS } from "../app_constants";
import {
isLineSegment,
type GlobalPoint,
type LineSegment,
} from "../../packages/math";
const renderLine = (
context: CanvasRenderingContext2D,
zoom: number,
segment: LineSegment<GlobalPoint>,
color: string,
) => {
context.save();
context.strokeStyle = color;
context.beginPath();
context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
context.stroke();
context.restore();
};
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888";
context.save();
context.beginPath();
context.moveTo(-10 * zoom, -10 * zoom);
context.lineTo(10 * zoom, 10 * zoom);
context.moveTo(10 * zoom, -10 * zoom);
context.lineTo(-10 * zoom, 10 * zoom);
context.stroke();
context.save();
};
const render = (
frame: DebugElement[],
context: CanvasRenderingContext2D,
appState: AppState,
) => {
frame.forEach((el: DebugElement) => {
switch (true) {
case isLineSegment(el.data):
renderLine(
context,
appState.zoom.value,
el.data as LineSegment<GlobalPoint>,
el.color,
);
break;
}
});
};
const _debugRenderer = (
canvas: HTMLCanvasElement,
appState: AppState,
scale: number,
refresh: () => void,
) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({
canvas,
scale,
normalizedWidth,
normalizedHeight,
viewBackgroundColor: "transparent",
});
// Apply zoom
context.save();
context.translate(
appState.scrollX * appState.zoom.value,
appState.scrollY * appState.zoom.value,
);
renderOrigin(context, appState.zoom.value);
if (
window.visualDebug?.currentFrame &&
window.visualDebug?.data &&
window.visualDebug.data.length > 0
) {
// Render only one frame
const [idx] = debugFrameData();
render(window.visualDebug.data[idx], context, appState);
} else {
// Render all debug frames
window.visualDebug?.data.forEach((frame) => {
render(frame, context, appState);
});
}
if (window.visualDebug) {
window.visualDebug!.data =
window.visualDebug?.data.map((frame) =>
frame.filter((el) => el.permanent),
) ?? [];
}
};
const debugFrameData = (): [number, number] => {
const currentFrame = window.visualDebug?.currentFrame ?? 0;
const frameCount = window.visualDebug?.data.length ?? 0;
if (frameCount > 0) {
return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
}
return [0, 0];
};
export const saveDebugState = (debug: { enabled: boolean }) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
JSON.stringify(debug),
);
} catch (error: any) {
console.error(error);
}
};
export const debugRenderer = throttleRAF(
(
canvas: HTMLCanvasElement,
appState: AppState,
scale: number,
refresh: () => void,
) => {
_debugRenderer(canvas, appState, scale, refresh);
},
{ trailing: true },
);
export const loadSavedDebugState = () => {
let debug;
try {
const savedDebugState = localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
);
if (savedDebugState) {
debug = JSON.parse(savedDebugState) as { enabled: boolean };
}
} catch (error: any) {
console.error(error);
}
return debug ?? { enabled: false };
};
export const isVisualDebuggerEnabled = () =>
Array.isArray(window.visualDebug?.data);
export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
const moveForward = useCallback(() => {
if (
!window.visualDebug?.currentFrame ||
isNaN(window.visualDebug?.currentFrame ?? -1)
) {
window.visualDebug!.currentFrame = 0;
}
window.visualDebug!.currentFrame += 1;
onChange();
}, [onChange]);
const moveBackward = useCallback(() => {
if (
!window.visualDebug?.currentFrame ||
isNaN(window.visualDebug?.currentFrame ?? -1) ||
window.visualDebug?.currentFrame < 1
) {
window.visualDebug!.currentFrame = 1;
}
window.visualDebug!.currentFrame -= 1;
onChange();
}, [onChange]);
const reset = useCallback(() => {
window.visualDebug!.currentFrame = undefined;
onChange();
}, [onChange]);
const trashFrames = useCallback(() => {
if (window.visualDebug) {
window.visualDebug.currentFrame = undefined;
window.visualDebug.data = [];
}
onChange();
}, [onChange]);
return (
<>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={trashFrames}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
{TrashIcon}
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={moveBackward}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
<ArrowheadArrowIcon flip />
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={reset}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
{CloseIcon}
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-backward"
aria-label="Move backward"
type="button"
onClick={moveForward}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
<ArrowheadArrowIcon />
</div>
</button>
</>
);
};
interface DebugCanvasProps {
appState: AppState;
scale: number;
}
const DebugCanvas = forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => {
const { width, height } = appState;
const canvasRef = useRef<HTMLCanvasElement>(null);
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
ref,
() => canvasRef.current,
[canvasRef],
);
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={canvasRef}
>
Debug Canvas
</canvas>
);
},
);
export default DebugCanvas;

View File

@@ -3,11 +3,11 @@ import { Card } from "../../packages/excalidraw/components/Card";
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import {
import type {
FileId,
NonDeletedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import {
import type {
AppState,
BinaryFileData,
BinaryFiles,

View File

@@ -1,7 +1,7 @@
import oc from "open-color";
import React from "react";
import { THEME } from "../../packages/excalidraw/constants";
import { Theme } from "../../packages/excalidraw/element/types";
import type { Theme } from "../../packages/excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(

View File

@@ -1,14 +1,15 @@
import { StoreAction } from "../../packages/excalidraw";
import { compressData } from "../../packages/excalidraw/data/encode";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import {
import type {
ExcalidrawElement,
ExcalidrawImageElement,
FileId,
InitializedExcalidrawImageElement,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import {
import type {
BinaryFileData,
BinaryFileMetadata,
ExcalidrawImperativeAPI,
@@ -238,5 +239,6 @@ export const updateStaleImageStatuses = (params: {
}
return element;
}),
storeAction: StoreAction.UPDATE,
});
};

View File

@@ -20,19 +20,23 @@ import {
get,
} from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
} from "../../packages/excalidraw/constants";
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import type {
ExcalidrawElement,
FileId,
} from "../../packages/excalidraw/element/types";
import {
import type {
AppState,
BinaryFileData,
BinaryFiles,
} from "../../packages/excalidraw/types";
import { MaybePromise } from "../../packages/excalidraw/utility-types";
import type { MaybePromise } from "../../packages/excalidraw/utility-types";
import { debounce } from "../../packages/excalidraw/utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
@@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
appState: AppState,
) => {
try {
const _appState = clearAppStateForLocalStorage(appState);
if (
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
) {
_appState.openSidebar = null;
}
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)),
JSON.stringify(_appState),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {

View File

@@ -1,12 +1,13 @@
import {
import { reconcileElements } from "../../packages/excalidraw";
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import { getSceneVersion } from "../../packages/excalidraw/element";
import Portal from "../collab/Portal";
import type Portal from "../collab/Portal";
import { restoreElements } from "../../packages/excalidraw/data/restore";
import {
import type {
AppState,
BinaryFileData,
BinaryFileMetadata,
@@ -19,13 +20,11 @@ import {
decryptData,
} from "../../packages/excalidraw/data/encryption";
import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from ".";
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client";
import {
RemoteExcalidrawElement,
reconcileElements,
} from "../../packages/excalidraw/data/reconcile";
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
// private
// -----------------------------------------------------------------------------

View File

@@ -9,30 +9,30 @@ import {
} from "../../packages/excalidraw/data/encryption";
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
import { restore } from "../../packages/excalidraw/data/restore";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
import { SceneBounds } from "../../packages/excalidraw/element/bounds";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import {
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import {
import type {
AppState,
BinaryFileData,
BinaryFiles,
SocketId,
UserIdleState,
} from "../../packages/excalidraw/types";
import { MakeBrand } from "../../packages/excalidraw/utility-types";
import type { MakeBrand } from "../../packages/excalidraw/utility-types";
import { bytesToHexString } from "../../packages/excalidraw/utils";
import type { WS_SUBTYPES } from "../app_constants";
import {
DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES,
WS_SUBTYPES,
} from "../app_constants";
import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase";
@@ -269,7 +269,6 @@ export const loadScene = async (
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
commitToStore: false,
};
};

View File

@@ -1,5 +1,5 @@
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { AppState } from "../../packages/excalidraw/types";
import type { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import type { AppState } from "../../packages/excalidraw/types";
import {
clearAppStateForLocalStorage,
getDefaultAppState,

View File

@@ -20,7 +20,7 @@
name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta name="image" content="https://excalidraw.com/og-image-2.png" />
<meta name="image" content="https://excalidraw.com/og-image-3.png" />
<!-- Open Graph / Facebook -->
<meta property="og:site_name" content="Excalidraw" />
@@ -35,7 +35,7 @@
property="og:description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
<meta property="og:image" content="https://excalidraw.com/og-image-3.png" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
@@ -51,7 +51,7 @@
/>
<meta
property="twitter:image"
content="https://excalidraw.com/og-twitter-v2.png"
content="https://excalidraw.com/og-image-3.png"
/>
<!-- General tags -->
@@ -95,6 +95,11 @@
color: #fff;
}
</style>
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!------------------------------------------------------------------------->
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<script>
@@ -115,8 +120,23 @@
window.location.href = "https://app.excalidraw.com";
}
</script>
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<% } %>
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/assets/fonts.css"
type="text/css"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@@ -124,22 +144,6 @@
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<link
rel="preload"
href="/Virgil.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/Cascadia.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
<script>
@@ -158,7 +162,6 @@
</script>
<% } %>
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>

View File

@@ -25,6 +25,7 @@
margin-bottom: auto;
margin-inline-start: auto;
margin-inline-end: 0.6em;
z-index: var(--zIndex-layerUI);
svg {
width: 1.2rem;
@@ -40,6 +41,10 @@
}
&.highlighted {
color: var(--color-promo);
font-weight: 700;
.dropdown-menu-item__icon g {
stroke-width: 2;
}
}
}
}

View File

@@ -26,17 +26,28 @@
"node": ">=18.0.0"
},
"dependencies": {
"vite-plugin-html": "3.2.2"
"firebase": "8.3.3",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
},
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
"build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"start": "yarn && vite",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000"
}
}

View File

@@ -58,8 +58,8 @@
font-size: 0.75rem;
line-height: 110%;
background: var(--color-success-lighter);
color: var(--color-success);
background: var(--color-success);
color: var(--color-success-text);
& > svg {
width: 0.875rem;

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
import { trackEvent } from "../../packages/excalidraw/analytics";
import { getFrame } from "../../packages/excalidraw/utils";
@@ -14,15 +13,16 @@ import {
share,
shareIOS,
shareWindows,
tablerCheckIcon,
} from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
import type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab";
import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly";
@@ -62,10 +62,11 @@ const ActiveRoomDialog = ({
handleClose: () => void;
}) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const [, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const isShareSupported = "share" in navigator;
const { onCopy, copyStatus } = useCopyStatus();
const copyRoomLink = async () => {
try {
@@ -129,26 +130,16 @@ const ActiveRoomDialog = ({
onClick={shareRoomLink}
/>
)}
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton
size="large"
label="Copy link"
icon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
<FilledButton
size="large"
label={t("buttons.copyLink")}
icon={copyIcon}
status={copyStatus}
onClick={() => {
copyRoomLink();
onCopy();
}}
/>
</div>
<div className="ShareDialog__active__description">
<p>

View File

@@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
class="welcome-screen-center"
>
<div
class="welcome-screen-center__logo virgil welcome-screen-decor"
class="welcome-screen-center__logo excalifont welcome-screen-decor"
>
<div
class="ExcalidrawLogo is-small"
@@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
</div>
</div>
<div
class="welcome-screen-center__heading welcome-screen-decor virgil"
class="welcome-screen-center__heading welcome-screen-decor excalifont"
>
All your data is saved locally in your browser.
</div>
@@ -216,23 +216,22 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
stroke-width="2"
viewBox="0 0 24 24"
>
<g>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M10 12l10 0"
d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
/>
<path
d="M10 12l4 4"
d="M21 12h-13l3 -3"
/>
<path
d="M10 12l4 -4"
/>
<path
d="M4 4l0 16"
d="M11 15l-3 -3"
/>
</g>
</svg>

View File

@@ -2,7 +2,6 @@ import { vi } from "vitest";
import {
act,
render,
updateSceneData,
waitFor,
} from "../../packages/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
@@ -12,7 +11,7 @@ import {
createRedoAction,
createUndoAction,
} from "../../packages/excalidraw/actions/actionHistory";
import { newElementWith } from "../../packages/excalidraw";
import { StoreAction, newElementWith } from "../../packages/excalidraw";
const { h } = window;
@@ -88,17 +87,17 @@ describe("collaboration", () => {
const rect1 = API.createElement({ ...rect1Props });
const rect2 = API.createElement({ ...rect2Props });
updateSceneData({
API.updateScene({
elements: syncInvalidIndices([rect1, rect2]),
commitToStore: true,
storeAction: StoreAction.CAPTURE,
});
updateSceneData({
API.updateScene({
elements: syncInvalidIndices([
rect1,
newElementWith(h.elements[1], { isDeleted: true }),
]),
commitToStore: true,
storeAction: StoreAction.CAPTURE,
});
await waitFor(() => {
@@ -143,8 +142,9 @@ describe("collaboration", () => {
});
// simulate force deleting the element remotely
updateSceneData({
API.updateScene({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
await waitFor(() => {
@@ -177,12 +177,12 @@ describe("collaboration", () => {
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
updateSceneData({
API.updateScene({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
commitToStore: true,
storeAction: StoreAction.CAPTURE,
});
await waitFor(() => {
@@ -215,8 +215,9 @@ describe("collaboration", () => {
});
// simulate force deleting the element remotely
updateSceneData({
API.updateScene({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
// snapshot was correctly updated and marked the element as deleted

View File

@@ -2,7 +2,7 @@ import { atom, useAtom } from "jotai";
import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "../packages/excalidraw";
import { EVENT } from "../packages/excalidraw/constants";
import { Theme } from "../packages/excalidraw/element/types";
import type { Theme } from "../packages/excalidraw/element/types";
import { CODES, KEYS } from "../packages/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants";

View File

@@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables
const envVars = loadEnv("", `../`);
@@ -22,6 +23,14 @@ export default defineConfig({
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path
return "[name]-[hash][extname]";
}
return "assets/[name]-[hash][extname]";
},
// Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load
@@ -39,8 +48,11 @@ export default defineConfig({
},
},
sourcemap: true,
// don't auto-inline small assets (i.e. fonts hosted on CDN)
assetsInlineLimit: 0,
},
plugins: [
woff2BrowserPlugin(),
react(),
checker({
typescript: true,
@@ -63,8 +75,8 @@ export default defineConfig({
},
workbox: {
// Don't push fonts and locales to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
// Don't push fonts, locales and wasm to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
runtimeCaching: [
{
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
@@ -98,6 +110,17 @@ export default defineConfig({
},
},
},
{
urlPattern: new RegExp(".wasm-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "wasm",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
],
},
manifest: {

View File

@@ -1,37 +1,28 @@
{
"private": true,
"name": "excalidraw-monorepo",
"packageManager": "yarn@1.22.22",
"workspaces": [
"excalidraw-app",
"packages/excalidraw",
"packages/utils",
"packages/math",
"examples/excalidraw",
"examples/excalidraw/*"
],
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
"@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0",
"@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0",
"@vitest/ui": "0.32.2",
"@vitest/coverage-v8": "2.0.5",
"@vitest/ui": "2.0.5",
"chai": "4.3.6",
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
@@ -41,17 +32,19 @@
"husky": "7.0.4",
"jsdom": "22.1.0",
"lint-staged": "12.3.7",
"patch-package": "8.0.0",
"pepjs": "0.5.3",
"postinstall-postinstall": "2.1.0",
"prettier": "2.6.2",
"rewire": "6.0.0",
"typescript": "4.9.4",
"vite": "5.0.12",
"vite-plugin-checker": "0.6.1",
"vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0",
"vitest": "1.0.1",
"vitest-canvas-mock": "0.3.2"
"vite-plugin-svgr": "4.2.0",
"vitest": "2.0.5",
"vitest-canvas-mock": "0.3.3"
},
"engines": {
"node": "18.0.0 - 20.x.x"
@@ -60,9 +53,9 @@
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ./scripts/build-version.js",
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
@@ -70,6 +63,7 @@
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"postinstall": "patch-package",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "yarn --cwd ./excalidraw-app start",
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
@@ -86,6 +80,13 @@
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
"release:excalidraw": "node scripts/release.js"
"release:excalidraw": "node scripts/release.js",
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"
},
"resolutions": {
"@types/react": "18.2.0",
"strip-ansi": "6.0.1"
}
}

View File

@@ -15,8 +15,14 @@ Please add the latest change on the top under the correct section.
### Features
- Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
@@ -29,17 +35,21 @@ Please add the latest change on the top under the correct section.
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
- Extended `window.EXCALIDRAW_ASSET_PATH` to accept array of paths `string[]` as a value, allowing to specify multiple base `URL` fallbacks. [#8286](https://github.com/excalidraw/excalidraw/pull/8286)
### Fixes
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
### Breaking Changes
- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
- Stats container CSS changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout.
### Breaking Changes
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
| | Before `commitToHistory` | After `storeAction` | Notes |
| --- | --- | --- | --- |
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be recorded by the store & history. Should be used for the most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be recorded immediately (likely exceptions which are part of some async multi-step process) or those not meant to be recorded at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being recorded with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
@@ -295,6 +305,7 @@ define: {
## 0.16.0 (2023-09-19)
- Add a `subtype` attribute to `ExcalidrawElement` to allow self-contained extensions of any `ExcalidrawElement` type. Implement MathJax support on stem.excalidraw.com as a `math` subtype of `ExcalidrawTextElement`. Both standard Latex input and simplified AsciiMath input are supported. [#6037](https://github.com/excalidraw/excalidraw/pull/6037).
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
- Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691)
- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691)

View File

@@ -20,7 +20,7 @@ After installation you will see a folder `excalidraw-assets` and `excalidraw-ass
Move the folder `excalidraw-assets` and `excalidraw-assets-dev` to the path where your assets are served.
By default it will try to load the files from [`https://unpkg.com/@excalidraw/excalidraw/dist/prod/`](https://unpkg.com/@excalidraw/excalidraw/dist/prod/)
By default it will try to load the files from [`https://unpkg.com/@excalidraw/excalidraw/dist/`](https://unpkg.com/@excalidraw/excalidraw/dist)
If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_ASSET_PATH` depending on environment (for example if you have different URL's for dev and prod) to the url from where you want to load the assets.

View File

@@ -1,4 +1,5 @@
import { alignElements, Alignment } from "../align";
import type { Alignment } from "../align";
import { alignElements } from "../align";
import {
AlignBottomIcon,
AlignLeftIcon,
@@ -10,13 +11,13 @@ import {
import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { StoreAction } from "../store";
import { AppClassProperties, AppState, UIAppState } from "../types";
import type { AppClassProperties, AppState, UIAppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";

View File

@@ -1,8 +1,8 @@
import {
BOUND_TEXT_PADDING,
ROUNDNESS,
VERTICAL_ALIGN,
TEXT_ALIGN,
VERTICAL_ALIGN,
} from "../constants";
import { isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement";
@@ -10,7 +10,7 @@ import {
computeBoundTextPosition,
computeContainerDimensionForBoundText,
getBoundTextElement,
measureText,
measureTextElement,
redrawTextBoundingBox,
} from "../element/textElement";
import {
@@ -23,15 +23,15 @@ import {
isTextBindableContainer,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import {
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "../element/types";
import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { arrayToMap, getFontString } from "../utils";
import type { AppState } from "../types";
import type { Mutable } from "../utility-types";
import { arrayToMap } from "../utils";
import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
@@ -51,11 +51,9 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { width, height } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const { width, height } = measureTextElement(boundTextElement, {
text: boundTextElement.originalText,
});
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
);
@@ -142,6 +140,7 @@ export const actionBindText = register({
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
});
mutateElement(container, {
boundElements: (container.boundElements || []).concat({
@@ -296,6 +295,7 @@ export const actionWrapTextInContainer = register({
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
},
false,
);

View File

@@ -18,13 +18,13 @@ import {
ZOOM_STEP,
} from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import type { AppState, Offsets } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
@@ -35,9 +35,10 @@ import {
isHandToolActive,
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { SceneBounds } from "../element/bounds";
import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "../store";
import { clamp, roundToStep } from "../../math";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@@ -104,7 +105,9 @@ export const actionClearCanvas = register({
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
showStats: appState.showStats,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
stats: appState.stats,
pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
@@ -244,6 +247,7 @@ export const actionResetZoom = register({
const zoomValueToFitBoundsOnViewport = (
bounds: SceneBounds,
viewportDimensions: { width: number; height: number },
viewportZoomFactor: number = 1, // default to 1 if not provided
) => {
const [x1, y1, x2, y2] = bounds;
const commonBoundsWidth = x2 - x1;
@@ -251,92 +255,89 @@ const zoomValueToFitBoundsOnViewport = (
const commonBoundsHeight = y2 - y1;
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
const zoomAdjustedToSteps =
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
const clampedZoomValueToFitElements = Math.min(
Math.max(zoomAdjustedToSteps, MIN_ZOOM),
1,
);
return clampedZoomValueToFitElements as NormalizedZoomValue;
const adjustedZoomValue =
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
return Math.min(adjustedZoomValue, 1);
};
export const zoomToFitBounds = ({
bounds,
appState,
canvasOffsets,
fitToViewport = false,
viewportZoomFactor = 0.7,
viewportZoomFactor = 1,
minZoom = -Infinity,
maxZoom = Infinity,
}: {
bounds: SceneBounds;
canvasOffsets?: Offsets;
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => {
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
const [x1, y1, x2, y2] = bounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
let newZoomValue;
let scrollX;
let scrollY;
const canvasOffsetLeft = canvasOffsets?.left ?? 0;
const canvasOffsetTop = canvasOffsets?.top ?? 0;
const canvasOffsetRight = canvasOffsets?.right ?? 0;
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
const effectiveCanvasWidth =
appState.width - canvasOffsetLeft - canvasOffsetRight;
const effectiveCanvasHeight =
appState.height - canvasOffsetTop - canvasOffsetBottom;
let adjustedZoomValue;
if (fitToViewport) {
const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1;
newZoomValue =
adjustedZoomValue =
Math.min(
appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight,
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
) as NormalizedZoomValue;
let appStateWidth = appState.width;
if (appState.openSidebar) {
const sidebarDOMElem = document.querySelector(
".sidebar",
) as HTMLElement | null;
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
appStateWidth = !isRTL
? appState.width - sidebarWidth
: appState.width + sidebarWidth;
}
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
effectiveCanvasWidth / commonBoundsWidth,
effectiveCanvasHeight / commonBoundsHeight,
) * viewportZoomFactor;
} else {
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
adjustedZoomValue = zoomValueToFitBoundsOnViewport(
bounds,
{
width: effectiveCanvasWidth,
height: effectiveCanvasHeight,
},
viewportZoomFactor,
);
}
const newZoomValue = getNormalizedZoom(
clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
);
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
});
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: { value: newZoomValue },
});
scrollX = centerScroll.scrollX;
scrollY = centerScroll.scrollY;
}
},
offsets: canvasOffsets,
zoom: { value: newZoomValue },
});
return {
appState: {
...appState,
scrollX,
scrollY,
scrollX: centerScroll.scrollX,
scrollY: centerScroll.scrollY,
zoom: { value: newZoomValue },
},
storeAction: StoreAction.NONE,
@@ -344,25 +345,34 @@ export const zoomToFitBounds = ({
};
export const zoomToFit = ({
canvasOffsets,
targetElements,
appState,
fitToViewport,
viewportZoomFactor,
minZoom,
maxZoom,
}: {
canvasOffsets?: Offsets;
targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
return zoomToFitBounds({
canvasOffsets,
bounds: commonBounds,
appState,
fitToViewport,
viewportZoomFactor,
minZoom,
maxZoom,
});
};
@@ -383,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
userToFollow: null,
},
fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
});
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
@@ -408,6 +419,7 @@ export const actionZoomToFitSelection = register({
userToFollow: null,
},
fitToViewport: true,
canvasOffsets: app.getEditorUIOffsets(),
});
},
// NOTE this action should use shift-2 per figma, alas
@@ -424,7 +436,7 @@ export const actionZoomToFit = register({
icon: zoomAreaIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) =>
perform: (elements, appState, _, app) =>
zoomToFit({
targetElements: elements,
appState: {
@@ -432,6 +444,7 @@ export const actionZoomToFit = register({
userToFollow: null,
},
fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}),
keyTest: (event) =>
event.code === CODES.ONE &&

View File

@@ -10,7 +10,7 @@ import {
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element";
import { getTextFromElements, isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
@@ -239,16 +239,8 @@ export const copyText = register({
includeBoundTextElement: true,
});
const text = selectedElements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join("\n\n");
try {
copyTextToSystemClipboard(text);
copyTextToSystemClipboard(getTextFromElements(selectedElements));
} catch (e) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
}

View File

@@ -4,21 +4,28 @@ import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
import type { ExcalidrawElement } from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import {
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
} from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => isFrameLikeElement(el)),
@@ -29,6 +36,26 @@ const deleteSelectedElements = (
return {
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene
.getNonDeletedElementsMap()
.get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId
? null
: bound.endBinding,
});
mutateElbowArrow(bound, elementsMap, bound.points);
}
});
}
return newElementWith(el, { isDeleted: true });
}
@@ -130,7 +157,11 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(element, selectedPointsIndices);
LinearElementEditor.deletePoints(
element,
selectedPointsIndices,
elementsMap,
);
return {
elements,
@@ -149,7 +180,7 @@ export const actionDeleteSelected = register({
};
}
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState);
deleteSelectedElements(elements, appState, app);
fixBindingsAfterDeletion(
nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]),

View File

@@ -3,16 +3,17 @@ import {
DistributeVerticallyIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute";
import type { Distribution } from "../distribute";
import { distributeElements } from "../distribute";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { StoreAction } from "../store";
import { AppClassProperties, AppState } from "../types";
import type { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";

View File

@@ -1,6 +1,6 @@
import { KEYS } from "../keys";
import { register } from "./register";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element";
import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton";
@@ -12,10 +12,10 @@ import {
getSelectedGroupForElement,
getElementsInGroup,
} from "../groups";
import { AppState } from "../types";
import type { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types";
import { GRID_SIZE } from "../constants";
import type { ActionResult } from "./types";
import { DEFAULT_GRID_SIZE } from "../constants";
import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
@@ -40,23 +40,23 @@ export const actionDuplicateSelection = register({
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints(
appState,
elementsMap,
);
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
try {
const newAppState = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene.getNonDeletedElementsMap(),
);
if (!ret) {
return {
elements,
appState: newAppState,
storeAction: StoreAction.CAPTURE,
};
} catch {
return false;
}
return {
elements,
appState: ret.appState,
storeAction: StoreAction.CAPTURE,
};
}
return {
@@ -100,8 +100,8 @@ const duplicateElements = (
groupIdMap,
element,
{
x: element.x + GRID_SIZE / 2,
y: element.y + GRID_SIZE / 2,
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
},
);
duplicatedElementsMap.set(newElement.id, newElement);

View File

@@ -1,3 +1,4 @@
import React from "react";
import { Excalidraw } from "../index";
import { queryByTestId, fireEvent } from "@testing-library/react";
import { render } from "../tests/test-utils";

View File

@@ -1,7 +1,7 @@
import { LockedIcon, UnlockedIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";

View File

@@ -16,7 +16,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
import type { Theme } from "../element/types";
import "../components/ToolIcon.scss";
import { StoreAction } from "../store";

View File

@@ -6,16 +6,17 @@ import { done } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";
import { mutateElement } from "../element/mutateElement";
import { isPathALoop } from "../math";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
maybeBindLinearElement,
bindOrUnbindLinearElement,
} from "../element/binding";
import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types";
import type { AppState } from "../types";
import { resetCursor } from "../cursor";
import { StoreAction } from "../store";
import { pointFrom } from "../../math";
import { isPathALoop } from "../shapes";
export const actionFinalize = register({
name: "finalize",
@@ -38,6 +39,7 @@ export const actionFinalize = register({
startBindingElement,
endBindingElement,
elementsMap,
scene,
);
}
return {
@@ -49,7 +51,6 @@ export const actionFinalize = register({
...appState,
cursorButton: "up",
editingLinearElement: null,
selectedLinearElement: null,
},
storeAction: StoreAction.CAPTURE,
};
@@ -72,8 +73,8 @@ export const actionFinalize = register({
const multiPointElement = appState.multiElement
? appState.multiElement
: appState.editingElement?.type === "freedraw"
? appState.editingElement
: appState.newElement?.type === "freedraw"
? appState.newElement
: null;
if (multiPointElement) {
@@ -112,10 +113,10 @@ export const actionFinalize = register({
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0];
mutateElement(multiPointElement, {
points: linePoints.map((point, index) =>
points: linePoints.map((p, index) =>
index === linePoints.length - 1
? ([firstPoint[0], firstPoint[1]] as const)
: point,
? pointFrom(firstPoint[0], firstPoint[1])
: p,
),
});
}
@@ -131,7 +132,13 @@ export const actionFinalize = register({
-1,
arrayToMap(elements),
);
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
maybeBindLinearElement(
multiPointElement,
appState,
{ x, y },
elementsMap,
elements,
);
}
}
@@ -169,9 +176,10 @@ export const actionFinalize = register({
? appState.activeTool
: activeTool,
activeEmbeddable: null,
draggingElement: null,
newElement: null,
selectionElement: null,
multiElement: null,
editingElement: null,
editingTextElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds:
@@ -197,7 +205,7 @@ export const actionFinalize = register({
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null ||
(!appState.draggingElement && appState.multiElement === null))) ||
(!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => (
@@ -209,6 +217,7 @@ export const actionFinalize = register({
onClick={updateData}
visible={appState.multiElement != null}
size={data?.size || "medium"}
style={{ pointerEvents: "all" }}
/>
),
});

View File

@@ -0,0 +1,211 @@
import React from "react";
import { Excalidraw } from "../index";
import { render } from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { pointFrom } from "../../math";
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
const { h } = window;
describe("flipping re-centers selection", () => {
it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
const elements = [
API.createElement({
type: "rectangle",
id: "rec1",
x: 100,
y: 100,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "rectangle",
id: "rec2",
x: 220,
y: 250,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "arrow",
id: "arr",
x: 149.9,
y: 95,
width: 156,
height: 239.9,
startBinding: {
elementId: "rec1",
focus: 0,
gap: 5,
fixedPoint: [0.49, -0.05],
},
endBinding: {
elementId: "rec2",
focus: 0,
gap: 5,
fixedPoint: [-0.05, 0.49],
},
startArrowhead: null,
endArrowhead: "arrow",
points: [
pointFrom(0, 0),
pointFrom(0, -35),
pointFrom(-90.9, -35),
pointFrom(-90.9, 204.9),
pointFrom(65.1, 204.9),
],
elbowed: true,
}),
];
await render(<Excalidraw initialData={{ elements }} />);
API.setSelectedElements(elements);
expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1");
expect(rec1?.x).toBeCloseTo(100);
expect(rec1?.y).toBeCloseTo(100);
const rec2 = h.elements.find((el) => el.id === "rec2");
expect(rec2?.x).toBeCloseTo(220);
expect(rec2?.y).toBeCloseTo(250);
});
});
describe("flipping arrowheads", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("flipping bound arrow should flip arrowheads only", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
});
it("flipping bound arrow should flip arrowheads only 2", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const rect2 = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
startBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
endBinding: {
elementId: rect2.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, rect2, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("circle");
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping unbound arrow shouldn't flip arrowheads", () => {
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
});
API.setElements([arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([rect, arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
});
});

View File

@@ -1,24 +1,32 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import {
import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
NonDeleted,
NonDeletedSceneElementsMap,
} from "../element/types";
import { resizeMultipleElements } from "../element/resizeElements";
import { AppClassProperties, AppState } from "../types";
import type { AppClassProperties, AppState } from "../types";
import { arrayToMap } from "../utils";
import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
import {
bindOrUnbindSelectedElements,
bindOrUnbindLinearElements,
isBindingEnabled,
unbindLinearElements,
} from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store";
import {
isArrowElement,
isElbowArrow,
isLinearElement,
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@@ -89,7 +97,6 @@ const flipSelectedElements = (
const updatedElements = flipElements(
selectedElements,
elements,
elementsMap,
appState,
flipDirection,
@@ -105,13 +112,28 @@ const flipSelectedElements = (
const flipElements = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
flipDirection: "horizontal" | "vertical",
app: AppClassProperties,
): ExcalidrawElement[] => {
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
if (
selectedElements.every(
(element) =>
isArrowElement(element) && (element.startBinding || element.endBinding),
)
) {
return selectedElements.map((element) => {
const _element = element as ExcalidrawArrowElement;
return newElementWith(_element, {
startArrowhead: _element.endArrowhead,
endArrowhead: _element.startArrowhead,
});
});
}
const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements);
resizeMultipleElements(
elementsMap,
@@ -119,13 +141,62 @@ const flipElements = (
elementsMap,
"nw",
true,
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
);
isBindingEnabled(appState)
? bindOrUnbindSelectedElements(selectedElements, app)
: unbindLinearElements(selectedElements, elementsMap);
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
isBindingEnabled(appState),
[],
);
// ---------------------------------------------------------------------------
// flipping arrow elements (and potentially other) makes the selection group
// "move" across the canvas because of how arrows can bump against the "wall"
// of the selection, so we need to center the group back to the original
// position so that repeated flips don't accumulate the offset
const { elbowArrows, otherElements } = selectedElements.reduce(
(
acc: {
elbowArrows: ExcalidrawElbowArrowElement[];
otherElements: ExcalidrawElement[];
},
element,
) =>
isElbowArrow(element)
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
: { ...acc, otherElements: acc.otherElements.concat(element) },
{ elbowArrows: [], otherElements: [] },
);
const { midX: newMidX, midY: newMidY } =
getCommonBoundingBox(selectedElements);
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
otherElements.forEach((element) =>
mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
elbowArrows.forEach((element) =>
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
),
);
// ---------------------------------------------------------------------------
return selectedElements;
};

View File

@@ -1,9 +1,9 @@
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameChildren } from "../frame";
import { KEYS } from "../keys";
import { AppClassProperties, AppState, UIAppState } from "../types";
import type { AppClassProperties, AppState, UIAppState } from "../types";
import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { register } from "./register";

View File

@@ -17,12 +17,12 @@ import {
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import {
import type {
ExcalidrawElement,
ExcalidrawTextElement,
OrderedExcalidrawElement,
} from "../element/types";
import { AppClassProperties, AppState } from "../types";
import type { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,

View File

@@ -1,25 +1,31 @@
import { Action, ActionResult } from "./types";
import type { Action, ActionResult } from "./types";
import { UndoIcon, RedoIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { History, HistoryChangedEvent } from "../history";
import { AppState } from "../types";
import type { History } from "../history";
import { HistoryChangedEvent } from "../history";
import type { AppClassProperties, AppState } from "../types";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import { SceneElementsMap } from "../element/types";
import { IStore, StoreAction } from "../store";
import type { SceneElementsMap } from "../element/types";
import type { Store } from "../store";
import { StoreAction } from "../store";
import { useEmitter } from "../hooks/useEmitter";
const writeData = (
const executeHistoryAction = (
app: AppClassProperties,
appState: Readonly<AppState>,
updater: () => [SceneElementsMap, AppState] | void,
): ActionResult => {
if (
!appState.multiElement &&
!appState.resizingElement &&
!appState.editingElement &&
!appState.draggingElement
!appState.editingTextElement &&
!appState.newElement &&
!appState.selectedElementsAreBeingDragged &&
!appState.selectionElement &&
!app.flowChartCreator.isCreatingChart
) {
const result = updater();
@@ -40,7 +46,7 @@ const writeData = (
return { storeAction: StoreAction.NONE };
};
type ActionCreator = (history: History, store: IStore) => Action;
type ActionCreator = (history: History, store: Store) => Action;
export const createUndoAction: ActionCreator = (history, store) => ({
name: "undo",
@@ -48,8 +54,8 @@ export const createUndoAction: ActionCreator = (history, store) => ({
icon: UndoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState) =>
writeData(appState, () =>
perform: (elements, appState, value, app) =>
executeHistoryAction(app, appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
@@ -63,7 +69,10 @@ export const createUndoAction: ActionCreator = (history, store) => ({
PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter,
new HistoryChangedEvent(),
new HistoryChangedEvent(
history.isUndoStackEmpty,
history.isRedoStackEmpty,
),
);
return (
@@ -74,6 +83,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
onClick={updateData}
size={data?.size || "medium"}
disabled={isUndoStackEmpty}
data-testid="button-undo"
/>
);
},
@@ -85,8 +95,8 @@ export const createRedoAction: ActionCreator = (history, store) => ({
icon: RedoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState) =>
writeData(appState, () =>
perform: (elements, appState, _, app) =>
executeHistoryAction(app, appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
@@ -101,7 +111,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({
PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,
new HistoryChangedEvent(),
new HistoryChangedEvent(
history.isUndoStackEmpty,
history.isRedoStackEmpty,
),
);
return (
@@ -112,6 +125,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
onClick={updateData}
size={data?.size || "medium"}
disabled={isRedoStackEmpty}
data-testid="button-redo"
/>
);
},

View File

@@ -1,9 +1,12 @@
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types";
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
import type { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store";
import { register } from "./register";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { lineEditorIcon } from "../components/icons";
export const actionToggleLinearEditor = register({
name: "toggleLinearEditor",
@@ -11,18 +14,24 @@ export const actionToggleLinearEditor = register({
label: (elements, appState, app) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement?.id
? "labels.lineEditor.exit"
})[0] as ExcalidrawLinearElement | undefined;
return selectedElement?.type === "arrow"
? "labels.lineEditor.editArrow"
: "labels.lineEditor.edit";
},
keywords: ["line"],
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (
!appState.editingLinearElement &&
selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0])
) {
return true;
}
return false;
@@ -45,4 +54,24 @@ export const actionToggleLinearEditor = register({
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ appState, updateData, app }) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement;
const label = t(
selectedElement.type === "arrow"
? "labels.lineEditor.editArrow"
: "labels.lineEditor.edit",
);
return (
<ToolButton
type="button"
icon={lineEditorIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});

View File

@@ -1,6 +1,6 @@
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import { GoToCollaboratorComponentProps } from "../components/UserList";
import type { GoToCollaboratorComponentProps } from "../components/UserList";
import {
eyeIcon,
microphoneIcon,
@@ -8,7 +8,7 @@ import {
} from "../components/icons";
import { t } from "../i18n";
import { StoreAction } from "../store";
import { Collaborator } from "../types";
import type { Collaborator } from "../types";
import { register } from "./register";
import clsx from "clsx";

View File

@@ -1,3 +1,4 @@
import React from "react";
import { Excalidraw } from "../index";
import { queryByTestId } from "@testing-library/react";
import { render } from "../tests/test-utils";
@@ -6,8 +7,6 @@ import { API } from "../tests/helpers/api";
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
const { h } = window;
describe("element locking", () => {
beforeEach(async () => {
await render(<Excalidraw />);
@@ -22,7 +21,7 @@ describe("element locking", () => {
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({
API.setAppState({
currentItemBackgroundColor: color,
});
const activeColor = queryByTestId(
@@ -40,14 +39,14 @@ describe("element locking", () => {
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({
API.setAppState({
currentItemBackgroundColor: color,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toHaveClass("active");
h.setState({
API.setAppState({
currentItemFillStyle: "solid",
});
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
@@ -57,7 +56,7 @@ describe("element locking", () => {
it("should not show fill style when background transparent", () => {
UI.clickTool("rectangle");
h.setState({
API.setAppState({
currentItemBackgroundColor: COLOR_PALETTE.transparent,
currentItemFillStyle: "hachure",
});
@@ -69,7 +68,7 @@ describe("element locking", () => {
it("should show horizontal text align for text tool", () => {
UI.clickTool("text");
h.setState({
API.setAppState({
currentItemTextAlign: "right",
});
@@ -85,7 +84,7 @@ describe("element locking", () => {
backgroundColor: "red",
fillStyle: "cross-hatch",
});
h.elements = [rect];
API.setElements([rect]);
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
@@ -98,7 +97,7 @@ describe("element locking", () => {
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "cross-hatch",
});
h.elements = [rect];
API.setElements([rect]);
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
@@ -114,7 +113,7 @@ describe("element locking", () => {
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
h.elements = [rect1, rect2];
API.setElements([rect1, rect2]);
API.setSelectedElements([rect1, rect2]);
const thinStrokeWidthButton = queryByTestId(
@@ -133,7 +132,7 @@ describe("element locking", () => {
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
});
h.elements = [rect1, rect2];
API.setElements([rect1, rect2]);
API.setSelectedElements([rect1, rect2]);
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
@@ -155,13 +154,15 @@ describe("element locking", () => {
});
const text = API.createElement({
type: "text",
fontFamily: FONT_FAMILY.Cascadia,
fontFamily: FONT_FAMILY["Comic Shanns"],
});
h.elements = [rect, text];
API.setElements([rect, text]);
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active",
);
});
});
});

View File

@@ -1,4 +1,6 @@
import { AppClassProperties, AppState, Primitive } from "../types";
import { useEffect, useMemo, useRef, useState } from "react";
import type { AppClassProperties, AppState, Primitive } from "../types";
import type { StoreActionType } from "../store";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
@@ -9,6 +11,7 @@ import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { IconPicker } from "../components/IconPicker";
import { FontPicker } from "../components/FontPicker/FontPicker";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons
@@ -38,9 +41,6 @@ import {
FontSizeExtraLargeIcon,
EdgeSharpIcon,
EdgeRoundIcon,
FreedrawIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
@@ -50,8 +50,12 @@ import {
ArrowheadDiamondIcon,
ArrowheadDiamondOutlineIcon,
fontSizeIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
} from "../components/icons";
import {
ARROW_TYPE,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
@@ -65,17 +69,17 @@ import {
redrawTextBoundingBox,
} from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getBoundTextElement } from "../element/textElement";
import {
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import {
isArrowElement,
isBoundToContainer,
isElbowArrow,
isLinearElement,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import {
import type {
Arrowhead,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
@@ -94,9 +98,25 @@ import {
isSomeElementSelected,
} from "../scene";
import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getShortcutKey } from "../utils";
import {
arrayToMap,
getFontFamilyString,
getShortcutKey,
tupleToCoors,
} from "../utils";
import { register } from "./register";
import { StoreAction } from "../store";
import { Fonts, getLineHeight } from "../fonts";
import {
bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math";
import { pointFrom, vector } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -115,7 +135,7 @@ export const changeProperty = (
return elements.map((element) => {
if (
selectedElementIds.get(element.id) ||
element.id === appState.editingElement?.id
element.id === appState.editingTextElement?.id
) {
return callback(element);
}
@@ -130,13 +150,13 @@ export const getFormValue = function <T extends Primitive>(
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
const editingElement = appState.editingElement;
const editingTextElement = appState.editingTextElement;
const nonDeletedElements = getNonDeletedElements(elements);
let ret: T | null = null;
if (editingElement) {
ret = getAttribute(editingElement);
if (editingTextElement) {
ret = getAttribute(editingTextElement);
}
if (!ret) {
@@ -167,7 +187,7 @@ const offsetElementAfterFontResize = (
prevElement: ExcalidrawTextElement,
nextElement: ExcalidrawTextElement,
) => {
if (isBoundToContainer(nextElement)) {
if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
return nextElement;
}
return mutateElement(
@@ -729,104 +749,389 @@ export const actionIncreaseFontSize = register({
},
});
type ChangeFontFamilyData = Partial<
Pick<
AppState,
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
>
> & {
/** cache of selected & editing elements populated on opened popup */
cachedElements?: Map<string, ExcalidrawElement>;
/** flag to reset all elements to their cached versions */
resetAll?: true;
/** flag to reset all containers to their cached versions */
resetContainers?: true;
};
export const actionChangeFontFamily = register({
name: "changeFontFamily",
label: "labels.fontFamily",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
elements: changeProperty(
const { cachedElements, resetAll, resetContainers, ...nextAppState } =
value as ChangeFontFamilyData;
if (resetAll) {
const nextElements = changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: value,
lineHeight: getDefaultLineHeight(value),
},
);
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
(element) => {
const cachedElement = cachedElements?.get(element.id);
if (cachedElement) {
const newElement = newElementWith(element, {
...cachedElement,
});
return newElement;
}
return oldElement;
return element;
},
true,
),
);
return {
elements: nextElements,
appState: {
...appState,
...nextAppState,
},
storeAction: StoreAction.UPDATE,
};
}
const { currentItemFontFamily, currentHoveredFontFamily } = value;
let nexStoreAction: StoreActionType = StoreAction.NONE;
let nextFontFamily: FontFamilyValues | undefined;
let skipOnHoverRender = false;
if (currentItemFontFamily) {
nextFontFamily = currentItemFontFamily;
nexStoreAction = StoreAction.CAPTURE;
} else if (currentHoveredFontFamily) {
nextFontFamily = currentHoveredFontFamily;
nexStoreAction = StoreAction.NONE;
const selectedTextElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).filter((element) => isTextElement(element));
// skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
if (selectedTextElements.length > 200) {
skipOnHoverRender = true;
} else {
let i = 0;
let textLengthAccumulator = 0;
while (
i < selectedTextElements.length &&
textLengthAccumulator < 5000
) {
const textElement = selectedTextElements[i] as ExcalidrawTextElement;
textLengthAccumulator += textElement?.originalText.length || 0;
i++;
}
if (textLengthAccumulator > 5000) {
skipOnHoverRender = true;
}
}
}
const result = {
appState: {
...appState,
currentItemFontFamily: value,
...nextAppState,
},
storeAction: StoreAction.CAPTURE,
storeAction: nexStoreAction,
};
if (nextFontFamily && !skipOnHoverRender) {
const elementContainerMapping = new Map<
ExcalidrawTextElement,
ExcalidrawElement | null
>();
let uniqueChars = new Set<string>();
let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
const fontFamily = Object.entries(FONT_FAMILY).find(
([_, value]) => value === nextFontFamily,
)?.[0];
// skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
if (
currentHoveredFontFamily &&
fontFamily &&
fontsCache.some((sig) => sig.startsWith(fontFamily))
) {
skipFontFaceCheck = true;
}
// following causes re-render so make sure we changed the family
// otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
Object.assign(result, {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (
isTextElement(oldElement) &&
(oldElement.fontFamily !== nextFontFamily ||
currentItemFontFamily) // force update on selection
) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: nextFontFamily,
lineHeight: getLineHeight(nextFontFamily!),
},
);
const cachedContainer =
cachedElements?.get(oldElement.containerId || "") || {};
const container = app.scene.getContainerElement(oldElement);
if (resetContainers && container && cachedContainer) {
// reset the container back to it's cached version
mutateElement(container, { ...cachedContainer }, false);
}
if (!skipFontFaceCheck) {
uniqueChars = new Set([
...uniqueChars,
...Array.from(newElement.originalText),
]);
}
elementContainerMapping.set(newElement, container);
return newElement;
}
return oldElement;
},
true,
),
});
// size is irrelevant, but necessary
const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily,
})}`;
const chars = Array.from(uniqueChars.values()).join();
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw
redrawTextBoundingBox(
element,
container,
app.scene.getNonDeletedElementsMap(),
false,
);
}
} else {
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
window.document.fonts.load(fontString, chars).then((fontFaces) => {
for (const [element, container] of elementContainerMapping) {
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
const latestElement = app.scene.getElement(element.id);
const latestContainer = container
? app.scene.getElement(container.id)
: null;
if (latestElement) {
// trigger async redraw
redrawTextBoundingBox(
latestElement as ExcalidrawTextElement,
latestContainer,
app.scene.getNonDeletedElementsMap(),
false,
);
}
}
// trigger update once we've mutated all the elements, which also updates our cache
app.fonts.onLoaded(fontFaces);
});
}
}
return result;
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const options: {
value: FontFamilyValues;
text: string;
icon: JSX.Element;
testId: string;
}[] = [
{
value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"),
icon: FreedrawIcon,
testId: "font-family-virgil",
},
{
value: FONT_FAMILY.Helvetica,
text: t("labels.normal"),
icon: FontFamilyNormalIcon,
testId: "font-family-normal",
},
{
value: FONT_FAMILY.Cascadia,
text: t("labels.code"),
icon: FontFamilyCodeIcon,
testId: "font-family-code",
},
];
PanelComponent: ({ elements, appState, app, updateData }) => {
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
const prevSelectedFontFamilyRef = useRef<number | null>(null);
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
const isUnmounted = useRef(true);
const selectedFontFamily = useMemo(() => {
const getFontFamily = (
elementsArray: readonly ExcalidrawElement[],
elementsMap: Map<string, ExcalidrawElement>,
) =>
getFormValue(
elementsArray,
appState,
(element) => {
if (isTextElement(element)) {
return element.fontFamily;
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
return boundTextElement.fontFamily;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(element, elementsMap) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
);
// popup opened, use cached elements
if (
batchedData.openPopup === "fontFamily" &&
appState.openPopup === "fontFamily"
) {
return getFontFamily(
Array.from(cachedElementsRef.current?.values() ?? []),
cachedElementsRef.current,
);
}
// popup closed, use all elements
if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
}
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
return prevSelectedFontFamilyRef.current;
}, [batchedData.openPopup, appState, elements, app.scene]);
useEffect(() => {
prevSelectedFontFamilyRef.current = selectedFontFamily;
}, [selectedFontFamily]);
useEffect(() => {
if (Object.keys(batchedData).length) {
updateData(batchedData);
// reset the data after we've used the data
setBatchedData({});
}
// call update only on internal state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchedData]);
useEffect(() => {
isUnmounted.current = false;
return () => {
isUnmounted.current = true;
};
}, []);
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamilyValues | false>
group="font-family"
options={options}
value={getFormValue(
elements,
appState,
(element) => {
if (isTextElement(element)) {
return element.fontFamily;
<FontPicker
isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily}
onSelect={(fontFamily) => {
setBatchedData({
openPopup: null,
currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily,
});
// defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
}}
onHover={(fontFamily) => {
setBatchedData({
currentHoveredFontFamily: fontFamily,
cachedElements: new Map(cachedElementsRef.current),
resetContainers: true,
});
}}
onLeave={() => {
setBatchedData({
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
});
}}
onPopupChange={(open) => {
if (open) {
// open, populate the cache from scratch
cachedElementsRef.current.clear();
const { editingTextElement } = appState;
// still check type to be safe
if (editingTextElement?.type === "text") {
// retrieve the latest version from the scene, as `editingTextElement` isn't mutated
const latesteditingTextElement = app.scene.getElement(
editingTextElement.id,
);
// inside the wysiwyg editor
cachedElementsRef.current.set(
editingTextElement.id,
newElementWith(
latesteditingTextElement || editingTextElement,
{},
true,
),
);
} else {
const selectedElements = getSelectedElements(
elements,
appState,
{
includeBoundTextElement: true,
},
);
for (const element of selectedElements) {
cachedElementsRef.current.set(
element.id,
newElementWith(element, {}, true),
);
}
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontFamily;
setBatchedData({
openPopup: "fontFamily",
});
} else {
// close, use the cache and clear it afterwards
const data = {
openPopup: null,
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
} as ChangeFontFamilyData;
if (isUnmounted.current) {
// in case the component was unmounted by the parent, trigger the update directly
updateData({ ...batchedData, ...data });
} else {
setBatchedData(data);
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
cachedElementsRef.current.clear();
}
}}
/>
</fieldset>
);
@@ -1019,8 +1324,12 @@ export const actionChangeRoundness = register({
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
elements: changeProperty(elements, appState, (el) => {
if (isElbowArrow(el)) {
return el;
}
return newElementWith(el, {
roundness:
value === "round"
? {
@@ -1029,8 +1338,8 @@ export const actionChangeRoundness = register({
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
}),
),
});
}),
appState: {
...appState,
currentItemRoundness: value,
@@ -1070,7 +1379,8 @@ export const actionChangeRoundness = register({
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(element) => element.hasOwnProperty("roundness"),
(element) =>
!isArrowElement(element) && element.hasOwnProperty("roundness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoundness,
)}
@@ -1233,3 +1543,206 @@ export const actionChangeArrowhead = register({
);
},
});
export const actionChangeArrowType = register({
name: "changeArrowType",
label: "Change arrow types",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (!isArrowElement(el)) {
return el;
}
const newElement = newElementWith(el, {
roundness:
value === ARROW_TYPE.round
? {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
elbowed: value === ARROW_TYPE.elbow,
points:
value === ARROW_TYPE.elbow || el.elbowed
? [el.points[0], el.points[el.points.length - 1]]
: el.points,
});
if (isElbowArrow(newElement)) {
const elementsMap = app.scene.getNonDeletedElementsMap();
app.dismissLinearEditor();
const startGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
0,
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startHoveredElement,
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(
newElement,
endHoveredElement,
"end",
elementsMap,
);
mutateElbowArrow(
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
vector(0, 0),
{
...(startElement && newElement.startBinding
? {
startBinding: {
// @ts-ignore TS cannot discern check above
...newElement.startBinding!,
...calculateFixedPointForElbowArrowBinding(
newElement,
startElement,
"start",
elementsMap,
),
},
}
: {}),
...(endElement && newElement.endBinding
? {
endBinding: {
// @ts-ignore TS cannot discern check above
...newElement.endBinding,
...calculateFixedPointForElbowArrowBinding(
newElement,
endElement,
"end",
elementsMap,
),
},
}
: {}),
},
);
}
return newElement;
}),
appState: {
...appState,
currentItemArrowType: value,
},
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset>
<legend>{t("labels.arrowtypes")}</legend>
<ButtonIconSelect
group="arrowtypes"
options={[
{
value: ARROW_TYPE.sharp,
text: t("labels.arrowtype_sharp"),
icon: sharpArrowIcon,
testId: "sharp-arrow",
},
{
value: ARROW_TYPE.round,
text: t("labels.arrowtype_round"),
icon: roundArrowIcon,
testId: "round-arrow",
},
{
value: ARROW_TYPE.elbow,
text: t("labels.arrowtype_elbowed"),
icon: elbowArrowIcon,
testId: "elbow-arrow",
},
]}
value={getFormValue(
elements,
appState,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? ARROW_TYPE.elbow
: element.roundness
? ARROW_TYPE.round
: ARROW_TYPE.sharp;
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
});

View File

@@ -2,7 +2,7 @@ import { KEYS } from "../keys";
import { register } from "./register";
import { selectGroupsForSelectedElements } from "../groups";
import { getNonDeletedElements, isTextElement } from "../element";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";

View File

@@ -12,10 +12,7 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "../constants";
import {
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import { getBoundTextElement } from "../element/textElement";
import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
@@ -24,9 +21,10 @@ import {
isArrowElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
import { ExcalidrawTextElement } from "../element/types";
import type { ExcalidrawTextElement } from "../element/types";
import { paintIcon } from "../components/icons";
import { StoreAction } from "../store";
import { getLineHeight } from "../fonts";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@@ -122,7 +120,7 @@ export const actionPasteStyles = register({
DEFAULT_TEXT_ALIGN,
lineHeight:
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
getDefaultLineHeight(fontFamily),
getLineHeight(fontFamily),
});
let container = null;
if (newElement.containerId) {

View File

@@ -0,0 +1,48 @@
import { isTextElement } from "../element";
import { newElementWith } from "../element/mutateElement";
import { measureText } from "../element/textElement";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import type { AppClassProperties } from "../types";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionTextAutoResize = register({
name: "autoResize",
label: "labels.autoResize",
icon: null,
trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
!selectedElements[0].autoResize
);
},
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
return {
appState,
elements: elements.map((element) => {
if (element.id === selectedElements[0].id && isTextElement(element)) {
const metrics = measureText(
element.originalText,
getFontString(element),
element.lineHeight,
);
return newElementWith(element, {
autoResize: true,
width: metrics.width,
height: metrics.height,
text: element.originalText,
});
}
return element;
}),
storeAction: StoreAction.CAPTURE,
};
},
});

View File

@@ -1,7 +1,6 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import type { AppState } from "../types";
import { gridIcon } from "../components/icons";
import { StoreAction } from "../store";
@@ -13,21 +12,21 @@ export const actionToggleGridMode = register({
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.gridSize,
predicate: (appState) => appState.gridModeEnabled,
},
perform(elements, appState) {
return {
appState: {
...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
gridModeEnabled: !this.checked!(appState),
objectsSnapModeEnabled: false,
},
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined";
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View File

@@ -17,7 +17,7 @@ export const actionToggleObjectsSnapMode = register({
appState: {
...appState,
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
gridModeEnabled: false,
},
storeAction: StoreAction.NONE,
};

View File

@@ -0,0 +1,55 @@
import { KEYS } from "../keys";
import { register } from "./register";
import type { AppState } from "../types";
import { searchIcon } from "../components/icons";
import { StoreAction } from "../store";
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
export const actionToggleSearchMenu = register({
name: "searchMenu",
icon: searchIcon,
keywords: ["search", "find"],
label: "search.title",
viewMode: true,
trackEvent: {
category: "search_menu",
action: "toggle",
predicate: (appState) => appState.gridModeEnabled,
},
perform(elements, appState, _, app) {
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB
) {
const searchInput =
app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
);
if (searchInput?.matches(":focus")) {
return {
appState: { ...appState, openSidebar: null },
storeAction: StoreAction.NONE,
};
}
searchInput?.focus();
searchInput?.select();
return false;
}
return {
appState: {
...appState,
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
openDialog: null,
},
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
});

View File

@@ -5,21 +5,22 @@ import { StoreAction } from "../store";
export const actionToggleStats = register({
name: "stats",
label: "stats.title",
label: "stats.fullTitle",
icon: abacusIcon,
paletteName: "Toggle stats",
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["edit", "attributes", "customize"],
perform(elements, appState) {
return {
appState: {
...appState,
showStats: !this.checked!(appState),
stats: { ...appState.stats, open: !this.checked!(appState) },
},
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.showStats,
checked: (appState) => appState.stats.open,
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
});

View File

@@ -20,6 +20,7 @@ import { StoreAction } from "../store";
export const actionSendBackward = register({
name: "sendBackward",
label: "labels.sendBackward",
keywords: ["move down", "zindex", "layer"],
icon: SendBackwardIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
@@ -49,6 +50,7 @@ export const actionSendBackward = register({
export const actionBringForward = register({
name: "bringForward",
label: "labels.bringForward",
keywords: ["move up", "zindex", "layer"],
icon: BringForwardIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
@@ -78,6 +80,7 @@ export const actionBringForward = register({
export const actionSendToBack = register({
name: "sendToBack",
label: "labels.sendToBack",
keywords: ["move down", "zindex", "layer"],
icon: SendToBackIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
@@ -114,6 +117,7 @@ export const actionSendToBack = register({
export const actionBringToFront = register({
name: "bringToFront",
label: "labels.bringToFront",
keywords: ["move up", "zindex", "layer"],
icon: BringToFrontIcon,
trackEvent: { category: "element" },

View File

@@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "./actionLink";
export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";

View File

@@ -1,14 +1,18 @@
import React from "react";
import {
import type {
Action,
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
ActionSource,
ActionPredicateFn,
} from "./types";
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
} from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { trackEvent } from "../analytics";
import { isPromiseLike } from "../utils";
@@ -42,6 +46,7 @@ const trackAction = (
export class ActionManager {
actions = {} as Record<ActionName, Action>;
actionPredicates = [] as ActionPredicateFn[];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -69,6 +74,37 @@ export class ActionManager {
this.app = app;
}
registerActionPredicate(predicate: ActionPredicateFn) {
if (!this.actionPredicates.includes(predicate)) {
this.actionPredicates.push(predicate);
}
}
filterActions(
filter: ActionPredicateFn,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
},
): Action[] {
// For testing
if (this === undefined) {
return [];
}
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState();
const data = opts?.data;
const actions: Action[] = [];
for (const key in this.actions) {
const action = this.actions[key as ActionName];
if (filter(action, elements, appState, this.app, data)) {
actions.push(action);
}
}
return actions;
}
registerAction(action: Action) {
this.actions[action.name] = action;
}
@@ -85,7 +121,7 @@ export class ActionManager {
(action) =>
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
: this.isActionEnabled(action, { noPredicates: true })) &&
action.keyTest &&
action.keyTest(
event,
@@ -144,7 +180,7 @@ export class ActionManager {
"PanelComponent" in this.actions[name] &&
(name in canvasActions
? canvasActions[name as keyof typeof canvasActions]
: true)
: this.isActionEnabled(this.actions[name], { noPredicates: true }))
) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
@@ -166,6 +202,7 @@ export class ActionManager {
return (
<PanelComponent
key={name}
elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()}
updateData={updateData}
@@ -179,13 +216,31 @@ export class ActionManager {
return null;
};
isActionEnabled = (action: Action) => {
const elements = this.getElementsIncludingDeleted();
isActionEnabled = (
action: Action,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
noPredicates?: boolean;
},
): boolean => {
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState();
const data = opts?.data;
return (
!action.predicate ||
action.predicate(elements, appState, this.app.props, this.app)
);
if (
!opts?.noPredicates &&
action.predicate &&
!action.predicate(elements, appState, this.app.props, this.app, data)
) {
return false;
}
let enabled = true;
this.actionPredicates.forEach((fn) => {
if (!fn(action, elements, appState, this.app, data)) {
enabled = false;
}
});
return enabled;
};
}

View File

@@ -1,4 +1,4 @@
import { Action } from "./types";
import type { Action } from "./types";
export let actions: readonly Action[] = [];

View File

@@ -1,12 +1,13 @@
import { isDarwin } from "../constants";
import { t } from "../i18n";
import { SubtypeOf } from "../utility-types";
import type { SubtypeOf } from "../utility-types";
import { getShortcutKey } from "../utils";
import { ActionName } from "./types";
import type { ActionName, CustomActionName } from "./types";
export type ShortcutName =
| SubtypeOf<
ActionName,
| CustomActionName
| "toggleTheme"
| "loadScene"
| "clearCanvas"
@@ -51,7 +52,17 @@ export type ShortcutName =
>
| "saveScene"
| "imageExport"
| "commandPalette";
| "commandPalette"
| "searchMenu";
export const registerCustomShortcuts = (
shortcuts: Record<CustomActionName, string[]>,
) => {
for (const key in shortcuts) {
const shortcut = key as CustomActionName;
shortcutMap[shortcut] = shortcuts[shortcut];
}
};
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -112,6 +123,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
};
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {

View File

@@ -1,14 +1,17 @@
import React from "react";
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
import {
import type React from "react";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
} from "../element/types";
import type {
AppClassProperties,
AppState,
ExcalidrawProps,
BinaryFiles,
UIAppState,
} from "../types";
import { MarkOptional } from "../utility-types";
import { StoreAction } from "../store";
import type { MarkOptional } from "../utility-types";
import type { StoreActionType } from "../store";
export type ActionSource =
| "ui"
@@ -26,7 +29,7 @@ export type ActionResult =
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
storeAction: keyof typeof StoreAction;
storeAction: StoreActionType;
replaceFiles?: boolean;
}
| false;
@@ -38,10 +41,24 @@ type ActionFn = (
app: AppClassProperties,
) => ActionResult | Promise<ActionResult>;
// Return `true` *unless* `Action` should be disabled
// given `elements`, `appState`, and optionally `data`.
export type ActionPredicateFn = (
action: Action,
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
data?: Record<string, any>,
) => boolean;
export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void;
export const makeCustomActionName = (name: string) =>
`custom.${name}` as CustomActionName;
export type CustomActionName = `custom.${string}`;
export type ActionName =
| CustomActionName
| "copy"
| "cut"
| "paste"
@@ -67,6 +84,7 @@ export type ActionName =
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"
@@ -131,7 +149,10 @@ export type ActionName =
| "setEmbeddableAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer"
| "commandPalette";
| "commandPalette"
| "autoResize"
| "elementStats"
| "searchMenu";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@@ -172,6 +193,7 @@ export interface Action {
appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
data?: Record<string, any>,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
trackEvent:
@@ -185,7 +207,8 @@ export interface Action {
| "history"
| "menu"
| "collab"
| "hyperlink";
| "hyperlink"
| "search_menu";
action?: string;
predicate?: (
appState: Readonly<AppState>,

View File

@@ -1,6 +1,7 @@
import { ElementsMap, ExcalidrawElement } from "./element/types";
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement";
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
import type { BoundingBox } from "./element/bounds";
import { getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups";
export interface Alignment {

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