Compare commits

..

91 Commits

Author SHA1 Message Date
640caba739 v0.17.4 2024-04-12 14:57:03 +02:00
2879c9d852 fix: Gist embed allowing unsafe html (#7883) 2024-04-12 13:58:38 +02:00
81046ccd6b fix: keep customData when converting to ExcalidrawElement (#7656)
* feat: keep customData when converting to ExcalidrawElement (#7654)

* docs: add changelog for keeping customData when converting to ExcalidrawElement
2024-02-09 19:01:52 +05:30
207a0bcc6e fix: umd build so it can be used in browser (#7349)
* fix: umd build so it can be used in browser

* fix lint

* increase size limit

* update changelog

* use json.stringify for env preact variable so its accessible as string

* update changelog
2024-02-09 19:00:43 +05:30
f53edb7437 fix: disable caching bounds for arrow labels (#7343) 2024-02-09 17:08:28 +05:30
8d0a8ce65b fix: bounds cached prematurely resulting in incorrectly rendered labels (#7339) 2024-02-09 17:08:12 +05:30
a528769b68 docs: upgrade to @excalidraw/excalidraw@0.17.0 (#7285) 2023-11-14 20:10:19 +05:30
ddb7585057 docs: Docs for v0.17.0 🚀 (#7248)
* feat: add docs for getCommonBounds

* docs: add docs for frames api support

* docs: update docs for regenerateIds opts in convertToExcalidrawElements

* add docs for ref removal

* add docs for lock support and insertOnCanvasDirectly in setActiveTool

* fix broken links

* update docs for next js support

* update docs for Preact

* add faq

* docs: add `onChange`, `onPointerDown`, `onPointerUp` docs

* docs: update `useDevice` docs

* update docs for disabling image tool

* add docs for withinBounds helpers

* fix lint

* upgrade excal

* add docusaurus2-dotenv for expose env vars

* fix env variable and upgrade excal

* Update dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>

* update docs

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>

* update docs for process.env

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2023-11-14 19:54:34 +05:30
111a48ffb1 docs: release @excalidraw/excalidraw@0.17.0 🎉 (#7284) 2023-11-14 19:53:59 +05:30
54153629c0 chore: update release scripts (#7282)
* chore: update release scripts

* update docs
2023-11-14 16:37:57 +05:30
9c425224c7 feat: support disabling image tool (#6320)
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-11-14 10:25:41 +01:00
9d1d45a8ea chore: update changelog (#7279)
* chore: update changelog

* fix

* Update CHANGELOG.md
2023-11-14 13:11:05 +05:30
029c3c48ba fix: image insertion bugs (#7278) 2023-11-13 15:34:59 +01:00
adfd95be33 build: support preact 🥳 (#7255)
* build: support preact

* add log

* Simplify the config and generate prod and dev builds for preact

* update changelog

* remove logs

* use env variable so its available during build time

* update cl

* fix
2023-11-13 16:18:36 +05:30
ceb255e8ee fix: exportToSvg to honor frameRendering also for name not only for frame itself (#7270)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2023-11-12 22:34:05 +00:00
ae5b9a4ffd fix: not cloning elements on export polluting Scene mapping (#7276) 2023-11-12 23:32:12 +01:00
3d4ff59f40 fix: Can't toggle penMode off due to missing typecheck in togglePenMode (#7273)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2023-11-12 13:24:13 +01:00
7b00089314 chore: bump @excalidraw/random-username (#7272) 2023-11-11 19:23:22 +01:00
af6b81df40 fix: Replace hard coded font family with const value in addFrameLabelsAsTextElements (#7269) 2023-11-11 10:04:02 +01:00
02cc8440c4 feat: allow D&D dice app domain for embeds (#7263)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2023-11-10 15:29:19 +00:00
6363492cee fix: perf issue when ungrouping elements within frame (#7265)
Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2023-11-10 16:13:08 +01:00
900b317bf3 feat: remove full screen shortcut (#7222) 2023-11-10 14:44:02 +00:00
68179356e6 fix: Fixes the shortcut collision between "toggleHandTool" and "distributeHorizontally" (#7189)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2023-11-10 15:33:02 +01:00
3ed15e95da Small typo fix frames.mdx (#7216) 2023-11-10 15:23:43 +01:00
798e1fd858 fix: allow pointer events when editing a linear element (#7238) 2023-11-10 15:21:59 +01:00
f66c93633c feat: make adaptive-roughness less aggressive (#7250) 2023-11-10 13:32:34 +01:00
cee00767df feat: support excalidrawAPI and remove refs support (#7251)
* feat: support excalidrawAPI and remove refs support

* update changelog

* remove ready and readyPromise

* update changelog

* update changelog
2023-11-10 15:33:43 +05:30
864c0b3ea8 feat: render frames on export (#7210) 2023-11-09 17:00:21 +01:00
a9a6f8eafb docs: update the docs with next js dynamic import support (#7252) 2023-11-09 16:03:35 +05:30
3c96943db3 test: fix mermaid test flake (#7249) 2023-11-07 18:06:15 +01:00
9006caff39 fix: make modal use viewport breakpoints (#7246) 2023-11-07 10:10:12 +01:00
ce7a847668 feat: export getCommonBounds util (#7247)
* feat: export getCommonBounds util

* add pr link

* fix
2023-11-07 14:19:13 +05:30
b1037b342d feat: make device breakpoints more specific (#7243) 2023-11-06 16:29:00 +01:00
18a7b97515 chore: Fix typo in comment in LocalData file (#7235) 2023-11-04 18:15:09 +01:00
e8def8da8d feat: Support mermaid flowchart and sequence diagrams to excalidraw diagrams 🥳 (#6920)
* feat: integrate mermaidToExcalidraw

* create mermaid to excal dialog

* allow mermaid syntax and export in preview

* fix

* fix webpack config

* fix markdown error by using named export

* center preview

* set elements as selected when inserted onto canvas

* persist mermaid data to storage

* store canvas data in refs

* load mermaid lazily

* tweak design

* compute width, height correctly for arrows

* fix undefined vertex issue

* add mermaid icon in dropdown

* add a note in dialog

* reset preview when error

* show error in preview when error

* show mermaid error messgae react way

* design tweaks

* add example and docs link

* fix

* tweak design to remove scroll bar

* show a spinner unless mermaid loaded

* regenerate ids when needed via programmatic api, this makes sure for mermaid diagrams ids are regenerated

* tweak

* add option to transform viewport to scene coords in transform api

* make opts optional and use 100% zoom when inserting to canvas

* fix arrow bindings in safari and firefox

* fix elements insert position and viewport centering

* fix: Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.

* defer rendering the preview

* tweak text

* fix tests

* remove only

* make design responsive

* fix: show extra tools dropdown in mobile

* fix mobile css

* width auto

* upgrade mermaid-to-excalidraw

* don't pass appState in deps as its not used

* upgrade mermaid-to-excalidraw to fix firefox issue

* use types from mermaid-to-excalidraw

* upgrade mermaid-to-excalidraw

* use stable version of mermaid-to-excalidraw

* upgrade mermaid-to-excalidraw

* fix width of shapes toolbar for smaller screen size and also fix regression of mobile menu

* use i18n

* better api

* enable test coverage in ui

* Add tests

* use common utils to update and get text editor

* updgrade mermaid-to-excalidraw to support sequence diagrams

* fix test

* don't update arrow container height anytime in when redrawing text bounding box

* increase size limit

* increase size limit of vendor to 900kb

* use openDialog for mermaid

* upgrade mermaid-to-excalidraw

* update frame id post generation

* upgrade mermaid-to-excalidraw to add entity codes support

* update size limit

* upgrade mermaid-to-excalidraw package with frame api changes

* upgrade mermaid-to-excalidraw to remove directive and use config

* don't highlight mermaid tool and remove unused api setSelection

* stop using loading state to update text area

* move some styling to scss

* review fixes

* use modifiedTableIcon props and remove stale snap

* css

* dialog css

* fix snap

* use dialog border

* change mermaidToExcalidrawLib to state

* better styling of errors

* make modal bigger

* fix mobile

* update snaps

* fix icon color

* fix dark mode insert button color

* horizontally center spinner

* render canvas conditionally on loaded state

* rd tweaks

* tweak class names

* remove max height

* typo in example

* upgrade mermaid-to-excalidraw

* simplify error state

* fix height & overflow on vertical breakpoint

* fix lint

* show errors in overlay

* set textarea font family

* reduce opacity

* update snap

* upgrade to mermaid  0.1.2

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
2023-11-03 17:41:34 +05:30
a7db41c5ba fix: align input :hover/:focus with spec (#7225) 2023-11-02 16:06:26 +01:00
d8166d9e1d fix: dialog remounting on className updates (#7224) 2023-11-02 16:06:15 +01:00
81c0259041 docs: add npm downloads rate badge to README.md file (#7127) 2023-11-01 13:27:57 +01:00
f5c91c3a0f feat: support frames via programmatic API (#7205)
* update frame id post generation

* support frames via programmatic API

* fix types

* add test for frames

* throw error when element doesn't exist

* naming tweaks

* update the api to use children

* consider max of frame dimensions and calculated bounds of elements

* consider bound elements in frame api
2023-11-01 17:14:04 +05:30
9b8de8a12e test: disable flaky test (#7213) 2023-10-31 12:05:08 +01:00
ea677d4581 feat: make clipboard more robust and reintroduce contextmenu actions (#7198) 2023-10-28 19:29:28 +00:00
ec2de7205f fix: don't update label position when dragging labelled arrows (#6891)
* fix: don't update label position when dragging labelled arrows

* lint

* add test

* don't update coords for label when labelled arrow inside frame

* increase locales bundle size limit
2023-10-27 12:06:11 +05:30
Are
d5e3f436dc feat: add approximate elements in bbox detection (#6727)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-26 23:33:00 +02:00
dcf4592e79 feat: regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping (#7195)
* feat: regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping

* type

* increase limit as some past PR(s) increased the bundle size

* review fixes

* update changelog
2023-10-27 00:43:48 +05:30
d1f8eec174 feat: support giphy.com embed domain (#7192) 2023-10-26 00:00:50 +02:00
0f81c30276 fix: frame add/remove/z-index ordering changes (#7194) 2023-10-25 23:16:02 +02:00
f098789d16 fix: element relative position when dragging multiple elements on grid (#7107)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-25 22:48:03 +02:00
f794b0bb90 fix: freedraw non-solid bg hitbox not working (#7193) 2023-10-25 17:21:01 +02:00
104f64f1dc revert: remove bound-arrows from frames (#7190) 2023-10-25 10:39:19 +02:00
71ad3c5356 fix: Actions panel ux improvement (#6850)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-24 18:36:13 +00:00
afea0df141 feat: renderer tweaks (#6698) 2023-10-20 17:45:37 +02:00
d2a508104e fix: Better fill rendering with latest RoughJS (#7031)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-20 15:08:24 +02:00
3697618266 feat: support props.locked for setActiveTool (#7153)
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-10-20 13:16:23 +02:00
e7cc2337ea feat: add onChange, onPointerDown, onPointerUp api subs (#7154) 2023-10-20 13:08:22 +02:00
9eb89f9960 build(deps): bump @babel/traverse from 7.18.9 to 7.23.2 in /dev-docs (#7165)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-20 11:31:27 +02:00
ab1bcc7615 chore: Update translations from Crowdin (#6695) 2023-10-20 11:29:28 +02:00
b1cac35269 feat: Closing of "Save to.." Dialog on Save To Disk (#7168)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-19 17:51:50 +00:00
83f86e2b86 fix: Fix for Strange Symbol Appearing on Canvas after Deleting Grouped Graphics (Issue #7116) (#7170)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-19 12:59:39 +02:00
7e38cab76e build(deps): bump @babel/traverse from 7.21.4 to 7.23.2 (#7171)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-19 12:33:16 +02:00
2cabb1f1f4 fix: attempt to fix flake in wysiwyg tests (#7173) 2023-10-19 12:32:31 +02:00
63650f82d1 feat: Added Copy/Paste from Google Docs (#7136)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-19 12:14:23 +02:00
dde3dac931 feat: remove bound-arrows from frames (#7157) 2023-10-17 18:18:20 +02:00
5b94cffc74 fix: ensure ClipboardItem created in the same tick to fix safari (#7066) 2023-10-16 11:38:57 +02:00
aaf73c8ff3 fix: double image dialog shown on insert (#7152) 2023-10-16 00:19:46 +02:00
44d9d5fcac fix: wysiwyg left in undefined state on reload (#7123) 2023-10-13 14:29:54 +02:00
89a3bbddb7 test: add more resizing tests (#7028)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-12 20:59:02 +02:00
b86184a849 fix: ensure relative z-index of elements added to frame is retained (#7134) 2023-10-12 15:00:23 +02:00
b552166924 feat: new dark mode theme & light theme tweaks (#7104)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-12 14:58:33 +02:00
26ff3993bb feat: better laser cursor for dark mode (#7132) 2023-10-11 11:17:27 +02:00
7ad02c359a fix: memoize static canvas on props.renderConfig (#7131) 2023-10-10 23:31:23 +02:00
2523fe82e3 feat: laser pointer improvements (#7128) 2023-10-10 13:55:55 +02:00
4ea079eb85 fix: regression from #6739 preventing redirect link in view mode (#7120)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-09 12:26:49 +02:00
f20ba90ffa perf: improve element in frame check (#7124) 2023-10-09 16:32:27 +08:00
03da9112cf fix: update links to excalidraw-app (#7072) 2023-10-08 19:37:17 -05:00
a249f332a2 fix: ensure we do not stop laser update prematurely (#7100) 2023-10-06 12:00:35 +02:00
Are
2e61926a6b feat: initial Laser Pointer MVP (#6739)
* feat: initial Laser pointer mvp

* feat: add laser-pointer package and integrate it with collab

* chore: fix yarn.lock

* feat: update laser-pointer package, prevent panning from showing

* feat: add laser pointer tool button when collaborating, migrate to official package

* feat: reduce laser tool button size

* update icon

* fix icon & rotate

* fix: lock zoom level

* fix icon

* add `selected` state, simplify and reduce api

* set up pointer callbacks in viewMode if laser tool active

* highlight extra-tools button if one of the nested tools active

* add shortcut to laser pointer

* feat: don't update paths if nothing changed

* ensure we reset flag if no rAF scheduled

* move `lastUpdate` to instance to optimize

* return early

* factor out into constants and add doc

* skip iteration instead of exit

* fix naming

* feat: remove testing variable on window

* destroy on editor unmount

* fix incorrectly resetting `lastUpdate` in `stop()`

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-05 17:05:16 +02:00
e921bfb1ae feat: Export iconFillColor() (#6996) 2023-10-04 18:17:22 -05:00
e6f74350ac refactor: DRY out tool typing (#7086) 2023-10-04 23:39:00 +02:00
fa33aa08ab refactor: refactor event globals to differentiate from lastPointerUp (#7084) 2023-10-04 16:18:22 +02:00
8b838049df fix: remove invisible elements safely (#7083) 2023-10-04 16:09:59 +02:00
1f4f5e11ae refactor: DRY out and simplify setting active tool from toolbar (#7079) 2023-10-04 00:16:54 +02:00
12420592ef feat: support menu / dropdown items to have selected state (#7078) 2023-10-03 23:35:47 +02:00
bfd318e765 docs: Update the excalidraw-app source-code link in README.md (#7035)
chore: Update the `excalidraw-app` source-code link in README.md
2023-10-03 08:41:13 -05:00
6a821f3b76 fix: Icon size in manifest (#7073) 2023-10-03 11:07:02 +02:00
84fd13e872 docs: fix minor grammar and spellings (#7039) 2023-10-02 10:11:02 +02:00
7d2b6f3374 docs: fix typo on homepage of developer docs (#7047) 2023-09-29 20:52:53 -05:00
ceb637f5ea fix: elements being dropped/duplicated when added to frame (#7057) 2023-09-29 15:40:14 +02:00
4c35eba72d feat: element alignments - snapping (#6256)
Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-09-28 16:28:08 +02:00
4765f5536e fix: frame name not editable on dbl-click (#7037) 2023-09-25 16:54:23 +02:00
556175558a fix: polyfill Element.replaceChildren (#7034) 2023-09-24 19:07:35 +02:00
4db73a7f95 docs: release @excalidraw/excalidraw@0.16.1 🎉 (#7020) 2023-09-21 10:28:21 +05:30
263 changed files with 16753 additions and 4833 deletions

View File

@ -1,3 +0,0 @@
## 2020-10-13
- Added ability to embed scene source into exported PNG/SVG files so you can import the scene from them (open via `Load` button or drag & drop). #2219

View File

@ -25,6 +25,9 @@
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
</a>
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
</a>
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
</a>
@ -70,7 +73,7 @@ The Excalidraw editor (npm package) supports:
## Excalidraw.com
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/src/excalidraw-app) is part of this repository as well, and the app features:
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/excalidraw-app) is part of this repository as well, and the app features:
- 📡&nbsp;PWA support (works offline).
- 🤼&nbsp;Real-time collaboration.

View File

@ -34,7 +34,7 @@ Open the `Menu` in the below playground and you will see the `custom footer` ren
```jsx live noInline
const MobileFooter = ({}) => {
const device = useDevice();
if (device.isMobile) {
if (device.editor.isMobile) {
return (
<Footer>
<button

View File

@ -38,6 +38,7 @@ To render an item, its recommended to use `MainMenu.Item`.
| Prop | Type | Required | Default | Description |
| --- | --- | :-: | :-: | --- |
| `onSelect` | `function` | Yes | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. |
| `selected` | `boolean` | No | `false` | Whether item is active |
| `children` | `React.ReactNode` | Yes | - | The content of the menu item |
| `icon` | `JSX.Element` | No | - | The icon used in the menu item |
| `shortcut` | `string` | No | - | The shortcut to be shown for the menu item |
@ -70,6 +71,7 @@ function App() {
| Prop | Type | Required | Default | Description |
| --- | --- | :-: | :-: | --- |
| `onSelect` | `function` | No | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. |
| `selected` | `boolean` | No | `false` | Whether item is active |
| `href` | `string` | Yes | - | The `href` attribute to be added to the `anchor` element. |
| `children` | `React.ReactNode` | Yes | - | The content of the menu item |
| `icon` | `JSX.Element` | No | - | The icon used in the menu item |

View File

@ -80,7 +80,7 @@ A given tab trigger button that switches to a given sidebar tab. It must be rend
| `className` | `string` | No | |
| `style` | `React.CSSProperties` | No | |
You can use the [`ref.toggleSidebar({ name: "custom" })`](/docs/@excalidraw/excalidraw/api/props/ref#toggleSidebar) api to control the sidebar, but we export a trigger button to make UI use cases easier.
You can use the [`ref.toggleSidebar({ name: "custom" })`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api#toggleSidebar) api to control the sidebar, but we export a trigger button to make UI use cases easier.
## Example

View File

@ -10,13 +10,17 @@ The [`ExcalidrawElementSkeleton`](https://github.com/excalidraw/excalidraw/blob/
**_Signature_**
<pre>
convertToExcalidrawElements(elements:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L133">
ExcalidrawElementSkeleton
</a>
)
</pre>
```ts
convertToExcalidrawElements(
elements: ExcalidrawElementSkeleton,
opts?: { regenerateIds: boolean }
): ExcalidrawElement[]
```
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `elements` | [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L137) | | The Excalidraw element Skeleton which needs to be converted to Excalidraw elements. |
| `opts` | `{ regenerateIds: boolean }` | ` {regenerateIds: true}` | By default `id` will be regenerated for all the elements irrespective of whether you pass the `id` so if you don't want the ids to regenerated, you can set this attribute to `false`. |
**_How to use_**
@ -24,13 +28,13 @@ The [`ExcalidrawElementSkeleton`](https://github.com/excalidraw/excalidraw/blob/
import { convertToExcalidrawElements } from "@excalidraw/excalidraw";
```
This function converts the Excalidraw Element Skeleton to excalidraw elements which could be then rendered on the canvas. Hence calling this function is necessary before passing it to APIs like [`initialData`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/initialdata), [`updateScene`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#updatescene) if you are using the Skeleton API
This function converts the Excalidraw Element Skeleton to excalidraw elements which could be then rendered on the canvas. Hence calling this function is necessary before passing it to APIs like [`initialData`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/initialdata), [`updateScene`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api#updatescene) if you are using the Skeleton API
## Supported Features
### Rectangle, Ellipse, and Diamond
To create these shapes you need to pass its `type` and `x` and `y` coordinates for position. The rest of the attributes are optional_.
To create these shapes you need to pass its `type` and `x` and `y` coordinates for position. The rest of the attributes are optional\_.
For the Skeleton API to work, `convertToExcalidrawElements` needs to be called before passing it to Excalidraw Component via initialData, updateScene or any such API.
@ -427,3 +431,45 @@ convertToExcalidrawElements([
```
![image](https://github.com/excalidraw/excalidraw/assets/11256141/a8b047c8-2eed-4aea-82a2-e1e6bbddb8d4)
### Frames
To create a frame, you need to pass `type`, `children` (list of Excalidraw element ids). The rest of the attributes are optional.
```ts
{
type: "frame";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawFrameElement>);
```
```ts
convertToExcalidrawElements([
{
"type": "rectangle",
"x": 10,
"y": 10,
"strokeWidth": 2,
"id": "1"
},
{
"type": "diamond",
"x": 120,
"y": 20,
"backgroundColor": "#fff3bf",
"strokeWidth": 2,
"label": {
"text": "HELLO EXCALIDRAW",
"strokeColor": "#099268",
"fontSize": 30
},
"id": "2"
},
{
"type": "frame",
"children": ["1", "2"],
"name": "My frame"
}]
}
```

View File

@ -1,27 +1,26 @@
# ref
# excalidrawAPI
<pre>
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">
createRef
</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
callbackRef
</a>{" "}
&#124; <br />
&#123; current: &#123; readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
resolvablePromise
</a> } }
(api:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L616">
ExcalidrawAPI
</a>
) => void;
</pre>
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs:
Once the callback is triggered, you will need to store the api in state to access it later.
```jsx showLineNumbers
export default function App() {
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />;
}
```
You can use this prop when you want to access some [Excalidraw APIs](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L616). We expose the below APIs :point_down:
| API | Signature | Usage |
| --- | --- | --- |
| ready | `boolean` | This is set to true once Excalidraw is rendered |
| [readyPromise](#readypromise) | `function` | This promise will be resolved with the api once excalidraw has rendered. This will be helpful when you want do some action on the host app once this promise resolves. For this to work you will have to pass ref as shown [here](#readypromise) |
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData |
| [addFiles](#addfiles) | `function` | add files data to the appState |
@ -39,54 +38,15 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the
| [setCursor](#setcursor) | `function` | This API can be used to set customise the mouse cursor on the canvas |
| [resetCursor](#resetcursor) | `function` | This API can be used to reset to default mouse cursor on the canvas |
| [toggleMenu](#togglemenu) | `function` | Toggles specific menus on/off |
| [onChange](#onChange) | `function` | Subscribes to change events |
| [onPointerDown](#onPointerDown) | `function` | Subscribes to `pointerdown` events |
| [onPointerUp](#onPointerUp) | `function` | Subscribes to `pointerup` events |
## readyPromise
:::info The `Ref` support has been removed in v0.17.0 so if you are using refs, please update the integration to use the `excalidrawAPI`.
<pre>
const excalidrawRef = &#123; current:&#123; readyPromise:
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
&nbsp;resolvablePromise
</a>
&nbsp;&#125; &#125;
</pre>
Additionally `ready` and `readyPromise` from the API have been discontinued. These APIs were found to be superfluous, and as part of the effort to streamline the APIs and maintain simplicity, they were removed in version v0.17.0.
Since plain object is passed as a `ref`, the `readyPromise` is resolved as soon as the component is mounted. Most of the time you will not need this unless you have a specific use case where you can't pass the `ref` in the react way and want to do some action on the host when this promise resolves.
```jsx showLineNumbers
const resolvablePromise = () => {
let resolve;
let reject;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
promise.resolve = resolve;
promise.reject = reject;
return promise;
};
const App = () => {
const excalidrawRef = useMemo(
() => ({
current: {
readyPromise: resolvablePromise(),
},
}),
[],
);
useEffect(() => {
excalidrawRef.current.readyPromise.then((api) => {
console.log("loaded", api);
});
}, [excalidrawRef]);
return (
<div style={{ height: "500px" }}>
<Excalidraw ref={excalidrawRef} />
</div>
);
};
```
:::
## updateScene
@ -387,14 +347,25 @@ This API can be used to get the files present in the scene. It may contain files
This API has the below signature. It sets the `tool` passed in param as the active tool.
<pre>
(tool: <br /> &#123; type:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
SHAPES
</a>
[number]["value"]&#124; "eraser" &#125; &#124;
<br /> &#123; type: "custom"; customType: string &#125;) => void
</pre>
```ts
(
tool: (
| (
| { type: Exclude<ToolType, "image"> }
| {
type: Extract<ToolType, "image">;
insertOnCanvasDirectly?: boolean;
}
)
| { type: "custom"; customType: string }
) & { locked?: boolean },
) => {};
```
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
## setCursor
@ -421,3 +392,51 @@ This API is especially useful when you render a custom [`<Sidebar/>`](/docs/@exc
```
This API can be used to reset to default mouse cursor.
## onChange
```tsx
(
callback: (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => void
) => () => void
```
Subscribes to change events, similar to [`props.onChange`](/docs/@excalidraw/excalidraw/api/props#onchange).
Returns an unsubscribe function.
## onPointerDown
```tsx
(
callback: (
activeTool: AppState["activeTool"],
pointerDownState: PointerDownState,
event: React.PointerEvent<HTMLElement>,
) => void,
) => () => void
```
Subscribes to canvas `pointerdown` events.
Returns an unsubscribe function.
## onPointerUp
```tsx
(
callback: (
activeTool: AppState["activeTool"],
pointerDownState: PointerDownState,
event: PointerEvent,
) => void,
) => () => void
```
Subscribes to canvas `pointerup` events.
Returns an unsubscribe function.

View File

@ -1,11 +1,11 @@
# Props
All `props` are *optional*.
All `props` are _optional_.
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; null></code> | `null` | The initial data with which app loads. |
| [`ref`](/docs/@excalidraw/excalidraw/api/props/ref) | `object` | _ | `Ref` to be passed to Excalidraw |
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; null></code> | `null` | The initial data with which app loads. |
| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | _ | Callback triggered with the excalidraw api once rendered |
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
@ -37,7 +37,7 @@ Beyond attributes that Excalidraw elements already support, you can store `custo
You can use this to add any extra information you need to keep track of.
You can add `customData` to elements when passing them as [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata), or using [`updateScene`](/docs/@excalidraw/excalidraw/api/props/ref#updatescene) / [`updateLibrary`](/docs/@excalidraw/excalidraw/api/props/ref#updatelibrary) afterwards.
You can add `customData` to elements when passing them as [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata), or using [`updateScene`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api#updatescene) / [`updateLibrary`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api#updatelibrary) afterwards.
```js showLineNumbers
{
@ -93,8 +93,16 @@ This callback is triggered when mouse pointer is updated.
This prop if passed will be triggered on pointer down events and has the below signature.
<pre>
(activeTool: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L115"> AppState["activeTool"]</a>, pointerDownState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L424">PointerDownState</a>) => void
(activeTool:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L115">
{" "}
AppState["activeTool"]
</a>
, pointerDownState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L424">
PointerDownState
</a>) => void
</pre>
### onScrollChange
@ -110,7 +118,11 @@ This prop if passed will be triggered when canvas is scrolled and has the below
This callback is triggered if passed when something is pasted into the scene. You can use this callback in case you want to do something additional when the paste event occurs.
<pre>
(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L18">ClipboardData</a>, event: ClipboardEvent &#124; null) => boolean
(data:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L18">
ClipboardData
</a>
, event: ClipboardEvent &#124; null) => boolean
</pre>
This callback must return a `boolean` value or a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise) which resolves to a boolean value.
@ -136,8 +148,11 @@ It is invoked with empty items when user clears the library. You can use this ca
This prop if passed will be triggered when clicked on `link`. To handle the redirect yourself (such as when using your own router for internal links), you must call `event.preventDefault()`.
<pre>
(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement</a>,
event: CustomEvent&lt;&#123; nativeEvent: MouseEvent }&gt;) => void
(element:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
ExcalidrawElement
</a>
, event: CustomEvent&lt;&#123; nativeEvent: MouseEvent }&gt;) => void
</pre>
Example:
@ -180,30 +195,30 @@ import { defaultLang, languages } from "@excalidraw/excalidraw";
### viewModeEnabled
This prop indicates whether the app is in `view mode`. When supplied, the value takes precedence over *intialData.appState.viewModeEnabled*, the `view mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
This prop indicates whether the app is in `view mode`. When supplied, the value takes precedence over _intialData.appState.viewModeEnabled_, the `view mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
### zenModeEnabled
This prop indicates whether the app is in `zen mode`. When supplied, the value takes precedence over *intialData.appState.zenModeEnabled*, the `zen mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
This prop indicates whether the app is in `zen mode`. When supplied, the value takes precedence over _intialData.appState.zenModeEnabled_, the `zen mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
### gridModeEnabled
This prop indicates whether the shows the grid. When supplied, the value takes precedence over *intialData.appState.gridModeEnabled*, the grid will be fully controlled by the host app, and users won't be able to toggle it from within the app.
This prop indicates whether the shows the grid. When supplied, the value takes precedence over _intialData.appState.gridModeEnabled_, the grid will be fully controlled by the host app, and users won't be able to toggle it from within the app.
### libraryReturnUrl
If supplied, this URL will be used when user tries to install a library from [libraries.excalidraw.com](https://libraries.excalidraw.com).
Defaults to *window.location.origin + window.location.pathname*. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab.
Defaults to _window.location.origin + window.location.pathname_. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab.
### theme
This prop controls Excalidraw's theme. When supplied, the value takes precedence over *intialData.appState.theme*, the theme will be fully controlled by the host app, and users won't be able to toggle it from within the app unless *UIOptions.canvasActions.toggleTheme* is set to `true`, in which case the `theme` prop will control Excalidraw's default theme with ability to allow theme switching (you must take care of updating the `theme` prop when you detect a change to `appState.theme` from the [onChange](#onchange) callback).
This prop controls Excalidraw's theme. When supplied, the value takes precedence over _intialData.appState.theme_, the theme will be fully controlled by the host app, and users won't be able to toggle it from within the app unless _UIOptions.canvasActions.toggleTheme_ is set to `true`, in which case the `theme` prop will control Excalidraw's default theme with ability to allow theme switching (you must take care of updating the `theme` prop when you detect a change to `appState.theme` from the [onChange](#onchange) callback).
You can use [`THEME`](/docs/@excalidraw/excalidraw/api/utils#theme) to specify the theme.
### name
This prop sets the `name` of the drawing which will be used when exporting the drawing. When supplied, the value takes precedence over *intialData.appState.name*, the `name` will be fully controlled by host app and the users won't be able to edit from within Excalidraw.
This prop sets the `name` of the drawing which will be used when exporting the drawing. When supplied, the value takes precedence over _intialData.appState.name_, the `name` will be fully controlled by host app and the users won't be able to edit from within Excalidraw.
### detectScroll
@ -236,4 +251,4 @@ validateEmbeddable?: boolean | string[] | RegExp | RegExp[] | ((link: string) =>
This is an optional property. By default we support a handful of well-known sites. You may allow additional sites or disallow the default ones by supplying a custom validator. If you pass `true`, all URLs will be allowed. You can also supply a list of hostnames, RegExp (or list of RegExp objects), or a function. If the function returns `undefined`, the built-in validator will be used.
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.

View File

@ -1,6 +1,6 @@
# UIOptions
This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasactions), [`dockedSidebarBreakpoint`](#dockedsidebarbreakpoint) and [`welcomeScreen`](#welcmescreen).
This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasactions), [`dockedSidebarBreakpoint`](#dockedsidebarbreakpoint) [`welcomeScreen`](#welcmescreen) and [`tools`](#tools).
<pre>
&#123;
@ -70,3 +70,12 @@ function App() {
);
}
```
## tools
This `prop ` controls the visibility of the tools in the editor.
Currently you can control the visibility of `image` tool via this prop.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| image | boolean | true | Decides whether `image` tool should be visible.

View File

@ -129,7 +129,7 @@ if (contents.type === MIME_TYPES.excalidraw) {
<pre>
loadSceneOrLibraryFromBlob(<br/>&nbsp;
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,<br/>&nbsp;
localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a> | null,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null,<br/>&nbsp;
fileHandle?: FileSystemHandle | null<br/>
@ -164,9 +164,9 @@ import { isLinearElement } from "@excalidraw/excalidraw";
**Signature**
```tsx
<pre>
isLinearElement(elementType?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L80">ExcalidrawElement</a>): boolean
```
</pre>
### getNonDeletedElements
@ -195,8 +195,10 @@ import { mergeLibraryItems } from "@excalidraw/excalidraw";
**_Signature_**
<pre>
mergeLibraryItems(localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>,<br/>&nbsp;
otherItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>
mergeLibraryItems(<br/>&nbsp;
localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>,<br/>&nbsp;
otherItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a><br/>
): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>
</pre>
### parseLibraryTokensFromUrl
@ -299,7 +301,7 @@ Open the `main menu` in the below example to view the footer.
```jsx live noInline
const MobileFooter = ({}) => {
const device = useDevice();
if (device.isMobile) {
if (device.editor.isMobile) {
return (
<Footer>
<button
@ -331,14 +333,15 @@ const App = () => (
render(<App />);
```
The `device` has the following `attributes`
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
| Name | Type | Description |
| --- | --- | --- |
| `isSmScreen` | `boolean` | Set to `true` when the device small screen is small (Width < `640px` ) |
| `isMobile` | `boolean` | Set to `true` when the device is `mobile` |
| `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
| `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |
| `viewport.isMobile` | `boolean` | Set to `true` when viewport is in `mobile` breakpoint |
| `viewport.isLandscape` | `boolean` | Set to `true` when the viewport is in `landscape` mode |
| `editor.canFitSidebar` | `boolean` | Set to `true` if there's enough space to fit the `sidebar` |
| `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint |
| `isTouchScreen` | `boolean` | Set to `true` for `touch` when touch event detected |
### i18n
@ -383,3 +386,94 @@ function App() {
);
}
```
### getCommonBounds
This util can be used to get the common bounds of the passed elements.
**_Signature_**
```ts
getCommonBounds(
elements: readonly ExcalidrawElement[]
): readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
]
```
**_How to use_**
```js
import { getCommonBounds } from "@excalidraw/excalidraw";
```
### elementsOverlappingBBox
To filter `elements` that are inside, overlap, or contain the `bounds` rectangle.
The bounds check is approximate and does not precisely follow the element's shape. You can also supply `errorMargin` which effectively makes the `bounds` larger by that amount.
This API has 3 `type`s of operation: `overlap`, `contain`, and `inside`:
- `overlap` - filters elements that are overlapping or inside bounds.
- `contain` - filters elements that are inside bounds or bounds inside elements.
- `inside` - filters elements that are inside bounds.
**_Signature_**
<pre>
elementsOverlappingBBox(<br/>&nbsp;
elements: readonly NonDeletedExcalidrawElement[];<br/>&nbsp;
bounds: <a href="https://github.com/excalidraw/excalidraw/blob/9c425224c789d083bf16e0597ce4a429b9ee008e/src/element/bounds.ts#L37-L42">Bounds</a> | ExcalidrawElement;<br/>&nbsp;
errorMargin?: number;<br/>&nbsp;
type: "overlap" | "contain" | "inside";<br/>
): NonDeletedExcalidrawElement[];
</pre>
**_How to use_**
```js
import { elementsOverlappingBBox } from "@excalidraw/excalidraw";
```
### isElementInsideBBox
Lower-level API than `elementsOverlappingBBox` to check if a single `element` is inside `bounds`. If `eitherDirection=true`, returns `true` if `element` is fully inside `bounds` rectangle, or vice versa. When `false`, it returns `true` only for the former case.
**_Signature_**
<pre>
isElementInsideBBox(<br/>&nbsp;
element: NonDeletedExcalidrawElement,<br/>&nbsp;
bounds: <a href="https://github.com/excalidraw/excalidraw/blob/9c425224c789d083bf16e0597ce4a429b9ee008e/src/element/bounds.ts#L37-L42">Bounds</a>,<br/>&nbsp;
eitherDirection = false,<br/>
): boolean
</pre>
**_How to use_**
```js
import { isElementInsideBBox } from "@excalidraw/excalidraw";
```
### elementPartiallyOverlapsWithOrContainsBBox
Checks if `element` is overlapping the `bounds` rectangle, or is fully inside.
**_Signature_**
<pre>
elementPartiallyOverlapsWithOrContainsBBox(<br/>&nbsp;
element: NonDeletedExcalidrawElement,<br/>&nbsp;
bounds: <a href="https://github.com/excalidraw/excalidraw/blob/9c425224c789d083bf16e0597ce4a429b9ee008e/src/element/bounds.ts#L37-L42">Bounds</a>,<br/>
): boolean
</pre>
**_How to use_**
```js
import { elementPartiallyOverlapsWithOrContainsBBox } from "@excalidraw/excalidraw";
```

View File

@ -1,6 +1,6 @@
# Customizing Styles
Excalidraw is using CSS variables to style certain components. To override them, you should set your own on the `.excalidraw` and `.excalidraw.theme--dark` (for dark mode variables) selectors.
Excalidraw uses CSS variables to style certain components. To override them, you should set your own on the `.excalidraw` and `.excalidraw.theme--dark` (for dark mode variables) selectors.
Make sure the selector has higher specificity, e.g. by prefixing it with your app's selector:

View File

@ -43,7 +43,7 @@ Once the version is released `@excalibot` will post a comment with the release v
To release the next stable version follow the below steps:
```bash
yarn prerelease version
yarn prerelease:excalidraw
```
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
@ -51,7 +51,7 @@ You need to pass the `version` for which you want to create the release. This wi
The next step is to run the `release` script:
```bash
yarn release
yarn release:excalidraw
```
This will publish the package.

View File

@ -2,7 +2,7 @@
### Does this package support collaboration ?
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same.
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same.
### Turning off Aggressive Anti-Fingerprinting in Brave browser
@ -18,7 +18,7 @@ We strongly recommend turning it off. You can follow the steps below on how to d
2. Once opened, look for **Aggressively Block Fingerprinting**
![Aggresive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
![Aggressive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
3. Switch to **Block Fingerprinting**
@ -31,6 +31,17 @@ We strongly recommend turning it off. You can follow the steps below on how to d
If disabling this setting doesn't fix the display of text elements, please consider opening an [issue](https://github.com/excalidraw/excalidraw/issues/new) on our GitHub, or message us on [Discord](https://discord.gg/UexuTaE).
### ReferenceError: process is not defined
When using `vite` or any build tools, you will have to make sure the `process` is accessible as we are accessing `process.env.IS_PREACT` to decide whether to use `preact` build.
Since Vite removes env variables by default, you can update the vite config to ensure its available :point_down:
```
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
},
```
## Need help?

View File

@ -30,25 +30,74 @@ function App() {
}
```
### Rendering Excalidraw only on client
### Next.js
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
The following worfklow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
Here are two ways on how you can render **Excalidraw** on **Next.js**.
1. Using **Next.js Dynamic** import [Recommended].
Since Excalidraw doesn't support server side rendering so you can also use `dynamic import` to render by setting `ssr` to `false`.
```jsx showLineNumbers
import dynamic from "next/dynamic";
const Excalidraw = dynamic(
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
{
ssr: false,
},
);
export default function App() {
return <Excalidraw />;
}
```
Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-dynamic-k8yjq2).
2. Importing Excalidraw once **client** is rendered.
```jsx showLineNumbers
import { useState, useEffect } from "react";
export default function App() {
const [Excalidraw, setExcalidraw] = useState(null);
useEffect(() => {
import("@excalidraw/excalidraw").then((comp) => setExcalidraw(comp.Excalidraw));
import("@excalidraw/excalidraw").then((comp) =>
setExcalidraw(comp.Excalidraw),
);
}, []);
return <>{Excalidraw && <Excalidraw />}</>;
}
```
Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-5xb3d)
The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm)
### Preact
Since we support `umd` build ships with `react/jsx-runtime` and `react-dom/client` inlined with the package. This conflicts with `Preact` and hence the build doesn't work directly with `Preact`.
However we have shipped a separate build for `Preact` so if you are using `Preact` you need to set `process.env.IS_PREACT` to `true` to use the `Preact` build.
Once the above `env` variable is set, you will be able to use the package in `Preact` as well.
:::info
When using `vite` or any build tools, you will have to make sure the `process` is accessible as we are accessing `process.env.IS_PREACT` to decide whether to use `preact` build.
Since Vite removes env variables by default, you can update the vite config to ensure its available :point_down:
```
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
},
```
:::
## Browser
To use it in a browser directly:

View File

@ -0,0 +1,22 @@
# Frames
## Ordering
Frames should be ordered where frame children come first, followed by the frame element itself:
```
[
other_element,
frame1_child1,
frame1_child2,
frame1,
other_element,
frame2_child1,
frame2_child2,
frame2,
other_element,
...
]
```
If not ordered correctly, the editor will still function, but the elements may not be rendered and clipped correctly. Further, the renderer relies on this ordering for performance optimizations.

View File

@ -15,7 +15,7 @@ In case you want to pick up something from the roadmap, comment on that issue an
1. Run `yarn` to install dependencies
1. Create a branch for your PR with `git checkout -b your-branch-name`
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork, run:
>
> ```bash
> git remote add upstream https://github.com/excalidraw/excalidraw.git

View File

@ -1,6 +1,11 @@
// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion
// Set the env variable to false so the excalidraw npm package doesn't throw
// process undefined as docusaurus doesn't expose env variables by default
process.env.IS_PREACT = "false";
/** @type {import('@docusaurus/types').Config} */
const config = {
title: "Excalidraw developer docs",
@ -139,7 +144,15 @@ const config = {
},
}),
themes: ["@docusaurus/theme-live-codeblock"],
plugins: ["docusaurus-plugin-sass"],
plugins: [
"docusaurus-plugin-sass",
[
"docusaurus2-dotenv",
{
systemvars: true,
},
],
],
};
module.exports = config;

View File

@ -18,7 +18,7 @@
"@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.15.2-eb020d0",
"@excalidraw/excalidraw": "0.17.0",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3",
@ -30,6 +30,7 @@
"devDependencies": {
"@docusaurus/module-type-aliases": "2.0.0-rc.1",
"@tsconfig/docusaurus": "^1.0.5",
"docusaurus2-dotenv": "1.4.0",
"typescript": "^4.7.4"
},
"browserslist": {

View File

@ -23,7 +23,11 @@ const sidebars = {
},
items: ["introduction/development", "introduction/contributing"],
},
{ type: "category", label: "Codebase", items: ["codebase/json-schema"] },
{
type: "category",
label: "Codebase",
items: ["codebase/json-schema", "codebase/frames"],
},
{
type: "category",
label: "@excalidraw/excalidraw",
@ -49,7 +53,7 @@ const sidebars = {
},
items: [
"@excalidraw/excalidraw/api/props/initialdata",
"@excalidraw/excalidraw/api/props/ref",
"@excalidraw/excalidraw/api/props/excalidraw-api",
"@excalidraw/excalidraw/api/props/render-props",
"@excalidraw/excalidraw/api/props/ui-options",
],

View File

@ -15,7 +15,7 @@ const FeatureList = [
Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
description: (
<>
Want to build your own app powered by Excalidraw by don't know where to
Want to build your own app powered by Excalidraw but don't know where to
start?
</>
),

View File

@ -145,6 +145,14 @@
dependencies:
"@babel/highlight" "^7.18.6"
"@babel/code-frame@^7.22.13":
version "7.22.13"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
dependencies:
"@babel/highlight" "^7.22.13"
chalk "^2.4.2"
"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8":
version "7.18.8"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d"
@ -202,6 +210,16 @@
"@jridgewell/gen-mapping" "^0.3.2"
jsesc "^2.5.1"
"@babel/generator@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.0.tgz#df5c386e2218be505b34837acbcb874d7a983420"
integrity sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==
dependencies:
"@babel/types" "^7.23.0"
"@jridgewell/gen-mapping" "^0.3.2"
"@jridgewell/trace-mapping" "^0.3.17"
jsesc "^2.5.1"
"@babel/helper-annotate-as-pure@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb"
@ -265,6 +283,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be"
integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==
"@babel/helper-environment-visitor@^7.22.20":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167"
integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==
"@babel/helper-explode-assignable-expression@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz#41f8228ef0a6f1a036b8dfdfec7ce94f9a6bc096"
@ -280,6 +303,14 @@
"@babel/template" "^7.18.6"
"@babel/types" "^7.18.9"
"@babel/helper-function-name@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759"
integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==
dependencies:
"@babel/template" "^7.22.15"
"@babel/types" "^7.23.0"
"@babel/helper-hoist-variables@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678"
@ -287,6 +318,13 @@
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-hoist-variables@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb"
integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==
dependencies:
"@babel/types" "^7.22.5"
"@babel/helper-member-expression-to-functions@^7.18.9":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz#1531661e8375af843ad37ac692c132841e2fd815"
@ -374,11 +412,28 @@
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-split-export-declaration@^7.22.6":
version "7.22.6"
resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz#322c61b7310c0997fe4c323955667f18fcefb91c"
integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==
dependencies:
"@babel/types" "^7.22.5"
"@babel/helper-string-parser@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f"
integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==
"@babel/helper-validator-identifier@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
"@babel/helper-validator-identifier@^7.22.20":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
"@babel/helper-validator-option@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8"
@ -412,11 +467,25 @@
chalk "^2.0.0"
js-tokens "^4.0.0"
"@babel/highlight@^7.22.13":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
dependencies:
"@babel/helper-validator-identifier" "^7.22.20"
chalk "^2.4.2"
js-tokens "^4.0.0"
"@babel/parser@^7.12.7", "@babel/parser@^7.18.6", "@babel/parser@^7.18.8", "@babel/parser@^7.18.9":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.9.tgz#f2dde0c682ccc264a9a8595efd030a5cc8fd2539"
integrity sha512-9uJveS9eY9DJ0t64YbIBZICtJy8a5QrDEVdiLCG97fVLpDTpGX7t8mMSb6OWw6Lrnjqj4O8zwjELX3dhoMgiBg==
"@babel/parser@^7.22.15", "@babel/parser@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719"
integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2"
@ -1147,19 +1216,28 @@
"@babel/parser" "^7.18.6"
"@babel/types" "^7.18.6"
"@babel/traverse@^7.12.9", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.9.tgz#deeff3e8f1bad9786874cb2feda7a2d77a904f98"
integrity sha512-LcPAnujXGwBgv3/WHv01pHtb2tihcyW1XuL9wd7jqh1Z8AQkTd+QVjMrMijrln0T7ED3UXLIy36P9Ao7W75rYg==
"@babel/template@^7.22.15":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==
dependencies:
"@babel/code-frame" "^7.18.6"
"@babel/generator" "^7.18.9"
"@babel/helper-environment-visitor" "^7.18.9"
"@babel/helper-function-name" "^7.18.9"
"@babel/helper-hoist-variables" "^7.18.6"
"@babel/helper-split-export-declaration" "^7.18.6"
"@babel/parser" "^7.18.9"
"@babel/types" "^7.18.9"
"@babel/code-frame" "^7.22.13"
"@babel/parser" "^7.22.15"
"@babel/types" "^7.22.15"
"@babel/traverse@^7.12.9", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9":
version "7.23.2"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8"
integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==
dependencies:
"@babel/code-frame" "^7.22.13"
"@babel/generator" "^7.23.0"
"@babel/helper-environment-visitor" "^7.22.20"
"@babel/helper-function-name" "^7.23.0"
"@babel/helper-hoist-variables" "^7.22.5"
"@babel/helper-split-export-declaration" "^7.22.6"
"@babel/parser" "^7.23.0"
"@babel/types" "^7.23.0"
debug "^4.1.0"
globals "^11.1.0"
@ -1171,6 +1249,15 @@
"@babel/helper-validator-identifier" "^7.18.6"
to-fast-properties "^2.0.0"
"@babel/types@^7.22.15", "@babel/types@^7.22.5", "@babel/types@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==
dependencies:
"@babel/helper-string-parser" "^7.22.5"
"@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0"
"@colors/colors@1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
@ -1631,10 +1718,10 @@
url-loader "^4.1.1"
webpack "^5.73.0"
"@excalidraw/excalidraw@0.15.2-eb020d0":
version "0.15.2-eb020d0"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2-eb020d0.tgz#25bd61e6f23da7c084fb16a3e0fe0dd9ad8e6533"
integrity sha512-TKGLzpOVqFQcwK1GFKTDXgg1s2U6tc5KE3qXuv87osbzOtftQn3x4+VH61vwdj11l00nEN80SMdXUC43T9uJqQ==
"@excalidraw/excalidraw@0.17.0":
version "0.17.0"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725"
integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==
"@hapi/hoek@^9.0.0":
version "9.3.0"
@ -1670,6 +1757,11 @@
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
"@jridgewell/resolve-uri@^3.1.0":
version "3.1.1"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721"
integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==
"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
@ -1688,6 +1780,19 @@
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
"@jridgewell/sourcemap-codec@^1.4.14":
version "1.4.15"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
"@jridgewell/trace-mapping@^0.3.17":
version "0.3.20"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz#72e45707cf240fa6b081d0366f8265b0cd10197f"
integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==
dependencies:
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@jridgewell/trace-mapping@^0.3.7", "@jridgewell/trace-mapping@^0.3.9":
version "0.3.14"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed"
@ -3462,6 +3567,13 @@ docusaurus-plugin-sass@0.2.3:
dependencies:
sass-loader "^10.1.1"
docusaurus2-dotenv@1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/docusaurus2-dotenv/-/docusaurus2-dotenv-1.4.0.tgz#9ab900e29de9081f9f1f28f7224ff63760385641"
integrity sha512-iWqem5fnBAyeBBtX75Fxp71uUAnwFaXzOmade8zAhN4vL3RG9m27sLSRwjJGVVgIkEo3esjGyCcTGTiCjfi+sg==
dependencies:
dotenv-webpack "1.7.0"
dom-converter@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768"
@ -3547,6 +3659,25 @@ dot-prop@^5.2.0:
dependencies:
is-obj "^2.0.0"
dotenv-defaults@^1.0.2:
version "1.1.1"
resolved "https://registry.yarnpkg.com/dotenv-defaults/-/dotenv-defaults-1.1.1.tgz#032c024f4b5906d9990eb06d722dc74cc60ec1bd"
integrity sha512-6fPRo9o/3MxKvmRZBD3oNFdxODdhJtIy1zcJeUSCs6HCy4tarUpd+G67UTU9tF6OWXeSPqsm4fPAB+2eY9Rt9Q==
dependencies:
dotenv "^6.2.0"
dotenv-webpack@1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/dotenv-webpack/-/dotenv-webpack-1.7.0.tgz#4384d8c57ee6f405c296278c14a9f9167856d3a1"
integrity sha512-wwNtOBW/6gLQSkb8p43y0Wts970A3xtNiG/mpwj9MLUhtPCQG6i+/DSXXoNN7fbPCU/vQ7JjwGmgOeGZSSZnsw==
dependencies:
dotenv-defaults "^1.0.2"
dotenv@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064"
integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==
duplexer3@^0.1.4:
version "0.1.5"
resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.5.tgz#0b5e4d7bad5de8901ea4440624c8e1d20099217e"

View File

@ -80,7 +80,8 @@ export const ExportToExcalidrawPlus: React.FC<{
appState: Partial<AppState>;
files: BinaryFiles;
onError: (error: Error) => void;
}> = ({ elements, appState, files, onError }) => {
onSuccess: () => void;
}> = ({ elements, appState, files, onError, onSuccess }) => {
const { t } = useI18n();
return (
<Card color="primary">
@ -107,6 +108,7 @@ export const ExportToExcalidrawPlus: React.FC<{
try {
trackEvent("export", "eplus", `ui (${getFrame()})`);
await exportToExcalidrawPlus(elements, appState, files);
onSuccess();
} catch (error: any) {
console.error(error);
if (error.name !== "AbortError") {

View File

@ -6,7 +6,7 @@
*
* - DataState refers to full state of the app: appState, elements, images,
* though some state is saved separately (collab username, library) for one
* reason or another. We also save different data to different sotrage
* reason or another. We also save different data to different storage
* (localStorage, indexedDB).
*/

View File

@ -107,7 +107,7 @@ export type SocketUpdateDataSource = {
type: "MOUSE_LOCATION";
payload: {
socketId: string;
pointer: { x: number; y: number };
pointer: { x: number; y: number; tool: "pointer" | "laser" };
button: "down" | "up";
selectedElementIds: AppState["selectedElementIds"];
username: string;

View File

@ -131,5 +131,5 @@ export class Debug {
};
};
}
//@ts-ignore
window.debug = Debug;

View File

@ -608,7 +608,7 @@ const ExcalidrawWrapper = () => {
canvas: HTMLCanvasElement,
) => {
if (exportedElements.length === 0) {
return window.alert(t("alerts.cannotExportEmptyCanvas"));
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (canvas) {
try {
@ -624,7 +624,7 @@ const ExcalidrawWrapper = () => {
);
if (errorMessage) {
setErrorMessage(errorMessage);
throw new Error(errorMessage);
}
if (url) {
@ -634,7 +634,7 @@ const ExcalidrawWrapper = () => {
if (error.name !== "AbortError") {
const { width, height } = canvas;
console.error(error, { width, height });
setErrorMessage(error.message);
throw new Error(error.message);
}
}
}
@ -691,7 +691,7 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
ref={excalidrawRefCallback}
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
@ -714,6 +714,11 @@ const ExcalidrawWrapper = () => {
},
});
}}
onSuccess={() => {
excalidrawAPI?.updateScene({
appState: { openDialog: null },
});
}}
/>
);
},

View File

@ -17,8 +17,10 @@ describe("Test MobileMenu", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
//@ts-ignore
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
// @ts-ignore
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
});
afterAll(() => {
@ -28,11 +30,15 @@ describe("Test MobileMenu", () => {
it("should set device correctly", () => {
expect(h.app.device).toMatchInlineSnapshot(`
{
"canDeviceFitSidebar": false,
"isLandscape": true,
"isMobile": true,
"isSmScreen": false,
"editor": {
"canFitSidebar": false,
"isMobile": true,
},
"isTouchScreen": false,
"viewport": {
"isLandscape": false,
"isMobile": true,
},
}
`);
});

View File

@ -8,6 +8,7 @@ import {
} from "../../excalidraw-app/collab/reconciliation";
import { randomInteger } from "../../src/random";
import { AppState } from "../../src/types";
import { cloneJSON } from "../../src/utils";
type Id = string;
type ElementLike = {
@ -93,8 +94,6 @@ const cleanElements = (elements: ReconciledElements) => {
});
};
const cloneDeep = (data: any) => JSON.parse(JSON.stringify(data));
const test = <U extends `${string}:${"L" | "R"}`>(
local: (Id | ElementLike)[],
remote: (Id | ElementLike)[],
@ -115,15 +114,15 @@ const test = <U extends `${string}:${"L" | "R"}`>(
"remote reconciliation",
);
const __local = cleanElements(cloneDeep(_remote));
const __remote = addParents(cleanElements(cloneDeep(remoteReconciled)));
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
if (bidirectional) {
try {
expect(
cleanElements(
reconcileElements(
cloneDeep(__local),
cloneDeep(__remote),
cloneJSON(__local),
cloneJSON(__remote),
{} as AppState,
),
),

View File

@ -20,7 +20,9 @@
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/random-username": "1.0.0",
"@excalidraw/laser-pointer": "1.2.0",
"@excalidraw/mermaid-to-excalidraw": "0.1.2",
"@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
"@sentry/browser": "6.2.5",
@ -48,11 +50,11 @@
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0",
"points-on-curve": "1.0.1",
"pwacompat": "2.0.17",
"react": "18.2.0",
"react-dom": "18.2.0",
"roughjs": "4.5.2",
"roughjs": "4.6.4",
"sass": "1.51.0",
"socket.io-client": "2.3.1",
"tunnel-rat": "0.1.2"
@ -124,10 +126,10 @@
"test": "yarn test:app",
"test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui",
"test:ui": "yarn test --ui --coverage.enabled=true",
"autorelease": "node scripts/autorelease.js",
"prerelease": "node scripts/prerelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
"release": "node scripts/release.js"
"release:excalidraw": "node scripts/release.js"
}
}

View File

@ -11,7 +11,7 @@
{
"src": "apple-touch-icon.png",
"type": "image/png",
"sizes": "256x256"
"sizes": "180x180"
}
],
"start_url": "/",

View File

@ -10,7 +10,7 @@ import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
@ -21,6 +21,7 @@ import {
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { Bounds } from "../element/bounds";
import { setCursor } from "../cursor";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@ -437,5 +438,6 @@ export const actionToggleHandTool = register({
commitToHistory: true,
};
},
keyTest: (event) => event.key === KEYS.H,
keyTest: (event) =>
!event.altKey && !event[KEYS.CTRL_OR_CMD] && event.key === KEYS.H,
});

View File

@ -3,33 +3,43 @@ import { register } from "./register";
import {
copyTextToSystemClipboard,
copyToClipboard,
createPasteEvent,
probablySupportsClipboardBlob,
probablySupportsClipboardWriteText,
readSystemClipboard,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
copyToClipboard(elementsToCopy, app.files);
try {
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
},
};
}
return {
commitToHistory: false,
};
},
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
@ -38,15 +48,55 @@ export const actionCopy = register({
export const actionPaste = register({
name: "paste",
trackEvent: { category: "element" },
perform: (elements: any, appStates: any, data, app) => {
app.pasteFromClipboard(null);
perform: async (elements, appState, data, app) => {
let types;
try {
types = await readSystemClipboard();
} catch (error: any) {
if (error.name === "AbortError" || error.name === "NotAllowedError") {
// user probably aborted the action. Though not 100% sure, it's best
// to not annoy them with an error message.
return false;
}
console.error(`actionPaste ${error.name}: ${error.message}`);
if (isFirefox) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
},
};
}
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
},
};
}
try {
app.pasteFromClipboard(createPasteEvent({ types }));
} catch (error: any) {
console.error(error);
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
},
};
}
return {
commitToHistory: false,
};
},
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
@ -55,13 +105,10 @@ export const actionPaste = register({
export const actionCut = register({
name: "cut",
trackEvent: { category: "element" },
perform: (elements, appState, data, app) => {
actionCopy.perform(elements, appState, data, app);
perform: (elements, appState, event: ClipboardEvent | null, app) => {
actionCopy.perform(elements, appState, event, app);
return actionDeleteSelected.perform(elements, appState);
},
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
});
@ -75,20 +122,23 @@ export const actionCopyAsSvg = register({
commitToHistory: false,
};
}
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
true,
);
try {
await exportCanvas(
"clipboard-svg",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
exportedElements,
appState,
app.files,
appState,
{
...appState,
exportingFrame,
},
);
return {
commitToHistory: false,
@ -124,16 +174,17 @@ export const actionCopyAsPng = register({
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
true,
);
try {
await exportCanvas(
"clipboard",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.files,
appState,
);
await exportCanvas("clipboard", exportedElements, appState, app.files, {
...appState,
exportingFrame,
});
return {
appState: {
...appState,

View File

@ -46,6 +46,7 @@ const deleteSelectedElements = (
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
},
};
};

View File

@ -25,7 +25,7 @@ import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons";
import {
bindElementsToFramesAfterDuplication,
getFrameElements,
getFrameChildren,
} from "../frame";
import {
excludeElementsInFramesFromSelection,
@ -155,7 +155,7 @@ const duplicateElements = (
groupId,
).flatMap((element) =>
isFrameElement(element)
? [...getFrameElements(elements, element.id), element]
? [...getFrameChildren(elements, element.id), element]
: [element],
);
@ -181,7 +181,7 @@ const duplicateElements = (
continue;
}
if (isElementAFrame) {
const elementsInFrame = getFrameElements(sortedElements, element.id);
const elementsInFrame = getFrameChildren(sortedElements, element.id);
elementsWithClones.push(
...markAsProcessed([

View File

@ -191,7 +191,15 @@ export const actionSaveFileToDisk = register({
},
app.files,
);
return { commitToHistory: false, appState: { ...appState, fileHandle } };
return {
commitToHistory: false,
appState: {
...appState,
openDialog: null,
fileHandle,
toast: { message: t("toast.fileSaved") },
},
};
} catch (error: any) {
if (error?.name !== "AbortError") {
console.error(error);
@ -209,7 +217,7 @@ export const actionSaveFileToDisk = register({
icon={saveAs}
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useDevice().isMobile}
showAriaLabel={useDevice().editor.isMobile}
hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)}
data-testid="save-as-button"

View File

@ -1,6 +1,6 @@
import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element";
import { updateActiveTool, resetCursor } from "../utils";
import { updateActiveTool } from "../utils";
import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
@ -15,6 +15,7 @@ import {
} from "../element/binding";
import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types";
import { resetCursor } from "../cursor";
export const actionFinalize = register({
name: "finalize",
@ -90,7 +91,9 @@ export const actionFinalize = register({
}
}
if (isInvisiblySmallElement(multiPointElement)) {
newElements = newElements.slice(0, -1);
newElements = newElements.filter(
(el) => el.id !== multiPointElement.id,
);
}
// If the multi point line closes the loop,

View File

@ -1,10 +1,11 @@
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { getFrameChildren } from "../frame";
import { KEYS } from "../keys";
import { AppClassProperties, AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils";
import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { register } from "./register";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
@ -20,7 +21,7 @@ export const actionSelectAllElementsInFrame = register({
const selectedFrame = app.scene.getSelectedElements(appState)[0];
if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameElements(
const elementsInFrame = getFrameChildren(
getNonDeletedElements(elements),
selectedFrame.id,
).filter((element) => !(element.type === "text" && element.containerId));

View File

@ -17,15 +17,12 @@ import {
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawTextElement,
} from "../element/types";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
getFrameElements,
groupByFrames,
removeElementsFromFrame,
replaceAllElementsInFrame,
@ -190,13 +187,6 @@ export const actionUngroup = register({
let nextElements = [...elements];
const selectedElements = app.scene.getSelectedElements(appState);
const frames = selectedElements
.filter((element) => element.frameId)
.map((element) =>
app.scene.getElement(element.frameId!),
) as ExcalidrawFrameElement[];
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
nextElements = nextElements.map((element) => {
if (isBoundToContainer(element)) {
@ -221,7 +211,19 @@ export const actionUngroup = register({
null,
);
frames.forEach((frame) => {
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElementFrameIds = new Set(
selectedElements
.filter((element) => element.frameId)
.map((element) => element.frameId!),
);
const targetFrames = getFrameElements(elements).filter((frame) =>
selectedElementFrameIds.has(frame.id),
);
targetFrames.forEach((frame) => {
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,

View File

@ -3,7 +3,6 @@ import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { KEYS } from "../keys";
export const actionToggleCanvasMenu = register({
@ -52,23 +51,6 @@ export const actionToggleEditMenu = register({
),
});
export const actionFullScreen = register({
name: "toggleFullScreen",
viewMode: true,
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => {
if (!isFullScreen()) {
allowFullScreen();
}
if (isFullScreen()) {
exitFullScreen();
}
return {
commitToHistory: false,
};
},
});
export const actionShortcuts = register({
name: "toggleShortcuts",
viewMode: true,

View File

@ -0,0 +1,167 @@
import { Excalidraw } from "../packages/excalidraw/index";
import { queryByTestId } from "@testing-library/react";
import { render } from "../tests/test-utils";
import { UI } from "../tests/helpers/ui";
import { API } from "../tests/helpers/api";
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
const { h } = window;
describe("element locking", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
describe("properties when tool selected", () => {
it("should show active background top picks", () => {
UI.clickTool("rectangle");
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({
currentItemBackgroundColor: color,
});
const activeColor = queryByTestId(
document.body,
`color-top-pick-${color}`,
);
expect(activeColor).toHaveClass("active");
});
it("should show fill style when background non-transparent", () => {
UI.clickTool("rectangle");
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({
currentItemBackgroundColor: color,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toHaveClass("active");
h.setState({
currentItemFillStyle: "solid",
});
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
expect(solidFillStyle).toHaveClass("active");
});
it("should not show fill style when background transparent", () => {
UI.clickTool("rectangle");
h.setState({
currentItemBackgroundColor: COLOR_PALETTE.transparent,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toBe(null);
});
it("should show horizontal text align for text tool", () => {
UI.clickTool("text");
h.setState({
currentItemTextAlign: "right",
});
const centerTextAlign = queryByTestId(document.body, `align-right`);
expect(centerTextAlign).toBeChecked();
});
});
describe("properties when elements selected", () => {
it("should show active styles when single element selected", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: "red",
fillStyle: "cross-hatch",
});
h.elements = [rect];
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
expect(crossHatchButton).toHaveClass("active");
});
it("should not show fill style selected element's background is transparent", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "cross-hatch",
});
h.elements = [rect];
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
expect(crossHatchButton).toBe(null);
});
it("should highlight common stroke width of selected elements", () => {
const rect1 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]);
const thinStrokeWidthButton = queryByTestId(
document.body,
`strokeWidth-thin`,
);
expect(thinStrokeWidthButton).toBeChecked();
});
it("should not highlight any stroke width button if no common style", () => {
const rect1 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
});
h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]);
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
expect(
queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-extraBold`),
).not.toBeChecked();
});
it("should show properties of different element types when selected", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
});
const text = API.createElement({
type: "text",
fontFamily: FONT_FAMILY.Cascadia,
});
h.elements = [rect, text];
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
});
});
});

View File

@ -1,4 +1,4 @@
import { AppState } from "../../src/types";
import { AppState, Primitive } from "../../src/types";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
@ -51,6 +51,7 @@ import {
DEFAULT_FONT_SIZE,
FONT_FAMILY,
ROUNDNESS,
STROKE_WIDTH,
VERTICAL_ALIGN,
} from "../constants";
import {
@ -82,7 +83,6 @@ import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
import { randomInteger } from "../random";
import {
canChangeRoundness,
canHaveArrowheads,
getCommonAttributeOfSelectedElements,
getSelectedElements,
@ -118,25 +118,44 @@ export const changeProperty = (
});
};
export const getFormValue = function <T>(
export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[],
appState: AppState,
getAttribute: (element: ExcalidrawElement) => T,
defaultValue: T,
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
const editingElement = appState.editingElement;
const nonDeletedElements = getNonDeletedElements(elements);
return (
(editingElement && getAttribute(editingElement)) ??
(isSomeElementSelected(nonDeletedElements, appState)
? getCommonAttributeOfSelectedElements(
nonDeletedElements,
let ret: T | null = null;
if (editingElement) {
ret = getAttribute(editingElement);
}
if (!ret) {
const hasSelection = isSomeElementSelected(nonDeletedElements, appState);
if (hasSelection) {
ret =
getCommonAttributeOfSelectedElements(
isRelevantElement === true
? nonDeletedElements
: nonDeletedElements.filter((el) => isRelevantElement(el)),
appState,
getAttribute,
)
: defaultValue) ??
defaultValue
);
) ??
(typeof defaultValue === "function"
? defaultValue(true)
: defaultValue);
} else {
ret =
typeof defaultValue === "function" ? defaultValue(false) : defaultValue;
}
}
return ret;
};
const offsetElementAfterFontResize = (
@ -247,6 +266,7 @@ export const actionChangeStrokeColor = register({
elements,
appState,
(element) => element.strokeColor,
true,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
@ -289,6 +309,7 @@ export const actionChangeBackgroundColor = register({
elements,
appState,
(element) => element.backgroundColor,
true,
appState.currentItemBackgroundColor,
)}
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
@ -307,7 +328,7 @@ export const actionChangeFillStyle = register({
trackEvent(
"element",
"changeFillStyle",
`${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
`${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
);
return {
elements: changeProperty(elements, appState, (el) =>
@ -338,23 +359,28 @@ export const actionChangeFillStyle = register({
} (${getShortcutKey("Alt-Click")})`,
icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
active: allElementsZigZag ? true : undefined,
testId: `fill-hachure`,
},
{
value: "cross-hatch",
text: t("labels.crossHatch"),
icon: FillCrossHatchIcon,
testId: `fill-cross-hatch`,
},
{
value: "solid",
text: t("labels.solid"),
icon: FillSolidIcon,
testId: `fill-solid`,
},
]}
value={getFormValue(
elements,
appState,
(element) => element.fillStyle,
appState.currentItemFillStyle,
(element) => element.hasOwnProperty("fillStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemFillStyle,
)}
onClick={(value, event) => {
const nextValue =
@ -393,26 +419,31 @@ export const actionChangeStrokeWidth = register({
group="stroke-width"
options={[
{
value: 1,
value: STROKE_WIDTH.thin,
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: 2,
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
},
{
value: 4,
value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
},
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeWidth,
appState.currentItemStrokeWidth,
(element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidth,
)}
onChange={(value) => updateData(value)}
/>
@ -461,7 +492,9 @@ export const actionChangeSloppiness = register({
elements,
appState,
(element) => element.roughness,
appState.currentItemRoughness,
(element) => element.hasOwnProperty("roughness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoughness,
)}
onChange={(value) => updateData(value)}
/>
@ -509,7 +542,9 @@ export const actionChangeStrokeStyle = register({
elements,
appState,
(element) => element.strokeStyle,
appState.currentItemStrokeStyle,
(element) => element.hasOwnProperty("strokeStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeStyle,
)}
onChange={(value) => updateData(value)}
/>
@ -549,6 +584,7 @@ export const actionChangeOpacity = register({
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
) ?? undefined
}
@ -607,7 +643,12 @@ export const actionChangeFontSize = register({
}
return null;
},
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
/>
@ -692,21 +733,25 @@ export const actionChangeFontFamily = register({
value: FontFamilyValues;
text: string;
icon: JSX.Element;
testId: string;
}[] = [
{
value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"),
icon: FreedrawIcon,
testId: "font-family-virgil",
},
{
value: FONT_FAMILY.Helvetica,
text: t("labels.normal"),
icon: FontFamilyNormalIcon,
testId: "font-family-normal",
},
{
value: FONT_FAMILY.Cascadia,
text: t("labels.code"),
icon: FontFamilyCodeIcon,
testId: "font-family-code",
},
];
@ -729,7 +774,12 @@ export const actionChangeFontFamily = register({
}
return null;
},
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
/>
@ -806,7 +856,10 @@ export const actionChangeTextAlign = register({
}
return null;
},
appState.currentItemTextAlign,
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
/>
@ -882,7 +935,9 @@ export const actionChangeVerticalAlign = register({
}
return null;
},
VERTICAL_ALIGN.MIDDLE,
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)}
onChange={(value) => updateData(value)}
/>
@ -947,9 +1002,9 @@ export const actionChangeRoundness = register({
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(canChangeRoundness(appState.activeTool.type) &&
appState.currentItemRoundness) ||
null,
(element) => element.hasOwnProperty("roundness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoundness,
)}
onChange={(value) => updateData(value)}
/>
@ -1043,6 +1098,7 @@ export const actionChangeArrowhead = register({
isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead
: appState.currentItemStartArrowhead,
true,
appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
@ -1089,6 +1145,7 @@ export const actionChangeArrowhead = register({
isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead
: appState.currentItemEndArrowhead,
true,
appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}

View File

@ -21,8 +21,10 @@ import {
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
isFrameElement,
isArrowElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
import { ExcalidrawTextElement } from "../element/types";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@ -99,16 +101,19 @@ export const actionPasteStyles = register({
if (isTextElement(newElement)) {
const fontSize =
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
(elementStylesToCopyFrom as ExcalidrawTextElement).fontSize ||
DEFAULT_FONT_SIZE;
const fontFamily =
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
(elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily ||
DEFAULT_FONT_FAMILY;
newElement = newElementWith(newElement, {
fontSize,
fontFamily,
textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
(elementStylesToCopyFrom as ExcalidrawTextElement).textAlign ||
DEFAULT_TEXT_ALIGN,
lineHeight:
elementStylesToCopyFrom.lineHeight ||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
getDefaultLineHeight(fontFamily),
});
let container = null;
@ -123,7 +128,10 @@ export const actionPasteStyles = register({
redrawTextBoundingBox(newElement, container);
}
if (newElement.type === "arrow") {
if (
newElement.type === "arrow" &&
isArrowElement(elementStylesToCopyFrom)
) {
newElement = newElementWith(newElement, {
startArrowhead: elementStylesToCopyFrom.startArrowhead,
endArrowhead: elementStylesToCopyFrom.endArrowhead,

View File

@ -15,6 +15,7 @@ export const actionToggleGridMode = register({
appState: {
...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
commitToHistory: false,
};

View File

@ -0,0 +1,28 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
export const actionToggleObjectsSnapMode = register({
name: "objectsSnapMode",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.objectsSnapModeEnabled,
},
perform(elements, appState) {
return {
appState: {
...appState,
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
},
commitToHistory: false,
};
},
checked: (appState) => appState.objectsSnapModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.objectsSnapModeEnabled === "undefined";
},
contextItemLabel: "buttons.objectsSnapMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
});

View File

@ -44,7 +44,6 @@ export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
export {
actionToggleCanvasMenu,
actionToggleEditMenu,
actionFullScreen,
actionShortcuts,
} from "./actionMenu";
@ -80,6 +79,7 @@ export {
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText";

View File

@ -29,7 +29,7 @@ const trackAction = (
trackEvent(
action.trackEvent.category,
action.trackEvent.action || action.name,
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
`${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
);
}
}
@ -119,10 +119,10 @@ export class ActionManager {
return true;
}
executeAction(
action: Action,
executeAction<T extends Action>(
action: T,
source: ActionSource = "api",
value: any = null,
value: Parameters<T["perform"]>[2] = null,
) {
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();

View File

@ -28,6 +28,7 @@ export type ShortcutName =
| "ungroup"
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "stats"
| "addToLibrary"
| "viewMode"
@ -74,6 +75,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")],
objectsSnapMode: [getShortcutKey("Alt+S")],
stats: [getShortcutKey("Alt+/")],
addToLibrary: [],
flipHorizontal: [getShortcutKey("Shift+H")],

View File

@ -51,6 +51,7 @@ export type ActionName =
| "pasteStyles"
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"

View File

@ -99,6 +99,12 @@ export const getDefaultAppState = (): Omit<
pendingImageElementId: null,
showHyperlinkPopup: false,
selectedLinearElement: null,
snapLines: [],
originSnapOffset: {
x: 0,
y: 0,
},
objectsSnapModeEnabled: false,
};
};
@ -206,6 +212,9 @@ const APP_STATE_STORAGE_CONF = (<
pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false },
snapLines: { browser: false, export: false, server: false },
originSnapOffset: { browser: false, export: false, server: false },
objectsSnapModeEnabled: { browser: true, export: false, server: false },
});
const _clearAppStateForStorage = <

View File

@ -1,27 +1,196 @@
import { parseClipboard } from "./clipboard";
import {
createPasteEvent,
parseClipboard,
serializeAsClipboardJSON,
} from "./clipboard";
import { API } from "./tests/helpers/api";
describe("Test parseClipboard", () => {
it("should parse valid json correctly", async () => {
let text = "123";
let clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
describe("parseClipboard()", () => {
it("should parse JSON as plaintext if not excalidraw-api/clipboard data", async () => {
let text;
let clipboardData;
// -------------------------------------------------------------------------
text = "123";
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
);
expect(clipboardData.text).toBe(text);
// -------------------------------------------------------------------------
text = "[123]";
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
);
expect(clipboardData.text).toBe(text);
clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
// -------------------------------------------------------------------------
text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
);
expect(clipboardData.text).toBe(text);
});
it("should parse valid excalidraw JSON if inside text/plain", async () => {
const rect = API.createElement({ type: "rectangle" });
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/plain": json,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
});
it("should parse valid excalidraw JSON if inside text/html", async () => {
const rect = API.createElement({ type: "rectangle" });
let json;
let clipboardData;
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": json,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<div> ${json}</div>`,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
});
it("should parse <image> `src` urls out of text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<img src="https://example.com/image.png" />`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "imageUrl",
value: "https://example.com/image.png",
},
]);
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "imageUrl",
value: "https://example.com/image.png",
},
{
type: "imageUrl",
value: "https://example.com/image2.png",
},
]);
});
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "text",
// trimmed
value: "hello",
},
{
type: "imageUrl",
value: "https://example.com/image.png",
},
{
type: "text",
value: "my friend!",
},
]);
});
it("should parse spreadsheet from either text/plain and text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<html>
<body>
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
</body>
</html>`,
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
});
});

View File

@ -3,14 +3,18 @@ import {
NonDeletedExcalidrawElement,
} from "./element/types";
import { BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import {
ALLOWED_PASTE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "./constants";
import { isInitializedImageElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { isPromiseLike, isTestEnv } from "./utils";
import { isMemberOf, isPromiseLike } from "./utils";
import { t } from "./i18n";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@ -18,17 +22,23 @@ type ElementsClipboard = {
files: BinaryFiles | undefined;
};
export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
export interface ClipboardData {
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
files?: BinaryFiles;
text?: string;
mixedContent?: PastedMixedContent;
errorMessage?: string;
programmaticAPI?: boolean;
}
let CLIPBOARD = "";
let PREFER_APP_CLIPBOARD = false;
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
type ParsedClipboardEvent =
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent };
export const probablySupportsClipboardReadText =
"clipboard" in navigator && "readText" in navigator.clipboard;
@ -58,10 +68,61 @@ const clipboardContainsElements = (
return false;
};
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
files: BinaryFiles | null,
) => {
export const createPasteEvent = ({
types,
files,
}: {
types?: { [key in AllowedPasteMimeTypes]?: string };
files?: File[];
}) => {
if (!types && !files) {
console.warn("createPasteEvent: no types or files provided");
}
const event = new ClipboardEvent("paste", {
clipboardData: new DataTransfer(),
});
if (types) {
for (const [type, value] of Object.entries(types)) {
try {
event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`);
}
} catch (error: any) {
throw new Error(error.message);
}
}
}
if (files) {
let idx = -1;
for (const file of files) {
idx++;
try {
event.clipboardData?.items.add(file);
if (event.clipboardData?.files[idx] !== file) {
throw new Error(
`Failed to set file "${file.name}" as clipboardData item`,
);
}
} catch (error: any) {
throw new Error(error.message);
}
}
}
return event;
};
export const serializeAsClipboardJSON = ({
elements,
files,
}: {
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}) => {
const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"),
);
@ -83,7 +144,7 @@ export const copyToClipboard = async (
);
}
// select binded text elements when copying
// select bound text elements when copying
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: elements.map((element) => {
@ -102,34 +163,20 @@ export const copyToClipboard = async (
}),
files: files ? _files : undefined,
};
const json = JSON.stringify(contents);
if (isTestEnv()) {
return json;
}
CLIPBOARD = json;
try {
PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json);
} catch (error: any) {
PREFER_APP_CLIPBOARD = true;
console.error(error);
}
return JSON.stringify(contents);
};
const getAppClipboard = (): Partial<ElementsClipboard> => {
if (!CLIPBOARD) {
return {};
}
try {
return JSON.parse(CLIPBOARD);
} catch (error: any) {
console.error(error);
return {};
}
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
files: BinaryFiles | null,
/** supply if available to make the operation more certain to succeed */
clipboardEvent?: ClipboardEvent | null,
) => {
await copyTextToSystemClipboard(
serializeAsClipboardJSON({ elements, files }),
clipboardEvent,
);
};
const parsePotentialSpreadsheet = (
@ -142,22 +189,137 @@ const parsePotentialSpreadsheet = (
return null;
};
/**
* Retrieves content from system clipboard (either from ClipboardEvent or
* via async clipboard API if supported)
*/
export const getSystemClipboard = async (
event: ClipboardEvent | null,
): Promise<string> => {
try {
const text = event
? event.clipboardData?.getData("text/plain")
: probablySupportsClipboardReadText &&
(await navigator.clipboard.readText());
/** internal, specific to parsing paste events. Do not reuse. */
function parseHTMLTree(el: ChildNode) {
let result: PastedMixedContent = [];
for (const node of el.childNodes) {
if (node.nodeType === 3) {
const text = node.textContent?.trim();
if (text) {
result.push({ type: "text", value: text });
}
} else if (node instanceof HTMLImageElement) {
const url = node.getAttribute("src");
if (url && url.startsWith("http")) {
result.push({ type: "imageUrl", value: url });
}
} else {
result = result.concat(parseHTMLTree(node));
}
}
return result;
}
return (text || "").trim();
const maybeParseHTMLPaste = (
event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData("text/html");
if (!html) {
return null;
}
try {
const doc = new DOMParser().parseFromString(html, "text/html");
const content = parseHTMLTree(doc.body);
if (content.length) {
return { type: "mixedContent", value: content };
}
} catch (error: any) {
console.error(`error in parseHTMLFromPaste: ${error.message}`);
}
return null;
};
export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
try {
if (navigator.clipboard?.readText) {
return { "text/plain": await navigator.clipboard?.readText() };
}
} catch (error: any) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
throw error;
}
}
let clipboardItems: ClipboardItems;
try {
clipboardItems = await navigator.clipboard?.read();
} catch (error: any) {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
}
throw error;
}
for (const item of clipboardItems) {
for (const type of item.types) {
if (!isMemberOf(ALLOWED_PASTE_MIME_TYPES, type)) {
continue;
}
try {
types[type] = await (await item.getType(type)).text();
} catch (error: any) {
console.warn(
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
);
}
}
}
if (Object.keys(types).length === 0) {
console.warn("No clipboard data found from clipboard.read().");
return types;
}
return types;
};
/**
* Parses "paste" ClipboardEvent.
*/
const parseClipboardEvent = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ParsedClipboardEvent> => {
try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) {
return {
type: "text",
value:
event.clipboardData?.getData("text/plain") ||
mixedContent.value
.map((item) => item.value)
.join("\n")
.trim(),
};
}
return mixedContent;
}
const text = event.clipboardData?.getData("text/plain");
return { type: "text", value: (text || "").trim() };
} catch {
return "";
return { type: "text", value: "" };
}
};
@ -165,34 +327,32 @@ export const getSystemClipboard = async (
* Attempts to parse clipboard. Prefers system clipboard.
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ClipboardData> => {
const systemClipboard = await getSystemClipboard(event);
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
// if system clipboard empty, couldn't be resolved, or contains previously
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
// elements
if (
!systemClipboard ||
(!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
) {
return getAppClipboard();
if (parsedEventData.type === "mixedContent") {
return {
mixedContent: parsedEventData.value,
};
}
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
const spreadsheetResult =
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
if (spreadsheetResult) {
return spreadsheetResult;
}
const appClipboardData = getAppClipboard();
try {
const systemClipboardData = JSON.parse(systemClipboard);
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
const spreadsheetResult =
!isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
if (spreadsheetResult) {
return spreadsheetResult;
}
} catch (error: any) {
console.error(error);
}
try {
const systemClipboardData = JSON.parse(parsedEventData.value);
const programmaticAPI =
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
if (clipboardContainsElements(systemClipboardData)) {
@ -205,18 +365,9 @@ export const parseClipboard = async (
programmaticAPI,
};
}
} catch (e) {}
// system clipboard doesn't contain excalidraw elements → return plaintext
// unless we set a flag to prefer in-app clipboard because browser didn't
// support storing to system clipboard on copy
return PREFER_APP_CLIPBOARD && appClipboardData.elements
? {
...appClipboardData,
text: isPlainPaste
? JSON.stringify(appClipboardData.elements, null, 2)
: undefined,
}
: { text: systemClipboard };
} catch {}
return { text: parsedEventData.value };
};
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
@ -249,28 +400,49 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
}
};
export const copyTextToSystemClipboard = async (text: string | null) => {
let copied = false;
export const copyTextToSystemClipboard = async (
text: string | null,
clipboardEvent?: ClipboardEvent | null,
) => {
// (1) first try using Async Clipboard API
if (probablySupportsClipboardWriteText) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
await navigator.clipboard.writeText(text || "");
copied = true;
return;
} catch (error: any) {
console.error(error);
}
}
// Note that execCommand doesn't allow copying empty strings, so if we're
// clearing clipboard using this API, we must copy at least an empty char
if (!copied && !copyTextViaExecCommand(text || " ")) {
throw new Error("couldn't copy");
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
try {
if (clipboardEvent) {
clipboardEvent.clipboardData?.setData("text/plain", text || "");
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
throw new Error("Failed to setData on clipboardEvent");
}
return;
}
} catch (error: any) {
console.error(error);
}
// (3) if that fails, use document.execCommand
if (!copyTextViaExecCommand(text)) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
}
};
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
const copyTextViaExecCommand = (text: string) => {
const copyTextViaExecCommand = (text: string | null) => {
// execCommand doesn't allow copying empty strings, so if we're
// clearing clipboard using this API, we must copy at least an empty char
if (!text) {
text = " ";
}
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const textarea = document.createElement("textarea");

View File

@ -2,13 +2,13 @@
.undo-redo-buttons {
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: 0 0 0 1px var(--color-surface-lowest);
}
.zoom-button,
.undo-redo-buttons button {
border: 1px solid var(--default-border-color) !important;
border-radius: 0 !important;
background-color: transparent !important;
background-color: var(--color-surface-low) !important;
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);

View File

@ -1,7 +1,7 @@
import React, { useState } from "react";
import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useDevice } from "../components/App";
import {
@ -11,21 +11,15 @@ import {
hasBackground,
hasStrokeStyle,
hasStrokeWidth,
hasText,
} from "../scene";
import { SHAPES } from "../shapes";
import { UIAppState, Zoom } from "../types";
import {
capitalizeString,
isTransparent,
updateActiveTool,
setCursorForShape,
} from "../utils";
import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks";
import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
import { Tooltip } from "./Tooltip";
@ -36,7 +30,13 @@ import {
import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
import {
EmbedIcon,
extraToolsIcon,
frameToolIcon,
mermaidLogoIcon,
laserPointerToolIcon,
} from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({
@ -66,7 +66,8 @@ export const SelectedShapeActions = ({
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons =
hasBackground(appState.activeTool.type) ||
(hasBackground(appState.activeTool.type) &&
!isTransparent(appState.currentItemBackgroundColor)) ||
targetElements.some(
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
@ -123,14 +124,15 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</>
)}
{(hasText(appState.activeTool.type) ||
targetElements.some((element) => hasText(element.type))) && (
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
{renderAction("changeFontSize")}
{renderAction("changeFontFamily")}
{suppportsHorizontalAlign(targetElements) &&
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements)) &&
renderAction("changeTextAlign")}
</>
)}
@ -200,8 +202,8 @@ export const SelectedShapeActions = ({
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{!device.isMobile && renderAction("duplicateSelection")}
{!device.isMobile && renderAction("deleteSelectedElements")}
{!device.editor.isMobile && renderAction("duplicateSelection")}
{!device.editor.isMobile && renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
@ -213,23 +215,33 @@ export const SelectedShapeActions = ({
};
export const ShapesSwitcher = ({
interactiveCanvas,
activeTool,
setAppState,
onImageAction,
appState,
app,
UIOptions,
}: {
interactiveCanvas: HTMLCanvasElement | null;
activeTool: UIAppState["activeTool"];
setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: UIAppState;
app: AppClassProperties;
UIOptions: AppProps["UIOptions"];
}) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const device = useDevice();
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable";
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
if (
UIOptions.tools?.[
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
] === false
) {
return null;
}
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
@ -251,155 +263,83 @@ export const ShapesSwitcher = ({
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
app.togglePenMode(true);
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
setAppState({
activeTool: nextActiveTool,
activeEmbeddable: null,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(interactiveCanvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
app.setActiveTool({
type: value,
insertOnCanvasDirectly: pointerType !== "mouse",
});
} else {
app.setActiveTool({ type: value });
}
}}
/>
);
})}
<div className="App-toolbar__divider" />
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className={clsx("App-toolbar__extra-tools-trigger", {
"App-toolbar__extra-tools-trigger--selected":
frameToolSelected ||
embeddableToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
// on top of it
(laserToolSelected && !app.props.isCollaborating),
})}
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "frame" })}
icon={frameToolIcon}
checked={activeTool.type === "frame"}
name="editor-current-shape"
title={`${capitalizeString(
t("toolBar.frame"),
)}${KEYS.F.toLocaleUpperCase()}`}
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
aria-label={capitalizeString(t("toolBar.frame"))}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "frame", "ui");
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}}
/>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
selected={frameToolSelected}
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "embeddable" })}
icon={EmbedIcon}
checked={activeTool.type === "embeddable"}
name="editor-current-shape"
title={capitalizeString(t("toolBar.embeddable"))}
aria-label={capitalizeString(t("toolBar.embeddable"))}
data-testid={`toolbar-embeddable`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "embeddable", "ui");
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}}
/>
</>
) : (
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className="App-toolbar__extra-tools-trigger"
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "laser" })}
icon={laserPointerToolIcon}
data-testid="toolbar-laser"
selected={laserToolSelected}
shortcut={KEYS.K.toLocaleUpperCase()}
>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
{t("toolBar.laser")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setOpenDialog("mermaid")}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -98,7 +98,7 @@ export const ColorInput = ({
}}
/>
{/* TODO reenable on mobile with a better UX */}
{!device.isMobile && (
{!device.editor.isMobile && (
<>
<div
style={{

View File

@ -80,7 +80,7 @@ const ColorPickerPopupContent = ({
);
const { container } = useExcalidrawContainer();
const { isMobile, isLandscape } = useDevice();
const device = useDevice();
const colorInputJSX = (
<div>
@ -136,8 +136,16 @@ const ColorPickerPopupContent = ({
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={isMobile && !isLandscape ? "bottom" : "right"}
align={isMobile && !isLandscape ? "center" : "start"}
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16}
sideOffset={20}
style={{

View File

@ -55,6 +55,7 @@ export const TopPicks = ({
type="button"
title={color}
onClick={() => onChange(color)}
data-testid={`color-top-pick-${color}`}
>
<div className="color-picker__button-outline" />
</button>

View File

@ -9,11 +9,7 @@ import {
} from "../actions/shortcuts";
import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager";
import {
useExcalidrawAppState,
useExcalidrawElements,
useExcalidrawSetAppState,
} from "./App";
import { useExcalidrawAppState, useExcalidrawElements } from "./App";
import React from "react";
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
@ -25,14 +21,14 @@ type ContextMenuProps = {
items: ContextMenuItems;
top: number;
left: number;
onClose: (callback?: () => void) => void;
};
export const CONTEXT_MENU_SEPARATOR = "separator";
export const ContextMenu = React.memo(
({ actionManager, items, top, left }: ContextMenuProps) => {
({ actionManager, items, top, left, onClose }: ContextMenuProps) => {
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
@ -54,7 +50,9 @@ export const ContextMenu = React.memo(
return (
<Popover
onCloseRequest={() => setAppState({ contextMenu: null })}
onCloseRequest={() => {
onClose();
}}
top={top}
left={left}
fitInViewport={true}
@ -102,7 +100,7 @@ export const ContextMenu = React.memo(
// we need update state before executing the action in case
// the action uses the appState it's being passed (that still
// contains a defined contextMenu) to return the next state.
setAppState({ contextMenu: null }, () => {
onClose(() => {
actionManager.executeAction(item, "contextMenu");
});
}}

View File

@ -33,14 +33,16 @@
color: var(--color-gray-40);
}
@include isMobile {
top: 1.25rem;
right: 1.25rem;
}
svg {
width: 1.5rem;
height: 1.5rem;
}
}
.Dialog--fullscreen {
.Dialog__close {
top: 1.25rem;
right: 1.25rem;
}
}
}

View File

@ -49,7 +49,7 @@ export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
const device = useDevice();
const isFullscreen = useDevice().viewport.isMobile;
useEffect(() => {
if (!islandNode) {
@ -101,7 +101,9 @@ export const Dialog = (props: DialogProps) => {
return (
<Modal
className={clsx("Dialog", props.className)}
className={clsx("Dialog", props.className, {
"Dialog--fullscreen": isFullscreen,
})}
labelledBy="dialog-title"
maxWidth={getDialogSize(props.size)}
onCloseRequest={onClose}
@ -119,7 +121,7 @@ export const Dialog = (props: DialogProps) => {
title={t("buttons.close")}
aria-label={t("buttons.close")}
>
{device.isMobile ? back : CloseIcon}
{isFullscreen ? back : CloseIcon}
</button>
<div className="Dialog__content">{props.children}</div>
</Island>

View File

@ -12,32 +12,32 @@
&--color-primary {
&.ExcButton--variant-filled {
--text-color: var(--input-bg-color);
--text-color: var(--color-surface-lowest);
--back-color: var(--color-primary);
&:hover {
--back-color: var(--color-primary-darker);
--back-color: var(--color-brand-hover);
}
&:active {
--back-color: var(--color-primary-darkest);
--back-color: var(--color-brand-active);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-primary);
--border-color: var(--color-primary);
--back-color: var(--input-bg-color);
--border-color: var(--color-border-outline);
--back-color: transparent;
&:hover {
--text-color: var(--color-primary-darker);
--border-color: var(--color-primary-darker);
--text-color: var(--color-brand-hover);
--border-color: var(--color-brand-hover);
}
&:active {
--text-color: var(--color-primary-darkest);
--border-color: var(--color-primary-darkest);
--text-color: var(--color-brand-active);
--border-color: var(--color-brand-active);
}
}
}

View File

@ -19,20 +19,35 @@
}
&__btn {
--background: var(--color-surface-mid);
display: flex;
column-gap: 0.5rem;
align-items: center;
border: 1px solid var(--default-border-color);
background-color: var(--background);
padding: 0.625rem 1rem;
border: 1px solid var(--background);
border-radius: var(--border-radius-lg);
color: var(--text-primary-color);
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.4px;
@at-root .excalidraw.theme--dark#{&} {
--background: var(--color-surface-high);
&:hover {
--background: #363541;
}
}
&:hover {
--background: var(--color-surface-high);
text-decoration: none;
}
&:active {
border-color: var(--color-primary);
}
}
&__link-icon {

View File

@ -165,6 +165,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[KEYS.E, KEYS["0"]]}
/>
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
<Shortcut
label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
@ -253,11 +254,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("helpDialog.movePageLeftRight")}
shortcuts={["Shift+PgUp/PgDn"]}
/>
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
<Shortcut
label={t("buttons.zenMode")}
shortcuts={[getShortcutKey("Alt+Z")]}
/>
<Shortcut
label={t("buttons.objectsSnapMode")}
shortcuts={[getShortcutKey("Alt+S")]}
/>
<Shortcut
label={t("labels.showGrid")}
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}

View File

@ -22,7 +22,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (appState.openSidebar && !device.canDeviceFitSidebar) {
if (appState.openSidebar && !device.editor.canFitSidebar) {
return null;
}

View File

@ -22,7 +22,7 @@ import { canvasToBlob } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../packages/utils";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
@ -34,6 +34,8 @@ import { Tooltip } from "./Tooltip";
import "./ImageExportDialog.scss";
import { useAppProps } from "./App";
import { FilledButton } from "./FilledButton";
import { cloneJSON } from "../utils";
import { prepareElementsForExport } from "../data";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@ -51,44 +53,47 @@ export const ErrorCanvasPreview = () => {
};
type ImageExportModalProps = {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
appStateSnapshot: Readonly<UIAppState>;
elementsSnapshot: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
};
const ImageExportModal = ({
appState,
elements,
appStateSnapshot,
elementsSnapshot,
files,
actionManager,
onExportImage,
}: ImageExportModalProps) => {
const hasSelection = isSomeElementSelected(
elementsSnapshot,
appStateSnapshot,
);
const appProps = useAppProps();
const [projectName, setProjectName] = useState(appState.name);
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const [projectName, setProjectName] = useState(appStateSnapshot.name);
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
const [exportWithBackground, setExportWithBackground] = useState(
appState.exportBackground,
appStateSnapshot.exportBackground,
);
const [exportDarkMode, setExportDarkMode] = useState(
appState.exportWithDarkMode,
appStateSnapshot.exportWithDarkMode,
);
const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
const [exportScale, setExportScale] = useState(appState.exportScale);
const [embedScene, setEmbedScene] = useState(
appStateSnapshot.exportEmbedScene,
);
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected
? getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
})
: elements;
const { exportedElements, exportingFrame } = prepareElementsForExport(
elementsSnapshot,
appStateSnapshot,
exportSelectionOnly,
);
useEffect(() => {
const previewNode = previewRef.current;
@ -102,10 +107,18 @@ const ImageExportModal = ({
}
exportToCanvas({
elements: exportedElements,
appState,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportWithDarkMode: exportDarkMode,
exportScale,
exportEmbedScene: embedScene,
},
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
})
.then((canvas) => {
setRenderError(null);
@ -119,7 +132,17 @@ const ImageExportModal = ({
console.error(error);
setRenderError(error);
});
}, [appState, files, exportedElements]);
}, [
appStateSnapshot,
files,
exportedElements,
exportingFrame,
projectName,
exportWithBackground,
exportDarkMode,
exportScale,
embedScene,
]);
return (
<div className="ImageExportModal">
@ -136,7 +159,8 @@ const ImageExportModal = ({
value={projectName}
style={{ width: "30ch" }}
disabled={
typeof appProps.name !== "undefined" || appState.viewModeEnabled
typeof appProps.name !== "undefined" ||
appStateSnapshot.viewModeEnabled
}
onChange={(event) => {
setProjectName(event.target.value);
@ -152,16 +176,16 @@ const ImageExportModal = ({
</div>
<div className="ImageExportModal__settings">
<h3>{t("imageExportDialog.header")}</h3>
{someElementIsSelected && (
{hasSelection && (
<ExportSetting
label={t("imageExportDialog.label.onlySelected")}
name="exportOnlySelected"
>
<Switch
name="exportOnlySelected"
checked={exportSelected}
checked={exportSelectionOnly}
onChange={(checked) => {
setExportSelected(checked);
setExportSelectionOnly(checked);
}}
/>
</ExportSetting>
@ -243,7 +267,9 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToPng")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, {
exportingFrame,
})
}
startIcon={downloadIcon}
>
@ -253,7 +279,9 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToSvg")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, {
exportingFrame,
})
}
startIcon={downloadIcon}
>
@ -264,7 +292,9 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
exportingFrame,
})
}
startIcon={copyIcon}
>
@ -325,15 +355,20 @@ export const ImageExportDialog = ({
onExportImage: AppClassProperties["onExportImage"];
onCloseRequest: () => void;
}) => {
if (appState.openDialog !== "imageExport") {
return null;
}
// we need to take a snapshot so that the exported state can't be modified
// while the dialog is open
const [{ appStateSnapshot, elementsSnapshot }] = useState(() => {
return {
appStateSnapshot: cloneJSON(appState),
elementsSnapshot: cloneJSON(elements),
};
});
return (
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
<ImageExportModal
elements={elements}
appState={appState}
elementsSnapshot={elementsSnapshot}
appStateSnapshot={appStateSnapshot}
files={files}
actionManager={actionManager}
onExportImage={onExportImage}

View File

@ -23,12 +23,15 @@ export type ExportCB = (
const JSONExportModal = ({
elements,
appState,
setAppState,
files,
actionManager,
exportOpts,
canvas,
onCloseRequest,
}: {
appState: UIAppState;
setAppState: React.Component<any, UIAppState>["setState"];
files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionManager;
@ -72,9 +75,14 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={() => {
onExportToBackend(elements, appState, files, canvas);
trackEvent("export", "link", `ui (${getFrame()})`);
onClick={async () => {
try {
trackEvent("export", "link", `ui (${getFrame()})`);
await onExportToBackend(elements, appState, files, canvas);
onCloseRequest();
} catch (error: any) {
setAppState({ errorMessage: error.message });
}
}}
/>
</Card>
@ -114,6 +122,7 @@ export const JSONExportDialog = ({
<JSONExportModal
elements={elements}
appState={appState}
setAppState={setAppState}
files={files}
actionManager={actionManager}
onCloseRequest={handleClose}

View File

@ -0,0 +1,309 @@
import { LaserPointer } from "@excalidraw/laser-pointer";
import { sceneCoordsToViewportCoords } from "../../utils";
import App from "../App";
import { getClientColor } from "../../clients";
// decay time in milliseconds
const DECAY_TIME = 1000;
// length of line in points before it starts decaying
const DECAY_LENGTH = 50;
const average = (a: number, b: number) => (a + b) / 2;
function getSvgPathFromStroke(points: number[][], closed = true) {
const len = points.length;
if (len < 4) {
return ``;
}
let a = points[0];
let b = points[1];
const c = points[2];
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(
2,
)},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average(
b[1],
c[1],
).toFixed(2)} T`;
for (let i = 2, max = len - 1; i < max; i++) {
a = points[i];
b = points[i + 1];
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(
2,
)} `;
}
if (closed) {
result += "Z";
}
return result;
}
declare global {
interface Window {
LPM: LaserPathManager;
}
}
function easeOutCubic(t: number) {
return 1 - Math.pow(1 - t, 3);
}
function instantiateCollabolatorState(): CollabolatorState {
return {
currentPath: undefined,
finishedPaths: [],
lastPoint: [-10000, -10000],
svg: document.createElementNS("http://www.w3.org/2000/svg", "path"),
};
}
function instantiatePath() {
LaserPointer.constants.cornerDetectionMaxAngle = 70;
return new LaserPointer({
simplify: 0,
streamline: 0.4,
sizeMapping: (c) => {
const pt = DECAY_TIME;
const pl = DECAY_LENGTH;
const t = Math.max(0, 1 - (performance.now() - c.pressure) / pt);
const l = (pl - Math.min(pl, c.totalLength - c.currentIndex)) / pl;
return Math.min(easeOutCubic(l), easeOutCubic(t));
},
});
}
type CollabolatorState = {
currentPath: LaserPointer | undefined;
finishedPaths: LaserPointer[];
lastPoint: [number, number];
svg: SVGPathElement;
};
export class LaserPathManager {
private ownState: CollabolatorState;
private collaboratorsState: Map<string, CollabolatorState> = new Map();
private rafId: number | undefined;
private isDrawing = false;
private container: SVGSVGElement | undefined;
constructor(private app: App) {
this.ownState = instantiateCollabolatorState();
}
destroy() {
this.stop();
this.isDrawing = false;
this.ownState = instantiateCollabolatorState();
this.collaboratorsState = new Map();
}
startPath(x: number, y: number) {
this.ownState.currentPath = instantiatePath();
this.ownState.currentPath.addPoint([x, y, performance.now()]);
this.updatePath(this.ownState);
}
addPointToPath(x: number, y: number) {
if (this.ownState.currentPath) {
this.ownState.currentPath?.addPoint([x, y, performance.now()]);
this.updatePath(this.ownState);
}
}
endPath() {
if (this.ownState.currentPath) {
this.ownState.currentPath.close();
this.ownState.finishedPaths.push(this.ownState.currentPath);
this.updatePath(this.ownState);
}
}
private updatePath(state: CollabolatorState) {
this.isDrawing = true;
if (!this.isRunning) {
this.start();
}
}
private isRunning = false;
start(svg?: SVGSVGElement) {
if (svg) {
this.container = svg;
this.container.appendChild(this.ownState.svg);
}
this.stop();
this.isRunning = true;
this.loop();
}
stop() {
this.isRunning = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
}
this.rafId = undefined;
}
loop() {
this.rafId = requestAnimationFrame(this.loop.bind(this));
this.updateCollabolatorsState();
if (this.isDrawing) {
this.update();
} else {
this.isRunning = false;
}
}
draw(path: LaserPointer) {
const stroke = path
.getStrokeOutline(path.options.size / this.app.state.zoom.value)
.map(([x, y]) => {
const result = sceneCoordsToViewportCoords(
{ sceneX: x, sceneY: y },
this.app.state,
);
return [result.x, result.y];
});
return getSvgPathFromStroke(stroke, true);
}
updateCollabolatorsState() {
if (!this.container || !this.app.state.collaborators.size) {
return;
}
for (const [key, collabolator] of this.app.state.collaborators.entries()) {
if (!this.collaboratorsState.has(key)) {
const state = instantiateCollabolatorState();
this.container.appendChild(state.svg);
this.collaboratorsState.set(key, state);
this.updatePath(state);
}
const state = this.collaboratorsState.get(key)!;
if (collabolator.pointer && collabolator.pointer.tool === "laser") {
if (collabolator.button === "down" && state.currentPath === undefined) {
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
state.currentPath = instantiatePath();
state.currentPath.addPoint([
collabolator.pointer.x,
collabolator.pointer.y,
performance.now(),
]);
this.updatePath(state);
}
if (collabolator.button === "down" && state.currentPath !== undefined) {
if (
collabolator.pointer.x !== state.lastPoint[0] ||
collabolator.pointer.y !== state.lastPoint[1]
) {
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
state.currentPath.addPoint([
collabolator.pointer.x,
collabolator.pointer.y,
performance.now(),
]);
this.updatePath(state);
}
}
if (collabolator.button === "up" && state.currentPath !== undefined) {
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
state.currentPath.addPoint([
collabolator.pointer.x,
collabolator.pointer.y,
performance.now(),
]);
state.currentPath.close();
state.finishedPaths.push(state.currentPath);
state.currentPath = undefined;
this.updatePath(state);
}
}
}
}
update() {
if (!this.container) {
return;
}
let somePathsExist = false;
for (const [key, state] of this.collaboratorsState.entries()) {
if (!this.app.state.collaborators.has(key)) {
state.svg.remove();
this.collaboratorsState.delete(key);
continue;
}
state.finishedPaths = state.finishedPaths.filter((path) => {
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
});
let paths = state.finishedPaths.map((path) => this.draw(path)).join(" ");
if (state.currentPath) {
paths += ` ${this.draw(state.currentPath)}`;
}
if (paths.trim()) {
somePathsExist = true;
}
state.svg.setAttribute("d", paths);
state.svg.setAttribute("fill", getClientColor(key));
}
this.ownState.finishedPaths = this.ownState.finishedPaths.filter((path) => {
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
});
let paths = this.ownState.finishedPaths
.map((path) => this.draw(path))
.join(" ");
if (this.ownState.currentPath) {
paths += ` ${this.draw(this.ownState.currentPath)}`;
}
paths = paths.trim();
if (paths) {
somePathsExist = true;
}
this.ownState.svg.setAttribute("d", paths);
this.ownState.svg.setAttribute("fill", "red");
if (!somePathsExist) {
this.isDrawing = false;
}
}
}

View File

@ -0,0 +1,41 @@
import "../ToolIcon.scss";
import clsx from "clsx";
import { ToolButtonSize } from "../ToolButton";
import { laserPointerToolIcon } from "../icons";
type LaserPointerIconProps = {
title?: string;
name?: string;
checked: boolean;
onChange?(): void;
isMobile?: boolean;
};
const DEFAULT_SIZE: ToolButtonSize = "small";
export const LaserPointerButton = (props: LaserPointerIconProps) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon__LaserPointer",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
},
)}
title={`${props.title}`}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name={props.name}
onChange={props.onChange}
checked={props.checked}
aria-label={props.title}
data-testid="toolbar-LaserPointer"
/>
<div className="ToolIcon__icon">{laserPointerToolIcon}</div>
</label>
);
};

View File

@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
import { LaserPathManager } from "./LaserPathManager";
import "./LaserToolOverlay.scss";
type LaserToolOverlayProps = {
manager: LaserPathManager;
};
export const LaserToolOverlay = ({ manager }: LaserToolOverlayProps) => {
const svgRef = useRef<SVGSVGElement | null>(null);
useEffect(() => {
if (svgRef.current) {
manager.start(svgRef.current);
}
return () => {
manager.stop();
};
}, [manager]);
return (
<div className="LaserToolOverlay">
<svg ref={svgRef} className="LaserToolOverlayCanvas" />
</div>
);
};

View File

@ -0,0 +1,20 @@
.excalidraw {
.LaserToolOverlay {
pointer-events: none;
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 2;
.LaserToolOverlayCanvas {
image-rendering: auto;
overflow: visible;
position: absolute;
top: 0;
left: 0;
}
}
}

View File

@ -55,28 +55,28 @@ import "./Toolbar.scss";
import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
interface LayerUIProps {
actionManager: ActionManager;
appState: UIAppState;
files: BinaryFiles;
canvas: HTMLCanvasElement;
interactiveCanvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: () => void;
onPenModeToggle: AppClassProperties["togglePenMode"];
showExitZenModeBtn: boolean;
langCode: Language["code"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
UIOptions: AppProps["UIOptions"];
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
onExportImage: AppClassProperties["onExportImage"];
renderWelcomeScreen: boolean;
children?: React.ReactNode;
app: AppClassProperties;
isCollaborating: boolean;
}
const DefaultMainMenu: React.FC<{
@ -121,7 +121,6 @@ const LayerUI = ({
setAppState,
elements,
canvas,
interactiveCanvas,
onLockToggle,
onHandToolToggle,
onPenModeToggle,
@ -129,11 +128,11 @@ const LayerUI = ({
renderTopRightUI,
renderCustomStats,
UIOptions,
onImageAction,
onExportImage,
renderWelcomeScreen,
children,
app,
isCollaborating,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@ -162,7 +161,10 @@ const LayerUI = ({
};
const renderImageExportDialog = () => {
if (!UIOptions.canvasActions.saveAsImage) {
if (
!UIOptions.canvasActions.saveAsImage ||
appState.openDialog !== "imageExport"
) {
return null;
}
@ -247,7 +249,7 @@ const LayerUI = ({
>
<HintViewer
appState={appState}
isMobile={device.isMobile}
isMobile={device.editor.isMobile}
device={device}
app={app}
/>
@ -256,7 +258,7 @@ const LayerUI = ({
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={onPenModeToggle}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
@ -277,17 +279,30 @@ const LayerUI = ({
<ShapesSwitcher
appState={appState}
interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
UIOptions={UIOptions}
app={app}
/>
</Stack.Row>
</Island>
{isCollaborating && (
<Island
style={{
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={appState.activeTool.type === "laser"}
onChange={() =>
app.setActiveTool({ type: "laser" })
}
isMobile
/>
</Island>
)}
</Stack.Row>
</Stack.Col>
</div>
@ -303,7 +318,7 @@ const LayerUI = ({
)}
>
<UserList collaborators={appState.collaborators} />
{renderTopRightUI?.(device.isMobile, appState)}
{renderTopRightUI?.(device.editor.isMobile, appState)}
{!appState.viewModeEnabled &&
// hide button when sidebar docked
(!isSidebarDocked ||
@ -324,7 +339,7 @@ const LayerUI = ({
trackEvent(
"sidebar",
`toggleDock (${docked ? "dock" : "undock"})`,
`(${device.isMobile ? "mobile" : "desktop"})`,
`(${device.editor.isMobile ? "mobile" : "desktop"})`,
);
}}
/>
@ -352,7 +367,7 @@ const LayerUI = ({
trackEvent(
"sidebar",
`${DEFAULT_SIDEBAR.name} (open)`,
`button (${device.isMobile ? "mobile" : "desktop"})`,
`button (${device.editor.isMobile ? "mobile" : "desktop"})`,
);
}
}}
@ -369,7 +384,7 @@ const LayerUI = ({
{appState.errorMessage}
</ErrorDialog>
)}
{eyeDropperState && !device.isMobile && (
{eyeDropperState && !device.editor.isMobile && (
<EyeDropper
colorPickerType={eyeDropperState.colorPickerType}
onCancel={() => {
@ -439,7 +454,7 @@ const LayerUI = ({
}
/>
)}
{device.isMobile && (
{device.editor.isMobile && (
<MobileMenu
app={app}
appState={appState}
@ -451,23 +466,22 @@ const LayerUI = ({
onLockToggle={onLockToggle}
onHandToolToggle={onHandToolToggle}
onPenModeToggle={onPenModeToggle}
interactiveCanvas={interactiveCanvas}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderSidebars={renderSidebars}
device={device}
renderWelcomeScreen={renderWelcomeScreen}
UIOptions={UIOptions}
/>
)}
{!device.isMobile && (
{!device.editor.isMobile && (
<>
<div
className="layer-ui__wrapper"
style={
appState.openSidebar &&
isSidebarDocked &&
device.canDeviceFitSidebar
device.editor.canFitSidebar
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
: {}
}
@ -539,18 +553,8 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
return false;
}
const {
canvas: _pC,
interactiveCanvas: _pIC,
appState: prevAppState,
...prev
} = prevProps;
const {
canvas: _nC,
interactiveCanvas: _nIC,
appState: nextAppState,
...next
} = nextProps;
const { canvas: _pC, appState: prevAppState, ...prev } = prevProps;
const { canvas: _nC, appState: nextAppState, ...next } = nextProps;
return (
isShallowEqual(

View File

@ -99,10 +99,10 @@
font-size: 0.75rem;
&:hover {
background-color: var(--color-primary-darker);
background-color: var(--color-brand-hover);
}
&:active {
background-color: var(--color-primary-darkest);
background-color: var(--color-brand-active);
}
}

View File

@ -47,7 +47,7 @@ export const LibraryUnit = memo(
}, [svg]);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().isMobile;
const isMobile = useDevice().editor.isMobile;
const adder = isPending && (
<div className="library-unit__adder">{PlusIcon}</div>
);

View File

@ -0,0 +1,221 @@
@import "../css/variables.module";
$verticalBreakpoint: 860px;
.excalidraw {
.dialog-mermaid {
&-title {
margin-bottom: 5px;
margin-top: 2px;
}
&-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
}
.Modal__content .Island {
box-shadow: none;
}
@at-root .excalidraw:not(.excalidraw--mobile)#{&} {
padding: 1.25rem;
.Modal__content {
height: 100%;
max-height: 750px;
@media screen and (max-width: $verticalBreakpoint) {
height: auto;
// When vertical, we want the height to span whole viewport.
// This is also important for the children not to overflow the
// modal/viewport (for some reason).
max-height: 100%;
}
.Island {
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
.Dialog__content {
display: flex;
flex: 1 1 auto;
}
}
}
}
}
.dialog-mermaid-body {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto;
height: 100%;
column-gap: 4rem;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
}
.dialog-mermaid-panels {
display: grid;
width: 100%;
grid-template-columns: 1fr 1fr;
justify-content: space-between;
gap: 4rem;
grid-row: 1;
grid-column: 1 / 3;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
margin-bottom: 4px;
margin-left: 4px;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 4px;
}
}
&-text {
display: flex;
flex-direction: column;
textarea {
width: 20rem;
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
height: 10rem;
}
}
}
&-preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
// acts as min-height
height: 200px;
flex-grow: 1;
position: relative;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
@media screen and (max-width: $verticalBreakpoint) {
// acts as min-height
height: 400px;
width: auto;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
&-preview-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
}
&-preview {
display: flex;
flex-direction: column;
}
.mermaid-error {
color: red;
font-weight: 800;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute;
z-index: 10;
p {
font-weight: 500;
font-family: Cascadia;
text-align: left;
white-space: pre-wrap;
font-size: 0.875rem;
padding: 0 10px;
}
}
}
.dialog-mermaid-buttons {
grid-column: 2;
.dialog-mermaid-insert {
&.excalidraw-button {
font-family: "Assistant";
font-weight: 600;
height: 2.5rem;
margin-top: 1em;
margin-bottom: 0.3em;
width: 7.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
}

View File

@ -0,0 +1,243 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../types";
import { useApp } from "./App";
import { Button } from "./Button";
import { Dialog } from "./Dialog";
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../constants";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../packages/excalidraw/index";
import { NonDeletedExcalidrawElement } from "../element/types";
import { canvasToBlob } from "../data/blob";
import { ArrowRightIcon } from "./icons";
import Spinner from "./Spinner";
import "./MermaidToExcalidraw.scss";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { t } from "../i18n";
import Trans from "./Trans";
const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const saveMermaidDataToStorage = (data: string) => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importMermaidDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
if (data) {
return data;
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const ErrorComp = ({ error }: { error: string }) => {
return (
<div data-testid="mermaid-error" className="mermaid-error">
Error! <p>{error}</p>
</div>
);
};
const MermaidToExcalidraw = () => {
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = useState<{
loaded: boolean;
api: {
parseMermaidToExcalidraw: (
defination: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
} | null;
}>({ loaded: false, api: null });
const [text, setText] = useState("");
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState(null);
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
const resetPreview = () => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
useEffect(() => {
const loadMermaidToExcalidrawLib = async () => {
const api = await import(
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
);
setMermaidToExcalidrawLib({ loaded: true, api });
};
loadMermaidToExcalidrawLib();
}, []);
useEffect(() => {
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
setText(data);
}, []);
useEffect(() => {
const renderExcalidrawPreview = async () => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (
!mermaidToExcalidrawLib.loaded ||
!canvasNode ||
!parent ||
!mermaidToExcalidrawLib.api
) {
return;
}
if (!deferredText) {
resetPreview();
return;
}
try {
const { elements, files } =
await mermaidToExcalidrawLib.api.parseMermaidToExcalidraw(
deferredText,
{
fontSize: DEFAULT_FONT_SIZE,
},
);
setError(null);
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
}),
files,
};
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (e: any) {
parent.style.background = "var(--default-bg-color)";
if (deferredText) {
setError(e.message);
}
}
};
renderExcalidrawPreview();
}, [deferredText, mermaidToExcalidrawLib]);
const onClose = () => {
app.setOpenDialog(null);
saveMermaidDataToStorage(text);
};
const onSelect = () => {
const { elements: newElements, files } = data.current;
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
onClose();
};
return (
<Dialog
className="dialog-mermaid"
onCloseRequest={onClose}
size={1200}
title={
<>
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
<span className="dialog-mermaid-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
{el}
</a>
)}
/>
<br />
</span>
</>
}
>
<div className="dialog-mermaid-body">
<div className="dialog-mermaid-panels">
<div className="dialog-mermaid-panels-text">
<label>{t("mermaid.syntax")}</label>
<textarea
onChange={(event) => setText(event.target.value)}
value={text}
/>
</div>
<div className="dialog-mermaid-panels-preview">
<label>{t("mermaid.preview")}</label>
<div className="dialog-mermaid-panels-preview-wrapper">
{error && <ErrorComp error={error} />}
{mermaidToExcalidrawLib.loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="dialog-mermaid-panels-preview-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
</div>
</div>
<div className="dialog-mermaid-buttons">
<Button className="dialog-mermaid-insert" onSelect={onSelect}>
{t("mermaid.button")}
<span>{ArrowRightIcon}</span>
</Button>
</div>
</div>
</Dialog>
);
};
export default MermaidToExcalidraw;

View File

@ -1,6 +1,7 @@
import React from "react";
import {
AppClassProperties,
AppProps,
AppState,
Device,
ExcalidrawProps,
@ -35,10 +36,8 @@ type MobileMenuProps = {
elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: () => void;
interactiveCanvas: HTMLCanvasElement | null;
onPenModeToggle: AppClassProperties["togglePenMode"];
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: (
isMobile: boolean,
appState: UIAppState,
@ -47,6 +46,7 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean;
UIOptions: AppProps["UIOptions"];
app: AppClassProperties;
};
@ -58,13 +58,13 @@ export const MobileMenu = ({
onLockToggle,
onHandToolToggle,
onPenModeToggle,
interactiveCanvas,
onImageAction,
renderTopRightUI,
renderCustomStats,
renderSidebars,
device,
renderWelcomeScreen,
UIOptions,
app,
}: MobileMenuProps) => {
const {
@ -85,14 +85,9 @@ export const MobileMenu = ({
<Stack.Row gap={1}>
<ShapesSwitcher
appState={appState}
interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
UIOptions={UIOptions}
app={app}
/>
</Stack.Row>
</Island>
@ -103,7 +98,7 @@ export const MobileMenu = ({
)}
<PenModeButton
checked={appState.penMode}
onChange={onPenModeToggle}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}

View File

@ -59,12 +59,6 @@
&:focus {
outline: none;
}
@include isMobile {
max-width: 100%;
border: 0;
border-radius: 0;
}
}
@keyframes Modal__background__fade-in {
@ -105,7 +99,7 @@
}
}
@include isMobile {
.Dialog--fullscreen {
.Modal {
padding: 0;
}
@ -116,6 +110,9 @@
left: 0;
right: 0;
bottom: 0;
max-width: 100%;
border: 0;
border-radius: 0;
}
}
}

View File

@ -1,27 +1,18 @@
@import "../css/variables.module";
.excalidraw {
--RadioGroup-background: #ffffff;
--RadioGroup-border: var(--color-gray-30);
--RadioGroup-background: var(--island-bg-color);
--RadioGroup-border: var(--color-surface-high);
--RadioGroup-choice-color-off: var(--color-primary);
--RadioGroup-choice-color-off-hover: var(--color-primary-darkest);
--RadioGroup-choice-background-off: white;
--RadioGroup-choice-background-off-active: var(--color-gray-20);
--RadioGroup-choice-color-off-hover: var(--color-brand-hover);
--RadioGroup-choice-background-off: var(--island-bg-color);
--RadioGroup-choice-background-off-active: var(--color-surface-high);
--RadioGroup-choice-color-on: white;
--RadioGroup-choice-color-on: var(--color-surface-lowest);
--RadioGroup-choice-background-on: var(--color-primary);
--RadioGroup-choice-background-on-hover: var(--color-primary-darker);
--RadioGroup-choice-background-on-active: var(--color-primary-darkest);
&.theme--dark {
--RadioGroup-background: var(--color-gray-85);
--RadioGroup-border: var(--color-gray-70);
--RadioGroup-choice-background-off: var(--color-gray-85);
--RadioGroup-choice-background-off-active: var(--color-gray-70);
--RadioGroup-choice-color-on: var(--color-gray-85);
}
--RadioGroup-choice-background-on-hover: var(--color-brand-hover);
--RadioGroup-choice-background-on-active: var(--color-brand-active);
.RadioGroup {
box-sizing: border-box;

View File

@ -113,11 +113,11 @@ export const SidebarInner = forwardRef(
if ((event.target as Element).closest(".sidebar-trigger")) {
return;
}
if (!docked || !device.canDeviceFitSidebar) {
if (!docked || !device.editor.canFitSidebar) {
closeLibrary();
}
},
[closeLibrary, docked, device.canDeviceFitSidebar],
[closeLibrary, docked, device.editor.canFitSidebar],
),
);
@ -125,7 +125,7 @@ export const SidebarInner = forwardRef(
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!docked || !device.canDeviceFitSidebar)
(!docked || !device.editor.canFitSidebar)
) {
closeLibrary();
}
@ -134,7 +134,7 @@ export const SidebarInner = forwardRef(
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [closeLibrary, docked, device.canDeviceFitSidebar]);
}, [closeLibrary, docked, device.editor.canFitSidebar]);
return (
<Island

View File

@ -18,7 +18,7 @@ export const SidebarHeader = ({
const props = useContext(SidebarPropsContext);
const renderDockButton = !!(
device.canDeviceFitSidebar && props.shouldRenderDockButton
device.editor.canFitSidebar && props.shouldRenderDockButton
);
return (

View File

@ -3,8 +3,7 @@
.excalidraw {
.sidebar-trigger {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
@include filledButtonOnCanvas;
width: auto;
height: var(--lg-button-size);

View File

@ -1,15 +1,13 @@
@import "../css/variables.module";
.excalidraw {
--Switch-disabled-color: #d6d6d6;
--Switch-track-background: white;
--Switch-thumb-background: #3d3d3d;
&.theme--dark {
--Switch-disabled-color: #5c5c5c;
--Switch-track-background: #242424;
--Switch-thumb-background: #b8b8b8;
}
--Switch-disabled-color: var(--color-border-outline);
--Switch-disabled-toggled-background: var(--color-border-outline-variant);
--Switch-disabled-border: var(--color-border-outline-variant);
--Switch-track-background: var(--island-bg-color);
--Switch-thumb-background: var(--color-on-surface);
--Switch-hover-background: var(--color-brand-hover);
--Switch-active-background: var(--color-brand-active);
.Switch {
position: relative;
@ -28,7 +26,11 @@
&:hover {
background: var(--Switch-track-background);
border: 1px solid #999999;
border: 1px solid var(--Switch-hover-background);
}
&:active {
border: 1px solid var(--Switch-active-background);
}
&.toggled {
@ -43,11 +45,11 @@
&.disabled {
background: var(--Switch-track-background);
border: 1px solid var(--Switch-disabled-color);
border: 1px solid var(--Switch-disabled-border);
&.toggled {
background: var(--Switch-disabled-color);
border: 1px solid var(--Switch-disabled-color);
background: var(--Switch-disabled-toggled-background);
border: 1px solid var(--Switch-disabled-toggled-background);
}
}
@ -92,7 +94,7 @@
}
&.disabled.toggled:before {
background: var(--color-gray-50);
background: var(--Switch-disabled-color);
}
& input {

View File

@ -1,25 +1,16 @@
@import "../css/variables.module";
.excalidraw {
--ExcTextField--color: var(--color-gray-80);
--ExcTextField--label-color: var(--color-gray-80);
--ExcTextField--background: white;
--ExcTextField--readonly--background: var(--color-gray-10);
--ExcTextField--readonly--color: var(--color-gray-80);
--ExcTextField--border: var(--color-gray-40);
--ExcTextField--border-hover: var(--color-gray-50);
--ExcTextField--placeholder: var(--color-gray-40);
&.theme--dark {
--ExcTextField--color: var(--color-gray-10);
--ExcTextField--label-color: var(--color-gray-20);
--ExcTextField--background: var(--color-gray-85);
--ExcTextField--readonly--background: var(--color-gray-80);
--ExcTextField--readonly--color: var(--color-gray-40);
--ExcTextField--border: var(--color-gray-70);
--ExcTextField--border-hover: var(--color-gray-60);
--ExcTextField--placeholder: var(--color-gray-80);
}
--ExcTextField--color: var(--color-on-surface);
--ExcTextField--label-color: var(--color-on-surface);
--ExcTextField--background: transparent;
--ExcTextField--readonly--background: var(--color-surface-high);
--ExcTextField--readonly--color: var(--color-on-surface);
--ExcTextField--border: var(--color-border-outline);
--ExcTextField--readonly--border: var(--color-border-outline-variant);
--ExcTextField--border-hover: var(--color-brand-hover);
--ExcTextField--border-active: var(--color-brand-active);
--ExcTextField--placeholder: var(--color-border-outline-variant);
.ExcTextField {
&--fullWidth {
@ -61,7 +52,7 @@
&:active,
&:focus-within {
border-color: var(--color-primary);
border-color: var(--ExcTextField--border-active);
}
}
@ -107,7 +98,7 @@
&--readonly {
background: var(--ExcTextField--readonly--background);
border-color: transparent;
border-color: var(--ExcTextField--readonly--border);
& input {
color: var(--ExcTextField--readonly--color);

View File

@ -83,12 +83,12 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
}
};
useEffect(
() => () => {
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
},
[],
);
};
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null);

View File

@ -97,10 +97,6 @@
}
}
// &:hover {
// background-color: var(--button-gray-2);
// }
&:active {
background-color: var(--button-gray-3);
}
@ -110,7 +106,6 @@
}
&--hide {
// visibility: hidden;
display: none !important;
}
}
@ -165,10 +160,24 @@
width: var(--lg-button-size);
height: var(--lg-button-size);
@media screen and (max-width: 450px) {
width: 1.8rem;
height: 1.8rem;
}
@media screen and (max-width: 379px) {
width: 1.5rem;
height: 1.5rem;
}
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
.ToolIcon__LaserPointer .ToolIcon__icon {
width: var(--default-button-size);
height: var(--default-button-size);
}
}
}

View File

@ -16,23 +16,35 @@
align-self: center;
background-color: var(--default-border-color);
margin: 0 0.25rem;
@include isMobile {
margin: 0;
}
}
}
.App-toolbar__extra-tools-trigger {
box-shadow: none;
border: 0;
background-color: transparent;
&:active {
background-color: var(--button-hover-bg);
box-shadow: 0 0 0 1px
var(--button-active-border, var(--color-primary-darkest)) inset;
}
&--selected,
&--selected:hover {
background: var(--color-primary-light);
color: var(--color-primary);
}
}
.App-toolbar__extra-tools-dropdown {
margin-top: 0.375rem;
right: 0;
min-width: 11.875rem;
z-index: 1;
}
}

View File

@ -193,6 +193,8 @@ const getRelevantAppStateProps = (
showHyperlinkPopup: appState.showHyperlinkPopup,
collaborators: appState.collaborators, // Necessary for collab. sessions
activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
});
const areEqual = (

View File

@ -114,11 +114,13 @@ const areEqual = (
return false;
}
return isShallowEqual(
// asserting AppState because we're being passed the whole AppState
// but resolve to only the StaticCanvas-relevant props
getRelevantAppStateProps(prevProps.appState as AppState),
getRelevantAppStateProps(nextProps.appState as AppState),
return (
isShallowEqual(
// asserting AppState because we're being passed the whole AppState
// but resolve to only the StaticCanvas-relevant props
getRelevantAppStateProps(prevProps.appState as AppState),
getRelevantAppStateProps(nextProps.appState as AppState),
) && isShallowEqual(prevProps.renderConfig, nextProps.renderConfig)
);
};

View File

@ -7,8 +7,6 @@
margin-top: 0.25rem;
&--mobile {
bottom: 55px;
top: auto;
left: 0;
width: 100%;
row-gap: 0.75rem;
@ -16,7 +14,7 @@
.dropdown-menu-container {
padding: 8px 8px;
box-sizing: border-box;
background-color: var(--island-bg-color);
// background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
position: relative;
@ -29,7 +27,7 @@
}
.dropdown-menu-container {
background-color: #fff !important;
background-color: var(--island-bg-color);
max-height: calc(100vh - 150px);
overflow-y: auto;
--gap: 2;
@ -40,7 +38,7 @@
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
color: var(--color-on-surface);
width: 100%;
box-sizing: border-box;
font-weight: normal;
@ -49,7 +47,7 @@
.dropdown-menu-item {
background-color: transparent;
border: 0;
border: 1px solid transparent;
align-items: center;
height: 2rem;
cursor: pointer;
@ -59,6 +57,11 @@
height: 2.25rem;
}
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
@ -75,6 +78,11 @@
text-decoration: none;
}
&:active {
background-color: var(--button-hover-bg);
border-color: var(--color-brand-active);
}
svg {
width: 1rem;
height: 1rem;
@ -93,22 +101,33 @@
font-weight: 500;
}
}
&.theme--dark {
.dropdown-menu-item {
color: var(--color-gray-40);
}
.dropdown-menu-container {
background-color: var(--color-gray-90) !important;
}
}
.dropdown-menu-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
--background: var(--color-surface-mid);
background-color: var(--background);
@at-root .excalidraw.theme--dark#{&} {
--background: var(--color-surface-high);
&:hover {
--background: #363541;
}
}
&:hover {
--background: var(--color-surface-high);
background-color: var(--background);
text-decoration: none;
}
&:active {
border-color: var(--color-primary);
}
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);

View File

@ -30,7 +30,7 @@ const MenuContent = ({
});
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.isMobile,
"dropdown-menu--mobile": device.editor.isMobile,
}).trim();
return (
@ -43,7 +43,7 @@ const MenuContent = ({
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
{device.isMobile ? (
{device.editor.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island

View File

@ -11,12 +11,14 @@ const DropdownMenuItem = ({
children,
shortcut,
className,
selected,
...rest
}: {
icon?: JSX.Element;
onSelect: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
selected?: boolean;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
@ -26,7 +28,7 @@ const DropdownMenuItem = ({
{...rest}
onClick={handleClick}
type="button"
className={getDropdownMenuItemClassName(className)}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>

View File

@ -14,7 +14,7 @@ const MenuItemContent = ({
<>
<div className="dropdown-menu-item__icon">{icon}</div>
<div className="dropdown-menu-item__text">{children}</div>
{shortcut && !device.isMobile && (
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>

View File

@ -3,15 +3,19 @@ import React from "react";
const DropdownMenuItemCustom = ({
children,
className = "",
selected,
...rest
}: {
children: React.ReactNode;
className?: string;
selected?: boolean;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
{...rest}
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className} ${
selected ? `dropdown-menu-item--selected` : ``
}`.trim()}
>
{children}
</div>

View File

@ -12,6 +12,7 @@ const DropdownMenuItemLink = ({
children,
onSelect,
className = "",
selected,
...rest
}: {
href: string;
@ -19,6 +20,7 @@ const DropdownMenuItemLink = ({
children: React.ReactNode;
shortcut?: string;
className?: string;
selected?: boolean;
onSelect?: (event: Event) => void;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
@ -29,7 +31,7 @@ const DropdownMenuItemLink = ({
href={href}
target="_blank"
rel="noreferrer"
className={getDropdownMenuItemClassName(className)}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
onClick={handleClick}
>

View File

@ -18,7 +18,7 @@ const MenuTrigger = ({
`dropdown-menu-button ${className}`,
"zen-mode-transition",
{
"dropdown-menu-button--mobile": device.isMobile,
"dropdown-menu-button--mobile": device.editor.isMobile,
},
).trim();
return (

View File

@ -6,8 +6,13 @@ export const DropdownMenuContentPropsContext = React.createContext<{
onSelect?: (event: Event) => void;
}>({});
export const getDropdownMenuItemClassName = (className = "") => {
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
export const getDropdownMenuItemClassName = (
className = "",
selected = false,
) => {
return `dropdown-menu-item dropdown-menu-item-base ${className} ${
selected ? "dropdown-menu-item--selected" : ""
}`.trim();
};
export const useHandleDropdownMenuItemClick = (

View File

@ -13,7 +13,7 @@ import clsx from "clsx";
import { Theme } from "../element/types";
import { THEME } from "../constants";
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
export const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
const handlerColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
@ -1653,3 +1653,38 @@ export const frameToolIcon = createIcon(
</g>,
tablerIconProps,
);
export const mermaidLogoIcon = createIcon(
<path
fill="currentColor"
d="M407.48,111.18C335.587,108.103 269.573,152.338 245.08,220C220.587,152.338 154.573,108.103 82.68,111.18C80.285,168.229 107.577,222.632 154.74,254.82C178.908,271.419 193.35,298.951 193.27,328.27L193.27,379.13L296.9,379.13L296.9,328.27C296.816,298.953 311.255,271.42 335.42,254.82C382.596,222.644 409.892,168.233 407.48,111.18Z"
/>,
);
export const ArrowRightIcon = createIcon(
<g strokeWidth="1.25">
<path d="M4.16602 10H15.8327" />
<path d="M12.5 13.3333L15.8333 10" />
<path d="M12.5 6.66666L15.8333 9.99999" />
</g>,
modifiedTablerIconProps,
);
export const laserPointerToolIcon = createIcon(
<g
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
transform="rotate(90 10 10)"
>
<path
clipRule="evenodd"
d="m9.644 13.69 7.774-7.773a2.357 2.357 0 0 0-3.334-3.334l-7.773 7.774L8 12l1.643 1.69Z"
/>
<path d="m13.25 3.417 3.333 3.333M10 10l2-2M5 15l3-3M2.156 17.894l1-1M5.453 19.029l-.144-1.407M2.377 11.887l.866 1.118M8.354 17.273l-1.194-.758M.953 14.652l1.408.13" />
</g>,
20,
);

View File

@ -14,6 +14,8 @@
--button-active-bg: var(--color-primary-darker);
box-shadow: 0 0 0 1px var(--color-surface-lowest);
flex-shrink: 0;
// double .active to force specificity

View File

@ -29,7 +29,7 @@ const MainMenu = Object.assign(
const device = useDevice();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.isMobile
const onClickOutside = device.editor.isMobile
? undefined
: () => setAppState({ openMenu: null });
@ -43,6 +43,7 @@ const MainMenu = Object.assign(
});
}}
data-testid="main-menu-trigger"
className="main-menu-trigger"
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>
@ -53,7 +54,7 @@ const MainMenu = Object.assign(
})}
>
{children}
{device.isMobile && appState.collaborators.size > 0 && (
{device.editor.isMobile && appState.collaborators.size > 0 && (
<fieldset className="UserList-Wrapper">
<legend>{t("labels.collaborators")}</legend>
<UserList

View File

@ -21,7 +21,7 @@ const WelcomeScreenMenuItemContent = ({
<>
<div className="welcome-screen-menu-item__icon">{icon}</div>
<div className="welcome-screen-menu-item__text">{children}</div>
{shortcut && !device.isMobile && (
{shortcut && !device.editor.isMobile && (
<div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
)}
</>

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