Compare commits

...

108 Commits

Author SHA1 Message Date
Ryan Di 913921631b fix contain dim change 2025-01-07 22:55:08 +08:00
Ryan Di ec39094933 simplify code 2025-01-07 19:23:26 +08:00
Ryan Di ef20c8b9fa tests for the updated api 2024-12-12 15:39:48 +08:00
Ryan Di 1c55160e3f debug both in app 2024-12-10 19:00:58 +08:00
Ryan Di e49db1dd3c align svg exports with canvas exports 2024-12-10 19:00:30 +08:00
Ryan Di cd8bfb06b5 fix padding and refactor 2024-12-10 18:37:53 +08:00
Ryan Di 7762b925ae debug preview fix 2024-12-05 16:02:04 +08:00
Ryan Di 113dfc0023 remove cover from fit options 2024-12-05 15:13:58 +08:00
Ryan Di 96b5cfc35d fix data param 2024-12-04 14:33:45 +08:00
Ryan Di 9da26fb7e0 unify exports in a single place 2024-12-04 14:31:15 +08:00
dwelle 392b362118 DEBUG 2024-11-26 12:12:40 +01:00
dwelle 0e02366dee wip 2024-11-26 12:12:40 +01:00
Márk Tolmács b2a6a87b10 chore: Remove @tldraw/vec (#8762)
Not needed.
2024-11-21 15:19:20 +01:00
Márk Tolmács ab8b3537b3 fix: Optimize frameToHighlight state change and snapLines state change (#8763)
Fix case when frame interactions recursively call setState() without any change.
2024-11-21 15:19:00 +01:00
Márk Tolmács d21e0008dd fix: Make some events expllicitly active to avoid console warnings (#8757)
Avoid chrome/edge reporting of by-default blocking event handlers
2024-11-21 15:18:18 +01:00
David Luzar 840f1428c4 chore: bump @excalidraw/mermaid-to-excalidraw@1.1.2 (#8830) 2024-11-20 13:10:07 +01:00
Márk Tolmács 2db5bbcb29 fix: Unify binding update options for updateBoundElements() (#8832)
Fix insonsistent naming for option newSize/oldSize for updateBoundElements()
2024-11-20 11:46:45 +01:00
David Luzar 0927431d0d chore: bump @excalidraw/mermaid-to-excalidraw (#8829) 2024-11-19 20:46:55 +01:00
dependabot[bot] 98c0a67333 build(deps): bump cross-spawn from 7.0.3 to 7.0.6 (#8824)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-19 20:34:35 +01:00
Aakansha Doshi 57cf577376 fix: cleanup scripts and support upto node 22 (#8794) 2024-11-11 23:56:00 +05:30
Hamir Mahal 6e0ee89ee4 fix: usage of node12 which is deprecated (#8791) 2024-11-11 12:05:55 +01:00
David Luzar 35f778a734 build: set PWA flag in dev to false (#8788) 2024-11-09 21:04:50 +01:00
Aakansha Doshi ee091d0dbd build: add a flag VITE_APP_ENABLE_PWA for enabling pwa in dev environment (#8784)
* build: add a flag VITE_APP_ENABLE_PWA for enabling pwa in dev environment

* fix

* set VITE_ENABLE_PWA to false in .env.development
2024-11-09 21:45:37 +05:30
Aakansha Doshi ef9ea14a75 fix: remove manifest.json (#8783)
* fix: remove manifest.json

* disable pwa in dev
2024-11-09 13:42:11 +05:30
Aakansha Doshi df168a6883 fix: load env vars correctly and set debug and linter flags to false explicitly in prod mode (#8770)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-11-07 21:14:49 +00:00
David Luzar 798f5f4dfb feat: update blog url (#8767) 2024-11-06 16:41:41 +00:00
Barnabás Molnár d9ad7c039b feat: export scene to e+ on workspace creation/redemption (#8514)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-11-04 22:35:45 +00:00
Márk Tolmács 7c0239e693 fix: Console error in dev mode due to missing font path in non-prod (#8756)
Fix console error due to missing font path in dev mode reported by Firefox.
2024-11-04 17:54:00 +01:00
Aakansha Doshi da33481fa3 chore: support upto node 22.x.x (#8755) 2024-11-04 18:57:40 +05:30
Marcel Mraz 70e0e8dc29 fix: text pushes UI due to padding (#8745) 2024-11-01 23:43:48 +02:00
Marcel Mraz 2734e646ca chore: simplify line-break regexes, separate text wrapping (#8715) 2024-10-30 14:24:12 +01:00
Marcel Mraz dfaaff4432 fix: fix trailing line whitespaces layout shift (#8714) 2024-10-30 14:11:13 +01:00
Marcel Mraz 03028eaa8c fix: load font faces in Safari manually (#8693) 2024-10-30 12:40:24 +01:00
David Luzar 79b181bcdc fix: restore svg image DataURL dimensions (#8730) 2024-10-29 22:40:24 +01:00
David Luzar f9815b8b4f fix: image cropping svg + compat mode (#8710)
Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2024-10-28 10:08:05 +01:00
codeman 96ed8a4331 chore: remove duplicated meta tag (#8718)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2024-10-25 16:29:48 +00:00
Hamir Mahal 33b01d4e80 fix: usage of node12 which is deprecated (#8709) 2024-10-25 10:39:44 +05:30
Milos Vetesnik 7d52176fea feat: added sitemap & fixed robot txt (#8699) 2024-10-24 12:49:33 +02:00
David Luzar 958e03fcc6 fix: image render perf (#8697) 2024-10-23 11:58:43 +02:00
David Luzar 4cedf3d966 feat: do not strip unknown element properties on restore (#8682) 2024-10-21 22:56:22 +02:00
Ryan Di e957c8e9ee feat: image cropping (#8613)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-10-21 22:26:52 +02:00
Denis Mishankov 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
Marcel Mraz 61623bbeba fix: Comic Shanns issues, new fonts structure (#8641) 2024-10-21 00:11:53 +02:00
David Luzar 15ca182333 fix: remove export-to-clip-as-svg shortcut for now (#8660) 2024-10-17 20:47:05 +02:00
Marcel Mraz b479f3bd65 feat: add first-class support for CJK (#8530) 2024-10-17 20:14:17 +02:00
Mathis Beauville 21815fb930 fix: text disappearing on edit (#8558) (#8624) 2024-10-17 13:11:48 +02:00
David Luzar 47ee8a0094 refactor: point() -> pointFrom() to fix compiler issue (#8578) 2024-10-01 21:27:17 +02:00
Subhadeep Sengupta a977dd1bf5 feat: Added reddit links as embeddable (#8099)
feat: #8063 Added reddit links as embeddable

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

* update changelog

* lint

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

* moved to encode.ts

* move base64 parsing out

---------

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

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

* lint

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

* upgrade vitest-ui and coverage

* pass empty set to fix type error and update snap

* set ignoreEmptyLines to false

* update threshold

* update coverage threshold

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

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

* disable pwa

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

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

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

* fix: prevent zooming browser over DOM

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-31 12:06:38 +02:00
Marcel Mraz ee30225062 feat: subset font glyphs for SVG export (#8384)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-30 22:26:06 +02:00
David Luzar 16cae4fc07 feat: reset copyStatus on export dialog settings change (#8443) 2024-08-29 15:10:08 +02:00
David Luzar 576bc0dbe5 feat: tweak copy button success animation (#8441) 2024-08-29 00:43:01 +02:00
David Luzar 00af35c692 feat: enable panning/zoom while in wysiwyg (#8437) 2024-08-29 00:42:46 +02:00
Márk Tolmács ea7c702cfc feat: Visual debugger (#8344)
Add visual debugger to the Excalidraw app (only).
2024-08-27 19:46:00 +02:00
spc-28 26d2296578 fix: fixed copy to clipboard button (#8426)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-27 00:27:44 +02:00
zsviczian afb68a6467 feat: improve elbow arrow keyboard move (#8392) 2024-08-26 15:58:54 +02:00
zsviczian b459e5cfd2 fix: context menu does not work after after dragging on StatsDragInput (#8386)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-26 15:58:46 +02:00
David Luzar 5facc0d6da fix: perf regression in getCommonBounds (#8429) 2024-08-26 15:57:28 +02:00
Paul Tune 824ad603e1 docs: Update function call in @excalidraw/mermaid-to-excalidraw (#8420) 2024-08-26 13:20:29 +02:00
Ryan Di 5e1ff7cafe perf: improve new element drawing (#8340)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-23 20:27:57 +02:00
David Luzar b5d7f5b4ba feat: rewrite d2c to not require token (#8269) 2024-08-20 18:06:22 +02:00
David Luzar fb4bb29aa5 fix: object snapping not working (#8381) 2024-08-15 18:48:25 +02:00
David Luzar 3cfcc7b489 feat: split gridSize from enabled state & support custom gridStep (#8364) 2024-08-14 14:59:14 +02:00
David Luzar 4320a3cf41 feat: improve zoom-to-content when creating flowchart (#8368) 2024-08-12 20:42:08 +02:00
Márk Tolmács 8420e1aa13 fix: Reimplement rectangle intersection (#8367) 2024-08-12 19:19:16 +02:00
Márk Tolmács 5daf1a1b4e fix: Round coordinates and sizes for rectangle intersection (#8366)
Round coordinates and sizes for rectangle intersection
2024-08-12 11:07:58 +02:00
David Luzar 97981804d7 feat: Stats popup style tweaks (#8361) 2024-08-11 19:33:44 +02:00
Clarence Chan f7b3befd0a fix: text content with tab characters act different in view/edit (#8336)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-09 20:20:36 +00:00
DDDDD12138 7b2bee9746 chore: remove unused parameter (#8355) 2024-08-09 21:39:40 +02:00
David Luzar 88014ace4a fix: drawing from 0-dimension canvas (#8356) 2024-08-09 21:36:04 +02:00
David Luzar 87a9430809 fix: disable flowchart keybindings inside inputs (#8353) 2024-08-09 18:44:17 +02:00
Márk Tolmács 99b91c46f7 fix: Yet more patching of intersect code (#8352)
* Yet more patching of intersect code
2024-08-09 17:33:12 +02:00
David Luzar 1ea5b26f25 fix: missing act() in flowchart tests (#8354) 2024-08-09 17:27:02 +02:00
Clarence Chan d5f4ee7b3f fix: z-index change by one causes app to freeze (#8314) 2024-08-09 15:26:08 +02:00
Márk Tolmács 261304c1a4 fix: Patch over intersection calculation issue (#8350)
* Patch over intersection calculation issue
2024-08-09 14:40:57 +02:00
Márk Tolmács 84398a7e5c fix: Point duplication in LEE on ALT+click (#8347) 2024-08-09 12:01:32 +02:00
Ryan Di 54491d13d4 feat: create flowcharts from a generic element using elbow arrows (#8329)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-08 21:43:15 +02:00
555 changed files with 38918 additions and 9099 deletions
+12 -3
View File
@@ -8,7 +8,7 @@ VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfu
VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000
VITE_APP_AI_BACKEND=http://localhost:3015
@@ -17,8 +17,6 @@ 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=
@@ -39,3 +37,14 @@ VITE_APP_COLLAPSE_OVERLAY=true
# Set this flag to false to disable eslint
VITE_APP_ENABLE_ESLINT=true
# Enable PWA in dev server
VITE_APP_ENABLE_PWA=false
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2g5T+Rub6Kbf1Mf57t0
7r2zeHuVg4dla3r5ryXMswtzz6x767octl6oLThn33mQsPSy3GKglFZoCTXJR4ij
ba8SxB04sL/N8eRrKja7TFWjCVtRwTTfyy771NYYNFVJclkxHyE5qw4m27crHF1y
UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
HQIDAQAB'
+15
View File
@@ -15,3 +15,18 @@ 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_ENABLE_TRACKING=false
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApQ0jM9Qz8TdFLzcuAZZX
/WvuKSOJxiw6AR/ZcE3eFQWM/mbFdhQgyK8eHGkKQifKzH1xUZjCxyXcxW6ZO02t
kPOPxhz+nxUrIoWCD/V4NGmUA1lxwHuO21HN1gzKrN3xHg5EGjyouR9vibT9VDGF
gq6+4Ic/kJX+AD2MM7Yre2+FsOdysrmuW2Fu3ahuC1uQE7pOe1j0k7auNP0y1q53
PrB8Ts2LUpepWC1l7zIXFm4ViDULuyWXTEpUcHSsEH8vpd1tckjypxCwkipfZsXx
iPszy0o0Dx2iArPfWMXlFAI9mvyFCyFC3+nSvfyAUb2C4uZgCwAuyFh/ydPF4DEE
PQIDAQAB'
# Set the below flags explicitly to false in production mode since vite loads and merges .env.local vars when running the build command
VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=false
VITE_APP_COLLAPSE_OVERLAY=false
# Enable eslint in dev server
VITE_APP_ENABLE_ESLINT=false
+1
View File
@@ -8,3 +8,4 @@ public/workbox
packages/excalidraw/types
examples/**/public
dev-dist
coverage
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
semantic:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v3.0.0
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -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
@@ -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
@@ -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 |
@@ -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 |
@@ -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);
+1 -1
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"
},
+41 -22
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==
@@ -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"
@@ -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,
@@ -369,10 +369,12 @@ export default function App({
return false;
}
await exportToClipboard({
elements: excalidrawAPI.getSceneElements(),
appState: excalidrawAPI.getAppState(),
files: excalidrawAPI.getFiles(),
type,
data: {
elements: excalidrawAPI.getSceneElements(),
appState: excalidrawAPI.getAppState(),
files: excalidrawAPI.getFiles(),
},
type: "json",
});
window.alert(`Copied to clipboard as ${type} successfully`);
};
@@ -817,15 +819,17 @@ export default function App({
return;
}
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
exportEmbedScene,
width: 300,
height: 100,
data: {
elements: excalidrawAPI?.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
exportEmbedScene,
width: 300,
height: 100,
},
files: excalidrawAPI?.getFiles(),
},
files: excalidrawAPI?.getFiles(),
});
appRef.current.querySelector(".export-svg").innerHTML =
svg.outerHTML;
@@ -841,14 +845,18 @@ export default function App({
return;
}
const blob = await exportToBlob({
elements: excalidrawAPI?.getSceneElements(),
mimeType: "image/png",
appState: {
...initialData.appState,
exportEmbedScene,
exportWithDarkMode,
data: {
elements: excalidrawAPI?.getSceneElements(),
appState: {
...initialData.appState,
exportEmbedScene,
exportWithDarkMode,
},
files: excalidrawAPI?.getFiles(),
},
config: {
mimeType: "image/png",
},
files: excalidrawAPI?.getFiles(),
});
setBlobUrl(window.URL.createObjectURL(blob));
}}
@@ -864,12 +872,14 @@ export default function App({
return;
}
const canvas = await exportToCanvas({
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
data: {
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
},
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
@@ -885,12 +895,14 @@ export default function App({
return;
}
const canvas = await exportToCanvas({
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
data: {
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
},
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
+1 -1
View File
@@ -36,4 +36,4 @@ yarn-error.log*
next-env.d.ts
# copied assets
public/*.woff2
public/**/*.woff2
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"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",
@@ -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";
@@ -1,2 +1,2 @@
# copied assets
public/*.woff2
public/**/*.woff2
@@ -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";
@@ -13,7 +13,7 @@
},
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"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"
+404 -58
View File
@@ -22,11 +22,16 @@ import { t } from "../packages/excalidraw/i18n";
import {
Excalidraw,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
StoreAction,
reconcileElements,
exportToCanvas,
exportToSvg,
} from "../packages/excalidraw";
import {
exportToBlob,
getNonDeletedElements,
} from "../packages/excalidraw/index";
import type {
AppState,
ExcalidrawImperativeAPI,
@@ -121,6 +126,16 @@ import {
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";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import { fileSave } from "../packages/excalidraw/data/filesystem";
import { type ExportSceneConfig } from "../packages/excalidraw/scene/export";
import { round } from "../packages/math";
polyfill();
@@ -337,6 +352,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
@@ -362,6 +379,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;
@@ -582,6 +616,19 @@ const ExcalidrawWrapper = () => {
};
}, [excalidrawAPI]);
const canvasPreviewContainerRef = useRef<HTMLDivElement>(null);
const svgPreviewContainerRef = useRef<HTMLDivElement>(null);
const [config, setConfig] = useState<ExportSceneConfig>({
scale: 1,
position: "center",
fit: "contain",
});
useEffect(() => {
localStorage.setItem("_exportConfig", JSON.stringify(config));
}, [config]);
const onChange = (
elements: readonly OrderedExcalidrawElement[],
appState: AppState,
@@ -591,6 +638,84 @@ const ExcalidrawWrapper = () => {
collabAPI.syncElements(elements);
}
const nonDeletedElements = getNonDeletedElements(elements);
const frame = nonDeletedElements.find(
(el) => el.strokeStyle === "dashed" && el.type === "rectangle",
);
exportToCanvas({
data: {
elements: nonDeletedElements.filter((x) => x.id !== frame?.id),
// .concat(
// restoreElements(
// [
// // @ts-ignore
// {
// type: "rectangle",
// width: appState.width / zoom,
// height: appState.height / zoom,
// x: -appState.scrollX,
// y: -appState.scrollY,
// fillStyle: "solid",
// strokeColor: "transparent",
// backgroundColor: "rgba(0,0,0,0.05)",
// roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS, value: 40 },
// },
// ],
// null,
// ),
// ),
appState,
files,
},
config: {
...(frame
? {
...config,
width: frame.width,
height: frame.height,
x: frame.x,
y: frame.y,
}
: config),
},
}).then((canvas) => {
if (canvasPreviewContainerRef.current) {
canvasPreviewContainerRef.current.replaceChildren(canvas);
document.querySelector(
".canvas_dims",
)!.innerHTML = `${canvas.width}x${canvas.height} (canvas)`;
}
});
exportToSvg({
data: {
elements: nonDeletedElements.filter((x) => x.id !== frame?.id),
appState,
files,
},
config: {
...(frame
? {
...config,
width: frame.width,
height: frame.height,
x: frame.x,
y: frame.y,
}
: config),
},
}).then((svg) => {
if (svgPreviewContainerRef.current) {
svgPreviewContainerRef.current.replaceChildren(svg);
document.querySelector(".svg_dims")!.innerHTML = `${round(
parseFloat(svg.getAttribute("width") ?? ""),
0,
)}x${round(parseFloat(svg.getAttribute("height") ?? ""), 0)} (svg)`;
}
});
// this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) {
@@ -622,6 +747,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>(
@@ -820,6 +955,7 @@ const ExcalidrawWrapper = () => {
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
@@ -845,64 +981,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">
@@ -1132,12 +1213,277 @@ const ExcalidrawWrapper = () => {
},
]}
/>
{isVisualDebuggerEnabled() && excalidrawAPI && (
<DebugCanvas
appState={excalidrawAPI.getAppState()}
scale={window.devicePixelRatio}
ref={debugCanvasRef}
/>
)}
</Excalidraw>
<div
style={{
display: "flex",
flexDirection: "column",
position: "fixed",
bottom: 60,
right: 60,
zIndex: 9999999999,
color: "black",
}}
>
<div style={{ display: "flex", gap: "1rem", flexDirection: "column" }}>
<div style={{ display: "flex", gap: "1rem" }}>
<label>
center{" "}
<input
type="checkbox"
checked={config.position === "center"}
onChange={() =>
setConfig((s) => ({
...s,
position: s.position === "center" ? "topLeft" : "center",
}))
}
/>
</label>
<label>
fit{" "}
<select
value={config.fit}
onChange={(event) =>
setConfig((s) => ({
...s,
fit: event.target.value as any,
}))
}
>
<option value="none">none</option>
<option value="contain">contain</option>
</select>
</label>
<label>
padding{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.padding}
onChange={(event) =>
setConfig((s) => ({
...s,
padding: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label>
scale{" "}
<input
type="number"
max={4}
style={{ width: "3rem" }}
value={config.scale}
onChange={(event) =>
setConfig((s) => ({
...s,
scale: !event.target.value.trim()
? undefined
: Math.min(parseFloat(event.target.value as any), 4),
}))
}
/>
</label>
</div>
<div style={{ display: "flex", gap: "1rem" }}>
<label
style={{
opacity:
config.maxWidthOrHeight != null ||
config.widthOrHeight != null
? 0.5
: undefined,
}}
>
width{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.width}
onChange={(event) =>
setConfig((s) => ({
...s,
width: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label
style={{
opacity:
config.maxWidthOrHeight != null ||
config.widthOrHeight != null
? 0.5
: undefined,
}}
>
height{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.height}
onChange={(event) =>
setConfig((s) => ({
...s,
height: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label>
x{" "}
<input
type="number"
style={{ width: "3rem" }}
value={config.x}
onChange={(event) =>
setConfig((s) => ({
...s,
x: !event.target.value.trim()
? undefined
: parseFloat(event.target.value as any) ?? undefined,
}))
}
/>
</label>
<label>
y{" "}
<input
type="number"
style={{ width: "3rem" }}
value={config.y}
onChange={(event) =>
setConfig((s) => ({
...s,
y: !event.target.value.trim()
? undefined
: parseFloat(event.target.value as any) ?? undefined,
}))
}
/>
</label>
<label
style={{
opacity: config.widthOrHeight != null ? 0.5 : undefined,
}}
>
maxWH{" "}
<input
type="number"
// max={600}
style={{ width: "3rem" }}
value={config.maxWidthOrHeight}
onChange={(event) =>
setConfig((s) => ({
...s,
maxWidthOrHeight: !event.target.value.trim()
? undefined
: parseInt(event.target.value as any),
}))
}
/>
</label>
<label>
widthOrHeight{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.widthOrHeight}
onChange={(event) =>
setConfig((s) => ({
...s,
widthOrHeight: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
</div>
</div>
<div className="canvas_dims">0x0</div>
<div
ref={canvasPreviewContainerRef}
onClick={() => {
exportToBlob({
data: {
elements: excalidrawAPI!.getSceneElements(),
files: excalidrawAPI?.getFiles() || null,
},
config,
}).then((blob) => {
fileSave(blob, {
name: "xx",
extension: "png",
description: "xxx",
});
});
}}
style={{
borderRadius: 12,
border: "1px solid #777",
overflow: "hidden",
padding: 10,
backgroundColor: "pink",
}}
/>
<div className="svg_dims">0x0</div>
<div
ref={svgPreviewContainerRef}
onClick={() => {
exportToBlob({
data: {
elements: excalidrawAPI!.getSceneElements(),
files: excalidrawAPI?.getFiles() || null,
},
config,
}).then((blob) => {
fileSave(blob, {
name: "xx",
extension: "png",
description: "xxx",
});
});
}}
style={{
borderRadius: 12,
border: "1px solid #777",
overflow: "hidden",
padding: 10,
backgroundColor: "pink",
}}
/>
</div>
</div>
);
};
const ExcalidrawApp = () => {
const isCloudExportWindow =
window.location.pathname === "/excalidraw-plus-export";
if (isCloudExportWindow) {
return <ExcalidrawPlusIframeExport />;
}
return (
<TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}>
+28 -33
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>
);
};
@@ -0,0 +1,222 @@
import { useLayoutEffect, useRef } from "react";
import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
import type {
FileId,
OrderedExcalidrawElement,
} from "../packages/excalidraw/element/types";
import type { AppState, BinaryFileData } from "../packages/excalidraw/types";
import { ExcalidrawError } from "../packages/excalidraw/errors";
import { base64urlToString } from "../packages/excalidraw/data/encode";
const EVENT_REQUEST_SCENE = "REQUEST_SCENE";
const EXCALIDRAW_PLUS_ORIGIN = import.meta.env.VITE_APP_PLUS_APP;
// -----------------------------------------------------------------------------
// outgoing message
// -----------------------------------------------------------------------------
type MESSAGE_REQUEST_SCENE = {
type: "REQUEST_SCENE";
jwt: string;
};
type MESSAGE_FROM_PLUS = MESSAGE_REQUEST_SCENE;
// incoming messages
// -----------------------------------------------------------------------------
type MESSAGE_READY = { type: "READY" };
type MESSAGE_ERROR = { type: "ERROR"; message: string };
type MESSAGE_SCENE_DATA = {
type: "SCENE_DATA";
elements: OrderedExcalidrawElement[];
appState: Pick<AppState, "viewBackgroundColor">;
files: { loadedFiles: BinaryFileData[]; erroredFiles: Map<FileId, true> };
};
type MESSAGE_FROM_EDITOR = MESSAGE_ERROR | MESSAGE_SCENE_DATA | MESSAGE_READY;
// -----------------------------------------------------------------------------
const parseSceneData = async ({
rawElementsString,
rawAppStateString,
}: {
rawElementsString: string | null;
rawAppStateString: string | null;
}): Promise<MESSAGE_SCENE_DATA> => {
if (!rawElementsString || !rawAppStateString) {
throw new ExcalidrawError("Elements or appstate is missing.");
}
try {
const elements = JSON.parse(
rawElementsString,
) as OrderedExcalidrawElement[];
if (!elements.length) {
throw new ExcalidrawError("Scene is empty, nothing to export.");
}
const appState = JSON.parse(rawAppStateString) as Pick<
AppState,
"viewBackgroundColor"
>;
const fileIds = elements.reduce((acc, el) => {
if ("fileId" in el && el.fileId) {
acc.push(el.fileId);
}
return acc;
}, [] as FileId[]);
const files = await LocalData.fileStorage.getFiles(fileIds);
return {
type: "SCENE_DATA",
elements,
appState,
files,
};
} catch (error: any) {
throw error instanceof ExcalidrawError
? error
: new ExcalidrawError("Failed to parse scene data.");
}
};
const verifyJWT = async ({
token,
publicKey,
}: {
token: string;
publicKey: string;
}) => {
try {
if (!publicKey) {
throw new ExcalidrawError("Public key is undefined");
}
const [header, payload, signature] = token.split(".");
if (!header || !payload || !signature) {
throw new ExcalidrawError("Invalid JWT format");
}
// JWT is using Base64URL encoding
const decodedPayload = base64urlToString(payload);
const decodedSignature = base64urlToString(signature);
const data = `${header}.${payload}`;
const signatureArrayBuffer = Uint8Array.from(decodedSignature, (c) =>
c.charCodeAt(0),
);
const keyData = publicKey.replace(/-----\w+ PUBLIC KEY-----/g, "");
const keyArrayBuffer = Uint8Array.from(atob(keyData), (c) =>
c.charCodeAt(0),
);
const key = await crypto.subtle.importKey(
"spki",
keyArrayBuffer,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
true,
["verify"],
);
const isValid = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
key,
signatureArrayBuffer,
new TextEncoder().encode(data),
);
if (!isValid) {
throw new Error("Invalid JWT");
}
const parsedPayload = JSON.parse(decodedPayload);
// Check for expiration
const currentTime = Math.floor(Date.now() / 1000);
if (parsedPayload.exp && parsedPayload.exp < currentTime) {
throw new Error("JWT has expired");
}
} catch (error) {
console.error("Failed to verify JWT:", error);
throw new Error(error instanceof Error ? error.message : "Invalid JWT");
}
};
export const ExcalidrawPlusIframeExport = () => {
const readyRef = useRef(false);
useLayoutEffect(() => {
const handleMessage = async (event: MessageEvent<MESSAGE_FROM_PLUS>) => {
if (event.origin !== EXCALIDRAW_PLUS_ORIGIN) {
throw new ExcalidrawError("Invalid origin");
}
if (event.data.type === EVENT_REQUEST_SCENE) {
if (!event.data.jwt) {
throw new ExcalidrawError("JWT is missing");
}
try {
try {
await verifyJWT({
token: event.data.jwt,
publicKey: import.meta.env.VITE_APP_PLUS_EXPORT_PUBLIC_KEY,
});
} catch (error: any) {
console.error(`Failed to verify JWT: ${error.message}`);
throw new ExcalidrawError("Failed to verify JWT");
}
const parsedSceneData: MESSAGE_SCENE_DATA = await parseSceneData({
rawAppStateString: localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
),
rawElementsString: localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
),
});
event.source!.postMessage(parsedSceneData, {
targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
});
} catch (error) {
const responseData: MESSAGE_ERROR = {
type: "ERROR",
message:
error instanceof ExcalidrawError
? error.message
: "Failed to export scene data",
};
event.source!.postMessage(responseData, {
targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
});
}
}
};
window.addEventListener("message", handleMessage);
// so we don't send twice in StrictMode
if (!readyRef.current) {
readyRef.current = true;
const message: MESSAGE_FROM_EDITOR = { type: "READY" };
window.parent.postMessage(message, EXCALIDRAW_PLUS_ORIGIN);
}
return () => {
window.removeEventListener("message", handleMessage);
};
}, []);
// Since this component is expected to run in a hidden iframe on Excaildraw+,
// it doesn't need to render anything. All the data we need is available in
// LocalStorage and IndexedDB. It only needs to handle the messaging between
// the parent window and the iframe with the relevant data.
return null;
};
+1
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",
+27 -2
View File
@@ -1,6 +1,7 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import type {
BinaryFileData,
ExcalidrawImperativeAPI,
SocketId,
} from "../../packages/excalidraw/types";
@@ -9,6 +10,7 @@ import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
@@ -157,7 +159,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
throw new AbortError();
}
return saveFilesToFirebase({
const { savedFiles, erroredFiles } = await saveFilesToFirebase({
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
files: await encodeFilesForUpload({
files: addedFiles,
@@ -165,6 +167,29 @@ class Collab extends PureComponent<CollabProps, CollabState> {
maxBytes: FILE_UPLOAD_MAX_BYTES,
}),
});
return {
savedFiles: savedFiles.reduce(
(acc: Map<FileId, BinaryFileData>, id) => {
const fileData = addedFiles.get(id);
if (fileData) {
acc.set(id, fileData);
}
return acc;
},
new Map(),
),
erroredFiles: erroredFiles.reduce(
(acc: Map<FileId, BinaryFileData>, id) => {
const fileData = addedFiles.get(id);
if (fileData) {
acc.set(id, fileData);
}
return acc;
},
new Map(),
),
};
},
});
this.excalidrawAPI = props.excalidrawAPI;
@@ -394,7 +419,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
.filter((element) => {
return (
isInitializedImageElement(element) &&
!this.fileManager.isFileHandled(element.fileId) &&
!this.fileManager.isFileTracked(element.fileId) &&
!element.isDeleted &&
(opts.forceFetchFiles
? element.status !== "pending" ||
+20 -14
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 (
-218
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;
+163
View File
@@ -0,0 +1,163 @@
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({
data: {
elements: children,
appState: {
...appState,
exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
files: excalidrawAPI.getFiles(),
},
config: {
exportingFrame: frame,
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");
}
}}
/>
</>
);
};
+23 -19
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>
);
},
);
+21
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 "../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,6 +31,7 @@ export const AppMainMenu: React.FC<{
/>
)}
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
@@ -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
+311
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,
canvasBackgroundColor: "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;
+50 -21
View File
@@ -16,14 +16,26 @@ import type {
BinaryFiles,
} from "../../packages/excalidraw/types";
type FileVersion = Required<BinaryFileData>["version"];
export class FileManager {
/** files being fetched */
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles_fetch = new Map<
ExcalidrawImageElement["fileId"],
true
>();
/** files being saved */
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private savingFiles = new Map<
ExcalidrawImageElement["fileId"],
FileVersion
>();
/* files already saved to persistent storage */
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
private erroredFiles_save = new Map<
ExcalidrawImageElement["fileId"],
FileVersion
>();
private _getFiles;
private _saveFiles;
@@ -37,8 +49,8 @@ export class FileManager {
erroredFiles: Map<FileId, true>;
}>;
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
savedFiles: Map<FileId, true>;
erroredFiles: Map<FileId, true>;
savedFiles: Map<FileId, BinaryFileData>;
erroredFiles: Map<FileId, BinaryFileData>;
}>;
}) {
this._getFiles = getFiles;
@@ -46,19 +58,28 @@ export class FileManager {
}
/**
* returns whether file is already saved or being processed
* returns whether file is saved/errored, or being processed
*/
isFileHandled = (id: FileId) => {
isFileTracked = (id: FileId) => {
return (
this.savedFiles.has(id) ||
this.fetchingFiles.has(id) ||
this.savingFiles.has(id) ||
this.erroredFiles.has(id)
this.fetchingFiles.has(id) ||
this.erroredFiles_fetch.has(id) ||
this.erroredFiles_save.has(id)
);
};
isFileSaved = (id: FileId) => {
return this.savedFiles.has(id);
isFileSavedOrBeingSaved = (file: BinaryFileData) => {
const fileVersion = this.getFileVersion(file);
return (
this.savedFiles.get(file.id) === fileVersion ||
this.savingFiles.get(file.id) === fileVersion
);
};
getFileVersion = (file: BinaryFileData) => {
return file.version ?? 1;
};
saveFiles = async ({
@@ -71,13 +92,16 @@ export class FileManager {
const addedFiles: Map<FileId, BinaryFileData> = new Map();
for (const element of elements) {
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if (
isInitializedImageElement(element) &&
files[element.fileId] &&
!this.isFileHandled(element.fileId)
fileData &&
// NOTE if errored during save, won't retry due to this check
!this.isFileSavedOrBeingSaved(fileData)
) {
addedFiles.set(element.fileId, files[element.fileId]);
this.savingFiles.set(element.fileId, true);
this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
}
}
@@ -86,8 +110,12 @@ export class FileManager {
addedFiles,
});
for (const [fileId] of savedFiles) {
this.savedFiles.set(fileId, true);
for (const [fileId, fileData] of savedFiles) {
this.savedFiles.set(fileId, this.getFileVersion(fileData));
}
for (const [fileId, fileData] of erroredFiles) {
this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
}
return {
@@ -121,10 +149,10 @@ export class FileManager {
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
for (const file of loadedFiles) {
this.savedFiles.set(file.id, true);
this.savedFiles.set(file.id, this.getFileVersion(file));
}
for (const [fileId] of erroredFiles) {
this.erroredFiles.set(fileId, true);
this.erroredFiles_fetch.set(fileId, true);
}
return { loadedFiles, erroredFiles };
@@ -160,7 +188,7 @@ export class FileManager {
): element is InitializedExcalidrawImageElement => {
return (
isInitializedImageElement(element) &&
this.isFileSaved(element.fileId) &&
this.savedFiles.has(element.fileId) &&
element.status === "pending"
);
};
@@ -169,7 +197,8 @@ export class FileManager {
this.fetchingFiles.clear();
this.savingFiles.clear();
this.savedFiles.clear();
this.erroredFiles.clear();
this.erroredFiles_fetch.clear();
this.erroredFiles_save.clear();
}
}
+18 -5
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) {
@@ -170,8 +183,8 @@ export class LocalData {
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, BinaryFileData>();
const erroredFiles = new Map<FileId, BinaryFileData>();
// before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed
@@ -182,10 +195,10 @@ export class LocalData {
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, true);
savedFiles.set(id, fileData);
} catch (error: any) {
console.error(error);
erroredFiles.set(id, true);
erroredFiles.set(id, fileData);
}
}),
);
+4 -4
View File
@@ -177,8 +177,8 @@ export const saveFilesToFirebase = async ({
}) => {
const firebase = await loadFirebaseStorage();
const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, true>();
const erroredFiles: FileId[] = [];
const savedFiles: FileId[] = [];
await Promise.all(
files.map(async ({ id, buffer }) => {
@@ -194,9 +194,9 @@ export const saveFilesToFirebase = async ({
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
},
);
savedFiles.set(id, true);
savedFiles.push(id);
} catch (error: any) {
erroredFiles.set(id, true);
erroredFiles.push(id);
}
}),
);
+9 -45
View File
@@ -54,12 +54,6 @@
content="https://excalidraw.com/og-image-3.png"
/>
<!-- General tags -->
<meta
name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<!------------------------------------------------------------------------->
<!-- to minimize white flash on load when user has dark mode enabled -->
<script>
@@ -124,51 +118,19 @@
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/fonts.css"
type="text/css"
/>
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<!-- in DEV we need to preload from the local server and without the hash -->
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
<link
rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/assets/fonts.css"
type="text/css"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@@ -250,6 +212,7 @@
</header>
<div id="root"></div>
<script type="module" src="index.tsx"></script>
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<!-- 100% privacy friendly analytics -->
<script>
// need to load this script dynamically bcs. of iframe embed tracking
@@ -282,5 +245,6 @@
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>
+10 -7
View File
@@ -23,20 +23,20 @@
]
},
"engines": {
"node": ">=18.0.0"
"node": "18.0.0 - 22.x.x"
},
"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",
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
"socket.io-client": "4.7.2",
"vite-plugin-html": "3.2.2"
},
"prettier": "@excalidraw/prettier-config",
"scripts": {
@@ -49,5 +49,8 @@
"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"
}
}
+2 -2
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;
+13 -23
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>
+3
View File
@@ -29,6 +29,9 @@ interface ImportMetaEnv {
// Enable eslint in dev server
VITE_APP_ENABLE_ESLINT: string;
// Enable PWA in dev server
VITE_APP_ENABLE_PWA: string;
VITE_APP_PLUS_LP: string;
VITE_APP_PLUS_APP: string;
+225 -192
View File
@@ -5,204 +5,237 @@ 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("", `../`);
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: Number(envVars.VITE_APP_PORT || 3000),
// open the browser
open: true,
},
// We need to specify the envDir since now there are no
//more located in parallel with the vite.config.ts file but in parent dir
envDir: "../",
build: {
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path
return "[name]-[hash][extname]";
}
return "assets/[name]-[hash][extname]";
},
// Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
manualChunks(id) {
if (
id.includes("packages/excalidraw/locales") &&
id.match(/en.json|percentages.json/) === null
) {
const index = id.indexOf("locales/");
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
},
},
export default defineConfig(({ mode }) => {
// To load .env variables
const envVars = loadEnv(mode, `../`);
// https://vitejs.dev/config/
return {
server: {
port: Number(envVars.VITE_APP_PORT || 3000),
// open the browser
open: true,
},
sourcemap: true,
},
plugins: [
woff2BrowserPlugin(),
react(),
checker({
typescript: true,
eslint:
envVars.VITE_APP_ENABLE_ESLINT === "false"
? undefined
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
overlay: {
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
},
}),
svgrPlugin(),
ViteEjsPlugin(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
/* set this flag to true to enable in Development mode */
enabled: false,
},
// We need to specify the envDir since now there are no
//more located in parallel with the vite.config.ts file but in parent dir
envDir: "../",
build: {
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
const family = chunkInfo.name.split("-")[0];
return `fonts/${family}/[name][extname]`;
}
workbox: {
// Don't push fonts and locales to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
runtimeCaching: [
{
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
handler: "CacheFirst",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
return "assets/[name]-[hash][extname]";
},
{
urlPattern: new RegExp("fonts.css"),
handler: "StaleWhileRevalidate",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
},
},
},
{
urlPattern: new RegExp("locales/[^/]+.js"),
handler: "CacheFirst",
options: {
cacheName: "locales",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
},
},
},
],
},
manifest: {
short_name: "Excalidraw",
name: "Excalidraw",
description:
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
icons: [
{
src: "android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "apple-touch-icon.png",
type: "image/png",
sizes: "180x180",
},
{
src: "favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "favicon-16x16.png",
sizes: "16x16",
type: "image/png",
},
],
start_url: "/",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",
file_handlers: [
{
action: "/",
accept: {
"application/vnd.excalidraw+json": [".excalidraw"],
},
},
],
share_target: {
action: "/web-share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "file",
accept: [
"application/vnd.excalidraw+json",
"application/json",
".excalidraw",
],
},
],
// 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
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
manualChunks(id) {
if (
id.includes("packages/excalidraw/locales") &&
id.match(/en.json|percentages.json/) === null
) {
const index = id.indexOf("locales/");
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
},
},
screenshots: [
{
src: "/screenshots/virtual-whiteboard.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/wireframe.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/illustration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/shapes.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/collaboration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/export.png",
type: "image/png",
sizes: "462x945",
},
],
},
}),
createHtmlPlugin({
minify: true,
}),
],
publicDir: "../public",
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,
eslint:
envVars.VITE_APP_ENABLE_ESLINT === "false"
? undefined
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
overlay: {
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
},
}),
svgrPlugin(),
ViteEjsPlugin(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
/* set this flag to true to enable in Development mode */
enabled: envVars.VITE_APP_ENABLE_PWA === "true",
},
workbox: {
// don't precache fonts, locales and separate chunks
globIgnores: [
"fonts.css",
"**/locales/**",
"service-worker.js",
"**/*.chunk-*.js",
],
runtimeCaching: [
{
urlPattern: new RegExp(".+.woff2"),
handler: "CacheFirst",
options: {
cacheName: "fonts",
expiration: {
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],
},
},
},
{
urlPattern: new RegExp("fonts.css"),
handler: "StaleWhileRevalidate",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
},
},
},
{
urlPattern: new RegExp("locales/[^/]+.js"),
handler: "CacheFirst",
options: {
cacheName: "locales",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
},
},
},
{
urlPattern: new RegExp(".chunk-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "chunk",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
],
},
manifest: {
short_name: "Excalidraw",
name: "Excalidraw",
description:
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
icons: [
{
src: "android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "apple-touch-icon.png",
type: "image/png",
sizes: "180x180",
},
{
src: "favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "favicon-16x16.png",
sizes: "16x16",
type: "image/png",
},
],
start_url: "/",
id:"excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",
file_handlers: [
{
action: "/",
accept: {
"application/vnd.excalidraw+json": [".excalidraw"],
},
},
],
share_target: {
action: "/web-share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "file",
accept: [
"application/vnd.excalidraw+json",
"application/json",
".excalidraw",
],
},
],
},
},
screenshots: [
{
src: "/screenshots/virtual-whiteboard.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/wireframe.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/illustration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/shapes.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/collaboration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/export.png",
type: "image/png",
sizes: "462x945",
},
],
},
}),
createHtmlPlugin({
minify: true,
}),
],
publicDir: "../public",
};
});
+19 -17
View File
@@ -6,6 +6,7 @@
"excalidraw-app",
"packages/excalidraw",
"packages/utils",
"packages/math",
"examples/excalidraw",
"examples/excalidraw/*"
],
@@ -20,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",
@@ -36,15 +37,15 @@
"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.6.0",
"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"
"node": "18.0.0 - 22.x.x"
},
"homepage": ".",
"prettier": "@excalidraw/prettier-config",
@@ -54,15 +55,9 @@
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
"start": "yarn --cwd ./excalidraw-app start",
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"start:production": "yarn --cwd ./excalidraw-app start:production",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@@ -73,15 +68,22 @@
"test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui --coverage.enabled=true",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"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",
"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"
"@types/react": "18.2.0",
"strip-ansi": "6.0.1"
}
}
+6
View File
@@ -15,6 +15,10 @@ 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)
@@ -39,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 |
+70 -57
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,6 +105,8 @@ export const actionClearCanvas = register({
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
stats: appState.stats,
pasteDialog: appState.pasteDialog,
activeTool:
@@ -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 &&
+37 -23
View File
@@ -9,8 +9,9 @@ import {
readSystemClipboard,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element";
import { exportAsImage } from "../data/index";
import { getTextFromElements, isTextElement } from "../element";
import { prepareElementsForExport } from "../data/index";
import { t } from "../i18n";
import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
@@ -136,25 +137,41 @@ export const actionCopyAsSvg = register({
);
try {
await exportCanvas(
"clipboard-svg",
exportedElements,
appState,
app.files,
{
await exportAsImage({
type: "clipboard-svg",
data: { elements: exportedElements, appState, files: app.files },
config: {
...appState,
exportingFrame,
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,
@@ -190,11 +207,16 @@ export const actionCopyAsPng = register({
true,
);
try {
await exportCanvas("clipboard", exportedElements, appState, app.files, {
...appState,
exportingFrame,
name: app.getName(),
await exportAsImage({
type: "clipboard",
data: { elements: exportedElements, appState, files: app.files },
config: {
...appState,
exportingFrame,
name: appState.name || app.getName(),
},
});
return {
appState: {
...appState,
@@ -239,16 +261,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"));
}
@@ -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)}
/>
);
},
});
@@ -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,
@@ -42,20 +42,21 @@ export const actionDuplicateSelection = register({
perform: (elements, appState, formData, app) => {
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene.getNonDeletedElementsMap(),
);
// 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 {
@@ -99,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);
+14 -2
View File
@@ -10,13 +10,13 @@ import { useDevice } from "../components/App";
import { KEYS } from "../keys";
import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem";
import { getExportSize } from "../scene/export";
import { getCanvasSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import type { Theme } from "../element/types";
import type { NonDeletedExcalidrawElement, Theme } from "../element/types";
import "../components/ToolIcon.scss";
import { StoreAction } from "../store";
@@ -58,6 +58,18 @@ export const actionChangeExportScale = register({
? getSelectedElements(elements, appState)
: elements;
const getExportSize = (
elements: readonly NonDeletedExcalidrawElement[],
padding: number,
scale: number,
): [number, number] => {
const [, , width, height] = getCanvasSize(elements).map((dimension) =>
Math.trunc(dimension * scale),
);
return [width + padding * 2, height + padding * 2];
};
return (
<>
{EXPORT_SCALES.map((s) => {
@@ -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",
@@ -50,7 +51,6 @@ export const actionFinalize = register({
...appState,
cursorButton: "up",
editingLinearElement: null,
selectedLinearElement: null,
},
storeAction: StoreAction.CAPTURE,
};
@@ -113,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,
),
});
}
@@ -179,7 +179,7 @@ export const actionFinalize = register({
newElement: null,
selectionElement: null,
multiElement: null,
editingElement: null,
editingTextElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds:
@@ -217,6 +217,7 @@ export const actionFinalize = register({
onClick={updateData}
visible={appState.multiElement != null}
size={data?.size || "medium"}
style={{ pointerEvents: "all" }}
/>
),
});
@@ -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);
});
});
+69 -2
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,
@@ -131,5 +155,48 @@ const flipElements = (
[],
);
// ---------------------------------------------------------------------------
// 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;
};
+12 -14
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,17 +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.editingTextElement &&
!appState.newElement &&
!appState.selectedElementsAreBeingDragged &&
!appState.selectionElement
!appState.selectionElement &&
!app.flowChartCreator.isCreatingChart
) {
const result = updater();
@@ -53,7 +55,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, value, app) =>
writeData(appState, () =>
executeHistoryAction(app, appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
@@ -61,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,
@@ -94,7 +94,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, _, app) =>
writeData(appState, () =>
executeHistoryAction(app, appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
@@ -102,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,
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
import type { AppClassProperties, AppState, Primitive } from "../types";
import type { StoreActionType } from "../store";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
@@ -115,6 +115,8 @@ import {
} 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;
@@ -133,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);
}
@@ -148,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) {
@@ -850,7 +852,7 @@ export const actionChangeFontFamily = register({
ExcalidrawTextElement,
ExcalidrawElement | null
>();
let uniqueGlyphs = new Set<string>();
let uniqueChars = new Set<string>();
let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
@@ -898,8 +900,8 @@ export const actionChangeFontFamily = register({
}
if (!skipFontFaceCheck) {
uniqueGlyphs = new Set([
...uniqueGlyphs,
uniqueChars = new Set([
...uniqueChars,
...Array.from(newElement.originalText),
]);
}
@@ -919,12 +921,9 @@ export const actionChangeFontFamily = register({
const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily,
})}`;
const glyphs = Array.from(uniqueGlyphs.values()).join();
const chars = Array.from(uniqueChars.values()).join();
if (
skipFontFaceCheck ||
window.document.fonts.check(fontString, glyphs)
) {
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
@@ -936,8 +935,8 @@ export const actionChangeFontFamily = register({
);
}
} else {
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
// 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);
@@ -1076,19 +1075,20 @@ export const actionChangeFontFamily = register({
// open, populate the cache from scratch
cachedElementsRef.current.clear();
const { editingElement } = appState;
const { editingTextElement } = appState;
if (editingElement?.type === "text") {
// retrieve the latest version from the scene, as `editingElement` isn't mutated
const latestEditingElement = app.scene.getElement(
editingElement.id,
// 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(
editingElement.id,
editingTextElement.id,
newElementWith(
latestEditingElement || editingElement,
latesteditingTextElement || editingTextElement,
{},
true,
),
@@ -1650,10 +1650,10 @@ export const actionChangeArrowType = register({
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
(point) =>
[point[0] - newElement.x, point[1] - newElement.y] as Point,
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
[0, 0],
vector(0, 0),
{
...(startElement && newElement.startBinding
? {
@@ -1685,19 +1685,6 @@ export const actionChangeArrowType = register({
: {}),
},
);
} else {
mutateElement(
newElement,
{
startBinding: newElement.startBinding
? { ...newElement.startBinding, fixedPoint: null }
: null,
endBinding: newElement.endBinding
? { ...newElement.endBinding, fixedPoint: null }
: null,
},
false,
);
}
return newElement;
@@ -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,
});
@@ -17,7 +17,7 @@ export const actionToggleObjectsSnapMode = register({
appState: {
...appState,
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
gridModeEnabled: false,
},
storeAction: StoreAction.NONE,
};
@@ -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,
});
+4
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";
+3 -3
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) => {
+6 -7
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;
@@ -137,7 +133,9 @@ export type ActionName =
| "wrapTextInContainer"
| "commandPalette"
| "autoResize"
| "elementStats";
| "elementStats"
| "searchMenu"
| "cropEditor";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@@ -191,7 +189,8 @@ export interface Action {
| "history"
| "menu"
| "collab"
| "hyperlink";
| "hyperlink"
| "search_menu";
action?: string;
predicate?: (
appState: Readonly<AppState>,
+1 -1
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 = new Set(["command_palette"]);
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
export const trackEvent = (
category: string,
+20 -7
View File
@@ -1,15 +1,18 @@
import { COLOR_PALETTE } from "./colors";
import {
ARROW_TYPE,
COLOR_WHITE,
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE,
DEFAULT_ZOOM_VALUE,
EXPORT_SCALES,
STATS_PANELS,
THEME,
DEFAULT_GRID_STEP,
} from "./constants";
import type { AppState, NormalizedZoomValue } from "./types";
import type { AppState } from "./types";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio
@@ -42,7 +45,7 @@ export const getDefaultAppState = (): Omit<
cursorButton: "up",
activeEmbeddable: null,
newElement: null,
editingElement: null,
editingTextElement: null,
editingGroupId: null,
editingLinearElement: null,
activeTool: {
@@ -59,7 +62,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,
@@ -95,10 +100,10 @@ export const getDefaultAppState = (): Omit<
editingFrame: null,
elementsToHighlight: null,
toast: null,
viewBackgroundColor: COLOR_PALETTE.white,
viewBackgroundColor: COLOR_WHITE,
zenModeEnabled: false,
zoom: {
value: 1 as NormalizedZoomValue,
value: DEFAULT_ZOOM_VALUE,
},
viewModeEnabled: false,
pendingImageElementId: null,
@@ -112,6 +117,9 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false,
userToFollow: null,
followedBy: new Set(),
isCropping: false,
croppingElementId: null,
searchMatches: [],
};
};
@@ -161,7 +169,7 @@ const APP_STATE_STORAGE_CONF = (<
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false },
newElement: { browser: false, export: false, server: false },
editingElement: { 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 },
@@ -174,6 +182,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: {
@@ -230,6 +240,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 = <
+32 -1
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.
@@ -1216,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;
+11 -24
View File
@@ -1,9 +1,8 @@
import type { Radians } from "../math";
import { pointFrom } from "../math";
import { DEFAULT_CHART_COLOR_INDEX, getAllColorsSpecificShade } from "./colors";
import {
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
getAllColorsSpecificShade,
} from "./colors";
import {
COLOR_CHARCOAL_BLACK,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
VERTICAL_ALIGN,
@@ -171,7 +170,7 @@ const commonProps = {
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: COLOR_PALETTE.black,
strokeColor: COLOR_CHARCOAL_BLACK,
roundness: null,
strokeStyle: "solid",
strokeWidth: 1,
@@ -203,7 +202,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",
@@ -258,10 +257,7 @@ const chartLines = (
x,
y,
width: chartWidth,
points: [
[0, 0],
[chartWidth, 0],
],
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
const yLine = newLinearElement({
@@ -272,10 +268,7 @@ const chartLines = (
x,
y,
height: chartHeight,
points: [
[0, 0],
[0, -chartHeight],
],
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
});
const maxLine = newLinearElement({
@@ -288,10 +281,7 @@ const chartLines = (
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
points: [
[0, 0],
[chartWidth, 0],
],
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
return [xLine, yLine, maxLine];
@@ -331,7 +321,7 @@ const chartBaseElements = (
y: y - chartHeight,
width: chartWidth,
height: chartHeight,
strokeColor: COLOR_PALETTE.black,
strokeColor: COLOR_CHARCOAL_BLACK,
fillStyle: "solid",
opacity: 6,
})
@@ -448,10 +438,7 @@ const chartTypeLine = (
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,
points: [
[0, 0],
[0, cy],
],
points: [pointFrom(0, 0), pointFrom(0, cy)],
});
});
+21 -23
View File
@@ -1,27 +1,25 @@
import oc from "open-color";
import type { Merge } from "./utility-types";
// FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
source: R,
keys: K,
) => {
return keys.reduce((acc, key: K[number]) => {
if (key in source) {
acc[key] = source[key];
}
return acc;
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
};
import {
COLOR_WHITE,
COLOR_CHARCOAL_BLACK,
COLOR_TRANSPARENT,
} from "./constants";
import { type Merge } from "./utility-types";
import { pick } from "./utils";
export type ColorPickerColor =
| Exclude<keyof oc, "indigo" | "lime">
| Exclude<keyof oc, "indigo" | "lime" | "black">
| "transparent"
| "charcoal"
| "bronze";
export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
{
charcoal: typeof COLOR_CHARCOAL_BLACK;
white: typeof COLOR_WHITE;
transparent: typeof COLOR_TRANSPARENT;
}
>;
// used general type instead of specific type (ColorPalette) to support custom colors
@@ -41,7 +39,7 @@ export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
export const getSpecificColorShades = (
color: Exclude<
ColorPickerColor,
"transparent" | "white" | "black" | "bronze"
"transparent" | "charcoal" | "black" | "white" | "bronze"
>,
indexArr: Readonly<ColorShadesIndexes>,
) => {
@@ -49,9 +47,9 @@ export const getSpecificColorShades = (
};
export const COLOR_PALETTE = {
transparent: "transparent",
black: "#1e1e1e",
white: "#ffffff",
transparent: COLOR_TRANSPARENT,
charcoal: COLOR_CHARCOAL_BLACK,
white: COLOR_WHITE,
// open-colors
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
@@ -87,7 +85,7 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
// ORDER matters for positioning in quick picker
export const DEFAULT_ELEMENT_STROKE_PICKS = [
COLOR_PALETTE.black,
COLOR_PALETTE.charcoal,
COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
@@ -125,7 +123,7 @@ export const DEFAULT_ELEMENT_STROKE_COLOR_PALETTE = {
transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray,
black: COLOR_PALETTE.black,
charcoal: COLOR_PALETTE.charcoal,
bronze: COLOR_PALETTE.bronze,
// rest
...COMMON_ELEMENT_SHADES,
@@ -136,7 +134,7 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray,
black: COLOR_PALETTE.black,
charcoal: COLOR_PALETTE.charcoal,
bronze: COLOR_PALETTE.bronze,
...COMMON_ELEMENT_SHADES,
+14 -19
View File
@@ -26,6 +26,7 @@ import { trackEvent } from "../analytics";
import {
hasBoundTextElement,
isElbowArrow,
isImageElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
@@ -45,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,
@@ -104,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";
@@ -125,6 +128,11 @@ export const SelectedShapeActions = ({
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
return (
<div className="panelColumn">
<div>
@@ -234,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">
@@ -243,6 +251,7 @@ export const SelectedShapeActions = ({
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")}
</div>
</fieldset>
@@ -400,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()}
@@ -410,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>
@@ -439,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
@@ -106,7 +106,7 @@ const ColorPickerPopupContent = ({
return (
<PropertiesPopover
container={container}
style={{ maxWidth: "208px" }}
style={{ maxWidth: "13rem" }}
onFocusOutside={(event) => {
// refocus due to eye dropper
focusPickerContent();
@@ -204,7 +204,7 @@ export const colorPickerKeyNavHandler = ({
});
if (!baseColorName) {
onChange(COLOR_PALETTE.black);
onChange(COLOR_PALETTE.charcoal);
}
}
@@ -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"],
@@ -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>
@@ -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;
};
@@ -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);
@@ -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}
@@ -21,7 +21,7 @@ export const DEFAULT_FONTS = [
value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon,
text: t("labels.handDrawn"),
testId: "font-family-handrawn",
testId: "font-family-hand-drawn",
},
{
value: FONT_FAMILY.Nunito,
@@ -21,6 +21,7 @@ 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;
@@ -62,12 +63,14 @@ export const FontPickerList = React.memo(
const allFonts = useMemo(
() =>
Array.from(Fonts.registered.entries())
.filter(([_, { metadata }]) => !metadata.serverSide)
.map(([familyId, { metadata, fonts }]) => {
.filter(
([_, { metadata }]) => !metadata.serverSide && !metadata.fallback,
)
.map(([familyId, { metadata, fontFaces }]) => {
const fontDescriptor = {
value: familyId,
icon: metadata.icon,
text: fonts[0].fontFace.family,
icon: metadata.icon ?? FontFamilyNormalIcon,
text: fontFaces[0]?.fontFace?.family ?? "Unknown",
};
if (metadata.deprecated) {
@@ -89,7 +92,7 @@ export const FontPickerList = React.memo(
);
const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFontFamilies()),
() => 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],
+25 -1
View File
@@ -22,7 +22,7 @@ const Header = () => (
</a>
<a
className="HelpDialog__btn"
href="https://blog.excalidraw.com"
href="https://plus.excalidraw.com/blog"
target="_blank"
rel="noopener noreferrer"
>
@@ -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")}
@@ -288,6 +298,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
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={[
@@ -9,6 +9,7 @@ $wide-viewport-width: 1000px;
box-sizing: border-box;
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
left: 0;
top: 100%;
+52 -7
View File
@@ -1,6 +1,7 @@
import { t } from "../i18n";
import type { AppClassProperties, Device, UIAppState } from "../types";
import {
isFlowchartNodeElement,
isImageElement,
isLinearElement,
isTextBindableContainer,
@@ -10,6 +11,9 @@ import { getShortcutKey } from "../utils";
import { isEraserActive } from "../appState";
import "./HintViewer.scss";
import { isNodeInFlowchart } from "../element/flowchart";
import { isGridModeEnabled } from "../snapping";
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
interface HintViewerProps {
appState: UIAppState;
@@ -18,10 +22,23 @@ interface HintViewerProps {
app: AppClassProperties;
}
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
const getHints = ({
appState,
isMobile,
device,
app,
}: HintViewerProps): null | string | string[] => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
appState.searchMatches?.length
) {
return t("hints.dismissSearch");
}
if (appState.openSidebar && !device.editor.canFitSidebar) {
return null;
}
@@ -79,21 +96,29 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.text_selected");
}
if (appState.editingElement && isTextElement(appState.editingElement)) {
if (appState.editingTextElement) {
return t("hints.text_editing");
}
if (appState.croppingElementId) {
return t("hints.leaveCropEditor");
}
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
return t("hints.enterCropEditor");
}
if (activeTool.type === "selection") {
if (
appState.selectionElement &&
!selectedElements.length &&
!appState.editingElement &&
!appState.editingTextElement &&
!appState.editingLinearElement
) {
return t("hints.deepBoxSelect");
}
if (appState.gridSize && appState.selectedElementsAreBeingDragged) {
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
return t("hints.disableSnapping");
}
@@ -115,6 +140,19 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
!appState.selectedElementsAreBeingDragged &&
isTextBindableContainer(selectedElements[0])
) {
if (isFlowchartNodeElement(selectedElements[0])) {
if (
isNodeInFlowchart(
selectedElements[0],
app.scene.getNonDeletedElementsMap(),
)
) {
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
}
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
}
return t("hints.bindTextToElement");
}
}
@@ -129,17 +167,24 @@ export const HintViewer = ({
device,
app,
}: HintViewerProps) => {
let hint = getHints({
const hints = getHints({
appState,
isMobile,
device,
app,
});
if (!hint) {
if (!hints) {
return null;
}
hint = getShortcutKey(hint);
const hint = Array.isArray(hints)
? hints
.map((hint) => {
return getShortcutKey(hint).replace(/\. ?$/, "");
})
.join(". ")
: getShortcutKey(hints);
return (
<div className="HintViewer">
@@ -23,7 +23,6 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import type { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../../utils/export";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog";
@@ -35,6 +34,8 @@ import "./ImageExportDialog.scss";
import { FilledButton } from "./FilledButton";
import { cloneJSON } from "../utils";
import { prepareElementsForExport } from "../data";
import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { exportToCanvas } from "../scene/export";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -89,6 +90,21 @@ const ImageExportModal = ({
const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null);
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
useEffect(() => {
// if user changes setting right after export to clipboard, reset the status
// so they don't have to wait for the timeout to click the button again
resetCopyStatus();
}, [
projectName,
exportWithBackground,
exportDarkMode,
exportScale,
embedScene,
resetCopyStatus,
]);
const { exportedElements, exportingFrame } = prepareElementsForExport(
elementsSnapshot,
appStateSnapshot,
@@ -105,20 +121,27 @@ const ImageExportModal = ({
if (!maxWidth) {
return;
}
exportToCanvas({
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportWithDarkMode: exportDarkMode,
exportScale,
exportEmbedScene: embedScene,
data: {
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportEmbedScene: embedScene,
},
files,
},
config: {
canvasBackgroundColor: !exportWithBackground
? false
: appStateSnapshot.viewBackgroundColor,
padding: DEFAULT_EXPORT_PADDING,
theme: exportDarkMode ? "dark" : "light",
scale: exportScale,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
},
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
})
.then((canvas) => {
setRenderError(null);
@@ -294,11 +317,17 @@ const ImageExportModal = ({
<FilledButton
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
exportingFrame,
})
}
status={copyStatus}
onClick={async () => {
await onExportImage(
EXPORT_IMAGE_TYPES.clipboard,
exportedElements,
{
exportingFrame,
},
);
onCopy();
}}
icon={copyIcon}
>
{t("imageExportDialog.button.copyPngToClipboard")}
@@ -27,99 +27,6 @@
& > * {
pointer-events: var(--ui-pointerEvents);
}
& > .Stats {
width: 204px;
position: absolute;
top: 60px;
font-size: 12px;
z-index: var(--zIndex-layerUI);
pointer-events: var(--ui-pointerEvents);
.title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h2 {
margin: 0;
}
}
.sectionContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.elementType {
font-size: 12px;
font-weight: 700;
margin-top: 8px;
}
.elementsCount {
width: 100%;
font-size: 12px;
display: flex;
justify-content: space-between;
margin-top: 8px;
}
.statsItem {
margin-top: 8px;
width: 100%;
margin-bottom: 4px;
display: grid;
gap: 4px;
.label {
margin-right: 4px;
}
}
h3 {
white-space: nowrap;
margin: 0;
}
.close {
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
table {
width: 100%;
th {
border-bottom: 1px solid var(--input-border-color);
padding: 4px;
}
tr {
td:nth-child(2) {
min-width: 24px;
text-align: right;
}
}
}
.divider {
width: 100%;
height: 1px;
background-color: var(--default-border-color);
}
:root[dir="rtl"] & {
left: 12px;
right: initial;
}
}
}
&__footer {
+5 -36
View File
@@ -53,18 +53,17 @@ import { LibraryIcon } from "./icons";
import { UIAppStateContext } from "../context/ui-appState";
import { DefaultSidebar } from "./DefaultSidebar";
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import "./LayerUI.scss";
import "./Toolbar.scss";
interface LayerUIProps {
actionManager: ActionManager;
appState: UIAppState;
@@ -85,14 +84,6 @@ interface LayerUIProps {
children?: React.ReactNode;
app: AppClassProperties;
isCollaborating: boolean;
openAIKey: string | null;
isOpenAIKeyPersisted: boolean;
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
onMagicSettingsConfirm: (
apiKey: string,
shouldPersist: boolean,
source: "tool" | "generation" | "settings",
) => void;
}
const DefaultMainMenu: React.FC<{
@@ -108,6 +99,7 @@ const DefaultMainMenu: React.FC<{
{UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage />
)}
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
@@ -149,10 +141,6 @@ const LayerUI = ({
children,
app,
isCollaborating,
openAIKey,
isOpenAIKeyPersisted,
onOpenAIAPIKeyChange,
onMagicSettingsConfirm,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@@ -360,7 +348,7 @@ const LayerUI = ({
)}
{shouldShowStats && (
<Stats
scene={app.scene}
app={app}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
@@ -482,25 +470,6 @@ const LayerUI = ({
}}
/>
)}
{appState.openDialog?.name === "settings" && (
<MagicSettings
openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => {
const source =
appState.openDialog?.name === "settings"
? appState.openDialog?.source
: "settings";
setAppState({ openDialog: null }, () => {
onMagicSettingsConfirm(apiKey, shouldPersist, source);
});
}}
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
<ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
@@ -1,18 +0,0 @@
.excalidraw {
.MagicSettings {
.Island {
height: 100%;
display: flex;
flex-direction: column;
}
}
.MagicSettings-confirm {
padding: 0.5rem 1rem;
}
.MagicSettings__confirm {
margin-top: 2rem;
margin-right: auto;
}
}
@@ -1,160 +0,0 @@
import { useState } from "react";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { MagicIcon, OpenAIIcon } from "./icons";
import { FilledButton } from "./FilledButton";
import { CheckboxItem } from "./CheckboxItem";
import { KEYS } from "../keys";
import { useUIAppState } from "../context/ui-appState";
import { InlineIcon } from "./InlineIcon";
import { Paragraph } from "./Paragraph";
import "./MagicSettings.scss";
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
export const MagicSettings = (props: {
openAIKey: string | null;
isPersisted: boolean;
onChange: (key: string, shouldPersist: boolean) => void;
onConfirm: (key: string, shouldPersist: boolean) => void;
onClose: () => void;
}) => {
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
const [shouldPersist, setShouldPersist] = useState<boolean>(
props.isPersisted,
);
const appState = useUIAppState();
const onConfirm = () => {
props.onConfirm(keyInputValue.trim(), shouldPersist);
};
if (appState.openDialog?.name !== "settings") {
return null;
}
return (
<Dialog
onCloseRequest={() => {
props.onClose();
props.onConfirm(keyInputValue.trim(), shouldPersist);
}}
title={
<div style={{ display: "flex" }}>
Wireframe to Code (AI){" "}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.1rem 0.5rem",
marginLeft: "1rem",
fontSize: 14,
borderRadius: "12px",
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
}}
>
Experimental
</div>
</div>
}
className="MagicSettings"
autofocus={false}
>
{/* <h2
style={{
margin: 0,
fontSize: "1.25rem",
paddingLeft: "2.5rem",
}}
>
AI Settings
</h2> */}
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
{/* <TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
<InlineIcon icon={brainIcon} /> Text to diagram
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="diagram-to-code">
<InlineIcon icon={MagicIcon} /> Wireframe to code
</TTDDialogTabTrigger>
</TTDDialogTabTriggers> */}
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
TODO
</TTDDialogTab> */}
<TTDDialogTab
// className="ttd-dialog-content"
tab="diagram-to-code"
>
<Paragraph>
For the diagram-to-code feature we use{" "}
<InlineIcon icon={OpenAIIcon} />
OpenAI.
</Paragraph>
<Paragraph>
While the OpenAI API is in beta, its use is strictly limited as
such we require you use your own API key. You can create an{" "}
<a
href="https://platform.openai.com/login?launch"
rel="noopener noreferrer"
target="_blank"
>
OpenAI account
</a>
, add a small credit (5 USD minimum), and{" "}
<a
href="https://platform.openai.com/api-keys"
rel="noopener noreferrer"
target="_blank"
>
generate your own API key
</a>
.
</Paragraph>
<Paragraph>
Your OpenAI key does not leave the browser, and you can also set
your own limit in your OpenAI account dashboard if needed.
</Paragraph>
<TextField
isRedacted
value={keyInputValue}
placeholder="Paste your API key here"
label="OpenAI API key"
onChange={(value) => {
setKeyInputValue(value);
props.onChange(value.trim(), shouldPersist);
}}
selectOnRender
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
/>
<Paragraph>
By default, your API token is not persisted anywhere so you'll need
to insert it again after reload. But, you can persist locally in
your browser below.
</Paragraph>
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
Persist API key in browser storage
</CheckboxItem>
<Paragraph>
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
tool to wrap your elements in a frame that will then allow you to
turn it into code. This dialog can be accessed using the{" "}
<b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
</Paragraph>
<FilledButton
className="MagicSettings__confirm"
size="large"
label="Confirm"
onClick={onConfirm}
/>
</TTDDialogTab>
</TTDDialogTabs>
</Dialog>
);
};
@@ -1,9 +1,9 @@
import oc from "open-color";
import React, { useLayoutEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics";
import type { ChartElements, Spreadsheet } from "../charts";
import { renderSpreadsheet } from "../charts";
import type { ChartType } from "../element/types";
import { COLOR_WHITE } from "../constants";
import { t } from "../i18n";
import { exportToSvg } from "../scene/export";
import type { UIAppState } from "../types";
@@ -41,14 +41,16 @@ const ChartPreviewBtn = (props: {
const previewNode = previewRef.current!;
(async () => {
svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: oc.white,
svg = await exportToSvg({
data: {
elements,
appState: {
exportBackground: false,
viewBackgroundColor: COLOR_WHITE,
},
files: null,
},
null, // files
);
});
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
@@ -7,8 +7,8 @@ import { t } from "../i18n";
import Trans from "./Trans";
import type { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas, exportToSvg } from "../../utils/export";
import {
COLOR_WHITE,
EDITOR_LS_KEYS,
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
@@ -24,6 +24,7 @@ import { ToolButton } from "./ToolButton";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import "./PublishLibrary.scss";
import { exportToCanvas, exportToSvg } from "../scene/export";
interface PublishLibraryDataParams {
authorName: string;
@@ -55,16 +56,20 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = OpenColor.white;
ctx.fillStyle = COLOR_WHITE;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw items
// ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({
elements: item.elements,
files: null,
maxWidthOrHeight: BOX_SIZE,
data: {
elements: item.elements,
files: null,
},
config: {
maxWidthOrHeight: BOX_SIZE,
},
});
const { width, height } = itemCanvas;
@@ -126,13 +131,15 @@ const SingleLibraryItem = ({
}
(async () => {
const svg = await exportToSvg({
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: OpenColor.white,
exportBackground: true,
data: {
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: COLOR_WHITE,
exportBackground: true,
},
files: null,
},
files: null,
});
node.innerHTML = svg.outerHTML;
})();
@@ -0,0 +1,110 @@
@import "open-color/open-color";
.excalidraw {
.layer-ui__search {
flex: 1 0 auto;
display: flex;
flex-direction: column;
padding: 8px 0 0 0;
}
.layer-ui__search-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.75rem;
.ExcTextField {
flex: 1 0 auto;
}
.ExcTextField__input {
background-color: #f5f5f9;
@at-root .excalidraw.theme--dark#{&} {
background-color: #31303b;
}
border-radius: var(--border-radius-md);
border: 0;
input::placeholder {
font-size: 0.9rem;
}
}
}
.layer-ui__search-count {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 8px 0 8px;
margin: 0 0.75rem 0.25rem 0.75rem;
font-size: 0.8em;
.result-nav {
display: flex;
.result-nav-btn {
width: 36px;
height: 36px;
--button-border: transparent;
&:active {
background-color: var(--color-surface-high);
}
&:first {
margin-right: 4px;
}
}
}
}
.layer-ui__search-result-container {
overflow-y: auto;
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.layer-ui__result-item {
display: flex;
align-items: center;
min-height: 2rem;
flex: 0 0 auto;
padding: 0.25rem 0.75rem;
cursor: pointer;
border: 1px solid transparent;
outline: none;
margin: 0 0.75rem;
border-radius: var(--border-radius-md);
.text-icon {
width: 1rem;
height: 1rem;
margin-right: 0.75rem;
}
.preview-text {
flex: 1;
max-height: 48px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
&:hover {
background-color: var(--color-surface-high);
}
&:active {
border-color: var(--color-primary);
}
&.active {
background-color: var(--color-surface-high);
}
}
}
@@ -0,0 +1,719 @@
import { Fragment, memo, useEffect, useRef, useState } from "react";
import { collapseDownIcon, upIcon, searchIcon } from "./icons";
import { TextField } from "./TextField";
import { Button } from "./Button";
import { useApp, useExcalidrawSetAppState } from "./App";
import { debounce } from "lodash";
import type { AppClassProperties } from "../types";
import { isTextElement, newTextElement } from "../element";
import type { ExcalidrawTextElement } from "../element/types";
import { measureText } from "../element/textElement";
import { addEventListener, getFontString } from "../utils";
import { KEYS } from "../keys";
import clsx from "clsx";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import { t } from "../i18n";
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
import { randomInteger } from "../random";
import { CLASSES, EVENT } from "../constants";
import { useStable } from "../hooks/useStable";
import "./SearchMenu.scss";
import { round } from "../../math";
const searchQueryAtom = atom<string>("");
export const searchItemInFocusAtom = atom<number | null>(null);
const SEARCH_DEBOUNCE = 350;
type SearchMatchItem = {
textElement: ExcalidrawTextElement;
searchQuery: SearchQuery;
index: number;
preview: {
indexInSearchQuery: number;
previewText: string;
moreBefore: boolean;
moreAfter: boolean;
};
matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[];
};
type SearchMatches = {
nonce: number | null;
items: SearchMatchItem[];
};
type SearchQuery = string & { _brand: "SearchQuery" };
export const SearchMenu = () => {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const searchInputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
const searchQuery = inputValue.trim() as SearchQuery;
const [isSearching, setIsSearching] = useState(false);
const [searchMatches, setSearchMatches] = useState<SearchMatches>({
nonce: null,
items: [],
});
const searchedQueryRef = useRef<SearchQuery | null>(null);
const lastSceneNonceRef = useRef<number | undefined>(undefined);
const [focusIndex, setFocusIndex] = useAtom(
searchItemInFocusAtom,
jotaiScope,
);
const elementsMap = app.scene.getNonDeletedElementsMap();
useEffect(() => {
if (isSearching) {
return;
}
if (
searchQuery !== searchedQueryRef.current ||
app.scene.getSceneNonce() !== lastSceneNonceRef.current
) {
searchedQueryRef.current = null;
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
});
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
});
});
}
}, [
isSearching,
searchQuery,
elementsMap,
app,
setAppState,
setFocusIndex,
lastSceneNonceRef,
]);
const goToNextItem = () => {
if (searchMatches.items.length > 0) {
setFocusIndex((focusIndex) => {
if (focusIndex === null) {
return 0;
}
return (focusIndex + 1) % searchMatches.items.length;
});
}
};
const goToPreviousItem = () => {
if (searchMatches.items.length > 0) {
setFocusIndex((focusIndex) => {
if (focusIndex === null) {
return 0;
}
return focusIndex - 1 < 0
? searchMatches.items.length - 1
: focusIndex - 1;
});
}
};
useEffect(() => {
setAppState((state) => {
return {
searchMatches: state.searchMatches.map((match, index) => {
if (index === focusIndex) {
return { ...match, focus: true };
}
return { ...match, focus: false };
}),
};
});
}, [focusIndex, setAppState]);
useEffect(() => {
if (searchMatches.items.length > 0 && focusIndex !== null) {
const match = searchMatches.items[focusIndex];
if (match) {
const zoomValue = app.state.zoom.value;
const matchAsElement = newTextElement({
text: match.searchQuery,
x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
width: match.matchedLines[0]?.width,
height: match.matchedLines[0]?.height,
fontSize: match.textElement.fontSize,
fontFamily: match.textElement.fontFamily,
});
const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
const fontSize = match.textElement.fontSize;
const isTextTiny =
fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
if (
!isElementCompletelyInViewport(
[matchAsElement],
app.canvas.width / window.devicePixelRatio,
app.canvas.height / window.devicePixelRatio,
{
offsetLeft: app.state.offsetLeft,
offsetTop: app.state.offsetTop,
scrollX: app.state.scrollX,
scrollY: app.state.scrollY,
zoom: app.state.zoom,
},
app.scene.getNonDeletedElementsMap(),
app.getEditorUIOffsets(),
) ||
isTextTiny
) {
let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
if (isTextTiny) {
if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
zoomOptions = { fitToContent: true };
} else {
zoomOptions = {
fitToViewport: true,
// calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
};
}
} else {
zoomOptions = { fitToContent: true };
}
app.scrollToContent(matchAsElement, {
animate: true,
duration: 300,
...zoomOptions,
canvasOffsets: app.getEditorUIOffsets(),
});
}
}
}
}, [focusIndex, searchMatches, app]);
useEffect(() => {
return () => {
setFocusIndex(null);
searchedQueryRef.current = null;
lastSceneNonceRef.current = undefined;
setAppState({
searchMatches: [],
});
setIsSearching(false);
};
}, [setAppState, setFocusIndex]);
const stableState = useStable({
goToNextItem,
goToPreviousItem,
searchMatches,
});
useEffect(() => {
const eventHandler = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
!app.state.openDialog &&
!app.state.openPopup
) {
event.preventDefault();
event.stopPropagation();
setAppState({
openSidebar: null,
});
return;
}
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
event.preventDefault();
event.stopPropagation();
if (!searchInputRef.current?.matches(":focus")) {
if (app.state.openDialog) {
setAppState({
openDialog: null,
});
}
searchInputRef.current?.focus();
searchInputRef.current?.select();
} else {
setAppState({
openSidebar: null,
});
}
}
if (
event.target instanceof HTMLElement &&
event.target.closest(".layer-ui__search")
) {
if (stableState.searchMatches.items.length) {
if (event.key === KEYS.ENTER) {
event.stopPropagation();
stableState.goToNextItem();
}
if (event.key === KEYS.ARROW_UP) {
event.stopPropagation();
stableState.goToPreviousItem();
} else if (event.key === KEYS.ARROW_DOWN) {
event.stopPropagation();
stableState.goToNextItem();
}
}
}
};
// `capture` needed to prevent firing on initial open from App.tsx,
// as well as to handle events before App ones
return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
capture: true,
passive: false,
});
}, [setAppState, stableState, app]);
const matchCount = `${searchMatches.items.length} ${
searchMatches.items.length === 1
? t("search.singleResult")
: t("search.multipleResults")
}`;
return (
<div className="layer-ui__search">
<div className="layer-ui__search-header">
<TextField
className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
value={inputValue}
ref={searchInputRef}
placeholder={t("search.placeholder")}
icon={searchIcon}
onChange={(value) => {
setInputValue(value);
setIsSearching(true);
const searchQuery = value.trim() as SearchQuery;
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
});
setFocusIndex(index);
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
});
setIsSearching(false);
});
}}
selectOnRender
/>
</div>
<div className="layer-ui__search-count">
{searchMatches.items.length > 0 && (
<>
{focusIndex !== null && focusIndex > -1 ? (
<div>
{focusIndex + 1} / {matchCount}
</div>
) : (
<div>{matchCount}</div>
)}
<div className="result-nav">
<Button
onSelect={() => {
goToNextItem();
}}
className="result-nav-btn"
>
{collapseDownIcon}
</Button>
<Button
onSelect={() => {
goToPreviousItem();
}}
className="result-nav-btn"
>
{upIcon}
</Button>
</div>
</>
)}
{searchMatches.items.length === 0 &&
searchQuery &&
searchedQueryRef.current && (
<div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
)}
</div>
<MatchList
matches={searchMatches}
onItemClick={setFocusIndex}
focusIndex={focusIndex}
searchQuery={searchQuery}
/>
</div>
);
};
const ListItem = (props: {
preview: SearchMatchItem["preview"];
searchQuery: SearchQuery;
highlighted: boolean;
onClick?: () => void;
}) => {
const preview = [
props.preview.moreBefore ? "..." : "",
props.preview.previewText.slice(0, props.preview.indexInSearchQuery),
props.preview.previewText.slice(
props.preview.indexInSearchQuery,
props.preview.indexInSearchQuery + props.searchQuery.length,
),
props.preview.previewText.slice(
props.preview.indexInSearchQuery + props.searchQuery.length,
),
props.preview.moreAfter ? "..." : "",
];
return (
<div
tabIndex={-1}
className={clsx("layer-ui__result-item", {
active: props.highlighted,
})}
onClick={props.onClick}
ref={(ref) => {
if (props.highlighted) {
ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
}
}}
>
<div className="preview-text">
{preview.flatMap((text, idx) => (
<Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
))}
</div>
</div>
);
};
interface MatchListProps {
matches: SearchMatches;
onItemClick: (index: number) => void;
focusIndex: number | null;
searchQuery: SearchQuery;
}
const MatchListBase = (props: MatchListProps) => {
return (
<div className="layer-ui__search-result-container">
{props.matches.items.map((searchMatch, index) => (
<ListItem
key={searchMatch.textElement.id + searchMatch.index}
searchQuery={props.searchQuery}
preview={searchMatch.preview}
highlighted={index === props.focusIndex}
onClick={() => props.onItemClick(index)}
/>
))}
</div>
);
};
const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
return (
prevProps.matches.nonce === nextProps.matches.nonce &&
prevProps.focusIndex === nextProps.focusIndex
);
};
const MatchList = memo(MatchListBase, areEqual);
const getMatchPreview = (
text: string,
index: number,
searchQuery: SearchQuery,
) => {
const WORDS_BEFORE = 2;
const WORDS_AFTER = 5;
const substrBeforeQuery = text.slice(0, index);
const wordsBeforeQuery = substrBeforeQuery.split(/\s+/);
// text = "small", query = "mall", not complete before
// text = "small", query = "smal", complete before
const isQueryCompleteBefore = substrBeforeQuery.endsWith(" ");
const startWordIndex =
wordsBeforeQuery.length -
WORDS_BEFORE -
1 -
(isQueryCompleteBefore ? 0 : 1);
let wordsBeforeAsString =
wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") +
(isQueryCompleteBefore ? " " : "");
const MAX_ALLOWED_CHARS = 20;
wordsBeforeAsString =
wordsBeforeAsString.length > MAX_ALLOWED_CHARS
? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
: wordsBeforeAsString;
const substrAfterQuery = text.slice(index + searchQuery.length);
const wordsAfter = substrAfterQuery.split(/\s+/);
// text = "small", query = "mall", complete after
// text = "small", query = "smal", not complete after
const isQueryCompleteAfter = !substrAfterQuery.startsWith(" ");
const numberOfWordsToTake = isQueryCompleteAfter
? WORDS_AFTER + 1
: WORDS_AFTER;
const wordsAfterAsString =
(isQueryCompleteAfter ? "" : " ") +
wordsAfter.slice(0, numberOfWordsToTake).join(" ");
return {
indexInSearchQuery: wordsBeforeAsString.length,
previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString,
moreBefore: startWordIndex > 0,
moreAfter: wordsAfter.length > numberOfWordsToTake,
};
};
const normalizeWrappedText = (
wrappedText: string,
originalText: string,
): string => {
const wrappedLines = wrappedText.split("\n");
const normalizedLines: string[] = [];
let originalIndex = 0;
for (let i = 0; i < wrappedLines.length; i++) {
let currentLine = wrappedLines[i];
const nextLine = wrappedLines[i + 1];
if (nextLine) {
const nextLineIndexInOriginal = originalText.indexOf(
nextLine,
originalIndex,
);
if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
while (j > 0) {
currentLine += " ";
j--;
}
}
}
normalizedLines.push(currentLine);
originalIndex = originalIndex + currentLine.length;
}
return normalizedLines.join("\n");
};
const getMatchedLines = (
textElement: ExcalidrawTextElement,
searchQuery: SearchQuery,
index: number,
) => {
const normalizedText = normalizeWrappedText(
textElement.text,
textElement.originalText,
);
const lines = normalizedText.split("\n");
const lineIndexRanges = [];
let currentIndex = 0;
let lineNumber = 0;
for (const line of lines) {
const startIndex = currentIndex;
const endIndex = startIndex + line.length - 1;
lineIndexRanges.push({
line,
startIndex,
endIndex,
lineNumber,
});
// Move to the next line's start index
currentIndex = endIndex + 1;
lineNumber++;
}
let startIndex = index;
let remainingQuery = textElement.originalText.slice(
index,
index + searchQuery.length,
);
const matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[] = [];
for (const lineIndexRange of lineIndexRanges) {
if (remainingQuery === "") {
break;
}
if (
startIndex >= lineIndexRange.startIndex &&
startIndex <= lineIndexRange.endIndex
) {
const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
const textToStart = lineIndexRange.line.slice(
0,
startIndex - lineIndexRange.startIndex,
);
const matchedWord = remainingQuery.slice(0, matchCapacity);
remainingQuery = remainingQuery.slice(matchCapacity);
const offset = measureText(
textToStart,
getFontString(textElement),
textElement.lineHeight,
true,
);
// measureText returns a non-zero width for the empty string
// which is not what we're after here, hence the check and the correction
if (textToStart === "") {
offset.width = 0;
}
if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
const lineLength = measureText(
lineIndexRange.line,
getFontString(textElement),
textElement.lineHeight,
true,
);
const spaceToStart =
textElement.textAlign === "center"
? (textElement.width - lineLength.width) / 2
: textElement.width - lineLength.width;
offset.width += spaceToStart;
}
const { width, height } = measureText(
matchedWord,
getFontString(textElement),
textElement.lineHeight,
);
const offsetX = offset.width;
const offsetY = lineIndexRange.lineNumber * offset.height;
matchedLines.push({
offsetX,
offsetY,
width,
height,
});
startIndex += matchCapacity;
}
}
return matchedLines;
};
const escapeSpecialCharacters = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
};
const handleSearch = debounce(
(
searchQuery: SearchQuery,
app: AppClassProperties,
cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
) => {
if (!searchQuery || searchQuery === "") {
cb([], null);
return;
}
const elements = app.scene.getNonDeletedElements();
const texts = elements.filter((el) =>
isTextElement(el),
) as ExcalidrawTextElement[];
texts.sort((a, b) => a.y - b.y);
const matchItems: SearchMatchItem[] = [];
const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
for (const textEl of texts) {
let match = null;
const text = textEl.originalText;
while ((match = regex.exec(text)) !== null) {
const preview = getMatchPreview(text, match.index, searchQuery);
const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
if (matchedLines.length > 0) {
matchItems.push({
textElement: textEl,
searchQuery,
preview,
index: match.index,
matchedLines,
});
}
}
}
const visibleIds = new Set(
app.visibleElements.map((visibleElement) => visibleElement.id),
);
const focusIndex =
matchItems.findIndex((matchItem) =>
visibleIds.has(matchItem.textElement.id),
) ?? null;
cb(matchItems, focusIndex);
},
SEARCH_DEBOUNCE,
);
@@ -52,8 +52,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;
@@ -1,5 +1,4 @@
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../clipboard";
import { useI18n } from "../i18n";
@@ -7,7 +6,8 @@ import { useI18n } from "../i18n";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { FilledButton } from "./FilledButton";
import { copyIcon, tablerCheckIcon } from "./icons";
import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { copyIcon } from "./icons";
import "./ShareableLinkDialog.scss";
@@ -24,7 +24,7 @@ export const ShareableLinkDialog = ({
setErrorMessage,
}: ShareableLinkDialogProps) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const [, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
@@ -46,7 +46,7 @@ export const ShareableLinkDialog = ({
ref.current?.select();
};
const { onCopy, copyStatus } = useCopyStatus();
return (
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
<div className="ShareableLinkDialog">
@@ -60,26 +60,16 @@ export const ShareableLinkDialog = ({
value={link}
selectOnRender
/>
<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="ShareableLinkDialog__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={() => {
onCopy();
copyRoomLink();
}}
/>
</div>
<div className="ShareableLinkDialog__description">
🔒 {t("alerts.uploadedSecurly")}
+3 -1
View File
@@ -6,16 +6,18 @@ const Spinner = ({
size = "1em",
circleWidth = 8,
synchronized = false,
className = "",
}: {
size?: string | number;
circleWidth?: number;
synchronized?: boolean;
className?: string;
}) => {
const mountTime = React.useRef(Date.now());
const mountDelay = -(mountTime.current % 1600);
return (
<div className="Spinner">
<div className={`Spinner ${className}`}>
<svg
viewBox="0 0 100 100"
style={{
@@ -2,13 +2,14 @@ import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import type { Degrees } from "../../../math";
import { degreesToRadians, radiansToDegrees } from "../../../math";
interface AngleProps {
element: ExcalidrawElement;
@@ -36,7 +37,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
}
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
const nextAngle = degreesToRadians(nextValue as Degrees);
mutateElement(latestElement, {
angle: nextAngle,
});
@@ -51,7 +52,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
}
const originalAngleInDegrees =
Math.round(radianToDegree(origElement.angle) * 100) / 100;
Math.round(radiansToDegrees(origElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
@@ -61,7 +62,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement(latestElement, {
angle: nextAngle,
@@ -80,7 +81,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
<DragInput
label="A"
icon={angleIcon}
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
elements={[element]}
dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")}
@@ -0,0 +1,67 @@
import StatsDragInput from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getStepSizedValue } from "./utils";
import { getNormalizedGridStep } from "../../scene";
interface PositionProps {
property: "gridStep";
scene: Scene;
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
}
const STEP_SIZE = 5;
const CanvasGrid = ({
property,
scene,
appState,
setAppState,
}: PositionProps) => {
return (
<StatsDragInput
label="Grid step"
sensitivity={8}
elements={[]}
dragInputCallback={({
nextValue,
instantChange,
shouldChangeByStepSize,
setInputValue,
}) => {
setAppState((state) => {
let nextGridStep;
if (nextValue) {
nextGridStep = nextValue;
} else if (instantChange) {
nextGridStep = shouldChangeByStepSize
? getStepSizedValue(
state.gridStep + STEP_SIZE * Math.sign(instantChange),
STEP_SIZE,
)
: state.gridStep + instantChange;
}
if (!nextGridStep) {
setInputValue(state.gridStep);
return null;
}
nextGridStep = getNormalizedGridStep(nextGridStep);
setInputValue(nextGridStep);
return {
gridStep: nextGridStep,
};
});
}}
scene={scene}
value={appState.gridStep}
property={property}
appState={appState}
/>
);
};
export default CanvasGrid;
@@ -31,7 +31,11 @@ const Collapsible = ({
{label}
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
</div>
{open && <>{children}</>}
{open && (
<div style={{ display: "flex", flexDirection: "column" }}>
{children}
</div>
)}
</>
);
};
@@ -23,7 +23,6 @@ const handleDimensionChange: DragInputCallbackType<
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
@@ -5,7 +5,7 @@
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
border-radius: var(--border-radius-md);
}
}
@@ -18,17 +18,18 @@
flex-shrink: 0;
border: 1px solid var(--default-border-color);
border-right: 0;
width: 2rem;
padding: 0 0.5rem 0 0.75rem;
min-width: 1rem;
height: 2rem;
box-sizing: border-box;
color: var(--popup-text-color);
:root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
}
:root[dir="rtl"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
}
@@ -55,11 +56,11 @@
letter-spacing: 0.4px;
:root[dir="ltr"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
}
:root[dir="rtl"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
border-left: 1px solid var(--default-border-color);
border-right: 0;
}
@@ -29,6 +29,7 @@ export type DragInputCallbackType<
nextValue?: number;
property: P;
originalAppState: AppState;
setInputValue: (value: number) => void;
}) => void;
interface StatsDragInputProps<
@@ -45,6 +46,8 @@ interface StatsDragInputProps<
property: T;
scene: Scene;
appState: AppState;
/** how many px you need to drag to get 1 unit change */
sensitivity?: number;
}
const StatsDragInput = <
@@ -61,6 +64,7 @@ const StatsDragInput = <
property,
scene,
appState,
sensitivity = 1,
}: StatsDragInputProps<T, E>) => {
const app = useApp();
const inputRef = useRef<HTMLInputElement>(null);
@@ -126,27 +130,49 @@ const StatsDragInput = <
nextValue: rounded,
property,
originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)),
});
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
}
};
const handleInputValueRef = useRef(handleInputValue);
handleInputValueRef.current = handleInputValue;
const callbacksRef = useRef<
Partial<{
handleInputValue: typeof handleInputValue;
onPointerUp: (event: PointerEvent) => void;
onPointerMove: (event: PointerEvent) => void;
}>
>({});
callbacksRef.current.handleInputValue = handleInputValue;
// make sure that clicking on canvas (which umounts the component)
// updates current input value (blur isn't triggered)
useEffect(() => {
const input = inputRef.current;
const callbacks = callbacksRef.current;
return () => {
const nextValue = input?.value;
if (nextValue) {
handleInputValueRef.current(
callbacks.handleInputValue?.(
nextValue,
stateRef.current.originalElements,
stateRef.current.originalAppState,
);
}
// generally not needed, but in case `pointerup` doesn't fire and
// we don't remove the listeners that way, we should at least remove
// on unmount
window.removeEventListener(
EVENT.POINTER_MOVE,
callbacks.onPointerMove!,
false,
);
window.removeEventListener(
EVENT.POINTER_UP,
callbacks.onPointerUp!,
false,
);
};
}, [
// we need to track change of `editable` state as mount/unmount
@@ -172,6 +198,8 @@ const StatsDragInput = <
ref={labelRef}
onPointerDown={(event) => {
if (inputRef.current && editable) {
document.body.classList.add("excalidraw-cursor-resize");
let startValue = Number(inputRef.current.value);
if (isNaN(startValue)) {
startValue = 0;
@@ -196,35 +224,43 @@ const StatsDragInput = <
const originalAppState: AppState = cloneJSON(appState);
let accumulatedChange: number | null = null;
document.body.classList.add("excalidraw-cursor-resize");
let accumulatedChange = 0;
let stepChange = 0;
const onPointerMove = (event: PointerEvent) => {
if (!accumulatedChange) {
accumulatedChange = 0;
}
if (
lastPointer &&
originalElementsMap !== null &&
originalElements !== null &&
accumulatedChange !== null
originalElements !== null
) {
const instantChange = event.clientX - lastPointer.x;
accumulatedChange += instantChange;
dragInputCallback({
accumulatedChange,
instantChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
});
if (instantChange !== 0) {
stepChange += instantChange;
if (Math.abs(stepChange) >= sensitivity) {
stepChange =
Math.sign(stepChange) *
Math.floor(Math.abs(stepChange) / sensitivity);
accumulatedChange += stepChange;
dragInputCallback({
accumulatedChange,
instantChange: stepChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
setInputValue: (value) => setInputValue(String(value)),
});
stepChange = 0;
}
}
}
lastPointer = {
@@ -233,27 +269,31 @@ const StatsDragInput = <
};
};
const onPointerUp = () => {
window.removeEventListener(
EVENT.POINTER_MOVE,
onPointerMove,
false,
);
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
lastPointer = null;
accumulatedChange = 0;
stepChange = 0;
originalElements = null;
originalElementsMap = null;
document.body.classList.remove("excalidraw-cursor-resize");
window.removeEventListener(EVENT.POINTER_UP, onPointerUp, false);
};
callbacksRef.current.onPointerMove = onPointerMove;
callbacksRef.current.onPointerUp = onPointerUp;
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
window.addEventListener(
EVENT.POINTER_UP,
() => {
window.removeEventListener(
EVENT.POINTER_MOVE,
onPointerMove,
false,
);
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
lastPointer = null;
accumulatedChange = null;
originalElements = null;
originalElementsMap = null;
document.body.classList.remove("excalidraw-cursor-resize");
},
false,
);
window.addEventListener(EVENT.POINTER_UP, onPointerUp, false);
}
}}
onPointerEnter={() => {
@@ -3,13 +3,14 @@ import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { isInGroup } from "../../groups";
import { degreeToRadian, radianToDegree } from "../../math";
import type Scene from "../../scene/Scene";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { AppState } from "../../types";
import type { Degrees } from "../../../math";
import { degreesToRadians, radiansToDegrees } from "../../../math";
interface MultiAngleProps {
elements: readonly ExcalidrawElement[];
@@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<
);
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
const nextAngle = degreesToRadians(nextValue as Degrees);
for (const element of editableLatestIndividualElements) {
if (!element) {
@@ -71,7 +72,7 @@ const handleDegreeChange: DragInputCallbackType<
}
const originalElement = editableOriginalIndividualElements[i];
const originalAngleInDegrees =
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
Math.round(radiansToDegrees(originalElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
@@ -81,7 +82,7 @@ const handleDegreeChange: DragInputCallbackType<
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement(
latestElement,
@@ -109,7 +110,7 @@ const MultiAngle = ({
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
);
const angles = editableLatestIndividualElements.map(
(el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
(el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100,
);
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
@@ -13,13 +13,14 @@ import type {
NonDeletedSceneElementsMap,
} from "../../element/types";
import type Scene from "../../scene/Scene";
import type { AppState, Point } from "../../types";
import type { AppState } from "../../types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { pointFrom, type GlobalPoint } from "../../../math";
interface MultiDimensionProps {
property: "width" | "height";
@@ -68,7 +69,6 @@ const resizeElementInGroup = (
originalElementsMap: ElementsMap,
) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(latestElement, updates, false);
const boundTextElement = getBoundTextElement(
@@ -78,7 +78,7 @@ const resizeElementInGroup = (
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, {
oldSize: { width: oldWidth, height: oldHeight },
newSize: { width: updates.width, height: updates.height },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@@ -104,7 +104,7 @@ const resizeGroup = (
nextHeight: number,
initialHeight: number,
aspectRatio: number,
anchor: Point,
anchor: GlobalPoint,
property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[],
@@ -181,7 +181,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
pointFrom(x1, y1),
property,
latestElements,
originalElements,
@@ -286,7 +286,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
pointFrom(x1, y1),
property,
latestElements,
originalElements,
@@ -4,7 +4,6 @@ import type {
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
@@ -14,6 +13,7 @@ import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types";
import { pointFrom, pointRotateRads } from "../../../math";
interface MultiPositionProps {
property: "x" | "y";
@@ -43,11 +43,9 @@ const moveElements = (
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(origElement.x, origElement.y),
pointFrom(cx, cy),
origElement.angle,
);
@@ -98,11 +96,9 @@ const moveGroupTo = (
latestElement.y + latestElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
latestElement.x,
latestElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(latestElement.x, latestElement.y),
pointFrom(cx, cy),
latestElement.angle,
);
@@ -174,11 +170,9 @@ const handlePositionChange: DragInputCallbackType<
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(origElement.x, origElement.y),
pointFrom(cx, cy),
origElement.angle,
);
@@ -246,7 +240,11 @@ const MultiPosition = ({
const [el] = elementsInUnit;
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(el.x, el.y),
pointFrom(cx, cy),
el.angle,
);
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
}),
@@ -1,10 +1,10 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { rotate } from "../../math";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { pointFrom, pointRotateRads } from "../../../math";
interface PositionProps {
property: "x" | "y";
@@ -32,11 +32,9 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(origElement.x, origElement.y),
pointFrom(cx, cy),
origElement.angle,
);
@@ -94,11 +92,9 @@ const Position = ({
scene,
appState,
}: PositionProps) => {
const [topLeftX, topLeftY] = rotate(
element.x,
element.y,
element.x + element.width / 2,
element.y + element.height / 2,
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(element.x, element.y),
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
element.angle,
);
const value =
@@ -0,0 +1,72 @@
.exc-stats {
width: 204px;
position: absolute;
top: 60px;
font-size: 12px;
z-index: var(--zIndex-layerUI);
pointer-events: var(--ui-pointerEvents);
:root[dir="rtl"] & {
left: 12px;
right: initial;
}
h2 {
font-size: 1.5em;
margin-block-start: 0.83em;
margin-block-end: 0.83em;
font-weight: bold;
}
h3 {
white-space: nowrap;
font-size: 1.17em;
margin: 0;
font-weight: bold;
}
&__rows {
display: flex;
flex-direction: column;
gap: 0.3125rem;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
display: grid;
gap: 4px;
div + div {
text-align: right;
}
}
&__row--heading {
text-align: center;
font-weight: bold;
margin: 0.25rem 0;
}
.title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h2 {
margin: 0;
}
}
.close {
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
}

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