Compare commits

...

129 Commits

Author SHA1 Message Date
d308451584 fix offsets 2024-10-25 11:28:01 +08:00
f444383b46 fix: image cropping svg + compat mode 2024-10-25 00:04:04 +02:00
7d52176fea feat: added sitemap & fixed robot txt (#8699) 2024-10-24 12:49:33 +02:00
958e03fcc6 fix: image render perf (#8697) 2024-10-23 11:58:43 +02:00
4cedf3d966 feat: do not strip unknown element properties on restore (#8682) 2024-10-21 22:56:22 +02:00
e957c8e9ee feat: image cropping (#8613)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-10-21 22:26:52 +02:00
eb09b48ae6 fix: undo/redo action for international keyboard layouts (#8649)
Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
2024-10-21 17:08:39 +02:00
61623bbeba fix: Comic Shanns issues, new fonts structure (#8641) 2024-10-21 00:11:53 +02:00
15ca182333 fix: remove export-to-clip-as-svg shortcut for now (#8660) 2024-10-17 20:47:05 +02:00
b479f3bd65 feat: add first-class support for CJK (#8530) 2024-10-17 20:14:17 +02:00
21815fb930 fix: text disappearing on edit (#8558) (#8624) 2024-10-17 13:11:48 +02:00
47ee8a0094 refactor: point() -> pointFrom() to fix compiler issue (#8578) 2024-10-01 21:27:17 +02:00
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
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
a80cb5896a feat: self-hosting existing google fonts (#8540) 2024-09-24 17:30:21 +02:00
6dfa18414a test: decrease min coverage thresholds (#8541) 2024-09-24 12:01:28 +00:00
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
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
44a1c8d857 fix: svg and png frame clipping cases (#8515) 2024-09-18 00:20:22 +02:00
e0a22edfbd fix: Re-route elbow arrows when pasted (#8448)
Re-route elbow arrows when pasted
2024-09-17 12:20:40 +02:00
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
508f16dc04 refactor: rename example App.tsx -> ExampleApp.tsx (#8501) 2024-09-13 16:56:32 +02:00
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
d4900e8f19 fix: Linear element complete button disabled (#8492) 2024-09-12 14:59:38 +02:00
caf2db934c fix: aspect ratio of distorted images are not preserved in SVG exports (#8061) 2024-09-12 14:11:08 +02:00
60e3801691 fix: WYSIWYG editor padding is not normalized with zoom.value (#8481) 2024-09-12 13:42:39 +02:00
80f3b75d42 chore: revert vite 5.4.2 -> 5.0.12 (#8499) 2024-09-12 12:56:45 +02:00
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
01e83cc9a5 fix: default sidebar triggers & behavior (#8498) 2024-09-11 20:19:14 +02:00
813f9b702e feat: merge search sidebar back to default sidebar (#8497) 2024-09-11 19:26:01 +02:00
fd39712ba6 fix: improve canvas search scroll behavior further (#8491) 2024-09-11 18:01:18 +02:00
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
72b7c937b1 feat: smarter zooming when scrolling to match & only match on search/switch (#8488) 2024-09-10 21:26:01 +02:00
d107215564 fix: select instead of focus search input (#8483) 2024-09-09 19:57:22 +02:00
6959a363f0 feat: canvas search (#8438)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-09 17:12:07 +02:00
5a11c70714 fix: image rendering issue when passed in initialData (#8471) 2024-09-08 23:56:00 +02:00
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
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
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
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
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
e3f31df747 docs : update dead link on main-menu page (#8454) 2024-09-01 22:31:13 +00:00
60e75406e0 refactor: remove unused env variable (#8457) 2024-09-02 00:25:12 +02:00
b396e07b90 fix: PropertiesPopover maxWidth changing fixed units to relative units (#8456) 2024-09-01 18:18:37 +02:00
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
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
16cae4fc07 feat: reset copyStatus on export dialog settings change (#8443) 2024-08-29 15:10:08 +02:00
576bc0dbe5 feat: tweak copy button success animation (#8441) 2024-08-29 00:43:01 +02:00
00af35c692 feat: enable panning/zoom while in wysiwyg (#8437) 2024-08-29 00:42:46 +02:00
ea7c702cfc feat: Visual debugger (#8344)
Add visual debugger to the Excalidraw app (only).
2024-08-27 19:46:00 +02:00
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
afb68a6467 feat: improve elbow arrow keyboard move (#8392) 2024-08-26 15:58:54 +02:00
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
5facc0d6da fix: perf regression in getCommonBounds (#8429) 2024-08-26 15:57:28 +02:00
824ad603e1 docs: Update function call in @excalidraw/mermaid-to-excalidraw (#8420) 2024-08-26 13:20:29 +02:00
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
b5d7f5b4ba feat: rewrite d2c to not require token (#8269) 2024-08-20 18:06:22 +02:00
fb4bb29aa5 fix: object snapping not working (#8381) 2024-08-15 18:48:25 +02:00
3cfcc7b489 feat: split gridSize from enabled state & support custom gridStep (#8364) 2024-08-14 14:59:14 +02:00
4320a3cf41 feat: improve zoom-to-content when creating flowchart (#8368) 2024-08-12 20:42:08 +02:00
8420e1aa13 fix: Reimplement rectangle intersection (#8367) 2024-08-12 19:19:16 +02:00
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
97981804d7 feat: Stats popup style tweaks (#8361) 2024-08-11 19:33:44 +02:00
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
7b2bee9746 chore: remove unused parameter (#8355) 2024-08-09 21:39:40 +02:00
88014ace4a fix: drawing from 0-dimension canvas (#8356) 2024-08-09 21:36:04 +02:00
87a9430809 fix: disable flowchart keybindings inside inputs (#8353) 2024-08-09 18:44:17 +02:00
99b91c46f7 fix: Yet more patching of intersect code (#8352)
* Yet more patching of intersect code
2024-08-09 17:33:12 +02:00
1ea5b26f25 fix: missing act() in flowchart tests (#8354) 2024-08-09 17:27:02 +02:00
d5f4ee7b3f fix: z-index change by one causes app to freeze (#8314) 2024-08-09 15:26:08 +02:00
261304c1a4 fix: Patch over intersection calculation issue (#8350)
* Patch over intersection calculation issue
2024-08-09 14:40:57 +02:00
84398a7e5c fix: Point duplication in LEE on ALT+click (#8347) 2024-08-09 12:01:32 +02:00
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
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
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
232242d2e9 test: skip test.yml in PRs (#8330) 2024-08-06 16:04:04 +02:00
f19ce30dfe chore: bump @testing-library/react 12.1.5 -> 16.0.0 (#8322) 2024-08-06 15:17:42 +02:00
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
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
b87925d253 build: add example apps public and vite dev-dist to eslintignore (#8326) 2024-08-05 23:43:48 +02:00
a6684b09f2 docs: fix typos in props.mdx (#8325) 2024-08-05 21:41:19 +00:00
b427617f66 build: add rm:build, rm:node_modules & clean-install scripts (#8323) 2024-08-05 23:37:33 +02:00
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
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
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
84d89b9a8a fix: throttle fractional indices validation (#8306) 2024-08-02 11:55:15 +02:00
e63dd025c9 fix: allow binding elbow arrows to frame children (#8309) 2024-08-01 19:40:54 +02:00
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
a133a70e87 build: update release script to build esm (#8308) 2024-08-01 20:19:37 +05:30
80ea7ca23f fix: skip registering font faces for local fonts (#8303) 2024-08-01 11:32:16 +02:00
e844580b14 feat: remove automatic frame naming (#8302) 2024-07-31 13:56:11 +02:00
5a0771ad9c fix: load fonts for exportToCanvas (#8298) 2024-07-30 17:23:35 +02:00
adcdbe2907 fix: re-add Cascadia Code with ligatures (#8291) 2024-07-30 11:15:11 +02:00
230d0edc44 feat: multiple fonts fallbacks (#8286) 2024-07-30 10:34:40 +02:00
d0a380758e feat: ability to debug the state of fractional indices (#8235) 2024-07-30 10:03:27 +02:00
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
2427e622b0 feat: improve mermaid detection on paste (#8287) 2024-07-27 12:36:54 +02:00
62228e0bbb feat: introduce font picker (#8012)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-07-25 18:55:55 +02:00
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
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
43b2476dfe fix: revert default element canvas padding change (#8266) 2024-07-22 11:47:16 +02:00
df8875a497 fix: freedraw jittering (#8238) 2024-07-14 08:44:47 +00:00
6fbc44fd1f fix: messed up env variable (#8231) 2024-07-11 14:33:35 +02:00
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
e52c2cd0b6 fix: log allowed events (#8224) 2024-07-09 12:16:14 +02:00
96eeec5119 feat: bump max file size (#8220) 2024-07-08 18:35:13 +02:00
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
db2c235cd4 Fix : exportToCanvas() doc example (#8127) 2024-07-08 08:52:05 +00:00
148b895f46 feat: smarter preferred lang detection (#8205) 2024-07-04 17:55:35 +02:00
d9258a736b chore: Consolidate i18n import in LanguageList component (#8201) 2024-07-04 17:34:16 +02:00
2e1f08c796 fix: memory leak - scene.destroy() and window.launchQueue (#8198) 2024-07-02 22:08:02 +02:00
1d5b41dabb fix: stop updating text versions on init (#8191) 2024-07-01 14:04:58 +02:00
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
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
abbeed3d5f feat: support Stats bound text fontSize editing (#8187) 2024-06-28 13:52:29 +02:00
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
744b3e5d09 fix: stats state leaking & race conds (#8177) 2024-06-26 23:31:08 +02:00
6ba9bd60e8 feat: allow props.initialData to be a function (#8135) 2024-06-24 11:36:49 +02:00
a1ffa064df fix: only bind arrow (#8152)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-06-19 10:55:35 +02:00
4dc4590f24 fix: repair invalid binding on restore & fix type check (#8133) 2024-06-13 19:42:08 +02:00
d2f67e619f feat: editable element stats (#6382)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-06-12 19:49:46 +02:00
22b39277f5 feat: paste as mermaid if applicable (#8116) 2024-06-11 19:19:22 +02:00
63dee03ef0 docs: remove extra braces in callback JSX (#8087)
Fix: Syantax error
2024-05-31 10:48:31 +00:00
08b13f971d fix: wysiwyg blur-submit on mobile (#8075) 2024-05-28 16:18:02 +02:00
69f4cc70cb feat: stop autoselecting text on text edit on mobile (#8076) 2024-05-28 16:17:52 +02:00
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
4eb9463f26 fix: restore linear dimensions from points (#8062) 2024-05-27 10:36:58 +02:00
6ed6131169 build: run tests on master branch (#8072)
* build: run tests on master branch

* lint
2024-05-27 12:44:20 +05:30
1ed98f9c93 fix: lp plus url (#8056) 2024-05-24 09:10:14 +00:00
629 changed files with 51263 additions and 12674 deletions

View File

@ -8,6 +8,7 @@
!package.json
!public/
!packages/
!scripts/
!tsconfig.json
!yarn.lock

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

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

@ -12,7 +12,7 @@ ARG NODE_ENV=production
RUN yarn build:app:docker
FROM nginx:1.24-alpine
FROM nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html

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

@ -31,7 +31,7 @@ The welcome screen consists of two main groups of subcomponents:
<img
src={require("@site/static/img/welcome-screen-overview.png").default}
alt="Excalidraw logo: Sketch handrawn like diagrams."
alt="Excalidraw logo: Sketch hand-drawn like diagrams."
/>
### Center

View File

@ -12,7 +12,7 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
| Font Family | Description |
| ----------- | ---------------------- |
| `Virgil` | The `handwritten` font |
| `Virgil` | The `Hand-drawn` font |
| `Helvetica` | The `Normal` Font |
| `Cascadia` | The `Code` Font |

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)} />;
}
```

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

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

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

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

@ -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 -r ../../../packages/excalidraw/dist/prod/fonts ./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 -r ../../../packages/excalidraw/dist/prod/fonts ./public",
"start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build",
"build:preview": "yarn build && vite preview --port 5002"
}
}

View File

@ -1,5 +1,4 @@
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";
@ -22,9 +21,7 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
import { t } from "../packages/excalidraw/i18n";
import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
StoreAction,
reconcileElements,
@ -93,7 +90,7 @@ 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";
@ -121,6 +118,14 @@ 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();
@ -172,11 +177,6 @@ if (window.self !== window.top) {
}
}
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
@ -322,19 +322,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
// ---------------------------------------------------------------------------
@ -346,6 +342,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
@ -371,6 +369,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;
@ -490,11 +505,7 @@ 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,
@ -595,10 +606,6 @@ const ExcalidrawWrapper = () => {
};
}, [excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
const onChange = (
elements: readonly OrderedExcalidrawElement[],
appState: AppState,
@ -639,6 +646,16 @@ const ExcalidrawWrapper = () => {
}
});
}
// 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>(
@ -837,6 +854,7 @@ const ExcalidrawWrapper = () => {
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
@ -862,64 +880,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">
@ -1149,6 +1112,13 @@ const ExcalidrawWrapper = () => {
},
]}
/>
{isVisualDebuggerEnabled() && excalidrawAPI && (
<DebugCanvas
appState={excalidrawAPI.getAppState()}
scale={window.devicePixelRatio}
ref={debugCanvasRef}
/>
)}
</Excalidraw>
</div>
);

View File

@ -9,6 +9,7 @@ import { t } from "../packages/excalidraw/i18n";
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
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

@ -116,20 +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;
}),
storeAction: StoreAction.UPDATE,
});
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

@ -2,11 +2,13 @@ import React from "react";
import {
loginIcon,
ExcalLogo,
eyeIcon,
} from "../../packages/excalidraw/components/icons";
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=""
>
@ -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

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

@ -20,6 +20,10 @@ import {
get,
} from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
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";
@ -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

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

View File

@ -26,17 +26,31 @@
"node": ">=18.0.0"
},
"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",
"vite-plugin-html": "3.2.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"
},
"devDependencies": {
"vite-plugin-sitemap": "0.7.1"
}
}

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,7 +13,6 @@ import {
share,
shareIOS,
shareWindows,
tablerCheckIcon,
} from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
@ -24,6 +22,7 @@ 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";
@ -63,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 {
@ -130,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>

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";
@ -88,12 +87,12 @@ describe("collaboration", () => {
const rect1 = API.createElement({ ...rect1Props });
const rect2 = API.createElement({ ...rect2Props });
updateSceneData({
API.updateScene({
elements: syncInvalidIndices([rect1, rect2]),
storeAction: StoreAction.CAPTURE,
});
updateSceneData({
API.updateScene({
elements: syncInvalidIndices([
rect1,
newElementWith(h.elements[1], { isDeleted: true }),
@ -143,7 +142,7 @@ describe("collaboration", () => {
});
// simulate force deleting the element remotely
updateSceneData({
API.updateScene({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
@ -178,7 +177,7 @@ 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 }),
@ -216,7 +215,7 @@ describe("collaboration", () => {
});
// simulate force deleting the element remotely
updateSceneData({
API.updateScene({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});

View File

@ -5,6 +5,8 @@ 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 Sitemap from "vite-plugin-sitemap";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables
const envVars = loadEnv("", `../`);
@ -22,6 +24,14 @@ export default defineConfig({
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
const family = chunkInfo.name.split("-")[0];
return `fonts/${family}/[name][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 +49,18 @@ export default defineConfig({
},
},
sourcemap: true,
// don't auto-inline small assets (i.e. fonts hosted on CDN)
assetsInlineLimit: 0,
},
plugins: [
Sitemap({
hostname: "https://excalidraw.com",
outDir: "build",
changefreq: "monthly",
// its static in public folder
generateRobotsTxt: false,
}),
woff2BrowserPlugin(),
react(),
checker({
typescript: true,
@ -63,17 +83,26 @@ export default defineConfig({
},
workbox: {
// Don't push fonts and locales to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
// don't precache fonts, locales and separate chunks
globIgnores: [
"fonts.css",
"**/locales/**",
"service-worker.js",
"**/*.chunk-*.js",
],
runtimeCaching: [
{
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
urlPattern: new RegExp(".+.woff2"),
handler: "CacheFirst",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
maxEntries: 1000,
maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
},
cacheableResponse: {
// 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
statuses: [0, 200],
},
},
},
@ -98,6 +127,17 @@ export default defineConfig({
},
},
},
{
urlPattern: new RegExp(".chunk-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "chunk",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
],
},
manifest: {

View File

@ -6,22 +6,12 @@
"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",
@ -31,8 +21,8 @@
"@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",
@ -47,12 +37,12 @@
"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.5.3",
"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"
@ -87,6 +77,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,16 @@ Please add the latest change on the top under the correct section.
### Features
- Added hand-drawn font for Chinese, Japanese and Korean (CJK) as a fallback for Excalifont. Improved overal text wrapping algorithm, not only accounting for CJK, but covering various edge cases with white spaces and text-align center/right. Added support for multi-codepoint emojis wrapping. Offloaded SVG export to Web Workers, with an automatic fallback to the main thread if not supported or not desired.[#8530](https://github.com/excalidraw/excalidraw/pull/8530)
- 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)
@ -35,6 +43,8 @@ Please add the latest change on the top under the correct section.
### Breaking Changes
- Stats container CSS changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout.
- `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 |

View File

@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import type { AppState, NormalizedZoomValue } from "../types";
import type { AppState, Offsets } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
@ -38,6 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
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";
@ -147,14 +147,32 @@ export const actionCopyAsSvg = register({
name: app.getName(),
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
return {
appState: {
toast: {
message: t("toast.copyToClipboardAsSvg", {
exportSelection: selectedElements.length
? t("toast.selection")
: t("toast.canvas"),
exportColorScheme: appState.exportWithDarkMode
? t("buttons.darkMode")
: t("buttons.lightMode"),
}),
},
},
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
storeAction: StoreAction.NONE,
@ -239,16 +257,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

@ -0,0 +1,55 @@
import { register } from "./register";
import { cropIcon } from "../components/icons";
import { StoreAction } from "../store";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { isImageElement } from "../element/typeChecks";
import type { ExcalidrawImageElement } from "../element/types";
export const actionToggleCropEditor = register({
name: "cropEditor",
label: "helpDialog.cropStart",
icon: cropIcon,
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["image", "crop"],
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawImageElement;
return {
appState: {
...appState,
isCropping: false,
croppingElementId: selectedElement.id,
},
storeAction: StoreAction.CAPTURE,
};
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.croppingElementId &&
selectedElements.length === 1 &&
isImageElement(selectedElements[0])
) {
return true;
}
return false;
},
PanelComponent: ({ appState, updateData, app }) => {
const label = t("helpDialog.cropStart");
return (
<ToolButton
type="button"
icon={cropIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});

View File

@ -5,20 +5,27 @@ import { t } from "../i18n";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import type { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
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

@ -15,7 +15,7 @@ import {
import type { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import type { ActionResult } from "./types";
import { GRID_SIZE } from "../constants";
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

@ -6,7 +6,6 @@ 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,
@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
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

@ -2,6 +2,8 @@ import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
NonDeleted,
NonDeletedSceneElementsMap,
@ -18,7 +20,13 @@ import {
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store";
import { isLinearElement } from "../element/typeChecks";
import {
isArrowElement,
isElbowArrow,
isLinearElement,
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@ -109,7 +117,23 @@ const flipElements = (
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,
@ -124,10 +148,55 @@ const flipElements = (
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
app,
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

@ -4,8 +4,8 @@ import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import type { History } from "../history";
import { HistoryChangedEvent } from "../history";
import type { AppState } from "../types";
import { KEYS } from "../keys";
import type { AppClassProperties, AppState } from "../types";
import { KEYS, matchKey } from "../keys";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import type { SceneElementsMap } from "../element/types";
@ -13,15 +13,19 @@ 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();
@ -50,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,
@ -59,9 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
),
),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter,
@ -91,8 +93,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,
@ -100,10 +102,8 @@ export const createRedoAction: ActionCreator = (history, store) => ({
),
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,

View File

@ -1,6 +1,6 @@
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
import type { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store";
import { register } from "./register";
@ -29,7 +29,8 @@ export const actionToggleLinearEditor = register({
if (
!appState.editingLinearElement &&
selectedElements.length === 1 &&
isLinearElement(selectedElements[0])
isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0])
) {
return true;
}

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

@ -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,
@ -27,6 +24,7 @@ import { getSelectedElements } from "../scene";
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

@ -1,6 +1,5 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
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

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

View File

@ -23,7 +23,6 @@ export type ShortcutName =
| "sendToBack"
| "bringToFront"
| "copyAsPng"
| "copyAsSvg"
| "group"
| "ungroup"
| "gridMode"
@ -51,7 +50,8 @@ export type ShortcutName =
>
| "saveScene"
| "imageExport"
| "commandPalette";
| "commandPalette"
| "searchMenu";
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
@ -87,7 +87,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
: getShortcutKey("CtrlOrCmd+Shift+]"),
],
copyAsPng: [getShortcutKey("Shift+Alt+C")],
copyAsSvg: [],
group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
@ -112,6 +111,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

@ -10,7 +10,6 @@ import type {
BinaryFiles,
UIAppState,
} from "../types";
import type { MarkOptional } from "../utility-types";
import type { StoreActionType } from "../store";
export type ActionSource =
@ -24,10 +23,7 @@ export type ActionSource =
export type ActionResult =
| {
elements?: readonly ExcalidrawElement[] | null;
appState?: MarkOptional<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
appState?: Partial<AppState> | null;
files?: BinaryFiles | null;
storeAction: StoreActionType;
replaceFiles?: boolean;
@ -70,6 +66,7 @@ export type ActionName =
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"
@ -135,7 +132,10 @@ export type ActionName =
| "createContainerFromText"
| "wrapTextInContainer"
| "commandPalette"
| "autoResize";
| "autoResize"
| "elementStats"
| "searchMenu"
| "cropEditor";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@ -189,7 +189,8 @@ export interface Action {
| "history"
| "menu"
| "collab"
| "hyperlink";
| "hyperlink"
| "search_menu";
action?: string;
predicate?: (
appState: Readonly<AppState>,

View File

@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
export const trackEvent = (
category: string,
@ -9,17 +9,20 @@ export const trackEvent = (
value?: number,
) => {
try {
// prettier-ignore
if (
typeof window === "undefined"
|| import.meta.env.VITE_WORKER_ID
// comment out to debug locally
|| import.meta.env.PROD
typeof window === "undefined" ||
import.meta.env.VITE_WORKER_ID ||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
) {
return;
}
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
return;
}
if (import.meta.env.DEV) {
// comment out to debug in dev
return;
}

View File

@ -1,11 +1,15 @@
import { COLOR_PALETTE } from "./colors";
import {
ARROW_TYPE,
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE,
EXPORT_SCALES,
STATS_PANELS,
THEME,
DEFAULT_GRID_STEP,
} from "./constants";
import type { AppState, NormalizedZoomValue } from "./types";
@ -32,13 +36,15 @@ export const getDefaultAppState = (): Omit<
currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up",
activeEmbeddable: null,
draggingElement: null,
editingElement: null,
newElement: null,
editingTextElement: null,
editingGroupId: null,
editingLinearElement: null,
activeTool: {
@ -55,7 +61,9 @@ export const getDefaultAppState = (): Omit<
exportEmbedScene: false,
exportWithDarkMode: false,
fileHandle: null,
gridSize: null,
gridSize: DEFAULT_GRID_SIZE,
gridStep: DEFAULT_GRID_STEP,
gridModeEnabled: false,
isBindingEnabled: true,
defaultSidebarDockedPreference: false,
isLoading: false,
@ -80,7 +88,10 @@ export const getDefaultAppState = (): Omit<
selectedElementsAreBeingDragged: false,
selectionElement: null,
shouldCacheIgnoreZoom: false,
showStats: false,
stats: {
open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
},
startBoundElement: null,
suggestedBindings: [],
frameRendering: { enabled: true, clip: true, name: true, outline: true },
@ -105,6 +116,9 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false,
userToFollow: null,
followedBy: new Set(),
isCropping: false,
croppingElementId: null,
searchMatches: [],
};
};
@ -138,6 +152,11 @@ const APP_STATE_STORAGE_CONF = (<
export: false,
server: false,
},
currentItemArrowType: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false },
@ -145,10 +164,11 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false },
newElement: { browser: false, export: false, server: false },
editingTextElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false },
@ -161,6 +181,8 @@ const APP_STATE_STORAGE_CONF = (<
exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true },
gridStep: { browser: true, export: true, server: true },
gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false },
defaultSidebarDockedPreference: {
@ -196,7 +218,7 @@ const APP_STATE_STORAGE_CONF = (<
},
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showStats: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
@ -217,6 +239,9 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false },
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
});
const _clearAppStateForStorage = <

View File

@ -0,0 +1,105 @@
export default class BinaryHeap<T> {
private content: T[] = [];
constructor(private scoreFunction: (node: T) => number) {}
sinkDown(idx: number) {
const node = this.content[idx];
while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
this.content[parentN] = node;
this.content[idx] = parent;
idx = parentN; // TODO: Optimize
} else {
break;
}
}
}
bubbleUp(idx: number) {
const length = this.content.length;
const node = this.content[idx];
const score = this.scoreFunction(node);
while (true) {
const child2N = (idx + 1) << 1;
const child1N = child2N - 1;
let swap = null;
let child1Score = 0;
if (child1N < length) {
const child1 = this.content[child1N];
child1Score = this.scoreFunction(child1);
if (child1Score < score) {
swap = child1N;
}
}
if (child2N < length) {
const child2 = this.content[child2N];
const child2Score = this.scoreFunction(child2);
if (child2Score < (swap === null ? score : child1Score)) {
swap = child2N;
}
}
if (swap !== null) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
break;
}
}
}
push(node: T) {
this.content.push(node);
this.sinkDown(this.content.length - 1);
}
pop(): T | null {
if (this.content.length === 0) {
return null;
}
const result = this.content[0];
const end = this.content.pop()!;
if (this.content.length > 0) {
this.content[0] = end;
this.bubbleUp(0);
}
return result;
}
remove(node: T) {
if (this.content.length === 0) {
return;
}
const i = this.content.indexOf(node);
const end = this.content.pop()!;
if (i < this.content.length) {
this.content[i] = end;
if (this.scoreFunction(end) < this.scoreFunction(node)) {
this.sinkDown(i);
} else {
this.bubbleUp(i);
}
}
}
size(): number {
return this.content.length;
}
rescoreElement(node: T) {
this.sinkDown(this.content.indexOf(node));
}
}

View File

@ -17,13 +17,16 @@ import {
hasBoundTextElement,
isBindableElement,
isBoundToContainer,
isImageElement,
isTextElement,
} from "./element/typeChecks";
import type {
ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
NonDeleted,
Ordered,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./element/types";
@ -626,6 +629,18 @@ export class AppStateChange implements Change<AppState> {
);
break;
case "croppingElementId": {
const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
break;
}
case "editingGroupId":
const editingGroupId = nextAppState[key];
@ -756,6 +771,7 @@ export class AppStateChange implements Change<AppState> {
selectedElementIds,
editingLinearElementId,
selectedLinearElementId,
croppingElementId,
...standaloneProps
} = delta as ObservedAppState;
@ -779,7 +795,10 @@ export class AppStateChange implements Change<AppState> {
}
}
type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
ElementUpdate<Ordered<T>>,
"seed"
>;
/**
* Elements change is a low level primitive to capture a change between two sets of elements.
@ -1100,7 +1119,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
try {
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
ElementsChange.redrawBoundArrows(nextElements, changedElements);
// the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
@ -1109,6 +1127,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
changedElements,
flags,
);
// Need ordered nextElements to avoid z-index binding issues
ElementsChange.redrawBoundArrows(nextElements, changedElements);
} catch (e) {
console.error(
`Couldn't mutate elements after applying elements change`,
@ -1214,6 +1235,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
});
}
if (isImageElement(element)) {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial;
@ -1460,7 +1493,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
) {
for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements);
updateBoundElements(element, elements, {
changedElements: changed,
});
}
}
}

View File

@ -1,3 +1,5 @@
import type { Radians } from "../math";
import { pointFrom } from "../math";
import {
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
@ -203,7 +205,7 @@ const chartXLabels = (
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2,
width: BAR_WIDTH,
angle: 5.87,
angle: 5.87 as Radians,
fontSize: 16,
textAlign: "center",
verticalAlign: "top",
@ -257,13 +259,8 @@ const chartLines = (
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
width: chartWidth,
points: [
[0, 0],
[chartWidth, 0],
],
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
const yLine = newLinearElement({
@ -273,13 +270,8 @@ const chartLines = (
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
height: chartHeight,
points: [
[0, 0],
[0, -chartHeight],
],
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
});
const maxLine = newLinearElement({
@ -289,15 +281,10 @@ const chartLines = (
type: "line",
x,
y: y - BAR_HEIGHT - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
points: [
[0, 0],
[chartWidth, 0],
],
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
return [xLine, yLine, maxLine];
@ -418,8 +405,6 @@ const chartTypeLine = (
type: "line",
x: x + BAR_GAP + BAR_WIDTH / 2,
y: y - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
height: maxY - minY,
width: maxX - minX,
strokeWidth: 2,
@ -453,15 +438,10 @@ const chartTypeLine = (
type: "line",
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
y: y - cy,
startArrowhead: null,
endArrowhead: null,
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,
points: [
[0, 0],
[0, cy],
],
points: [pointFrom(0, 0), pointFrom(0, cy)],
});
});

View File

@ -21,10 +21,12 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import {
hasBoundTextElement,
isElbowArrow,
isImageElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
@ -44,11 +46,11 @@ import {
frameToolIcon,
mermaidLogoIcon,
laserPointerToolIcon,
OpenAIIcon,
MagicIcon,
} from "./icons";
import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels";
import { CLASSES } from "../constants";
export const canChangeStrokeColor = (
appState: UIAppState,
@ -103,7 +105,9 @@ export const SelectedShapeActions = ({
) {
isSingleElementBoundContainer = true;
}
const isEditing = Boolean(appState.editingElement);
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const device = useDevice();
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
@ -121,7 +125,13 @@ export const SelectedShapeActions = ({
const showLineEditorAction =
!appState.editingLinearElement &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]);
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
return (
<div className="panelColumn">
@ -155,13 +165,16 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</>
)}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<>{renderAction("changeArrowType")}</>
)}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
{renderAction("changeFontSize")}
{renderAction("changeFontFamily")}
{renderAction("changeFontSize")}
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")}
@ -229,7 +242,7 @@ export const SelectedShapeActions = ({
</div>
</fieldset>
)}
{!isEditing && targetElements.length > 0 && (
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
@ -238,6 +251,7 @@ export const SelectedShapeActions = ({
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")}
</div>
</fieldset>
@ -395,7 +409,7 @@ export const ShapesSwitcher = ({
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && (
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
@ -405,20 +419,6 @@ export const ShapesSwitcher = ({
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "open-settings", "d2c");
app.setOpenDialog({
name: "settings",
source: "settings",
tab: "diagram-to-code",
});
}}
icon={OpenAIIcon}
data-testid="toolbar-magicSettings"
>
{t("toolBar.magicSettings")}
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content>
@ -434,7 +434,7 @@ export const ZoomActions = ({
renderAction: ActionManager["renderAction"];
zoom: Zoom;
}) => (
<Stack.Col gap={1} className="zoom-actions">
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
<Stack.Row align="center">
{renderAction("zoomOut")}
{renderAction("resetZoom")}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
@import "../css/theme";
.excalidraw {
button.standalone {
@include outlineButtonIconStyles;
& > * {
// dissalow pointer events on children, so we always have event.target on the button itself
pointer-events: none;
}
}
}

View File

@ -0,0 +1,36 @@
import { forwardRef } from "react";
import clsx from "clsx";
import "./ButtonIcon.scss";
interface ButtonIconProps {
icon: JSX.Element;
title: string;
className?: string;
testId?: string;
/** if not supplied, defaults to value identity check */
active?: boolean;
/** include standalone style (could interfere with parent styles) */
standalone?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
(props, ref) => {
const { title, className, testId, active, standalone, icon, onClick } =
props;
return (
<button
type="button"
ref={ref}
key={title}
title={title}
data-testid={testId}
className={clsx(className, { standalone, active })}
onClick={onClick}
>
{icon}
</button>
);
},
);

View File

@ -1,4 +1,5 @@
import clsx from "clsx";
import { ButtonIcon } from "./ButtonIcon";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>(
@ -24,21 +25,17 @@ export const ButtonIconSelect = <T extends Object>(
}
),
) => (
<div className="buttonList buttonListIcon">
<div className="buttonList">
{props.options.map((option) =>
props.type === "button" ? (
<button
type="button"
<ButtonIcon
key={option.text}
onClick={(event) => props.onClick(option.value, event)}
className={clsx({
active: option.active ?? props.value === option.value,
})}
data-testid={option.testId}
icon={option.icon}
title={option.text}
>
{option.icon}
</button>
testId={option.testId}
active={option.active ?? props.value === option.value}
onClick={(event) => props.onClick(option.value, event)}
/>
) : (
<label
key={option.text}

View File

@ -0,0 +1,10 @@
export const ButtonSeparator = () => (
<div
style={{
width: 1,
height: "1rem",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
);

View File

@ -20,7 +20,7 @@
align-items: center;
@include isMobile {
max-width: 175px;
max-width: 11rem;
}
}

View File

@ -1,22 +1,24 @@
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
import { isTransparent } from "../../utils";
import type { ExcalidrawElement } from "../../element/types";
import type { AppState } from "../../types";
import { TopPicks } from "./TopPicks";
import { ButtonSeparator } from "../ButtonSeparator";
import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useDevice, useExcalidrawContainer } from "../App";
import { useExcalidrawContainer } from "../App";
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
import { COLOR_PALETTE } from "../../colors";
import PickerHeading from "./PickerHeading";
import { t } from "../../i18n";
import clsx from "clsx";
import { useRef } from "react";
import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput";
import { useRef } from "react";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import "./ColorPicker.scss";
@ -71,6 +73,7 @@ const ColorPickerPopupContent = ({
| "palette"
| "updateData"
>) => {
const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(
@ -78,9 +81,6 @@ const ColorPickerPopupContent = ({
jotaiScope,
);
const { container } = useExcalidrawContainer();
const device = useDevice();
const colorInputJSX = (
<div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
@ -94,6 +94,7 @@ const ColorPickerPopupContent = ({
/>
</div>
);
const popoverRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => {
@ -103,120 +104,73 @@ const ColorPickerPopupContent = ({
};
return (
<Popover.Portal container={container}>
<Popover.Content
ref={popoverRef}
className="focus-visible-none"
data-prevent-outside-click
onFocusOutside={(event) => {
focusPickerContent();
<PropertiesPopover
container={container}
style={{ maxWidth: "13rem" }}
onFocusOutside={(event) => {
// refocus due to eye dropper
focusPickerContent();
event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
event.preventDefault();
}
}}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
// return focus to excalidraw container unless
// user focuses an interactive element, such as a button, or
// enters the text editor by clicking on canvas with the text tool
if (container && !isInteractive(document.activeElement)) {
container.focus();
}
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16}
sideOffset={20}
style={{
zIndex: "var(--zIndex-layerUI)",
backgroundColor: "var(--popup-bg-color)",
maxWidth: "208px",
maxHeight: window.innerHeight,
padding: "12px",
borderRadius: "8px",
boxSizing: "border-box",
overflowY: "auto",
boxShadow:
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
}}
>
{palette ? (
<Picker
palette={palette}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
}}
onClose={() => {
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
>
{palette ? (
<Picker
palette={palette}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
colorPickerType: type,
};
state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
colorPickerType: type,
};
state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
colorPickerType: type,
};
});
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else if (isWritableElement(event.target)) {
focusPickerContent();
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
});
}}
/>
</Popover.Content>
</Popover.Portal>
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
</PropertiesPopover>
);
};
@ -232,7 +186,7 @@ const ColorPickerTrigger = ({
return (
<Popover.Trigger
type="button"
className={clsx("color-picker__button active-color", {
className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": color === "transparent" || !color,
})}
aria-label={label}
@ -268,14 +222,7 @@ export const ColorPicker = ({
type={type}
topPicks={topPicks}
/>
<div
style={{
width: 1,
height: "100%",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
<ButtonSeparator />
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {

View File

@ -138,7 +138,7 @@ export const Picker = ({
event.stopPropagation();
}
}}
className="color-picker-content"
className="color-picker-content properties-content"
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>

View File

@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
import { SHAPES } from "../../shapes";
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
import { useStableCallback } from "../../hooks/useStableCallback";
import { actionClearCanvas, actionLink } from "../../actions";
import {
actionClearCanvas,
actionLink,
actionToggleSearchMenu,
} from "../../actions";
import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import type { CommandPaletteItem } from "./types";
@ -275,6 +279,7 @@ function CommandPaletteInner({
actionManager.actions.increaseFontSize,
actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionLink,
].map((action: Action) =>
actionToCommand(
@ -382,6 +387,15 @@ function CommandPaletteInner({
}
},
},
{
label: t("search.title"),
category: DEFAULT_CATEGORIES.app,
icon: searchIcon,
viewMode: true,
perform: () => {
actionManager.executeAction(actionToggleSearchMenu);
},
},
{
label: t("labels.changeStroke"),
keywords: ["color", "outline"],

View File

@ -9,7 +9,7 @@ import {
import {
assertExcalidrawWithSidebar,
assertSidebarDockButton,
} from "./Sidebar/Sidebar.test";
} from "./Sidebar/siderbar.test.helpers";
const { h } = window;

View File

@ -1,8 +1,11 @@
import clsx from "clsx";
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { useTunnels } from "../context/tunnels";
import { useUIAppState } from "../context/ui-appState";
import { t } from "../i18n";
import type { MarkOptional, Merge } from "../utility-types";
import { composeEventHandlers } from "../utils";
import { useExcalidrawSetAppState } from "./App";
@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
import { LibraryMenu } from "./LibraryMenu";
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
import { Sidebar } from "./Sidebar/Sidebar";
import "../components/dropdownMenu/DropdownMenu.scss";
import { SearchMenu } from "./SearchMenu";
import { LibraryIcon, searchIcon } from "./icons";
const DefaultSidebarTrigger = withInternalFallback(
"DefaultSidebarTrigger",
@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
);
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
const DefaultTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return (
<DefaultSidebarTabTriggersTunnel.In>
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
{children}
</DefaultSidebarTabTriggersTunnel.In>
);
};
@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
return (
<Sidebar
{...rest}
name="default"
key="default"
className={clsx("default-sidebar", className)}
docked={docked ?? appState.defaultSidebarDockedPreference}
docked={
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
}
onDock={
// `onDock=false` disables docking.
// if `docked` passed, but no onDock passed, disable manual docking.
onDock === false || (!onDock && docked != null)
isForceDocked || onDock === false || (!onDock && docked != null)
? undefined
: // compose to allow the host app to listen on default behavior
composeEventHandlers(onDock, (docked) => {
@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
>
<Sidebar.Tabs>
<Sidebar.Header>
{rest.__fallback && (
<div
style={{
color: "var(--color-primary)",
fontSize: "1.2em",
fontWeight: "bold",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
paddingRight: "1em",
}}
>
{t("toolBar.library")}
</div>
)}
<DefaultSidebarTabTriggersTunnel.Out />
<Sidebar.TabTriggers>
<Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
{searchIcon}
</Sidebar.TabTrigger>
<Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
{LibraryIcon}
</Sidebar.TabTrigger>
<DefaultSidebarTabTriggersTunnel.Out />
</Sidebar.TabTriggers>
</Sidebar.Header>
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
<LibraryMenu />
</Sidebar.Tab>
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
<SearchMenu />
</Sidebar.Tab>
{children}
</Sidebar.Tabs>
</Sidebar>

View File

@ -0,0 +1,17 @@
import { useLayoutEffect } from "react";
import { useApp } from "../App";
import type { GenerateDiagramToCode } from "../../types";
export const DiagramToCodePlugin = (props: {
generate: GenerateDiagramToCode;
}) => {
const app = useApp();
useLayoutEffect(() => {
app.setPlugins({
diagramToCode: { generate: props.generate },
});
}, [app, props.generate]);
return null;
};

View File

@ -1,5 +1,19 @@
@import "../css/variables.module.scss";
@keyframes successStatusAnimation {
0% {
transform: scale(0.35);
}
50% {
transform: scale(1.25);
}
100% {
transform: scale(1);
}
}
.excalidraw {
.ExcButton {
--text-color: transparent;
@ -16,11 +30,20 @@
.Spinner {
--spinner-color: var(--color-surface-lowest);
position: absolute;
visibility: visible;
}
&[disabled] {
.ExcButton__statusIcon {
visibility: visible;
position: absolute;
width: 1.2rem;
height: 1.2rem;
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
}
&.ExcButton--status-loading,
&.ExcButton--status-success {
pointer-events: none;
.ExcButton__contents {
@ -28,6 +51,10 @@
}
}
&[disabled] {
pointer-events: none;
}
&,
&__contents {
display: flex;
@ -119,6 +146,46 @@
}
}
&--color-success {
&.ExcButton--variant-filled {
--text-color: var(--color-success-text);
--back-color: var(--color-success);
.Spinner {
--spinner-color: var(--color-success);
}
&:hover {
--back-color: var(--color-success-darker);
}
&:active {
--back-color: var(--color-success-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-success-contrast);
--border-color: var(--color-success-contrast);
--back-color: transparent;
.Spinner {
--spinner-color: var(--color-success-contrast);
}
&:hover {
--text-color: var(--color-success-contrast-hover);
--border-color: var(--color-success-contrast-hover);
}
&:active {
--text-color: var(--color-success-contrast-active);
--border-color: var(--color-success-contrast-active);
}
}
}
&--color-muted {
&.ExcButton--variant-filled {
--text-color: var(--island-bg-color);

View File

@ -5,9 +5,15 @@ import "./FilledButton.scss";
import { AbortError } from "../errors";
import Spinner from "./Spinner";
import { isPromiseLike } from "../utils";
import { tablerCheckIcon } from "./icons";
export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
export type ButtonColor =
| "primary"
| "danger"
| "warning"
| "muted"
| "success";
export type ButtonSize = "medium" | "large";
export type FilledButtonProps = {
@ -15,6 +21,7 @@ export type FilledButtonProps = {
children?: React.ReactNode;
onClick?: (event: React.MouseEvent) => void;
status?: null | "loading" | "success";
variant?: ButtonVariant;
color?: ButtonColor;
@ -37,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
size = "medium",
fullWidth,
className,
status,
},
ref,
) => {
@ -46,8 +54,11 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
const ret = onClick?.(event);
if (isPromiseLike(ret)) {
try {
// delay loading state to prevent flicker in case of quick response
const timer = window.setTimeout(() => {
setIsLoading(true);
}, 50);
try {
await ret;
} catch (error: any) {
if (!(error instanceof AbortError)) {
@ -56,11 +67,15 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
console.warn(error);
}
} finally {
clearTimeout(timer);
setIsLoading(false);
}
}
};
const _status = isLoading ? "loading" : status;
color = _status === "success" ? "success" : color;
return (
<button
className={clsx(
@ -68,6 +83,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
`ExcButton--color-${color}`,
`ExcButton--variant-${variant}`,
`ExcButton--size-${size}`,
`ExcButton--status-${_status}`,
{ "ExcButton--fullWidth": fullWidth },
className,
)}
@ -75,10 +91,16 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
type="button"
aria-label={label}
ref={ref}
disabled={isLoading}
disabled={_status === "loading" || _status === "success"}
>
<div className="ExcButton__contents">
{isLoading && <Spinner />}
{_status === "loading" ? (
<Spinner className="ExcButton__statusIcon" />
) : (
_status === "success" && (
<div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
)
)}
{icon && (
<div className="ExcButton__icon" aria-hidden>
{icon}

View File

@ -0,0 +1,15 @@
@import "../../css/variables.module.scss";
.excalidraw {
.FontPicker__container {
display: grid;
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
align-items: center;
@include isMobile {
max-width: calc(
2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons
}
}
}

View File

@ -0,0 +1,110 @@
import React, { useCallback, useMemo } from "react";
import * as Popover from "@radix-ui/react-popover";
import { FontPickerList } from "./FontPickerList";
import { FontPickerTrigger } from "./FontPickerTrigger";
import { ButtonIconSelect } from "../ButtonIconSelect";
import {
FontFamilyCodeIcon,
FontFamilyNormalIcon,
FreedrawIcon,
} from "../icons";
import { ButtonSeparator } from "../ButtonSeparator";
import type { FontFamilyValues } from "../../element/types";
import { FONT_FAMILY } from "../../constants";
import { t } from "../../i18n";
import "./FontPicker.scss";
export const DEFAULT_FONTS = [
{
value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon,
text: t("labels.handDrawn"),
testId: "font-family-hand-drawn",
},
{
value: FONT_FAMILY.Nunito,
icon: FontFamilyNormalIcon,
text: t("labels.normal"),
testId: "font-family-normal",
},
{
value: FONT_FAMILY["Comic Shanns"],
icon: FontFamilyCodeIcon,
text: t("labels.code"),
testId: "font-family-code",
},
];
const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
export const isDefaultFont = (fontFamily: number | null) => {
if (!fontFamily) {
return false;
}
return defaultFontFamilies.has(fontFamily);
};
interface FontPickerProps {
isOpened: boolean;
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (fontFamily: FontFamilyValues) => void;
onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void;
onPopupChange: (open: boolean) => void;
}
export const FontPicker = React.memo(
({
isOpened,
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onPopupChange,
}: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback(
(value: number | false) => {
if (value) {
onSelect(value);
}
},
[onSelect],
);
return (
<div role="dialog" aria-modal="true" className="FontPicker__container">
<ButtonIconSelect<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
<ButtonSeparator />
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
{isOpened && (
<FontPickerList
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={hoveredFontFamily}
onSelect={onSelectCallback}
onHover={onHover}
onLeave={onLeave}
onOpen={() => onPopupChange(true)}
onClose={() => onPopupChange(false)}
/>
)}
</Popover.Root>
</div>
);
},
(prev, next) =>
prev.isOpened === next.isOpened &&
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);

View File

@ -0,0 +1,271 @@
import React, {
useMemo,
useState,
useRef,
useEffect,
useCallback,
type KeyboardEventHandler,
} from "react";
import { useApp, useAppProps, useExcalidrawContainer } from "../App";
import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import { type FontFamilyValues } from "../../element/types";
import { arrayToList, debounce, getFontFamilyString } from "../../utils";
import { t } from "../../i18n";
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import { Fonts } from "../../fonts";
import type { ValueOf } from "../../utility-types";
import { FontFamilyNormalIcon } from "../icons";
export interface FontDescriptor {
value: number;
icon: JSX.Element;
text: string;
deprecated?: true;
badge?: {
type: ValueOf<typeof DropDownMenuItemBadgeType>;
placeholder: string;
};
}
interface FontPickerListProps {
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (value: number) => void;
onHover: (value: number) => void;
onLeave: () => void;
onOpen: () => void;
onClose: () => void;
}
export const FontPickerList = React.memo(
({
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onOpen,
onClose,
}: FontPickerListProps) => {
const { container } = useExcalidrawContainer();
const { fonts } = useApp();
const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const allFonts = useMemo(
() =>
Array.from(Fonts.registered.entries())
.filter(
([_, { metadata }]) => !metadata.serverSide && !metadata.fallback,
)
.map(([familyId, { metadata, fontFaces }]) => {
const fontDescriptor = {
value: familyId,
icon: metadata.icon ?? FontFamilyNormalIcon,
text: fontFaces[0]?.fontFace?.family ?? "Unknown",
};
if (metadata.deprecated) {
Object.assign(fontDescriptor, {
deprecated: metadata.deprecated,
badge: {
type: DropDownMenuItemBadgeType.RED,
placeholder: t("fontList.badge.old"),
},
});
}
return fontDescriptor as FontDescriptor;
})
.sort((a, b) =>
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
),
[],
);
const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFamilies()),
// cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily],
);
const sceneFonts = useMemo(
() => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated
[allFonts, sceneFamilies],
);
const availableFonts = useMemo(
() =>
allFonts.filter(
(font) =>
!sceneFamilies.has(font.value) &&
(showDeprecatedFonts || !font.deprecated), // skip deprecated fonts
),
[allFonts, sceneFamilies, showDeprecatedFonts],
);
const filteredFonts = useMemo(
() =>
arrayToList(
[...sceneFonts, ...availableFonts].filter((font) =>
font.text?.toLowerCase().includes(searchTerm),
),
),
[sceneFonts, availableFonts, searchTerm],
);
const hoveredFont = useMemo(() => {
let font;
if (hoveredFontFamily) {
font = filteredFonts.find((font) => font.value === hoveredFontFamily);
} else if (selectedFontFamily) {
font = filteredFonts.find((font) => font.value === selectedFontFamily);
}
if (!font && searchTerm) {
if (filteredFonts[0]?.value) {
// hover first element on search
onHover(filteredFonts[0].value);
} else {
// re-render cache on no results
onLeave();
}
}
return font;
}, [
hoveredFontFamily,
selectedFontFamily,
searchTerm,
filteredFonts,
onHover,
onLeave,
]);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => {
const handled = fontPickerKeyHandler({
event,
inputRef,
hoveredFont,
filteredFonts,
onSelect,
onHover,
onClose,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
},
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
);
useEffect(() => {
onOpen();
return () => {
onClose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sceneFilteredFonts = useMemo(
() => filteredFonts.filter((font) => sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const availableFilteredFonts = useMemo(
() => filteredFonts.filter((font) => !sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const renderFont = (font: FontDescriptor, index: number) => (
<DropdownMenuItem
key={font.value}
icon={font.icon}
value={font.value}
order={index}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
hovered={font.value === hoveredFont?.value}
selected={font.value === selectedFontFamily}
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
onSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
{font.text}
{font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)}
</DropdownMenuItem>
);
const groups = [];
if (sceneFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
{sceneFilteredFonts.map(renderFont)}
</DropdownMenuGroup>,
);
}
if (availableFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
{availableFilteredFonts.map((font, index) =>
renderFont(font, index + sceneFilteredFonts.length),
)}
</DropdownMenuGroup>,
);
}
return (
<PropertiesPopover
className="properties-content"
container={container}
style={{ width: "15rem" }}
onClose={onClose}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<QuickSearch
ref={inputRef}
placeholder={t("quickSearch.placeholder")}
onChange={debounce(setSearchTerm, 20)}
/>
<ScrollableList
className="dropdown-menu fonts manual-hover"
placeholder={t("fontList.empty")}
>
{groups.length ? groups : null}
</ScrollableList>
</PropertiesPopover>
);
},
(prev, next) =>
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);

View File

@ -0,0 +1,38 @@
import * as Popover from "@radix-ui/react-popover";
import { useMemo } from "react";
import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons";
import type { FontFamilyValues } from "../../element/types";
import { t } from "../../i18n";
import { isDefaultFont } from "./FontPicker";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
}
export const FontPickerTrigger = ({
selectedFontFamily,
}: FontPickerTriggerProps) => {
const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily],
);
return (
<Popover.Trigger asChild>
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
<div>
<ButtonIcon
standalone
icon={TextIcon}
title={t("labels.showFonts")}
className="properties-trigger"
testId={"font-family-show-fonts"}
active={isTriggerActive}
// no-op
onClick={() => {}}
/>
</div>
</Popover.Trigger>
);
};

View File

@ -0,0 +1,66 @@
import type { Node } from "../../utils";
import { KEYS } from "../../keys";
import { type FontDescriptor } from "./FontPickerList";
interface FontPickerKeyNavHandlerProps {
event: React.KeyboardEvent<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
hoveredFont: Node<FontDescriptor> | undefined;
filteredFonts: Node<FontDescriptor>[];
onClose: () => void;
onSelect: (value: number) => void;
onHover: (value: number) => void;
}
export const fontPickerKeyHandler = ({
event,
inputRef,
hoveredFont,
filteredFonts,
onClose,
onSelect,
onHover,
}: FontPickerKeyNavHandlerProps) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.F
) {
// refocus input on the popup trigger shortcut
inputRef.current?.focus();
return true;
}
if (event.key === KEYS.ESCAPE) {
onClose();
return true;
}
if (event.key === KEYS.ENTER) {
if (hoveredFont?.value) {
onSelect(hoveredFont.value);
}
return true;
}
if (event.key === KEYS.ARROW_DOWN) {
if (hoveredFont?.next) {
onHover(hoveredFont.next.value);
} else if (filteredFonts[0]?.value) {
onHover(filteredFonts[0].value);
}
return true;
}
if (event.key === KEYS.ARROW_UP) {
if (hoveredFont?.prev) {
onHover(hoveredFont.prev.value);
} else if (filteredFonts[filteredFonts.length - 1]?.value) {
onHover(filteredFonts[filteredFonts.length - 1].value);
}
return true;
}
};

View File

@ -8,7 +8,7 @@
h3 {
margin: 1.5rem 0;
font-weight: bold;
font-weight: 700;
font-size: 1.125rem;
}
@ -82,7 +82,7 @@
&__island {
h4 {
font-size: 1rem;
font-weight: bold;
font-weight: 700;
margin: 0;
margin-bottom: 0.625rem;
}

View File

@ -222,6 +222,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
]}
isOr={false}
/>
<Shortcut
label={t("helpDialog.cropStart")}
shortcuts={[t("helpDialog.doubleClick"), getShortcutKey("Enter")]}
isOr={true}
/>
<Shortcut
label={t("helpDialog.cropFinish")}
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
isOr={true}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut
label={t("helpDialog.preventBinding")}
@ -285,9 +295,13 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("Alt+Shift+D")]}
/>
<Shortcut
label={t("stats.title")}
label={t("stats.fullTitle")}
shortcuts={[getShortcutKey("Alt+/")]}
/>
<Shortcut
label={t("search.title")}
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
/>
<Shortcut
label={t("commandPalette.title")}
shortcuts={
@ -304,6 +318,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
className="HelpDialog__island--editor"
caption={t("helpDialog.editor")}
>
<Shortcut
label={t("helpDialog.createFlowchart")}
shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
isOr={true}
/>
<Shortcut
label={t("helpDialog.navigateFlowchart")}
shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
isOr={true}
/>
<Shortcut
label={t("labels.moveCanvas")}
shortcuts={[
@ -458,6 +482,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]}
/>
<Shortcut
label={t("labels.showFonts")}
shortcuts={[getShortcutKey("Shift+F")]}
/>
<Shortcut
label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}

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