Compare commits

..

44 Commits

Author SHA1 Message Date
Ryan Di 3b5d62c8d6 fix uppercase typo 2025-02-05 21:05:27 +11:00
Ryan Di 4f74274d04 animated trail for lasso selection 2025-02-05 20:59:51 +11:00
Ryan Di 52eaf64591 feat: box select frame & children to allow resizing at the same time (#9031)
* box select frame & children

* avoid selecting children twice to avoid double their moving

* do not show ele stats if frame and children selected together

* do not update frame membership if selected together

* do not group frame and its children

* comment and refactor code

* hide align altogether

* include frame children when selecting all

* simplify

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-28 22:10:16 +01:00
David Luzar 7028daa44a fix: remove flushSync to fix flickering (#9057) 2025-01-28 19:23:35 +01:00
Ashwin Temkar 65f218b100 fix: excalidraw issue #9045 flowcharts: align attributes of new node (#9047)
* fix: excalidraw#9045 by modifying the stroke style, opacity, and fill style for the new node and next nodes.

* fix: added roughness and opacity to the arrowbindings
2025-01-25 17:05:50 +01:00
Alplune 807b3c59f2 fix: align arrows bound to elements excalidraw#8833 (#8998) 2025-01-25 17:00:39 +01:00
Alplune b8da5065fd fix: update elbow arrow on font size change #8798 (#9002) 2025-01-25 17:00:26 +01:00
Márk Tolmács 49f1276ef2 fix: Undo for elbow arrows create incorrect routing (#9046) 2025-01-24 20:18:08 +01:00
Ashwin Temkar 8f20b29b73 fix: #8575 , Flowchart clones the current arrowhead (#8581)
* fix: #8575, Flowchart clones the current arrowhead

* fix: #8575, changed stroke color, style and width to startBindingElement
2025-01-24 16:50:07 +01:00
David Luzar f87c2cde09 feat: allow installing libs from excal github (#9041) 2025-01-23 16:50:47 +01:00
Ryan Di 0bf234fcc9 fix: adding partial group to frame (#9014)
* prevent new frame from including partial groups

* separate wrapped partial group
2025-01-23 07:26:12 +08:00
Ryan Di dd1b45a25a perf: reduce unnecessary frame clippings (#8980)
* reduce unnecessary frame clippings

* further optim
2025-01-23 07:25:46 +08:00
David Luzar ec06fbc1fc fix: do not refocus element link input on unrelated updates (#9037) 2025-01-22 21:30:15 +01:00
David Luzar fa05ae1230 refactor: remove defaultProps (#9035) 2025-01-22 12:43:02 +01:00
Márk Tolmács 91ebf8b0ea feat: Elbow arrow segment fixing & positioning (#8952)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-01-17 18:07:03 +01:00
Arnost Pleskot 8551823da9 feat: update jotai (#9015)
* feat: update jotai in excalidraw package

* feat: update jotai in excalidraw-app

* fix: exports from excalidraw/jotai

* fix: use isolated react hooks

* test: use jotai provider in <Trans /> test

* remove unused package

* refactor & make safer

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-16 16:59:11 +01:00
David Luzar ae6bee3403 feat: do not delete frame children on frame delete (#9011) 2025-01-14 21:08:25 +01:00
David Luzar 46f42ef8d7 fix: arrow binding behaving unexpectedly on pointerup (#9010)
* fix: arrow binding behaving unexpectedly on pointerup

* update snaps
2025-01-14 19:36:47 +01:00
Ryan Di 00b5b0a0ca feat: add action to wrap selected items in a frame (#9005)
* feat: add action to wrap selected items in a frame

* fix type

* select frame on wrap & refactor

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-13 15:03:56 +00:00
YongJoon Kim c92f3bebf5 fix: change cursor by tool change immediately (#8212) 2025-01-09 14:26:12 +01:00
Marcel Mraz 2ac55067cd fix: package build fails on worker chunks (#8990) 2025-01-07 11:22:36 +00:00
David Luzar 78ab12c7e6 fix: z-index clash in mobile UI (#8985) 2025-01-06 21:21:11 +01:00
David Luzar f2f8219917 feat: reintroduce .excalidraw.png default when embedding scene (#8979) 2025-01-05 22:21:39 +01:00
한별 12c39d1034 feat: add mimeTypes on file save (#8946) 2025-01-05 21:12:07 +00:00
Ryan Di d33e42e3a1 feat: add crowfoot to arrowheads (#8942)
* crowfoot many

* crowfoot one

* one or many

* add icons for crowfoot

* add crowfoot icons

* adjust arrowhead selection popover

* make options collapsible

* swap triangle and bar

* switch to radix popover

* put triangle outline in the first row

* align shadow with new design spec

* remove unused flag

* swap order

* tweak labels

* handle shift+tab

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Jakub Królak <108676707+j-krolak@users.noreply.github.com>
2025-01-05 21:50:24 +01:00
zsviczian 3b9ffd9586 fix: elbow arrows do not work within frames (issue: #8964) (#8969)
check for !isFrameLikeElement
2025-01-05 21:47:20 +01:00
David Luzar b63689c230 feat: make HTML attribute sanitization stricter (#8977)
* feat: make HTML attribute sanitization stricter

* fix double escape
2025-01-05 21:45:04 +01:00
David Luzar c84babf574 feat: validate library install urls (#8976) 2025-01-05 17:10:55 +01:00
David Luzar 36274f1f3e feat: cleanup svg export and move payload to <metadata> (#8975) 2025-01-05 16:53:05 +01:00
Aakansha Doshi 798c795405 docs: add demo link for browser integration (#8956) 2024-12-27 14:39:08 +09:00
Ryan Di 107eae3916 refactor: separate resizing logic from pointer (#8155)
* separate resizing logic for a single element

* replace resize logic in stats

* do not recompute width and height from points when they're already given

* correctly update linear elements' position when resized

* update snapshots

* lint

* simplify linear resizing logic

* fix initial scale for aspect ratio

* update tests for linear elements

* test typo

* separate pointer from resizing for multiple elements

* lint and simplify

* fix tests

* lint

* provide scene in param instead

* type

* refactor code

* fix floating in tests

* remove restrictions/checks on width & height

* update pointer to dimension to prevent regression

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-23 11:10:35 +01:00
zsviczian 56fca30bd0 fix: normalizeSVG width and height from viewbox when size includes decimal points (#8939)
Update image.ts
2024-12-22 23:10:11 +01:00
Ryan Di 1e3399eac8 fix: make arrow binding area adapt to zoom levels (#8927)
* make binding area adapt to zoom

* revert stroke color

* normalize binding gap

* reduce normalized gap
2024-12-22 22:55:50 +01:00
David Luzar 873698a1a2 fix: robust state.editingFrame teardown (#8941) 2024-12-22 22:47:39 +01:00
Ryan Di 606ac6c743 fix: regression on dragging a selected frame by its name (#8924)
fix hit element check for a selected frame's name
2024-12-22 22:47:21 +01:00
Ryan Di d99e4a23ca feat: use stats panel to crop (#8848)
* feat: use stats panel to crop

* fix: test flake

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-17 13:15:30 +01:00
Ryan Di 551bae07a7 feat: snap when cropping as well (#8831)
* crop with snap

* make crop snap work with cmd as well

* turn off grid with cmd as well in crop
2024-12-16 18:31:33 +08:00
Shreyansh Jain 2af3221974 fix: right-click paste for images in clipboard (Issue #8826) (#8845)
* Fix right-click paste command for images (Issue #8826)

* Fix clipboard logic for multiple paste types

* fix: remove unused code

* refactor & robustness

* fix: creating paste event with image files

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-10 20:10:34 +00:00
Antonio Della Fortuna 9b401f6ea3 fix: fixed image transparency by adding alpha option to preserve image alpha channel (#8895)
added alpha option to preserve image alpha channel
2024-12-10 13:41:10 +01:00
Marcel Mraz 8a1152ce36 fix: Flush pending DOM updates before .focus() (#8901) 2024-12-09 21:57:37 +01:00
Ryan Di b5652b8e36 fix: normalize svg using only absolute sizing (#8854) 2024-11-27 13:09:44 +01:00
David Luzar 31e2a0cb4a fix: element link selector dialog z-index & positioning (#8853) 2024-11-26 23:18:20 +01:00
Ryan Di c0b80a03bd feat: in canvas links between shapes (#8812)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-11-26 18:53:25 +01:00
David Luzar a758aaf8f6 fix: update old blog links & add canonical url (#8846) 2024-11-26 17:42:25 +01:00
175 changed files with 9398 additions and 5853 deletions
+15 -1
View File
@@ -3,6 +3,20 @@
"rules": {
"import/no-anonymous-default-export": "off",
"no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
"@typescript-eslint/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"disallowTypeAnnotations": false,
"fixStyle": "separate-type-imports"
}
],
"no-restricted-imports": [
"error",
{
"name": "jotai",
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
}
]
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
<h4 align="center">
<a href="https://excalidraw.com">Excalidraw Editor</a> |
<a href="https://blog.excalidraw.com">Blog</a> |
<a href="https://plus.excalidraw.com/blog">Blog</a> |
<a href="https://docs.excalidraw.com">Documentation</a> |
<a href="https://plus.excalidraw.com">Excalidraw+</a>
</h4>
@@ -3,31 +3,32 @@
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. |
| [`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. |
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events |
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
| [`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. |
| [`onPointerDown`](#onpointerdown) | `function` | \_ | This prop if passed gets triggered on pointer down events |
| [`onScrollChange`](#onscrollchange) | `function` | \_ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | \_ | Callback to be triggered if passed when something is pasted into the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | \_ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`generateLinkForSelection`](#generateLinkForSelection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. |
| [`onLinkOpen`](#onlinkopen) | `function` | \_ | The callback if supplied is triggered when any link is opened. |
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
| [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | _ | Render function that renders custom UI in top right corner |
| [`renderCustomStats`](/docs/@excalidraw/excalidraw/api/props/render-props#rendercustomstats) | `function` | _ | Render function that can be used to render custom stats on the stats dialog. |
| [`viewModeEnabled`](#viewmodeenabled) | `boolean` | _ | This indicates if the app is in `view` mode. |
| [`zenModeEnabled`](#zenmodeenabled) | `boolean` | _ | This indicates if the `zen` mode is enabled |
| [`gridModeEnabled`](#gridmodeenabled) | `boolean` | _ | This indicates if the `grid` mode is enabled |
| [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
| [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | \_ | Render function that renders custom UI in top right corner |
| [`renderCustomStats`](/docs/@excalidraw/excalidraw/api/props/render-props#rendercustomstats) | `function` | \_ | Render function that can be used to render custom stats on the stats dialog. |
| [`viewModeEnabled`](#viewmodeenabled) | `boolean` | \_ | This indicates if the app is in `view` mode. |
| [`zenModeEnabled`](#zenmodeenabled) | `boolean` | \_ | This indicates if the `zen` mode is enabled |
| [`gridModeEnabled`](#gridmodeenabled) | `boolean` | \_ | This indicates if the `grid` mode is enabled |
| [`libraryReturnUrl`](#libraryreturnurl) | `string` | \_ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
| [`theme`](#theme) | `"light"` &#124; `"dark"` | `"light"` | The theme of the Excalidraw component |
| [`name`](#name) | `string` | | Name of the drawing |
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
| [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
@@ -93,9 +94,8 @@ 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:{" "}
(activeTool:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L115">
{" "}
AppState["activeTool"]
@@ -143,6 +143,14 @@ This callback if supplied will get triggered when the library is updated and has
It is invoked with empty items when user clears the library. You can use this callback when you want to do something additional when library is updated for example persisting it to local storage.
### generateLinkForSelection
This prop if passed will be used to replace the default link generation function. The idea is that the host app can take over the creation of element links, which can be used to navigate to a particular element or a group. If the host app chooses a different key for element link id, then the host app should also take care of the handling and the navigation in `onLinkOpen`.
```tsx
(id: string, type: "element" | "group") => string;
```
### onLinkOpen
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()`.
@@ -207,8 +215,7 @@ This prop indicates whether the shows the grid. When supplied, the value takes p
### 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.
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.
### theme
@@ -220,7 +227,6 @@ You can use [`THEME`](/docs/@excalidraw/excalidraw/api/utils#theme) to specify t
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
Indicates whether Excalidraw should listen for `scroll` event on the nearest scrollable container in the DOM tree and recompute the coordinates (e.g. to correctly handle the cursor) when the component's position changes. You can disable this when you either know this doesn't affect your app or you want to take care of it yourself (calling the [`refresh()`](#ref) method).
@@ -12,7 +12,7 @@ import { Excalidraw } from "@excalidraw/excalidraw";
Throughout the documentation we use live, editable Excalidraw examples like the one shown below.
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes.
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes.
For example, we're passing a `theme` prop to it based on the current color theme of the docs you're just reading.
:::
@@ -70,9 +70,9 @@ If you are using `pages router` then importing the wrapper dynamically would wor
height: 141.9765625,
},]));
return (
<div style={{height:"500px", width:"500px"}}>
<div style={{height:"500px", width:"500px"}}>
<Excalidraw />
</div>
</div>
);
};
export default ExcalidrawWrapper;
@@ -84,8 +84,8 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
@@ -97,7 +97,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
export default function Page() {
return (
<ExcalidrawWrapper />
<ExcalidrawWrapper />
);
}
```
@@ -108,7 +108,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
@@ -153,7 +153,7 @@ Since Vite removes env variables by default, you can update the vite config to e
"process.env.IS_PREACT": JSON.stringify("true"),
},
```
:::
:::
## Browser
@@ -235,3 +235,5 @@ root.render(React.createElement(App));
</TabItem>
</Tabs>
You can try it out [here](https://codesandbox.io/p/sandbox/excalidraw-in-browser-tlqom?file=%2Findex.html%3A1%2C10).
+2 -2
View File
@@ -66,7 +66,7 @@ const config = {
label: "Docs",
},
{
to: "https://blog.excalidraw.com",
to: "https://plus.excalidraw.com/blog",
label: "Blog",
position: "left",
},
@@ -111,7 +111,7 @@ const config = {
items: [
{
label: "Blog",
to: "https://blog.excalidraw.com",
to: "https://plus.excalidraw.com/blog",
},
{
label: "GitHub",
+29 -41
View File
@@ -369,12 +369,10 @@ export default function ExampleApp({
return false;
}
await exportToClipboard({
data: {
elements: excalidrawAPI.getSceneElements(),
appState: excalidrawAPI.getAppState(),
files: excalidrawAPI.getFiles(),
},
type: "json",
elements: excalidrawAPI.getSceneElements(),
appState: excalidrawAPI.getAppState(),
files: excalidrawAPI.getFiles(),
type,
});
window.alert(`Copied to clipboard as ${type} successfully`);
};
@@ -819,17 +817,15 @@ export default function ExampleApp({
return;
}
const svg = await exportToSvg({
data: {
elements: excalidrawAPI?.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
exportEmbedScene,
width: 300,
height: 100,
},
files: excalidrawAPI?.getFiles(),
elements: excalidrawAPI?.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
exportEmbedScene,
width: 300,
height: 100,
},
files: excalidrawAPI?.getFiles(),
});
appRef.current.querySelector(".export-svg").innerHTML =
svg.outerHTML;
@@ -845,18 +841,14 @@ export default function ExampleApp({
return;
}
const blob = await exportToBlob({
data: {
elements: excalidrawAPI?.getSceneElements(),
appState: {
...initialData.appState,
exportEmbedScene,
exportWithDarkMode,
},
files: excalidrawAPI?.getFiles(),
},
config: {
mimeType: "image/png",
elements: excalidrawAPI?.getSceneElements(),
mimeType: "image/png",
appState: {
...initialData.appState,
exportEmbedScene,
exportWithDarkMode,
},
files: excalidrawAPI?.getFiles(),
});
setBlobUrl(window.URL.createObjectURL(blob));
}}
@@ -872,14 +864,12 @@ export default function ExampleApp({
return;
}
const canvas = await exportToCanvas({
data: {
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
@@ -895,14 +885,12 @@ export default function ExampleApp({
return;
}
const canvas = await exportToCanvas({
data: {
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
+17 -359
View File
@@ -25,13 +25,7 @@ import {
TTDDialogTrigger,
StoreAction,
reconcileElements,
exportToCanvas,
exportToSvg,
} from "../packages/excalidraw";
import {
exportToBlob,
getNonDeletedElements,
} from "../packages/excalidraw/index";
import type {
AppState,
ExcalidrawImperativeAPI,
@@ -96,9 +90,13 @@ import {
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import { Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai";
import {
Provider,
useAtom,
useAtomValue,
useAtomWithInitialValue,
appJotaiStore,
} from "./app-jotai";
import "./index.scss";
import type { ResolutionType } from "../packages/excalidraw/utility-types";
@@ -123,7 +121,7 @@ import {
share,
youtubeIcon,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
import DebugCanvas, {
@@ -133,9 +131,7 @@ import DebugCanvas, {
} from "./components/DebugCanvas";
import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import { fileSave } from "../packages/excalidraw/data/filesystem";
import { type ExportSceneConfig } from "../packages/excalidraw/scene/export";
import { round } from "../packages/math";
import { isElementLink } from "../packages/excalidraw/element/elementLink";
polyfill();
@@ -336,8 +332,7 @@ const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode();
@@ -616,19 +611,6 @@ const ExcalidrawWrapper = () => {
};
}, [excalidrawAPI]);
const canvasPreviewContainerRef = useRef<HTMLDivElement>(null);
const svgPreviewContainerRef = useRef<HTMLDivElement>(null);
const [config, setConfig] = useState<ExportSceneConfig>({
scale: 1,
position: "center",
fit: "contain",
});
useEffect(() => {
localStorage.setItem("_exportConfig", JSON.stringify(config));
}, [config]);
const onChange = (
elements: readonly OrderedExcalidrawElement[],
appState: AppState,
@@ -638,84 +620,6 @@ const ExcalidrawWrapper = () => {
collabAPI.syncElements(elements);
}
const nonDeletedElements = getNonDeletedElements(elements);
const frame = nonDeletedElements.find(
(el) => el.strokeStyle === "dashed" && el.type === "rectangle",
);
exportToCanvas({
data: {
elements: nonDeletedElements.filter((x) => x.id !== frame?.id),
// .concat(
// restoreElements(
// [
// // @ts-ignore
// {
// type: "rectangle",
// width: appState.width / zoom,
// height: appState.height / zoom,
// x: -appState.scrollX,
// y: -appState.scrollY,
// fillStyle: "solid",
// strokeColor: "transparent",
// backgroundColor: "rgba(0,0,0,0.05)",
// roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS, value: 40 },
// },
// ],
// null,
// ),
// ),
appState,
files,
},
config: {
...(frame
? {
...config,
width: frame.width,
height: frame.height,
x: frame.x,
y: frame.y,
}
: config),
},
}).then((canvas) => {
if (canvasPreviewContainerRef.current) {
canvasPreviewContainerRef.current.replaceChildren(canvas);
document.querySelector(
".canvas_dims",
)!.innerHTML = `${canvas.width}x${canvas.height} (canvas)`;
}
});
exportToSvg({
data: {
elements: nonDeletedElements.filter((x) => x.id !== frame?.id),
appState,
files,
},
config: {
...(frame
? {
...config,
width: frame.width,
height: frame.height,
x: frame.x,
y: frame.y,
}
: config),
},
}).then((svg) => {
if (svgPreviewContainerRef.current) {
svgPreviewContainerRef.current.replaceChildren(svg);
document.querySelector(".svg_dims")!.innerHTML = `${round(
parseFloat(svg.getAttribute("width") ?? ""),
0,
)}x${round(parseFloat(svg.getAttribute("height") ?? ""), 0)} (svg)`;
}
});
// this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) {
@@ -948,6 +852,12 @@ const ExcalidrawWrapper = () => {
</div>
);
}}
onLinkOpen={(element, event) => {
if (element.link && isElementLink(element.link)) {
event.preventDefault();
excalidrawAPI?.scrollToContent(element.link, { animate: true });
}
}}
>
<AppMainMenu
onCollabDialogOpen={onCollabDialogOpen}
@@ -1221,258 +1131,6 @@ const ExcalidrawWrapper = () => {
/>
)}
</Excalidraw>
<div
style={{
display: "flex",
flexDirection: "column",
position: "fixed",
bottom: 60,
right: 60,
zIndex: 9999999999,
color: "black",
}}
>
<div style={{ display: "flex", gap: "1rem", flexDirection: "column" }}>
<div style={{ display: "flex", gap: "1rem" }}>
<label>
center{" "}
<input
type="checkbox"
checked={config.position === "center"}
onChange={() =>
setConfig((s) => ({
...s,
position: s.position === "center" ? "topLeft" : "center",
}))
}
/>
</label>
<label>
fit{" "}
<select
value={config.fit}
onChange={(event) =>
setConfig((s) => ({
...s,
fit: event.target.value as any,
}))
}
>
<option value="none">none</option>
<option value="contain">contain</option>
</select>
</label>
<label>
padding{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.padding}
onChange={(event) =>
setConfig((s) => ({
...s,
padding: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label>
scale{" "}
<input
type="number"
max={4}
style={{ width: "3rem" }}
value={config.scale}
onChange={(event) =>
setConfig((s) => ({
...s,
scale: !event.target.value.trim()
? undefined
: Math.min(parseFloat(event.target.value as any), 4),
}))
}
/>
</label>
</div>
<div style={{ display: "flex", gap: "1rem" }}>
<label
style={{
opacity:
config.maxWidthOrHeight != null ||
config.widthOrHeight != null
? 0.5
: undefined,
}}
>
width{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.width}
onChange={(event) =>
setConfig((s) => ({
...s,
width: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label
style={{
opacity:
config.maxWidthOrHeight != null ||
config.widthOrHeight != null
? 0.5
: undefined,
}}
>
height{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.height}
onChange={(event) =>
setConfig((s) => ({
...s,
height: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label>
x{" "}
<input
type="number"
style={{ width: "3rem" }}
value={config.x}
onChange={(event) =>
setConfig((s) => ({
...s,
x: !event.target.value.trim()
? undefined
: parseFloat(event.target.value as any) ?? undefined,
}))
}
/>
</label>
<label>
y{" "}
<input
type="number"
style={{ width: "3rem" }}
value={config.y}
onChange={(event) =>
setConfig((s) => ({
...s,
y: !event.target.value.trim()
? undefined
: parseFloat(event.target.value as any) ?? undefined,
}))
}
/>
</label>
<label
style={{
opacity: config.widthOrHeight != null ? 0.5 : undefined,
}}
>
maxWH{" "}
<input
type="number"
// max={600}
style={{ width: "3rem" }}
value={config.maxWidthOrHeight}
onChange={(event) =>
setConfig((s) => ({
...s,
maxWidthOrHeight: !event.target.value.trim()
? undefined
: parseInt(event.target.value as any),
}))
}
/>
</label>
<label>
widthOrHeight{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.widthOrHeight}
onChange={(event) =>
setConfig((s) => ({
...s,
widthOrHeight: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
</div>
</div>
<div className="canvas_dims">0x0</div>
<div
ref={canvasPreviewContainerRef}
onClick={() => {
exportToBlob({
data: {
elements: excalidrawAPI!.getSceneElements(),
files: excalidrawAPI?.getFiles() || null,
},
config,
}).then((blob) => {
fileSave(blob, {
name: "xx",
extension: "png",
description: "xxx",
});
});
}}
style={{
borderRadius: 12,
border: "1px solid #777",
overflow: "hidden",
padding: 10,
backgroundColor: "pink",
}}
/>
<div className="svg_dims">0x0</div>
<div
ref={svgPreviewContainerRef}
onClick={() => {
exportToBlob({
data: {
elements: excalidrawAPI!.getSceneElements(),
files: excalidrawAPI?.getFiles() || null,
},
config,
}).then((blob) => {
fileSave(blob, {
name: "xx",
extension: "png",
description: "xxx",
});
});
}}
style={{
borderRadius: 12,
border: "1px solid #777",
overflow: "hidden",
padding: 10,
backgroundColor: "pink",
}}
/>
</div>
</div>
);
};
@@ -1486,7 +1144,7 @@ const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}>
<Provider store={appJotaiStore}>
<ExcalidrawWrapper />
</Provider>
</TopErrorBoundary>
+36 -2
View File
@@ -1,3 +1,37 @@
import { unstable_createStore } from "jotai";
// eslint-disable-next-line no-restricted-imports
import {
atom,
Provider,
useAtom,
useAtomValue,
useSetAtom,
createStore,
type PrimitiveAtom,
} from "jotai";
import { useLayoutEffect } from "react";
export const appJotaiStore = unstable_createStore();
export const appJotaiStore = createStore();
export { atom, Provider, useAtom, useAtomValue, useSetAtom };
export const useAtomWithInitialValue = <
T extends unknown,
A extends PrimitiveAtom<T>,
>(
atom: A,
initialValue: T | (() => T),
) => {
const [value, setValue] = useAtom(atom);
useLayoutEffect(() => {
if (typeof initialValue === "function") {
// @ts-ignore
setValue(initialValue());
} else {
setValue(initialValue);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [value, setValue] as const;
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { useSetAtom } from "jotai";
import React from "react";
import { useI18n, languages } from "../../packages/excalidraw/i18n";
import { useSetAtom } from "../app-jotai";
import { appLangCodeAtom } from "./language-state";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
@@ -1,5 +1,5 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import { atom, useAtom } from "../app-jotai";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());
+1 -2
View File
@@ -79,8 +79,7 @@ import { newElementWith } from "../../packages/excalidraw/element/mutateElement"
import { decryptData } from "../../packages/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { atom } from "jotai";
import { appJotaiStore } from "../app-jotai";
import { appJotaiStore, atom } from "../app-jotai";
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
+1 -1
View File
@@ -2,9 +2,9 @@ import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
import { warning } from "../../packages/excalidraw/components/icons";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { atom } from "../app-jotai";
import "./CollabError.scss";
import { atom } from "jotai";
type ErrorIndicator = {
message: string | null;
+8 -12
View File
@@ -21,19 +21,15 @@ export const AIComponents = ({
const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({
data: {
elements: children,
appState: {
...appState,
exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
files: excalidrawAPI.getFiles(),
},
config: {
exportingFrame: frame,
mimeType: MIME_TYPES.jpg,
elements: children,
appState: {
...appState,
exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
});
const dataURL = await getDataURL(blob);
+1 -1
View File
@@ -84,7 +84,7 @@ const _debugRenderer = (
scale,
normalizedWidth,
normalizedHeight,
canvasBackgroundColor: "transparent",
viewBackgroundColor: "transparent",
});
// Apply zoom
+1 -1
View File
@@ -8,7 +8,7 @@ export const EncryptedIcon = () => {
return (
<a
className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/"
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
target="_blank"
rel="noopener noreferrer"
aria-label={t("encrypted.link")}
+2
View File
@@ -54,6 +54,8 @@
content="https://excalidraw.com/og-image-3.png"
/>
<link rel="canonical" href="https://excalidraw.com" />
<!------------------------------------------------------------------------->
<!-- to minimize white flash on load when user has dark mode enabled -->
<script>
+1 -1
View File
@@ -32,7 +32,7 @@
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"jotai": "2.11.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2",
+3 -3
View File
@@ -18,11 +18,11 @@ import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab";
import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
import { atom, useAtom, useAtomValue } from "../app-jotai";
import "./ShareDialog.scss";
type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly";
+9 -10
View File
@@ -1,4 +1,3 @@
import { atom, useAtom } from "jotai";
import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "../packages/excalidraw";
import { EVENT } from "../packages/excalidraw/constants";
@@ -6,18 +5,18 @@ import type { Theme } from "../packages/excalidraw/element/types";
import { CODES, KEYS } from "../packages/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants";
export const appThemeAtom = atom<Theme | "system">(
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT,
);
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
window.matchMedia?.("(prefers-color-scheme: dark)");
export const useHandleAppTheme = () => {
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const [appTheme, setAppTheme] = useState<Theme | "system">(() => {
return (
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT
);
});
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
useEffect(() => {
@@ -66,5 +65,5 @@ export const useHandleAppTheme = () => {
}
}, [appTheme]);
return { editorTheme };
return { editorTheme, appTheme, setAppTheme };
};
+20 -15
View File
@@ -21,10 +21,8 @@ import type { AppClassProperties, AppState, UIAppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
export const alignActionsPredicate = (
appState: UIAppState,
_: unknown,
app: AppClassProperties,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
@@ -48,6 +46,7 @@ const alignSelectedElements = (
selectedElements,
elementsMap,
alignment,
app.scene,
);
const updatedElementsMap = arrayToMap(updatedElements);
@@ -64,7 +63,8 @@ export const actionAlignTop = register({
label: "labels.alignTop",
icon: AlignTopIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
@@ -79,7 +79,7 @@ export const actionAlignTop = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={AlignTopIcon}
onClick={() => updateData(null)}
@@ -97,7 +97,8 @@ export const actionAlignBottom = register({
label: "labels.alignBottom",
icon: AlignBottomIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
@@ -112,7 +113,7 @@ export const actionAlignBottom = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={AlignBottomIcon}
onClick={() => updateData(null)}
@@ -130,7 +131,8 @@ export const actionAlignLeft = register({
label: "labels.alignLeft",
icon: AlignLeftIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
@@ -145,7 +147,7 @@ export const actionAlignLeft = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={AlignLeftIcon}
onClick={() => updateData(null)}
@@ -163,7 +165,8 @@ export const actionAlignRight = register({
label: "labels.alignRight",
icon: AlignRightIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
@@ -178,7 +181,7 @@ export const actionAlignRight = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={AlignRightIcon}
onClick={() => updateData(null)}
@@ -196,7 +199,8 @@ export const actionAlignVerticallyCentered = register({
label: "labels.centerVertically",
icon: CenterVerticallyIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
@@ -209,7 +213,7 @@ export const actionAlignVerticallyCentered = register({
},
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={CenterVerticallyIcon}
onClick={() => updateData(null)}
@@ -225,7 +229,8 @@ export const actionAlignHorizontallyCentered = register({
label: "labels.centerHorizontally",
icon: CenterHorizontallyIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
@@ -238,7 +243,7 @@ export const actionAlignHorizontallyCentered = register({
},
PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)}
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={CenterHorizontallyIcon}
onClick={() => updateData(null)}
+2 -1
View File
@@ -87,7 +87,8 @@ export const actionClearCanvas = register({
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.clearCanvas &&
!appState.viewModeEnabled
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector"
);
},
perform: (elements, appState, _, app) => {
+12 -16
View File
@@ -9,9 +9,8 @@ import {
readSystemClipboard,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportAsImage } from "../data/index";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { getTextFromElements, isTextElement } from "../element";
import { prepareElementsForExport } from "../data/index";
import { t } from "../i18n";
import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
@@ -137,15 +136,17 @@ export const actionCopyAsSvg = register({
);
try {
await exportAsImage({
type: "clipboard-svg",
data: { elements: exportedElements, appState, files: app.files },
config: {
await exportCanvas(
"clipboard-svg",
exportedElements,
appState,
app.files,
{
...appState,
exportingFrame,
name: app.getName(),
},
});
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
@@ -207,16 +208,11 @@ export const actionCopyAsPng = register({
true,
);
try {
await exportAsImage({
type: "clipboard",
data: { elements: exportedElements, appState, files: app.files },
config: {
...appState,
exportingFrame,
name: appState.name || app.getName(),
},
await exportCanvas("clipboard", exportedElements, appState, app.files, {
...appState,
exportingFrame,
name: app.getName(),
});
return {
appState: {
...appState,
@@ -7,7 +7,7 @@ import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { getElementsInGroup, selectGroupsForSelectedElements } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import {
@@ -18,14 +18,12 @@ import {
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => isFrameLikeElement(el)),
@@ -33,48 +31,99 @@ const deleteSelectedElements = (
).map((el) => el.id),
);
return {
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene
.getNonDeletedElementsMap()
.get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId
? null
: bound.endBinding,
});
mutateElbowArrow(bound, elementsMap, bound.points);
}
});
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
let shouldSelectEditingGroup = true;
const nextElements = elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
});
mutateElement(bound, { points: bound.points });
}
});
}
return newElementWith(el, { isDeleted: true });
}
// if deleting a frame, remove the children from it and select them
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
shouldSelectEditingGroup = false;
selectedElementIds[el.id] = true;
return newElementWith(el, { frameId: null });
}
if (isBoundToContainer(el) && appState.selectedElementIds[el.containerId]) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
let nextEditingGroupId = appState.editingGroupId;
// select next eligible element in currently editing group or supergroup
if (shouldSelectEditingGroup && appState.editingGroupId) {
const elems = getElementsInGroup(
nextElements,
appState.editingGroupId,
).filter((el) => !el.isDeleted);
if (elems.length > 1) {
if (elems[0]) {
selectedElementIds[elems[0].id] = true;
}
} else {
nextEditingGroupId = null;
if (elems[0]) {
selectedElementIds[elems[0].id] = true;
}
const lastElementInGroup = elems[0];
if (lastElementInGroup) {
const editingGroupIdx = lastElementInGroup.groupIds.findIndex(
(groupId) => {
return groupId === appState.editingGroupId;
},
);
const superGroupId = lastElementInGroup.groupIds[editingGroupIdx + 1];
if (superGroupId) {
const elems = getElementsInGroup(nextElements, superGroupId).filter(
(el) => !el.isDeleted,
);
if (elems.length > 1) {
nextEditingGroupId = superGroupId;
elems.forEach((el) => {
selectedElementIds[el.id] = true;
});
}
}
return newElementWith(el, { isDeleted: true });
}
}
}
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
return newElementWith(el, { isDeleted: true });
}
if (
isBoundToContainer(el) &&
appState.selectedElementIds[el.containerId]
) {
return newElementWith(el, { isDeleted: true });
}
return el;
}),
return {
elements: nextElements,
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
...selectGroupsForSelectedElements(
{
selectedElementIds,
editingGroupId: nextEditingGroupId,
},
nextElements,
appState,
null,
),
},
};
};
@@ -157,11 +206,7 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(
element,
selectedPointsIndices,
elementsMap,
);
LinearElementEditor.deletePoints(element, selectedPointsIndices);
return {
elements,
@@ -0,0 +1,105 @@
import { copyTextToSystemClipboard } from "../clipboard";
import { copyIcon, elementLinkIcon } from "../components/icons";
import {
canCreateLinkFromElements,
defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection,
} from "../element/elementLink";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionCopyElementLink = register({
name: "copyElementLink",
label: "labels.copyElementLink",
icon: copyIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
try {
if (window.location) {
const idAndType = getLinkIdAndTypeFromSelection(
selectedElements,
appState,
);
if (idAndType) {
await copyTextToSystemClipboard(
app.props.generateLinkForSelection
? app.props.generateLinkForSelection(idAndType.id, idAndType.type)
: defaultGetElementLinkFromSelection(
idAndType.id,
idAndType.type,
),
);
return {
appState: {
toast: {
message: t("toast.elementLinkCopied"),
closable: true,
},
},
storeAction: StoreAction.NONE,
};
}
return {
appState,
elements,
app,
storeAction: StoreAction.NONE,
};
}
} catch (error: any) {
console.error(error);
}
return {
appState,
elements,
app,
storeAction: StoreAction.NONE,
};
},
predicate: (elements, appState) =>
canCreateLinkFromElements(getSelectedElements(elements, appState)),
});
export const actionLinkToElement = register({
name: "linkToElement",
label: "labels.linkToElement",
icon: elementLinkIcon,
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
if (
selectedElements.length !== 1 ||
!canCreateLinkFromElements(selectedElements)
) {
return { elements, appState, app, storeAction: StoreAction.NONE };
}
return {
appState: {
...appState,
openDialog: {
name: "elementLinkSelector",
sourceElementId: getSelectedElements(elements, appState)[0].id,
},
},
storeAction: StoreAction.CAPTURE,
};
},
predicate: (elements, appState, appProps, app) => {
const selectedElements = getSelectedElements(elements, appState);
return (
appState.openDialog?.name !== "elementLinkSelector" &&
selectedElements.length === 1 &&
canCreateLinkFromElements(selectedElements)
);
},
trackEvent: false,
});
+2 -14
View File
@@ -10,13 +10,13 @@ import { useDevice } from "../components/App";
import { KEYS } from "../keys";
import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem";
import { getCanvasSize } from "../scene/export";
import { getExportSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import type { NonDeletedExcalidrawElement, Theme } from "../element/types";
import type { Theme } from "../element/types";
import "../components/ToolIcon.scss";
import { StoreAction } from "../store";
@@ -58,18 +58,6 @@ export const actionChangeExportScale = register({
? getSelectedElements(elements, appState)
: elements;
const getExportSize = (
elements: readonly NonDeletedExcalidrawElement[],
padding: number,
scale: number,
): [number, number] => {
const [, , width, height] = getCanvasSize(elements).map((dimension) =>
Math.trunc(dimension * scale),
);
return [width + padding * 2, height + padding * 2];
};
return (
<>
{EXPORT_SCALES.map((s) => {
@@ -49,12 +49,13 @@ describe("flipping re-centers selection", () => {
},
startArrowhead: null,
endArrowhead: "arrow",
fixedSegments: null,
points: [
pointFrom(0, 0),
pointFrom(0, -35),
pointFrom(-90.9, -35),
pointFrom(-90.9, 204.9),
pointFrom(65.1, 204.9),
pointFrom(-90, -35),
pointFrom(-90, 204),
pointFrom(66, 204),
],
elbowed: true,
}),
@@ -70,13 +71,13 @@ describe("flipping re-centers selection", () => {
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1");
expect(rec1?.x).toBeCloseTo(100);
expect(rec1?.y).toBeCloseTo(100);
const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1.x).toBeCloseTo(100, 0);
expect(rec1.y).toBeCloseTo(100, 0);
const rec2 = h.elements.find((el) => el.id === "rec2");
expect(rec2?.x).toBeCloseTo(220);
expect(rec2?.y).toBeCloseTo(250);
const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2.x).toBeCloseTo(220, 0);
expect(rec2.y).toBeCloseTo(250, 0);
});
});
+21 -19
View File
@@ -12,7 +12,6 @@ import { resizeMultipleElements } from "../element/resizeElements";
import type { AppClassProperties, AppState } from "../types";
import { arrayToMap } from "../utils";
import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
import {
bindOrUnbindLinearElements,
isBindingEnabled,
@@ -25,8 +24,9 @@ import {
isElbowArrow,
isLinearElement,
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement } from "../element/newElement";
import { getCommonBoundingBox } from "../element/bounds";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@@ -132,18 +132,25 @@ const flipElements = (
});
}
const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements);
const { midX, midY } = getCommonBoundingBox(selectedElements);
resizeMultipleElements(
elementsMap,
selectedElements,
elementsMap,
"nw",
true,
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
app.scene,
new Map(
Array.from(elementsMap.values()).map((element) => [
element.id,
deepCopyElement(element),
]),
),
{
flipByX: flipDirection === "horizontal",
flipByY: flipDirection === "vertical",
shouldResizeFromCenter: true,
shouldMaintainAspectRatio: true,
},
);
bindOrUnbindLinearElements(
@@ -153,6 +160,7 @@ const flipElements = (
app.scene,
isBindingEnabled(appState),
[],
appState.zoom,
);
// ---------------------------------------------------------------------------
@@ -185,16 +193,10 @@ const flipElements = (
}),
);
elbowArrows.forEach((element) =>
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
),
mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
// ---------------------------------------------------------------------------
+70 -2
View File
@@ -1,6 +1,6 @@
import { getNonDeletedElements } from "../element";
import { getCommonBounds, getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { addElementsToFrame, removeAllElementsFromFrame } from "../frame";
import { getFrameChildren } from "../frame";
import { KEYS } from "../keys";
import type { AppClassProperties, AppState, UIAppState } from "../types";
@@ -10,6 +10,10 @@ import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks";
import { frameToolIcon } from "../components/icons";
import { StoreAction } from "../store";
import { getSelectedElements } from "../scene";
import { newFrameElement } from "../element/newElement";
import { getElementsInGroup } from "../groups";
import { mutateElement } from "../element/mutateElement";
const isSingleFrameSelected = (
appState: UIAppState,
@@ -144,3 +148,67 @@ export const actionSetFrameAsActiveTool = register({
!event.altKey &&
event.key.toLocaleLowerCase() === KEYS.F,
});
export const actionWrapSelectionInFrame = register({
name: "wrapSelectionInFrame",
label: "labels.wrapSelectionInFrame",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length > 0 &&
!selectedElements.some((element) => isFrameLikeElement(element))
);
},
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
const [x1, y1, x2, y2] = getCommonBounds(
selectedElements,
app.scene.getNonDeletedElementsMap(),
);
const PADDING = 16;
const frame = newFrameElement({
x: x1 - PADDING,
y: y1 - PADDING,
width: x2 - x1 + PADDING * 2,
height: y2 - y1 + PADDING * 2,
});
// for a selected partial group, we want to remove it from the remainder of the group
if (appState.editingGroupId) {
const elementsInGroup = getElementsInGroup(
selectedElements,
appState.editingGroupId,
);
for (const elementInGroup of elementsInGroup) {
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
mutateElement(
elementInGroup,
{
groupIds: elementInGroup.groupIds.slice(0, index),
},
false,
);
}
}
const nextElements = addElementsToFrame(
[...app.scene.getElementsIncludingDeleted(), frame],
selectedElements,
frame,
appState,
);
return {
elements: nextElements,
appState: {
selectedElementIds: { [frame.id]: true },
},
storeAction: StoreAction.CAPTURE,
};
},
});
+12 -5
View File
@@ -25,8 +25,10 @@ import type {
import type { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
frameAndChildrenSelectedTogether,
getElementsInResizingFrame,
getFrameLikeElements,
getRootElements,
groupByFrameLikes,
removeElementsFromFrame,
replaceAllElementsInFrame,
@@ -60,8 +62,11 @@ const enableActionGroup = (
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
selectedElements.length >= 2 &&
!allElementsInSameGroup(selectedElements) &&
!frameAndChildrenSelectedTogether(selectedElements)
);
};
@@ -71,10 +76,12 @@ export const actionGroup = register({
icon: (appState) => <GroupIcon theme={appState.theme} />,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
const selectedElements = getRootElements(
app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
}),
);
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, storeAction: StoreAction.NONE };
+238 -200
View File
@@ -53,6 +53,9 @@ import {
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon,
} from "../components/icons";
import {
ARROW_TYPE,
@@ -86,6 +89,7 @@ import type {
FontFamilyValues,
TextAlign,
VerticalAlign,
NonDeletedSceneElementsMap,
} from "../element/types";
import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
@@ -112,11 +116,11 @@ import {
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
updateBoundElements,
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math";
import { pointFrom, vector } from "../../math";
import { pointFrom } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -216,33 +220,47 @@ const changeFontSize = (
) => {
const newFontSizes = new Set<number>();
const updatedElements = changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
);
// Update arrow elements after text elements have been updated
const updatedElementsMap = arrayToMap(updatedElements);
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).forEach((element) => {
if (isTextElement(element)) {
updateBoundElements(
element,
updatedElementsMap as NonDeletedSceneElementsMap,
);
}
});
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
),
elements: updatedElements,
appState: {
...appState,
// update state only if we've set all select text elements to
@@ -1405,59 +1423,65 @@ const getArrowheadOptions = (flip: boolean) => {
keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />,
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "e",
icon: <ArrowheadBarIcon flip={flip} />,
},
{
value: "dot",
text: t("labels.arrowhead_circle"),
keyBinding: null,
icon: <ArrowheadCircleIcon flip={flip} />,
showInPicker: false,
},
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "r",
icon: <ArrowheadCircleIcon flip={flip} />,
showInPicker: false,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: null,
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
showInPicker: false,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "t",
keyBinding: "e",
},
{
value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: null,
showInPicker: false,
keyBinding: "r",
},
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "a",
icon: <ArrowheadCircleIcon flip={flip} />,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: "s",
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
},
{
value: "diamond",
text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: null,
showInPicker: false,
keyBinding: "d",
},
{
value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: null,
showInPicker: false,
keyBinding: "f",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "z",
icon: <ArrowheadBarIcon flip={flip} />,
},
{
value: "crowfoot_one",
text: t("labels.arrowhead_crowfoot_one"),
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
keyBinding: "c",
},
{
value: "crowfoot_many",
text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadCrowfootIcon flip={flip} />,
keyBinding: "x",
},
{
value: "crowfoot_one_or_many",
text: t("labels.arrowhead_crowfoot_one_or_many"),
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
] as const;
};
@@ -1521,6 +1545,7 @@ export const actionChangeArrowhead = register({
appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
<IconPicker
label="arrowhead_end"
@@ -1537,6 +1562,7 @@ export const actionChangeArrowhead = register({
appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
</div>
</fieldset>
@@ -1549,150 +1575,162 @@ export const actionChangeArrowType = register({
label: "Change arrow types",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (!isArrowElement(el)) {
return el;
}
const newElement = newElementWith(el, {
roundness:
value === ARROW_TYPE.round
? {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
elbowed: value === ARROW_TYPE.elbow,
points:
value === ARROW_TYPE.elbow || el.elbowed
? [el.points[0], el.points[el.points.length - 1]]
: el.points,
const newElements = changeProperty(elements, appState, (el) => {
if (!isArrowElement(el)) {
return el;
}
const newElement = newElementWith(el, {
roundness:
value === ARROW_TYPE.round
? {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
elbowed: value === ARROW_TYPE.elbow,
points:
value === ARROW_TYPE.elbow || el.elbowed
? [el.points[0], el.points[el.points.length - 1]]
: el.points,
});
if (isElbowArrow(newElement)) {
const elementsMap = app.scene.getNonDeletedElementsMap();
app.dismissLinearEditor();
const startGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
0,
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
appState.zoom,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startHoveredElement,
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
mutateElement(newElement, {
points: [finalStartPoint, finalEndPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
...(startElement && newElement.startBinding
? {
startBinding: {
// @ts-ignore TS cannot discern check above
...newElement.startBinding!,
...calculateFixedPointForElbowArrowBinding(
newElement,
startElement,
"start",
elementsMap,
),
},
}
: {}),
...(endElement && newElement.endBinding
? {
endBinding: {
// @ts-ignore TS cannot discern check above
...newElement.endBinding,
...calculateFixedPointForElbowArrowBinding(
newElement,
endElement,
"end",
elementsMap,
),
},
}
: {}),
});
if (isElbowArrow(newElement)) {
const elementsMap = app.scene.getNonDeletedElementsMap();
LinearElementEditor.updateEditorMidPointsCache(
newElement,
elementsMap,
app.state,
);
}
app.dismissLinearEditor();
return newElement;
});
const startGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
0,
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const newState = {
...appState,
currentItemArrowType: value,
};
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startHoveredElement,
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
// Change the arrow type and update any other state settings for
// the arrow.
const selectedId = appState.selectedLinearElement?.elementId;
if (selectedId) {
const selected = newElements.find((el) => el.id === selectedId);
if (selected) {
newState.selectedLinearElement = new LinearElementEditor(
selected as ExcalidrawLinearElement,
);
}
}
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(
newElement,
endHoveredElement,
"end",
elementsMap,
);
mutateElbowArrow(
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
vector(0, 0),
{
...(startElement && newElement.startBinding
? {
startBinding: {
// @ts-ignore TS cannot discern check above
...newElement.startBinding!,
...calculateFixedPointForElbowArrowBinding(
newElement,
startElement,
"start",
elementsMap,
),
},
}
: {}),
...(endElement && newElement.endBinding
? {
endBinding: {
// @ts-ignore TS cannot discern check above
...newElement.endBinding,
...calculateFixedPointForElbowArrowBinding(
newElement,
endElement,
"end",
elementsMap,
),
},
}
: {}),
},
);
}
return newElement;
}),
appState: {
...appState,
currentItemArrowType: value,
},
return {
elements: newElements,
appState: newState,
storeAction: StoreAction.CAPTURE,
};
},
@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
import type { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { selectAllIcon } from "../components/icons";
import { StoreAction } from "../store";
@@ -20,17 +19,17 @@ export const actionSelectAll = register({
return false;
}
const selectedElementIds = excludeElementsInFramesFromSelection(
elements.filter(
const selectedElementIds = elements
.filter(
(element) =>
!element.isDeleted &&
!(isTextElement(element) && element.containerId) &&
!element.locked,
),
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
map[element.id] = true;
return map;
}, {});
)
.reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
map[element.id] = true;
return map;
}, {});
return {
appState: {
+2
View File
@@ -47,6 +47,7 @@ export type ShortcutName =
| "saveFileToDisk"
| "saveToActiveFile"
| "toggleShortcuts"
| "wrapSelectionInFrame"
>
| "saveScene"
| "imageExport"
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [],
};
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+4 -1
View File
@@ -135,7 +135,10 @@ export type ActionName =
| "autoResize"
| "elementStats"
| "searchMenu"
| "cropEditor";
| "copyElementLink"
| "linkToElement"
| "cropEditor"
| "wrapSelectionInFrame";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
+14 -5
View File
@@ -1,8 +1,10 @@
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement";
import { mutateElement } from "./element/mutateElement";
import type { BoundingBox } from "./element/bounds";
import { getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups";
import { updateBoundElements } from "./element/binding";
import type Scene from "./scene/Scene";
export interface Alignment {
position: "start" | "center" | "end";
@@ -13,6 +15,7 @@ export const alignElements = (
selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
alignment: Alignment,
scene: Scene,
): ExcalidrawElement[] => {
const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements,
@@ -26,12 +29,18 @@ export const alignElements = (
selectionBoundingBox,
alignment,
);
return group.map((element) =>
newElementWith(element, {
return group.map((element) => {
// update element
const updatedEle = mutateElement(element, {
x: element.x + translation.x,
y: element.y + translation.y,
}),
);
});
// update bound elements
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: group,
});
return updatedEle;
});
});
};
+12 -2
View File
@@ -20,7 +20,7 @@ export interface AnimatedTrailOptions {
}
export class AnimatedTrail implements Trail {
private currentTrail?: LaserPointer;
currentTrail?: LaserPointer;
private pastTrails: LaserPointer[] = [];
private container?: SVGSVGElement;
@@ -28,7 +28,7 @@ export class AnimatedTrail implements Trail {
constructor(
private animationFrameHandler: AnimationFrameHandler,
private app: App,
protected app: App,
private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>,
) {
@@ -98,6 +98,16 @@ export class AnimatedTrail implements Trail {
}
}
getCurrentTrail() {
return this.currentTrail;
}
clearTrails() {
this.pastTrails = [];
this.currentTrail = undefined;
this.update();
}
private update() {
this.start();
}
+8 -5
View File
@@ -1,18 +1,17 @@
import { COLOR_PALETTE } from "./colors";
import {
ARROW_TYPE,
COLOR_WHITE,
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE,
DEFAULT_ZOOM_VALUE,
EXPORT_SCALES,
STATS_PANELS,
THEME,
DEFAULT_GRID_STEP,
} from "./constants";
import type { AppState } from "./types";
import type { AppState, NormalizedZoomValue } from "./types";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio
@@ -85,6 +84,7 @@ export const getDefaultAppState = (): Omit<
scrollX: 0,
scrollY: 0,
selectedElementIds: {},
hoveredElementIds: {},
selectedGroupIds: {},
selectedElementsAreBeingDragged: false,
selectionElement: null,
@@ -100,10 +100,10 @@ export const getDefaultAppState = (): Omit<
editingFrame: null,
elementsToHighlight: null,
toast: null,
viewBackgroundColor: COLOR_WHITE,
viewBackgroundColor: COLOR_PALETTE.white,
zenModeEnabled: false,
zoom: {
value: DEFAULT_ZOOM_VALUE,
value: 1 as NormalizedZoomValue,
},
viewModeEnabled: false,
pendingImageElementId: null,
@@ -120,6 +120,7 @@ export const getDefaultAppState = (): Omit<
isCropping: false,
croppingElementId: null,
searchMatches: [],
lassoSelectionEnabled: false,
};
};
@@ -211,6 +212,7 @@ const APP_STATE_STORAGE_CONF = (<
scrollX: { browser: true, export: false, server: false },
scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false },
hoveredElementIds: { browser: false, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false },
selectedElementsAreBeingDragged: {
browser: false,
@@ -243,6 +245,7 @@ const APP_STATE_STORAGE_CONF = (<
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
lassoSelectionEnabled: { browser: true, export: false, server: false },
});
const _clearAppStateForStorage = <
+7 -4
View File
@@ -1,8 +1,11 @@
import type { Radians } from "../math";
import { pointFrom } from "../math";
import { DEFAULT_CHART_COLOR_INDEX, getAllColorsSpecificShade } from "./colors";
import {
COLOR_CHARCOAL_BLACK,
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
getAllColorsSpecificShade,
} from "./colors";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
VERTICAL_ALIGN,
@@ -170,7 +173,7 @@ const commonProps = {
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: COLOR_CHARCOAL_BLACK,
strokeColor: COLOR_PALETTE.black,
roundness: null,
strokeStyle: "solid",
strokeWidth: 1,
@@ -321,7 +324,7 @@ const chartBaseElements = (
y: y - chartHeight,
width: chartWidth,
height: chartHeight,
strokeColor: COLOR_CHARCOAL_BLACK,
strokeColor: COLOR_PALETTE.black,
fillStyle: "solid",
opacity: 6,
})
+65 -35
View File
@@ -18,6 +18,8 @@ import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
import { createFile, isSupportedImageFileType } from "./data/blob";
import { ExcalidrawError } from "./errors";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -39,7 +41,7 @@ export interface ClipboardData {
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
type ParsedClipboardEvent =
type ParsedClipboardEventTextData =
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent };
@@ -75,7 +77,7 @@ export const createPasteEvent = ({
types,
files,
}: {
types?: { [key in AllowedPasteMimeTypes]?: string };
types?: { [key in AllowedPasteMimeTypes]?: string | File };
files?: File[];
}) => {
if (!types && !files) {
@@ -88,6 +90,11 @@ export const createPasteEvent = ({
if (types) {
for (const [type, value] of Object.entries(types)) {
if (typeof value !== "string") {
files = files || [];
files.push(value);
continue;
}
try {
event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) {
@@ -217,14 +224,14 @@ function parseHTMLTree(el: ChildNode) {
const maybeParseHTMLPaste = (
event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData("text/html");
const html = event.clipboardData?.getData(MIME_TYPES.html);
if (!html) {
return null;
}
try {
const doc = new DOMParser().parseFromString(html, "text/html");
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
const content = parseHTMLTree(doc.body);
@@ -238,34 +245,44 @@ const maybeParseHTMLPaste = (
return null;
};
/**
* Reads OS clipboard programmatically. May not work on all browsers.
* Will prompt user for permission if not granted.
*/
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;
}
}
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
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;
try {
if (navigator.clipboard?.readText) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
const readText = await navigator.clipboard?.readText();
if (readText) {
return { [MIME_TYPES.text]: 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 {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
}
throw error;
}
}
throw error;
}
@@ -276,10 +293,20 @@ export const readSystemClipboard = async () => {
continue;
}
try {
types[type] = await (await item.getType(type)).text();
if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
types[type] = await (await item.getType(type)).text();
} else if (isSupportedImageFileType(type)) {
const imageBlob = await item.getType(type);
const file = createFile(imageBlob, type, undefined);
types[type] = file;
} else {
throw new ExcalidrawError(`Unsupported clipboard type: ${type}`);
}
} catch (error: any) {
console.warn(
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
error instanceof ExcalidrawError
? error.message
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
);
}
}
@@ -296,10 +323,10 @@ export const readSystemClipboard = async () => {
/**
* Parses "paste" ClipboardEvent.
*/
const parseClipboardEvent = async (
const parseClipboardEventTextData = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ParsedClipboardEvent> => {
): Promise<ParsedClipboardEventTextData> => {
try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
@@ -308,7 +335,7 @@ const parseClipboardEvent = async (
return {
type: "text",
value:
event.clipboardData?.getData("text/plain") ||
event.clipboardData?.getData(MIME_TYPES.text) ||
mixedContent.value
.map((item) => item.value)
.join("\n")
@@ -319,7 +346,7 @@ const parseClipboardEvent = async (
return mixedContent;
}
const text = event.clipboardData?.getData("text/plain");
const text = event.clipboardData?.getData(MIME_TYPES.text);
return { type: "text", value: (text || "").trim() };
} catch {
@@ -328,13 +355,16 @@ const parseClipboardEvent = async (
};
/**
* Attempts to parse clipboard. Prefers system clipboard.
* Attempts to parse clipboard event.
*/
export const parseClipboard = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
const parsedEventData = await parseClipboardEventTextData(
event,
isPlainPaste,
);
if (parsedEventData.type === "mixedContent") {
return {
@@ -423,8 +453,8 @@ export const copyTextToSystemClipboard = async (
// (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) {
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
throw new Error("Failed to setData on clipboardEvent");
}
return;
+23 -21
View File
@@ -1,25 +1,27 @@
import oc from "open-color";
import {
COLOR_WHITE,
COLOR_CHARCOAL_BLACK,
COLOR_TRANSPARENT,
} from "./constants";
import { type Merge } from "./utility-types";
import { pick } from "./utils";
import type { Merge } from "./utility-types";
// FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
source: R,
keys: K,
) => {
return keys.reduce((acc, key: K[number]) => {
if (key in source) {
acc[key] = source[key];
}
return acc;
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
};
export type ColorPickerColor =
| Exclude<keyof oc, "indigo" | "lime" | "black">
| Exclude<keyof oc, "indigo" | "lime">
| "transparent"
| "charcoal"
| "bronze";
export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{
charcoal: typeof COLOR_CHARCOAL_BLACK;
white: typeof COLOR_WHITE;
transparent: typeof COLOR_TRANSPARENT;
}
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
>;
// used general type instead of specific type (ColorPalette) to support custom colors
@@ -39,7 +41,7 @@ export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
export const getSpecificColorShades = (
color: Exclude<
ColorPickerColor,
"transparent" | "charcoal" | "black" | "white" | "bronze"
"transparent" | "white" | "black" | "bronze"
>,
indexArr: Readonly<ColorShadesIndexes>,
) => {
@@ -47,9 +49,9 @@ export const getSpecificColorShades = (
};
export const COLOR_PALETTE = {
transparent: COLOR_TRANSPARENT,
charcoal: COLOR_CHARCOAL_BLACK,
white: COLOR_WHITE,
transparent: "transparent",
black: "#1e1e1e",
white: "#ffffff",
// open-colors
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
@@ -85,7 +87,7 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
// ORDER matters for positioning in quick picker
export const DEFAULT_ELEMENT_STROKE_PICKS = [
COLOR_PALETTE.charcoal,
COLOR_PALETTE.black,
COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
@@ -123,7 +125,7 @@ export const DEFAULT_ELEMENT_STROKE_COLOR_PALETTE = {
transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray,
charcoal: COLOR_PALETTE.charcoal,
black: COLOR_PALETTE.black,
bronze: COLOR_PALETTE.bronze,
// rest
...COMMON_ELEMENT_SHADES,
@@ -134,7 +136,7 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray,
charcoal: COLOR_PALETTE.charcoal,
black: COLOR_PALETTE.black,
bronze: COLOR_PALETTE.bronze,
...COMMON_ELEMENT_SHADES,
+7 -1
View File
@@ -51,6 +51,7 @@ import {
import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels";
import { CLASSES } from "../constants";
import { alignActionsPredicate } from "../actions/actionAlign";
export const canChangeStrokeColor = (
appState: UIAppState,
@@ -90,10 +91,12 @@ export const SelectedShapeActions = ({
appState,
elementsMap,
renderAction,
app,
}: {
appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
}) => {
const targetElements = getTargetElements(elementsMap, appState);
@@ -133,6 +136,9 @@ export const SelectedShapeActions = ({
targetElements.length === 1 &&
isImageElement(targetElements[0]);
const showAlignActions =
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
return (
<div className="panelColumn">
<div>
@@ -200,7 +206,7 @@ export const SelectedShapeActions = ({
</div>
</fieldset>
{targetElements.length > 1 && !isSingleElementBoundContainer && (
{showAlignActions && !isSingleElementBoundContainer && (
<fieldset>
<legend>{t("labels.align")}</legend>
<div className="buttonList">
@@ -1,7 +1,6 @@
import { atom, useAtom } from "jotai";
import { actionClearCanvas } from "../actions";
import { t } from "../i18n";
import { jotaiScope } from "../jotai";
import { atom, useAtom } from "../editor-jotai";
import { useExcalidrawActionManager } from "./App";
import ConfirmDialog from "./ConfirmDialog";
@@ -10,7 +9,6 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
export const ActiveConfirmDialog = () => {
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
activeConfirmDialogAtom,
jotaiScope,
);
const actionManager = useExcalidrawActionManager();
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getColor } from "./ColorPicker";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { eyeDropperIcon } from "../icons";
import { jotaiScope } from "../../jotai";
import { useAtom } from "../../editor-jotai";
import { KEYS } from "../../keys";
import { activeEyeDropperAtom } from "../EyeDropper";
import clsx from "clsx";
@@ -57,10 +56,7 @@ export const ColorInput = ({
}
}, [activeSection]);
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
useEffect(() => {
return () => {
@@ -5,7 +5,6 @@ import { TopPicks } from "./TopPicks";
import { ButtonSeparator } from "../ButtonSeparator";
import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useExcalidrawContainer } from "../App";
@@ -15,7 +14,7 @@ import PickerHeading from "./PickerHeading";
import { t } from "../../i18n";
import clsx from "clsx";
import { useRef } from "react";
import { jotaiScope } from "../../jotai";
import { useAtom } from "../../editor-jotai";
import { ColorInput } from "./ColorInput";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
@@ -76,10 +75,7 @@ const ColorPickerPopupContent = ({
const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
const colorInputJSX = (
<div>
@@ -1,5 +1,5 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
@@ -5,7 +5,7 @@ import type { ExcalidrawElement } from "../../element/types";
import { ShadeList } from "./ShadeList";
import PickerColorList from "./PickerColorList";
import { useAtom } from "jotai";
import { useAtom } from "../../editor-jotai";
import { CustomColorList } from "./CustomColorList";
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
import PickerHeading from "./PickerHeading";
@@ -1,5 +1,5 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
@@ -1,5 +1,5 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
@@ -1,7 +1,7 @@
import type { ExcalidrawElement } from "../../element/types";
import { atom } from "jotai";
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
import { atom } from "../../editor-jotai";
export const getColorNameAndShadeFromColor = ({
palette,
@@ -204,7 +204,7 @@ export const colorPickerKeyNavHandler = ({
});
if (!baseColorName) {
onChange(COLOR_PALETTE.charcoal);
onChange(COLOR_PALETTE.black);
}
}
@@ -36,7 +36,7 @@ import {
getShortcutKey,
isWritableElement,
} from "../../utils";
import { atom, useAtom } from "jotai";
import { atom, useAtom, editorJotaiStore } from "../../editor-jotai";
import { deburr } from "../../deburr";
import type { MarkRequired } from "../../utility-types";
import { InlineIcon } from "../InlineIcon";
@@ -48,7 +48,6 @@ import {
actionLink,
actionToggleSearchMenu,
} from "../../actions";
import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import type { CommandPaletteItem } from "./types";
import * as defaultItems from "./defaultCommandPaletteItems";
@@ -56,6 +55,10 @@ import { trackEvent } from "../../analytics";
import { useStable } from "../../hooks/useStable";
import "./CommandPalette.scss";
import {
actionCopyElementLink,
actionLinkToElement,
} from "../../actions/actionElementLink";
const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null);
@@ -259,6 +262,7 @@ function CommandPaletteInner({
actionManager.actions.cut,
actionManager.actions.copy,
actionManager.actions.deleteSelectedElements,
actionManager.actions.wrapSelectionInFrame,
actionManager.actions.copyStyles,
actionManager.actions.pasteStyles,
actionManager.actions.bringToFront,
@@ -281,6 +285,8 @@ function CommandPaletteInner({
actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionLink,
actionCopyElementLink,
actionLinkToElement,
].map((action: Action) =>
actionToCommand(
action,
@@ -342,7 +348,7 @@ function CommandPaletteInner({
keywords: ["delete", "destroy"],
viewMode: false,
perform: () => {
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
},
},
{
@@ -1,13 +1,13 @@
import { flushSync } from "react-dom";
import { t } from "../i18n";
import type { DialogProps } from "./Dialog";
import { Dialog } from "./Dialog";
import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
import { jotaiScope } from "../jotai";
import { useSetAtom } from "../editor-jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void;
@@ -26,7 +26,7 @@ const ConfirmDialog = (props: Props) => {
...rest
} = props;
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const { container } = useExcalidrawContainer();
return (
@@ -43,7 +43,14 @@ const ConfirmDialog = (props: Props) => {
onClick={() => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
onCancel();
// flush any pending updates synchronously,
// otherwise it could lead to crash in some chromium versions (131.0.6778.86),
// when `.focus` is invoked with container in some intermediate state
// (container seems mounted in DOM, but focus still causes a crash)
flushSync(() => {
onCancel();
});
container?.focus();
}}
/>
@@ -52,7 +59,14 @@ const ConfirmDialog = (props: Props) => {
onClick={() => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
onConfirm();
// flush any pending updates synchronously,
// otherwise it leads to crash in some chromium versions (131.0.6778.86),
// when `.focus` is invoked with container in some intermediate state
// (container seems mounted in DOM, but focus still causes a crash)
flushSync(() => {
onConfirm();
});
container?.focus();
}}
actionType="danger"
+2 -3
View File
@@ -11,9 +11,8 @@ import "./Dialog.scss";
import { Island } from "./Island";
import { Modal } from "./Modal";
import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai";
import { useSetAtom } from "../editor-jotai";
import { t } from "../i18n";
import { CloseIcon } from "./icons";
@@ -92,7 +91,7 @@ export const Dialog = (props: DialogProps) => {
}, [islandNode, props.autofocus]);
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const onClose = () => {
setAppState({ openMenu: null });
@@ -0,0 +1,87 @@
@import "../css/variables.module.scss";
.excalidraw {
.ElementLinkDialog {
position: absolute;
top: var(--editor-container-padding);
left: var(--editor-container-padding);
z-index: var(--zIndex-modal);
border-radius: 10px;
padding: 1.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: var(--shadow-island);
background-color: var(--island-bg-color);
@include isMobile {
left: 0;
margin-left: 0.5rem;
margin-right: 0.5rem;
width: calc(100% - 1rem);
box-sizing: border-box;
z-index: 5;
}
.ElementLinkDialog__header {
h2 {
margin-top: 0;
margin-bottom: 0.5rem;
@include isMobile {
font-size: 1.25rem;
}
}
p {
margin: 0;
@include isMobile {
font-size: 0.875rem;
}
}
margin-bottom: 1.5rem;
@include isMobile {
margin-bottom: 1rem;
}
}
.ElementLinkDialog__input {
display: flex;
.ElementLinkDialog__input-field {
flex: 1;
}
.ElementLinkDialog__remove {
color: $oc-red-9;
margin-left: 1rem;
.ToolIcon__icon {
width: 2rem;
height: 2rem;
}
.ToolIcon__icon svg {
color: $oc-red-6;
}
}
}
.ElementLinkDialog__actions {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
@include isMobile {
font-size: 0.875rem;
margin-top: 1rem;
}
}
}
}
@@ -0,0 +1,174 @@
import { TextField } from "./TextField";
import type { AppProps, AppState, UIAppState } from "../types";
import DialogActionButton from "./DialogActionButton";
import { getSelectedElements } from "../scene";
import {
defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection,
} from "../element/elementLink";
import { mutateElement } from "../element/mutateElement";
import { useCallback, useEffect, useState } from "react";
import { t } from "../i18n";
import type { ElementsMap, ExcalidrawElement } from "../element/types";
import { ToolButton } from "./ToolButton";
import { TrashIcon } from "./icons";
import { KEYS } from "../keys";
import "./ElementLinkDialog.scss";
import { normalizeLink } from "../data/url";
const ElementLinkDialog = ({
sourceElementId,
onClose,
elementsMap,
appState,
generateLinkForSelection = defaultGetElementLinkFromSelection,
}: {
sourceElementId: ExcalidrawElement["id"];
elementsMap: ElementsMap;
appState: UIAppState;
onClose?: () => void;
generateLinkForSelection: AppProps["generateLinkForSelection"];
}) => {
const originalLink = elementsMap.get(sourceElementId)?.link ?? null;
const [nextLink, setNextLink] = useState<string | null>(originalLink);
const [linkEdited, setLinkEdited] = useState(false);
useEffect(() => {
const selectedElements = getSelectedElements(elementsMap, appState);
let nextLink = originalLink;
if (selectedElements.length > 0 && generateLinkForSelection) {
const idAndType = getLinkIdAndTypeFromSelection(
selectedElements,
appState as AppState,
);
if (idAndType) {
nextLink = normalizeLink(
generateLinkForSelection(idAndType.id, idAndType.type),
);
}
}
setNextLink(nextLink);
}, [
elementsMap,
appState,
appState.selectedElementIds,
originalLink,
generateLinkForSelection,
]);
const handleConfirm = useCallback(() => {
if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) {
const elementToLink = elementsMap.get(sourceElementId);
elementToLink &&
mutateElement(elementToLink, {
link: nextLink,
});
}
if (!nextLink && linkEdited && sourceElementId) {
const elementToLink = elementsMap.get(sourceElementId);
elementToLink &&
mutateElement(elementToLink, {
link: null,
});
}
onClose?.();
}, [sourceElementId, nextLink, elementsMap, linkEdited, onClose]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
appState.openDialog?.name === "elementLinkSelector" &&
event.key === KEYS.ENTER
) {
handleConfirm();
}
if (
appState.openDialog?.name === "elementLinkSelector" &&
event.key === KEYS.ESCAPE
) {
onClose?.();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [appState, onClose, handleConfirm]);
return (
<div className="ElementLinkDialog">
<div className="ElementLinkDialog__header">
<h2>{t("elementLink.title")}</h2>
<p>{t("elementLink.desc")}</p>
</div>
<div className="ElementLinkDialog__input">
<TextField
value={nextLink ?? ""}
onChange={(value) => {
if (!linkEdited) {
setLinkEdited(true);
}
setNextLink(value);
}}
onKeyDown={(event) => {
if (event.key === KEYS.ENTER) {
handleConfirm();
}
}}
className="ElementLinkDialog__input-field"
selectOnRender
/>
{originalLink && nextLink && (
<ToolButton
type="button"
title={t("buttons.remove")}
aria-label={t("buttons.remove")}
label={t("buttons.remove")}
onClick={() => {
// removes the link from the input
// but doesn't update the element
// when confirmed, will remove the link from the element
setNextLink(null);
setLinkEdited(true);
}}
className="ElementLinkDialog__remove"
icon={TrashIcon}
/>
)}
</div>
<div className="ElementLinkDialog__actions">
<DialogActionButton
label={t("buttons.cancel")}
onClick={() => {
onClose?.();
}}
style={{
marginRight: 10,
}}
/>
<DialogActionButton
label={t("buttons.confirm")}
onClick={handleConfirm}
actionType="primary"
/>
</div>
</div>
);
};
export default ElementLinkDialog;
@@ -1,4 +1,3 @@
import { atom } from "jotai";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { rgbToHex } from "../colors";
@@ -14,6 +13,7 @@ import { useStable } from "../hooks/useStable";
import "./EyeDropper.scss";
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
import type { ExcalidrawElement } from "../element/types";
import { atom } from "../editor-jotai";
export type EyeDropperProperties = {
keepOpenOnAlt: boolean;
@@ -15,7 +15,6 @@
top: var(--editor-container-padding);
right: var(--editor-container-padding);
bottom: var(--editor-container-padding);
z-index: 2;
}
.FixedSideContainer_side_top.zen-mode {
+11 -45
View File
@@ -1,19 +1,16 @@
@import "../css/variables.module.scss";
.excalidraw {
.picker-container {
display: inline-block;
box-sizing: border-box;
margin-right: 0.25rem;
}
.picker {
padding: 0.5rem;
background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75);
// ˇˇ yeah, i dunno, open to suggestions here :D
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
box-shadow: var(--shadow-island);
border-radius: 4px;
position: absolute;
:root[dir="rtl"] & {
padding: 0.4rem;
}
}
.picker-container button,
@@ -55,47 +52,16 @@
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
}
.picker-triangle {
width: 0;
height: 0;
position: relative;
top: -10px;
:root[dir="ltr"] & {
left: 12px;
}
:root[dir="rtl"] & {
right: 12px;
}
z-index: 10;
&:before {
content: "";
position: absolute;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent transparentize($oc-black, 0.9);
top: -1px;
}
&:after {
content: "";
position: absolute;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color);
}
}
.picker-content {
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(3, auto);
grid-template-columns: repeat(4, auto);
grid-gap: 0.5rem;
border-radius: 4px;
:root[dir="rtl"] & {
padding: 0.4rem;
}
}
.picker-collapsible {
font-size: 0.75rem;
padding: 0.5rem 0;
}
.picker-keybinding {
+129 -100
View File
@@ -1,10 +1,22 @@
import React from "react";
import { Popover } from "./Popover";
import React, { useEffect } from "react";
import * as Popover from "@radix-ui/react-popover";
import { isArrowKey, KEYS } from "../keys";
import { getLanguage, t } from "../i18n";
import clsx from "clsx";
import Collapsible from "./Stats/Collapsible";
import { atom, useAtom } from "../editor-jotai";
import { useDevice } from "..";
import "./IconPicker.scss";
import { isArrowKey, KEYS } from "../keys";
import { getLanguage } from "../i18n";
import clsx from "clsx";
const moreOptionsAtom = atom(false);
type Option<T> = {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
};
function Picker<T>({
options,
@@ -12,30 +24,16 @@ function Picker<T>({
label,
onChange,
onClose,
numberOfOptionsToAlwaysShow = options.length,
}: {
label: string;
value: T;
options: {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
options: readonly Option<T>[];
onChange: (value: T) => void;
onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) {
const rFirstItem = React.useRef<HTMLButtonElement>();
const rActiveItem = React.useRef<HTMLButtonElement>();
const rGallery = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
// After the component is first mounted focus on first input
if (rActiveItem.current) {
rActiveItem.current.focus();
} else if (rGallery.current) {
rGallery.current.focus();
}
}, []);
const device = useDevice();
const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find(
@@ -44,28 +42,19 @@ function Picker<T>({
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation
const index = options.indexOf(pressedOption);
(rGallery!.current!.children![index] as any).focus();
onChange(pressedOption.value);
event.preventDefault();
} else if (event.key === KEYS.TAB) {
// Tab navigation cycle through options. If the user tabs
// away from the picker, close the picker. We need to use
// a timeout here to let the stack clear before checking.
setTimeout(() => {
const active = rActiveItem.current;
const docActive = document.activeElement;
if (active !== docActive) {
onClose();
}
}, 0);
const index = options.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey
? (options.length + index - 1) % options.length
: (index + 1) % options.length;
onChange(options[nextIndex].value);
} else if (isArrowKey(event.key)) {
// Arrow navigation
const { activeElement } = document;
const isRTL = getLanguage().rtl;
const index = Array.prototype.indexOf.call(
rGallery!.current!.children,
activeElement,
);
const index = options.findIndex((option) => option.value === value);
if (index !== -1) {
const length = options.length;
let nextIndex = index;
@@ -73,19 +62,26 @@ function Picker<T>({
switch (event.key) {
// Select the next option
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
case KEYS.ARROW_DOWN: {
nextIndex = (index + 1) % length;
break;
}
// Select the previous option
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
case KEYS.ARROW_UP: {
nextIndex = (length + index - 1) % length;
break;
// Go the next row
case KEYS.ARROW_DOWN: {
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
break;
}
// Go the previous row
case KEYS.ARROW_UP: {
nextIndex =
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
break;
}
}
(rGallery.current!.children![nextIndex] as any).focus();
onChange(options[nextIndex].value);
}
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -97,15 +93,26 @@ function Picker<T>({
event.stopPropagation();
};
return (
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
onKeyDown={handleKeyDown}
>
<div className="picker-content" ref={rGallery}>
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const alwaysVisibleOptions = React.useMemo(
() => options.slice(0, numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
const moreOptions = React.useMemo(
() => options.slice(numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
useEffect(() => {
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
setShowMoreOptions(true);
}
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
const renderOptions = (options: Option<T>[]) => {
return (
<div className="picker-content">
{options.map((option, i) => (
<button
type="button"
@@ -113,7 +120,6 @@ function Picker<T>({
active: value === option.value,
})}
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(option.value);
}}
title={`${option.text} ${
@@ -122,16 +128,13 @@ function Picker<T>({
aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined}
key={option.text}
ref={(el) => {
if (el && i === 0) {
rFirstItem.current = el;
ref={(ref) => {
if (value === option.value) {
// Use a timeout here to render focus properly
setTimeout(() => {
ref?.focus();
}, 0);
}
if (el && option.value === value) {
rActiveItem.current = el;
}
}}
onFocus={() => {
onChange(option.value);
}}
>
{option.icon}
@@ -141,7 +144,43 @@ function Picker<T>({
</button>
))}
</div>
</div>
);
};
return (
<Popover.Content
side={
device.editor.isMobile && !device.viewport.isLandscape
? "top"
: "bottom"
}
align="start"
sideOffset={12}
style={{ zIndex: "var(--zIndex-popup)" }}
onKeyDown={handleKeyDown}
>
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
>
{renderOptions(alwaysVisibleOptions)}
{moreOptions.length > 0 && (
<Collapsible
label={t("labels.more_options")}
open={showMoreOptions}
openTrigger={() => {
setShowMoreOptions((value) => !value);
}}
className="picker-collapsible"
>
{renderOptions(moreOptions)}
</Collapsible>
)}
</div>
</Popover.Content>
);
}
@@ -151,6 +190,7 @@ export function IconPicker<T>({
options,
onChange,
group = "",
numberOfOptionsToAlwaysShow,
}: {
label: string;
value: T;
@@ -159,51 +199,40 @@ export function IconPicker<T>({
text: string;
icon: JSX.Element;
keyBinding: string | null;
showInPicker?: boolean;
}[];
onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string;
}) {
const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null);
const isRTL = getLanguage().rtl;
return (
<div>
<button
name={group}
type="button"
className={isActive ? "active" : ""}
aria-label={label}
onClick={() => setActive(!isActive)}
ref={rPickerButton}
>
{options.find((option) => option.value === value)?.icon}
</button>
<React.Suspense fallback="">
{isActive ? (
<>
<Popover
onCloseRequest={(event) =>
event.target !== rPickerButton.current && setActive(false)
}
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
>
<Picker
options={options.filter((opt) => opt.showInPicker !== false)}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
rPickerButton.current?.focus();
}}
/>
</Popover>
<div className="picker-triangle" />
</>
) : null}
</React.Suspense>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger
name={group}
type="button"
aria-label={label}
onClick={() => setActive(!isActive)}
ref={rPickerButton}
className={isActive ? "active" : ""}
>
{options.find((option) => option.value === value)?.icon}
</Popover.Trigger>
{isActive && (
<Picker
options={options}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
}}
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
/>
)}
</Popover.Root>
</div>
);
}
@@ -23,6 +23,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import type { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../../utils/export";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog";
@@ -35,7 +36,6 @@ import { FilledButton } from "./FilledButton";
import { cloneJSON } from "../utils";
import { prepareElementsForExport } from "../data";
import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { exportToCanvas } from "../scene/export";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -123,25 +123,19 @@ const ImageExportModal = ({
}
exportToCanvas({
data: {
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportEmbedScene: embedScene,
},
files,
},
config: {
canvasBackgroundColor: !exportWithBackground
? false
: appStateSnapshot.viewBackgroundColor,
padding: DEFAULT_EXPORT_PADDING,
theme: exportDarkMode ? "dark" : "light",
scale: exportScale,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
elements: exportedElements,
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);
+105 -87
View File
@@ -41,8 +41,7 @@ import { trackEvent } from "../analytics";
import { useDevice } from "./App";
import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import { useAtom, useAtomValue } from "../editor-jotai";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
@@ -60,6 +59,7 @@ import { LaserPointerButton } from "./LaserPointerButton";
import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import ElementLinkDialog from "./ElementLinkDialog";
import "./LayerUI.scss";
import "./Toolbar.scss";
@@ -84,6 +84,7 @@ interface LayerUIProps {
children?: React.ReactNode;
app: AppClassProperties;
isCollaborating: boolean;
generateLinkForSelection?: AppProps["generateLinkForSelection"];
}
const DefaultMainMenu: React.FC<{
@@ -141,14 +142,14 @@ const LayerUI = ({
children,
app,
isCollaborating,
generateLinkForSelection,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
@@ -218,6 +219,7 @@ const LayerUI = ({
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
/>
</Island>
</Section>
@@ -232,7 +234,8 @@ const LayerUI = ({
const shouldShowStats =
appState.stats.open &&
!appState.zenModeEnabled &&
!appState.viewModeEnabled;
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector";
return (
<FixedSideContainer side="top">
@@ -241,90 +244,91 @@ const LayerUI = ({
{renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col>
{!appState.viewModeEnabled && (
<Section heading="shapes" className="shapes-section">
{(heading: React.ReactNode) => (
<div style={{ position: "relative" }}>
{renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
)}
<Stack.Col gap={4} align="start">
<Stack.Row
gap={1}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
})}
>
<Island
padding={1}
className={clsx("App-toolbar", {
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
<Section heading="shapes" className="shapes-section">
{(heading: React.ReactNode) => (
<div style={{ position: "relative" }}>
{renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
)}
<Stack.Col gap={4} align="start">
<Stack.Row
gap={1}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
})}
>
<HintViewer
appState={appState}
isMobile={device.editor.isMobile}
device={device}
app={app}
/>
{heading}
<Stack.Row gap={1}>
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider" />
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
<ShapesSwitcher
<Island
padding={1}
className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled,
})}
>
<HintViewer
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
isMobile={device.editor.isMobile}
device={device}
app={app}
/>
</Stack.Row>
</Island>
{isCollaborating && (
<Island
style={{
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={
appState.activeTool.type === TOOL_TYPE.laser
}
onChange={() =>
app.setActiveTool({ type: TOOL_TYPE.laser })
}
isMobile
/>
{heading}
<Stack.Row gap={1}>
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider" />
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
<ShapesSwitcher
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
/>
</Stack.Row>
</Island>
)}
</Stack.Row>
</Stack.Col>
</div>
)}
</Section>
)}
{isCollaborating && (
<Island
style={{
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={
appState.activeTool.type === TOOL_TYPE.laser
}
onChange={() =>
app.setActiveTool({ type: TOOL_TYPE.laser })
}
isMobile
/>
</Island>
)}
</Stack.Row>
</Stack.Col>
</div>
)}
</Section>
)}
<div
className={clsx(
"layer-ui__wrapper__top-right zen-mode-transition",
@@ -341,6 +345,7 @@ const LayerUI = ({
)}
{renderTopRightUI?.(device.editor.isMobile, appState)}
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
// hide button when sidebar docked
(!isSidebarDocked ||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
@@ -376,7 +381,7 @@ const LayerUI = ({
);
};
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
const layerUIJSX = (
<>
@@ -471,6 +476,19 @@ const LayerUI = ({
/>
)}
<ActiveConfirmDialog />
{appState.openDialog?.name === "elementLinkSelector" && (
<ElementLinkDialog
sourceElementId={appState.openDialog.sourceElementId}
onClose={() => {
setAppState({
openDialog: null,
});
}}
elementsMap={app.scene.getNonDeletedElementsMap()}
appState={appState}
generateLinkForSelection={generateLinkForSelection}
/>
)}
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}
@@ -547,11 +565,11 @@ const LayerUI = ({
return (
<UIAppStateContext.Provider value={appState}>
<Provider scope={tunnels.jotaiScope}>
<TunnelsJotaiProvider>
<TunnelsContext.Provider value={tunnels}>
{layerUIJSX}
</TunnelsContext.Provider>
</Provider>
</TunnelsJotaiProvider>
</UIAppStateContext.Provider>
);
};
@@ -14,8 +14,7 @@ import type {
} from "../types";
import LibraryMenuItems from "./LibraryMenuItems";
import { trackEvent } from "../analytics";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import { atom, useAtom } from "../editor-jotai";
import Spinner from "./Spinner";
import {
useApp,
@@ -61,7 +60,7 @@ export const LibraryMenuContent = ({
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [libraryItemsData] = useAtom(libraryItemsAtom);
const _onAddToLibrary = useCallback(
(elements: LibraryItem["elements"]) => {
@@ -1,7 +1,7 @@
import { useCallback, useState } from "react";
import { t } from "../i18n";
import Trans from "./Trans";
import { jotaiScope } from "../jotai";
import { useAtom } from "../editor-jotai";
import type { LibraryItem, LibraryItems, UIAppState } from "../types";
import { useApp, useExcalidrawSetAppState } from "./App";
import { saveLibraryAsJSON } from "../data/json";
@@ -17,7 +17,6 @@ import {
import { ToolButton } from "./ToolButton";
import { fileOpen } from "../data/filesystem";
import { muteFSAbortError } from "../utils";
import { useAtom } from "jotai";
import ConfirmDialog from "./ConfirmDialog";
import PublishLibrary from "./PublishLibrary";
import { Dialog } from "./Dialog";
@@ -51,10 +50,9 @@ export const LibraryDropdownMenuButton: React.FC<{
appState,
className,
}) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [libraryItemsData] = useAtom(libraryItemsAtom);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
isLibraryMenuOpenAtom,
jotaiScope,
);
const renderRemoveLibAlert = () => {
@@ -286,7 +284,7 @@ export const LibraryDropdownMenu = ({
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [libraryItemsData] = useAtom(libraryItemsAtom);
const removeFromLibrary = async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
+13 -5
View File
@@ -91,9 +91,10 @@ export const MobileMenu = ({
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container">
{!appState.viewModeEnabled && (
<DefaultSidebarTriggerTunnel.Out />
)}
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
<DefaultSidebarTriggerTunnel.Out />
)}
<PenModeButton
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
@@ -129,7 +130,10 @@ export const MobileMenu = ({
};
const renderAppToolbar = () => {
if (appState.viewModeEnabled) {
if (
appState.viewModeEnabled ||
appState.openDialog?.name === "elementLinkSelector"
) {
return (
<div className="App-toolbar-content">
<MainMenuTunnel.Out />
@@ -154,7 +158,9 @@ export const MobileMenu = ({
return (
<>
{renderSidebars()}
{!appState.viewModeEnabled && renderToolbar()}
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
renderToolbar()}
<div
className="App-bottom-bar"
style={{
@@ -166,12 +172,14 @@ export const MobileMenu = ({
<Island padding={0}>
{appState.openMenu === "shape" &&
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
/>
</Section>
) : null}
@@ -1,8 +1,7 @@
import React from "react";
import { useAtom } from "jotai";
import { useTunnels } from "../../context/tunnels";
import { jotaiScope } from "../../jotai";
import { useAtom } from "../../editor-jotai";
import { Dialog } from "../Dialog";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
@@ -23,7 +22,6 @@ const OverwriteConfirmDialog = Object.assign(
const { OverwriteConfirmDialogTunnel } = useTunnels();
const [overwriteConfirmState, setState] = useAtom(
overwriteConfirmStateAtom,
jotaiScope,
);
if (!overwriteConfirmState.active) {
@@ -1,5 +1,4 @@
import { atom } from "jotai";
import { jotaiStore } from "../../jotai";
import { atom, editorJotaiStore } from "../../editor-jotai";
import type React from "react";
export type OverwriteConfirmState =
@@ -32,7 +31,7 @@ export async function openConfirmModal({
color: "danger" | "warning";
}) {
return new Promise<boolean>((resolve) => {
jotaiStore.set(overwriteConfirmStateAtom, {
editorJotaiStore.set(overwriteConfirmStateAtom, {
active: true,
onConfirm: () => resolve(true),
onClose: () => resolve(false),
@@ -1,9 +1,9 @@
import oc from "open-color";
import React, { useLayoutEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics";
import type { ChartElements, Spreadsheet } from "../charts";
import { renderSpreadsheet } from "../charts";
import type { ChartType } from "../element/types";
import { COLOR_WHITE } from "../constants";
import { t } from "../i18n";
import { exportToSvg } from "../scene/export";
import type { UIAppState } from "../types";
@@ -41,16 +41,17 @@ const ChartPreviewBtn = (props: {
const previewNode = previewRef.current!;
(async () => {
svg = await exportToSvg({
data: {
elements,
appState: {
exportBackground: false,
viewBackgroundColor: COLOR_WHITE,
},
files: null,
svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
});
null, // files
{
skipInliningFonts: true,
},
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
@@ -7,8 +7,8 @@ import { t } from "../i18n";
import Trans from "./Trans";
import type { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas, exportToSvg } from "../../utils/export";
import {
COLOR_WHITE,
EDITOR_LS_KEYS,
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
@@ -24,7 +24,6 @@ import { ToolButton } from "./ToolButton";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import "./PublishLibrary.scss";
import { exportToCanvas, exportToSvg } from "../scene/export";
interface PublishLibraryDataParams {
authorName: string;
@@ -56,20 +55,16 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = COLOR_WHITE;
ctx.fillStyle = OpenColor.white;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw items
// ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({
data: {
elements: item.elements,
files: null,
},
config: {
maxWidthOrHeight: BOX_SIZE,
},
elements: item.elements,
files: null,
maxWidthOrHeight: BOX_SIZE,
});
const { width, height } = itemCanvas;
@@ -131,15 +126,14 @@ const SingleLibraryItem = ({
}
(async () => {
const svg = await exportToSvg({
data: {
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: COLOR_WHITE,
exportBackground: true,
},
files: null,
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: OpenColor.white,
exportBackground: true,
},
files: null,
skipInliningFonts: true,
});
node.innerHTML = svg.outerHTML;
})();
@@ -11,8 +11,7 @@ import { measureText } from "../element/textElement";
import { addEventListener, getFontString } from "../utils";
import { KEYS } from "../keys";
import clsx from "clsx";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import { atom, useAtom } from "../editor-jotai";
import { t } from "../i18n";
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
import { randomInteger } from "../random";
@@ -58,7 +57,7 @@ export const SearchMenu = () => {
const searchInputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
const [inputValue, setInputValue] = useAtom(searchQueryAtom);
const searchQuery = inputValue.trim() as SearchQuery;
const [isSearching, setIsSearching] = useState(false);
@@ -70,10 +69,7 @@ export const SearchMenu = () => {
const searchedQueryRef = useRef<SearchQuery | null>(null);
const lastSceneNonceRef = useRef<number | undefined>(undefined);
const [focusIndex, setFocusIndex] = useAtom(
searchItemInFocusAtom,
jotaiScope,
);
const [focusIndex, setFocusIndex] = useAtom(searchItemInFocusAtom);
const elementsMap = app.scene.getNonDeletedElementsMap();
useEffect(() => {
@@ -8,8 +8,7 @@ import React, {
useCallback,
} from "react";
import { Island } from "../Island";
import { atom, useSetAtom } from "jotai";
import { jotaiScope } from "../../jotai";
import { atom, useSetAtom } from "../../editor-jotai";
import type { SidebarProps, SidebarPropsContextValue } from "./common";
import { SidebarPropsContext } from "./common";
import { SidebarHeader } from "./SidebarHeader";
@@ -58,7 +57,7 @@ export const SidebarInner = forwardRef(
const setAppState = useExcalidrawSetAppState();
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom);
useLayoutEffect(() => {
setIsSidebarDockedAtom(!!docked);
@@ -9,6 +9,7 @@ interface CollapsibleProps {
open: boolean;
openTrigger: () => void;
children: React.ReactNode;
className?: string;
}
const Collapsible = ({
@@ -16,6 +17,7 @@ const Collapsible = ({
open,
openTrigger,
children,
className,
}: CollapsibleProps) => {
return (
<>
@@ -26,6 +28,7 @@ const Collapsible = ({
justifyContent: "space-between",
alignItems: "center",
}}
className={className}
onClick={openTrigger}
>
{label}
@@ -1,10 +1,18 @@
import type { ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { resizeSingleElement } from "../../element/resizeElements";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { isImageElement } from "../../element/typeChecks";
import {
MINIMAL_CROP_SIZE,
getUncroppedWidthAndHeight,
} from "../../element/cropElement";
import { mutateElement } from "../../element/mutateElement";
import { clamp, round } from "../../../math";
interface DimensionDragInputProps {
property: "width" | "height";
@@ -23,20 +31,124 @@ const handleDimensionChange: DragInputCallbackType<
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
property,
originalAppState,
instantChange,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
if (origElement) {
const latestElement = elementsMap.get(origElement.id);
if (origElement && latestElement) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
const aspectRatio = origElement.width / origElement.height;
if (originalAppState.croppingElementId === origElement.id) {
const element = elementsMap.get(origElement.id);
if (!element || !isImageElement(element) || !element.crop) {
return;
}
const crop = element.crop;
let nextCrop = { ...crop };
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
const naturalToUncroppedWidthRatio = crop.naturalWidth / uncroppedWidth;
const naturalToUncroppedHeightRatio =
crop.naturalHeight / uncroppedHeight;
const MAX_POSSIBLE_WIDTH = isFlippedByX
? crop.width + crop.x
: crop.naturalWidth - crop.x;
const MAX_POSSIBLE_HEIGHT = isFlippedByY
? crop.height + crop.y
: crop.naturalHeight - crop.y;
const MIN_WIDTH = MINIMAL_CROP_SIZE * naturalToUncroppedWidthRatio;
const MIN_HEIGHT = MINIMAL_CROP_SIZE * naturalToUncroppedHeightRatio;
if (nextValue !== undefined) {
if (property === "width") {
const nextValueInNatural = nextValue * naturalToUncroppedWidthRatio;
const nextCropWidth = clamp(
nextValueInNatural,
MIN_WIDTH,
MAX_POSSIBLE_WIDTH,
);
nextCrop = {
...nextCrop,
width: nextCropWidth,
x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
};
} else if (property === "height") {
const nextValueInNatural = nextValue * naturalToUncroppedHeightRatio;
const nextCropHeight = clamp(
nextValueInNatural,
MIN_HEIGHT,
MAX_POSSIBLE_HEIGHT,
);
nextCrop = {
...nextCrop,
height: nextCropHeight,
y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
};
}
mutateElement(element, {
crop: nextCrop,
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
});
return;
}
const changeInWidth = property === "width" ? instantChange : 0;
const changeInHeight = property === "height" ? instantChange : 0;
const nextCropWidth = clamp(
crop.width + changeInWidth,
MIN_WIDTH,
MAX_POSSIBLE_WIDTH,
);
const nextCropHeight = clamp(
crop.height + changeInHeight,
MIN_WIDTH,
MAX_POSSIBLE_HEIGHT,
);
nextCrop = {
...crop,
x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
width: nextCropWidth,
height: nextCropHeight,
};
mutateElement(element, {
crop: nextCrop,
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
});
return;
}
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
@@ -55,14 +167,17 @@ const handleDimensionChange: DragInputCallbackType<
MIN_WIDTH_OR_HEIGHT,
);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
keepAspectRatio,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
);
return;
@@ -99,14 +214,17 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
keepAspectRatio,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
);
}
};
@@ -117,9 +235,25 @@ const DimensionDragInput = ({
scene,
appState,
}: DimensionDragInputProps) => {
const value =
Math.round((property === "width" ? element.width : element.height) * 100) /
100;
let value = round(property === "width" ? element.width : element.height, 2);
if (
appState.croppingElementId &&
appState.croppingElementId === element.id &&
isImageElement(element) &&
element.crop
) {
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
if (property === "width") {
const ratio = uncroppedWidth / element.crop.naturalWidth;
value = round(element.crop.width * ratio, 2);
}
if (property === "height") {
const ratio = uncroppedHeight / element.crop.naturalHeight;
value = round(element.crop.height * ratio, 2);
}
}
return (
<DragInput
@@ -2,7 +2,10 @@ import { useMemo } from "react";
import { getCommonBounds, isTextElement } from "../../element";
import { updateBoundElements } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import { rescalePointsInElement } from "../../element/resizeElements";
import {
rescalePointsInElement,
resizeSingleElement,
} from "../../element/resizeElements";
import {
getBoundTextElement,
handleBindTextResize,
@@ -17,7 +20,7 @@ import type { AppState } from "../../types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils";
import { getElementsInAtomicUnit } from "./utils";
import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { pointFrom, type GlobalPoint } from "../../../math";
@@ -150,7 +153,6 @@ const handleDimensionChange: DragInputCallbackType<
property,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) {
@@ -223,15 +225,17 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
false,
latestElement,
origElement,
elementsMap,
elements,
scene,
false,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldInformMutation: false,
},
);
}
}
@@ -324,14 +328,17 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
false,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldInformMutation: false,
},
);
}
}
@@ -237,6 +237,7 @@ const MultiPosition = ({
const [x1, y1] = getCommonBounds(elementsInUnit);
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
}
const [el] = elementsInUnit;
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
@@ -4,7 +4,13 @@ import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { pointFrom, pointRotateRads } from "../../../math";
import { clamp, pointFrom, pointRotateRads, round } from "../../../math";
import { isImageElement } from "../../element/typeChecks";
import {
getFlipAdjustedCropPosition,
getUncroppedWidthAndHeight,
} from "../../element/cropElement";
import { mutateElement } from "../../element/mutateElement";
interface PositionProps {
property: "x" | "y";
@@ -18,12 +24,14 @@ const STEP_SIZE = 10;
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
accumulatedChange,
instantChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
@@ -38,6 +46,82 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.angle,
);
if (originalAppState.croppingElementId === origElement.id) {
const element = elementsMap.get(origElement.id);
if (!element || !isImageElement(element) || !element.crop) {
return;
}
const crop = element.crop;
let nextCrop = crop;
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
if (nextValue !== undefined) {
if (property === "x") {
const nextValueInNatural =
nextValue * (crop.naturalWidth / uncroppedWidth);
if (isFlippedByX) {
nextCrop = {
...crop,
x: clamp(
crop.naturalWidth - nextValueInNatural - crop.width,
0,
crop.naturalWidth - crop.width,
),
};
} else {
nextCrop = {
...crop,
x: clamp(
nextValue * (crop.naturalWidth / uncroppedWidth),
0,
crop.naturalWidth - crop.width,
),
};
}
}
if (property === "y") {
nextCrop = {
...crop,
y: clamp(
nextValue * (crop.naturalHeight / uncroppedHeight),
0,
crop.naturalHeight - crop.height,
),
};
}
mutateElement(element, {
crop: nextCrop,
});
return;
}
const changeInX =
(property === "x" ? instantChange : 0) * (isFlippedByX ? -1 : 1);
const changeInY =
(property === "y" ? instantChange : 0) * (isFlippedByY ? -1 : 1);
nextCrop = {
...crop,
x: clamp(crop.x + changeInX, 0, crop.naturalWidth - crop.width),
y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height),
};
mutateElement(element, {
crop: nextCrop,
});
return;
}
if (nextValue !== undefined) {
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
@@ -97,8 +181,22 @@ const Position = ({
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
element.angle,
);
const value =
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
let value = round(property === "x" ? topLeftX : topLeftY, 2);
if (
appState.croppingElementId === element.id &&
isImageElement(element) &&
element.crop
) {
const flipAdjustedPosition = getFlipAdjustedCropPosition(element);
if (flipAdjustedPosition) {
value = round(
property === "x" ? flipAdjustedPosition.x : flipAdjustedPosition.y,
2,
);
}
}
return (
<StatsDragInput
+45 -4
View File
@@ -23,12 +23,15 @@ import Collapsible from "./Collapsible";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
import { isElbowArrow, isImageElement } from "../../element/typeChecks";
import CanvasGrid from "./CanvasGrid";
import clsx from "clsx";
import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping";
import { getUncroppedWidthAndHeight } from "../../element/cropElement";
import { round } from "../../../math";
import { frameAndChildrenSelectedTogether } from "../../frame";
interface StatsProps {
app: AppClassProperties;
@@ -128,6 +131,13 @@ export const StatsInner = memo(
const multipleElements =
selectedElements.length > 1 ? selectedElements : null;
const cropMode =
appState.croppingElementId && isImageElement(singleElement);
const unCroppedDimension = cropMode
? getUncroppedWidthAndHeight(singleElement)
: null;
const [sceneDimension, setSceneDimension] = useState<{
width: number;
height: number;
@@ -161,6 +171,10 @@ export const StatsInner = memo(
return getAtomicUnits(selectedElements, appState);
}, [selectedElements, appState]);
const _frameAndChildrenSelectedTogether = useMemo(() => {
return frameAndChildrenSelectedTogether(selectedElements);
}, [selectedElements]);
return (
<div className="exc-stats">
<Island padding={3}>
@@ -217,7 +231,7 @@ export const StatsInner = memo(
{renderCustomStats?.(elements, appState)}
</Collapsible>
{selectedElements.length > 0 && (
{!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
<div
id="elementStats"
style={{
@@ -244,8 +258,34 @@ export const StatsInner = memo(
<StatsRows>
{singleElement && (
<>
{cropMode && (
<StatsRow heading>
{t("labels.unCroppedDimension")}
</StatsRow>
)}
{appState.croppingElementId &&
isImageElement(singleElement) &&
unCroppedDimension && (
<StatsRow columns={2}>
<div>{t("stats.width")}</div>
<div>{round(unCroppedDimension.width, 2)}</div>
</StatsRow>
)}
{appState.croppingElementId &&
isImageElement(singleElement) &&
unCroppedDimension && (
<StatsRow columns={2}>
<div>{t("stats.height")}</div>
<div>{round(unCroppedDimension.height, 2)}</div>
</StatsRow>
)}
<StatsRow heading data-testid="stats-element-type">
{t(`element.${singleElement.type}`)}
{appState.croppingElementId
? t("labels.imageCropping")
: t(`element.${singleElement.type}`)}
</StatsRow>
<StatsRow>
@@ -387,7 +427,8 @@ export const StatsInner = memo(
prev.selectedElements === next.selectedElements &&
prev.appState.stats.panels === next.appState.stats.panels &&
prev.gridModeEnabled === next.gridModeEnabled &&
prev.appState.gridStep === next.appState.gridStep
prev.appState.gridStep === next.appState.gridStep &&
prev.appState.croppingElementId === next.appState.croppingElementId
);
},
);
+3 -101
View File
@@ -5,17 +5,7 @@ import {
updateBoundElements,
} from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import {
measureFontSizeFromWidth,
rescalePointsInElement,
} from "../../element/resizeElements";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import { getBoundTextElement } from "../../element/textElement";
import {
isFrameLikeElement,
isLinearElement,
@@ -34,7 +24,6 @@ import {
} from "../../groups";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getFontString } from "../../utils";
export type StatsInputProperty =
| "x"
@@ -121,95 +110,6 @@ export const newOrigin = (
};
};
export const resizeElement = (
nextWidth: number,
nextHeight: number,
keepAspectRatio: boolean,
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
shouldInformMutation = true,
) => {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;
}
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement) {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
}
mutateElement(
latestElement,
{
...newOrigin(
latestElement.x,
latestElement.y,
latestElement.width,
latestElement.height,
nextWidth,
nextHeight,
latestElement.angle,
),
width: nextWidth,
height: nextHeight,
...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, elements, scene, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement) {
boundTextFont = {
fontSize: boundTextElement.fontSize,
};
if (keepAspectRatio) {
const updatedElement = {
...latestElement,
width: nextWidth,
height: nextHeight,
};
const nextFont = measureFontSizeFromWidth(
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
);
boundTextFont = {
fontSize: nextFont?.size ?? boundTextElement.fontSize,
};
}
}
updateBoundElements(latestElement, elementsMap, {
newSize: { width: nextWidth, height: nextHeight },
});
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
};
export const moveElement = (
newTopLeftX: number,
newTopLeftY: number,
@@ -300,6 +200,7 @@ export const updateBindings = (
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
@@ -310,6 +211,7 @@ export const updateBindings = (
scene,
true,
[],
options?.zoom,
);
} else {
updateBoundElements(latestElement, elementsMap, options);
@@ -25,7 +25,7 @@ import type { BinaryFiles } from "../../types";
import { ArrowRightIcon } from "../icons";
import "./TTDDialog.scss";
import { atom, useAtom } from "jotai";
import { atom, useAtom } from "../../editor-jotai";
import { trackEvent } from "../../analytics";
import { InlineIcon } from "../InlineIcon";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
@@ -91,16 +91,12 @@ export const convertMermaidToExcalidraw = async ({
};
const canvas = await exportToCanvas({
data: {
elements: data.current.elements,
files: data.current.files,
},
config: {
padding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
},
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)
+135 -129
View File
@@ -55,146 +55,152 @@ type ToolButtonProps =
onPointerDown?(data: { pointerType: PointerType }): void;
});
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
const { id: excalId } = useExcalidrawContainer();
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${props.size}`;
export const ToolButton = React.forwardRef(
(
{
size = "medium",
visible = true,
className = "",
...props
}: ToolButtonProps,
ref,
) => {
const { id: excalId } = useExcalidrawContainer();
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${size}`;
const [isLoading, setIsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const isMountedRef = useRef(true);
const isMountedRef = useRef(true);
const onClick = async (event: React.MouseEvent) => {
const ret = "onClick" in props && props.onClick?.(event);
const onClick = async (event: React.MouseEvent) => {
const ret = "onClick" in props && props.onClick?.(event);
if (isPromiseLike(ret)) {
try {
setIsLoading(true);
await ret;
} catch (error: any) {
if (!(error instanceof AbortError)) {
throw error;
} else {
console.warn(error);
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
if (isPromiseLike(ret)) {
try {
setIsLoading(true);
await ret;
} catch (error: any) {
if (!(error instanceof AbortError)) {
throw error;
} else {
console.warn(error);
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}
}
};
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null);
if (
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return (
<button
className={clsx(
"ToolIcon_type_button",
sizeCn,
className,
visible && !props.hidden
? "ToolIcon_type_button--show"
: "ToolIcon_type_button--hide",
{
ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title}
aria-label={props["aria-label"]}
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
{props.isLoading && <Spinner />}
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)}
{props.children}
</button>
);
}
if (
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return (
<button
className={clsx(
"ToolIcon_type_button",
sizeCn,
props.className,
props.visible && !props.hidden
? "ToolIcon_type_button--show"
: "ToolIcon_type_button--hide",
{
ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
<label
className={clsx("ToolIcon", className)}
title={props.title}
aria-label={props["aria-label"]}
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
{props.isLoading && <Spinner />}
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)}
{props.children}
</button>
);
}
return (
<label
className={clsx("ToolIcon", props.className)}
title={props.title}
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.({ pointerType: event.pointerType || null });
}}
onPointerUp={() => {
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
>
<input
className={`ToolIcon_type_radio ${sizeCn}`}
type="radio"
name={props.name}
aria-label={props["aria-label"]}
aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
id={`${excalId}-${props.id}`}
onChange={() => {
props.onChange?.({ pointerType: lastPointerTypeRef.current });
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.({ pointerType: event.pointerType || null });
}}
checked={props.checked}
ref={innerRef}
/>
<div className="ToolIcon__icon">
{props.icon}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">{props.keyBindingLabel}</span>
)}
</div>
</label>
);
});
ToolButton.defaultProps = {
visible: true,
className: "",
size: "medium",
};
onPointerUp={() => {
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
>
<input
className={`ToolIcon_type_radio ${sizeCn}`}
type="radio"
name={props.name}
aria-label={props["aria-label"]}
aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
id={`${excalId}-${props.id}`}
onChange={() => {
props.onChange?.({ pointerType: lastPointerTypeRef.current });
}}
checked={props.checked}
ref={innerRef}
/>
<div className="ToolIcon__icon">
{props.icon}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
</div>
</label>
);
},
);
ToolButton.displayName = "ToolButton";
@@ -4,6 +4,7 @@ import fallbackLangData from "../locales/en.json";
import Trans from "./Trans";
import type { TranslationKeys } from "../i18n";
import { EditorJotaiProvider } from "../editor-jotai";
describe("Test <Trans/>", () => {
it("should translate the the strings correctly", () => {
@@ -17,7 +18,7 @@ describe("Test <Trans/>", () => {
};
const { getByTestId } = render(
<>
<EditorJotaiProvider>
<div data-testid="test1">
<Trans
i18nKey={"transTest.key1" as unknown as TranslationKeys}
@@ -51,7 +52,7 @@ describe("Test <Trans/>", () => {
connect-link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
</>,
</EditorJotaiProvider>,
);
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
@@ -182,6 +182,7 @@ const getRelevantAppStateProps = (
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
editingGroupId: appState.editingGroupId,
editingLinearElement: appState.editingLinearElement,
selectedElementIds: appState.selectedElementIds,
@@ -92,11 +92,14 @@ const getRelevantAppStateProps = (
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
hoveredElementIds: appState.hoveredElementIds,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize,
@@ -1,6 +1,6 @@
import { atom, useAtom } from "jotai";
import React, { useLayoutEffect, useRef } from "react";
import { useTunnels } from "../../context/tunnels";
import { atom } from "../../editor-jotai";
export const withInternalFallback = <P,>(
componentName: string,
@@ -13,9 +13,11 @@ export const withInternalFallback = <P,>(
__fallback?: boolean;
}
> = (props) => {
const { jotaiScope } = useTunnels();
const {
tunnelsJotai: { useAtom },
} = useTunnels();
// for rerenders
const [, setCounter] = useAtom(renderAtom, jotaiScope);
const [, setCounter] = useAtom(renderAtom);
// for initial & subsequent renders. Tracked as component state
// due to excalidraw multi-instance scanerios.
const metaRef = useRef({
@@ -13,7 +13,7 @@ import type {
} from "../../element/types";
import { ToolButton } from "../ToolButton";
import { FreedrawIcon, TrashIcon } from "../icons";
import { FreedrawIcon, TrashIcon, elementLinkIcon } from "../icons";
import { t } from "../../i18n";
import {
useCallback,
@@ -30,18 +30,19 @@ import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip";
import { getSelectedElements } from "../../scene";
import { hitElementBoundingBox } from "../../element/collision";
import { isLocalLink, normalizeLink } from "../../data/url";
import "./Hyperlink.scss";
import { trackEvent } from "../../analytics";
import { useAppProps, useExcalidrawAppState } from "../App";
import { useAppProps, useDevice, useExcalidrawAppState } from "../App";
import { isEmbeddableElement } from "../../element/typeChecks";
import { getLinkHandleFromCoords } from "./helpers";
import { pointFrom, type GlobalPoint } from "../../../math";
import { isElementLink } from "../../element/elementLink";
const CONTAINER_WIDTH = 320;
import "./Hyperlink.scss";
const POPUP_WIDTH = 380;
const POPUP_HEIGHT = 42;
const POPUP_PADDING = 5;
const SPACE_BOTTOM = 85;
const CONTAINER_PADDING = 5;
const CONTAINER_HEIGHT = 42;
const AUTO_HIDE_TIMEOUT = 500;
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
@@ -73,6 +74,7 @@ export const Hyperlink = ({
}) => {
const appState = useExcalidrawAppState();
const appProps = useAppProps();
const device = useDevice();
const linkVal = element.link || "";
@@ -168,8 +170,19 @@ export const Hyperlink = ({
};
}, [handleSubmit]);
useEffect(() => {
if (
isEditing &&
inputRef?.current &&
!(device.viewport.isMobile || device.isTouchScreen)
) {
inputRef.current.select();
}
}, [isEditing, device.viewport.isMobile, device.isTouchScreen]);
useEffect(() => {
let timeoutId: number | null = null;
const handlePointerMove = (event: PointerEvent) => {
if (isEditing) {
return;
@@ -201,11 +214,8 @@ export const Hyperlink = ({
const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete");
mutateElement(element, { link: null });
if (isEditing) {
inputRef.current!.value = "";
}
setAppState({ showHyperlinkPopup: false });
}, [setAppState, element, isEditing]);
}, [setAppState, element]);
const onEdit = () => {
trackEvent("hyperlink", "edit", "popup-ui");
@@ -229,19 +239,14 @@ export const Hyperlink = ({
style={{
top: `${y}px`,
left: `${x}px`,
width: CONTAINER_WIDTH,
padding: CONTAINER_PADDING,
}}
onClick={() => {
if (!element.link && !isEditing) {
setAppState({ showHyperlinkPopup: "editor" });
}
width: POPUP_WIDTH,
padding: POPUP_PADDING,
}}
>
{isEditing ? (
<input
className={clsx("excalidraw-hyperlinkContainer-input")}
placeholder="Type or paste your link here"
placeholder={t("labels.link.hint")}
ref={inputRef}
value={inputVal}
onChange={(event) => setInputVal(event.target.value)}
@@ -302,6 +307,21 @@ export const Hyperlink = ({
icon={FreedrawIcon}
/>
)}
<ToolButton
type="button"
title={t("labels.linkToElement")}
aria-label={t("labels.linkToElement")}
label={t("labels.linkToElement")}
onClick={() => {
setAppState({
openDialog: {
name: "elementLinkSelector",
sourceElementId: element.id,
},
});
}}
icon={elementLinkIcon}
/>
{linkVal && !isEmbeddableElement(element) && (
<ToolButton
type="button"
@@ -328,7 +348,7 @@ const getCoordsForPopover = (
{ sceneX: x1 + element.width / 2, sceneY: y1 },
appState,
);
const x = viewportX - appState.offsetLeft - CONTAINER_WIDTH / 2;
const x = viewportX - appState.offsetLeft - POPUP_WIDTH / 2;
const y = viewportY - appState.offsetTop - SPACE_BOTTOM;
return { x, y };
};
@@ -338,12 +358,10 @@ export const getContextMenuLabel = (
appState: UIAppState,
) => {
const selectedElements = getSelectedElements(elements, appState);
const label = selectedElements[0]?.link
? isEmbeddableElement(selectedElements[0])
? "labels.link.editEmbed"
: "labels.link.edit"
: isEmbeddableElement(selectedElements[0])
? "labels.link.createEmbed"
const label = isEmbeddableElement(selectedElements[0])
? "labels.link.editEmbed"
: selectedElements[0]?.link
? "labels.link.edit"
: "labels.link.create";
return label;
};
@@ -376,7 +394,9 @@ const renderTooltip = (
tooltipDiv.classList.add("excalidraw-tooltip--visible");
tooltipDiv.style.maxWidth = "20rem";
tooltipDiv.textContent = element.link;
tooltipDiv.textContent = isElementLink(element.link)
? t("labels.link.goToElement")
: element.link;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -450,9 +470,9 @@ const shouldHideLinkPopup = (
if (
clientX >= popoverX - threshold &&
clientX <= popoverX + CONTAINER_WIDTH + CONTAINER_PADDING * 2 + threshold &&
clientX <= popoverX + POPUP_WIDTH + POPUP_PADDING * 2 + threshold &&
clientY >= popoverY - threshold &&
clientY <= popoverY + threshold + CONTAINER_PADDING * 2 + CONTAINER_HEIGHT
clientY <= popoverY + threshold + POPUP_PADDING * 2 + POPUP_HEIGHT
) {
return false;
}
@@ -16,6 +16,11 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`,
)}`;
export const ELEMENT_LINK_IMG = document.createElement("img");
ELEMENT_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-big-right-line"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 9v-3.586a1 1 0 0 1 1.707 -.707l6.586 6.586a1 1 0 0 1 0 1.414l-6.586 6.586a1 1 0 0 1 -1.707 -.707v-3.586h-6v-6h6z" /><path d="M3 9v6" /></svg>`,
)}`;
export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: Radians,
+63
View File
@@ -1352,6 +1352,54 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
),
);
export const ArrowheadCrowfootIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCrowfootOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
</g>,
{ width: 40, height: 20 },
),
);
export const FontSizeSmallIcon = createIcon(
<>
<g clipPath="url(#a)">
@@ -2156,3 +2204,18 @@ export const cropIcon = createIcon(
</g>,
tablerIconProps,
);
export const elementLinkIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M19 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M19 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 7l0 10" />
<path d="M7 5l10 0" />
<path d="M7 19l10 0" />
<path d="M19 7l0 10" />
</g>,
tablerIconProps,
);
@@ -32,9 +32,8 @@ import {
actionToggleTheme,
} from "../../actions";
import clsx from "clsx";
import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai";
import { useSetAtom } from "../../editor-jotai";
import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
@@ -189,10 +188,7 @@ Help.displayName = "Help";
export const ClearCanvas = () => {
const { t } = useI18n();
const setActiveConfirmDialog = useSetAtom(
activeConfirmDialogAtom,
jotaiScope,
);
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionClearCanvas)) {
+18 -14
View File
@@ -1,7 +1,7 @@
import cssVariables from "./css/variables.module.scss";
import type { AppProps, AppState } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import type { NormalizedZoomValue } from "./types";
import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
@@ -108,6 +108,7 @@ export const YOUTUBE_STATES = {
export const ENV = {
TEST: "test",
DEVELOPMENT: "development",
};
export const CLASSES = {
@@ -183,14 +184,6 @@ export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
export const DEFAULT_ZOOM_VALUE = 1 as NormalizedZoomValue;
// -----------------------------------------------
// !!! these colors are tied to color picker !!!
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
export const COLOR_TRANSPARENT = "transparent";
// -----------------------------------------------
export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
// a small epsilon to make side resizing always take precedence
@@ -199,6 +192,8 @@ const EPSILON = 0.00001;
export const DEFAULT_COLLISION_THRESHOLD =
2 * SIDE_RESIZING_THRESHOLD - EPSILON;
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
// keep this in sync with CSS
export const COLOR_VOICE_CALL = "#a2f1a6";
@@ -219,9 +214,9 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif",
} as const;
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
export const MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
@@ -235,6 +230,12 @@ export const MIME_TYPES = {
...IMAGE_MIME_TYPES,
} as const;
export const ALLOWED_PASTE_MIME_TYPES = [
MIME_TYPES.text,
MIME_TYPES.html,
...Object.values(IMAGE_MIME_TYPES),
] as const;
export const EXPORT_IMAGE_TYPES = {
png: "png",
svg: "svg",
@@ -309,7 +310,6 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_SMALLEST_EXPORT_SIZE = 20; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
@@ -390,8 +390,8 @@ export const DEFAULT_ELEMENT_PROPS: {
opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"];
} = {
strokeColor: COLOR_CHARCOAL_BLACK,
backgroundColor: COLOR_TRANSPARENT,
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
@@ -417,6 +417,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
// use these constants to easily identify reference sites
export const TOOL_TYPE = {
selection: "selection",
lassoSelection: "lassoSelection",
rectangle: "rectangle",
diamond: "diamond",
ellipse: "ellipse",
@@ -455,3 +456,6 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
round: "round",
elbow: "elbow",
};
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
export const ELEMENT_LINK_KEY = "element";
+7 -2
View File
@@ -1,5 +1,6 @@
import React from "react";
import tunnel from "tunnel-rat";
import { createIsolation } from "jotai-scope";
export type Tunnel = ReturnType<typeof tunnel>;
@@ -14,13 +15,17 @@ type TunnelsContextValue = {
DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel;
TTDDialogTriggerTunnel: Tunnel;
jotaiScope: symbol;
// this can be removed once we create jotai stores per each editor
// instance
tunnelsJotai: ReturnType<typeof createIsolation>;
};
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
export const useTunnels = () => React.useContext(TunnelsContext);
const tunnelsJotai = createIsolation();
export const useInitializeTunnels = () => {
return React.useMemo((): TunnelsContextValue => {
return {
@@ -34,7 +39,7 @@ export const useInitializeTunnels = () => {
DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(),
TTDDialogTriggerTunnel: tunnel(),
jotaiScope: Symbol(),
tunnelsJotai,
};
}, []);
};
@@ -340,7 +340,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"elementId": "text-2",
"fixedPoint": null,
"focus": 0,
"gap": 205,
"gap": 14,
},
"fillStyle": "solid",
"frameId": null,
+8 -3
View File
@@ -5,6 +5,7 @@ import { clearElementsForExport } from "../element";
import type { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError, ImageSceneDataError } from "../errors";
import { calculateScrollCenter } from "../scene";
import { decodeSvgBase64Payload } from "../scene/export";
import type { AppState, DataURL, LibraryItem } from "../types";
import type { ValueOf } from "../utility-types";
import { bytesToHexString, isPromiseLike } from "../utils";
@@ -47,7 +48,7 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
}
if (blob.type === MIME_TYPES.svg) {
try {
return (await import("./image")).decodeSvgMetadata({
return decodeSvgBase64Payload({
svg: contents,
});
} catch (error: any) {
@@ -106,11 +107,15 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
return type === "png" || type === "svg";
};
export const isSupportedImageFileType = (type: string | null | undefined) => {
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
};
export const isSupportedImageFile = (
blob: Blob | null | undefined,
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {};
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
return isSupportedImageFileType(type);
};
export const loadSceneOrLibraryFromBlob = async (
@@ -329,7 +334,7 @@ export const resizeImageFile = async (
}
return new File(
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })],
file.name,
{
type: opts.outputType || file.type,
+3 -1
View File
@@ -82,6 +82,7 @@ export const fileSave = (
name: string;
/** file extension */
extension: FILE_EXTENSION;
mimeTypes?: string[];
description: string;
/** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null;
@@ -93,10 +94,11 @@ export const fileSave = (
fileName: `${opts.name}.${opts.extension}`,
description: opts.description,
extensions: [`.${opts.extension}`],
mimeTypes: opts.mimeTypes,
},
opts.fileHandle,
);
};
export type { FileSystemHandle };
export { nativeFileSystemSupported };
export type { FileSystemHandle };
+1 -54
View File
@@ -1,7 +1,7 @@
import decodePng from "png-chunks-extract";
import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode";
import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { encode, decode } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { blobToArrayBuffer } from "./blob";
@@ -67,56 +67,3 @@ export const decodePngMetadata = async (blob: Blob) => {
}
throw new Error("INVALID");
};
// -----------------------------------------------------------------------------
// SVG
// -----------------------------------------------------------------------------
export const encodeSvgMetadata = ({ text }: { text: string }) => {
const base64 = stringToBase64(
JSON.stringify(encode({ text })),
true /* is already byte string */,
);
let metadata = "";
metadata += `<!-- payload-type:${MIME_TYPES.excalidraw} -->`;
metadata += `<!-- payload-version:2 -->`;
metadata += "<!-- payload-start -->";
metadata += base64;
metadata += "<!-- payload-end -->";
return metadata;
};
export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match(
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
);
if (!match) {
throw new Error("INVALID");
}
const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
const version = versionMatch?.[1] || "1";
const isByteString = version !== "1";
try {
const json = base64ToString(match[1], isByteString);
const encodedData = JSON.parse(json);
if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON
if (
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
return json;
}
throw new Error("FAILED");
}
return decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
}
}
throw new Error("INVALID");
};
+51 -68
View File
@@ -5,6 +5,7 @@ import {
import {
DEFAULT_EXPORT_PADDING,
DEFAULT_FILENAME,
IMAGE_MIME_TYPES,
isFirefox,
MIME_TYPES,
} from "../constants";
@@ -15,8 +16,9 @@ import type {
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { getElementsOverlappingFrame } from "../frame";
import { t } from "../i18n";
import { isSomeElementSelected, getSelectedElements } from "../scene";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas, exportToSvg } from "../scene/export";
import type { ExportType } from "../scene/types";
import type { AppState, BinaryFiles } from "../types";
@@ -25,7 +27,6 @@ import { canvasToBlob } from "./blob";
import type { FileSystemHandle } from "./filesystem";
import { fileSave } from "./filesystem";
import { serializeAsJSON } from "./json";
import { getElementsOverlappingFrame } from "../frame";
export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json";
@@ -81,53 +82,46 @@ export const prepareElementsForExport = (
};
};
export const exportAsImage = async ({
type,
data,
config,
}: {
type: Omit<ExportType, "backend">;
data: {
elements: ExportedElements;
appState: AppState;
files: BinaryFiles;
};
config: {
export const exportCanvas = async (
type: Omit<ExportType, "backend">,
elements: ExportedElements,
appState: AppState,
files: BinaryFiles,
{
exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING,
viewBackgroundColor,
name = appState.name || DEFAULT_FILENAME,
fileHandle = null,
exportingFrame = null,
}: {
exportBackground: boolean;
padding?: number;
exportPadding?: number;
viewBackgroundColor: string;
/** filename, if applicable */
name?: string;
fileHandle?: FileSystemHandle | null;
exportingFrame: ExcalidrawFrameLikeElement | null;
};
}) => {
// clone
const cfg = Object.assign({}, config);
cfg.padding = cfg.padding ?? DEFAULT_EXPORT_PADDING;
cfg.fileHandle = cfg.fileHandle ?? null;
cfg.exportingFrame = cfg.exportingFrame ?? null;
cfg.name = cfg.name || DEFAULT_FILENAME;
if (data.elements.length === 0) {
},
) => {
if (elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (type === "svg" || type === "clipboard-svg") {
const svgPromise = exportToSvg({
data: {
elements: data.elements,
appState: {
exportBackground: cfg.exportBackground,
exportWithDarkMode: data.appState.exportWithDarkMode,
viewBackgroundColor: data.appState.viewBackgroundColor,
exportScale: data.appState.exportScale,
exportEmbedScene: data.appState.exportEmbedScene && type === "svg",
},
files: data.files,
const svgPromise = exportToSvg(
elements,
{
exportBackground,
exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor,
exportPadding,
exportScale: appState.exportScale,
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
config: { exportingFrame: cfg.exportingFrame, padding: cfg.padding },
});
files,
{ exportingFrame },
);
if (type === "svg") {
return fileSave(
svgPromise.then((svg) => {
@@ -135,9 +129,10 @@ export const exportAsImage = async ({
}),
{
description: "Export to SVG",
name: cfg.name,
extension: data.appState.exportEmbedScene ? "excalidraw.svg" : "svg",
fileHandle: cfg.fileHandle,
name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
mimeTypes: [IMAGE_MIME_TYPES.svg],
fileHandle,
},
);
} else if (type === "clipboard-svg") {
@@ -151,33 +146,22 @@ export const exportAsImage = async ({
}
}
const tempCanvas = exportToCanvas({
data,
config: {
canvasBackgroundColor: !cfg.exportBackground
? false
: cfg.viewBackgroundColor,
padding: cfg.padding,
theme: data.appState.exportWithDarkMode ? "dark" : "light",
scale: data.appState.exportScale,
fit: "none",
exportingFrame: cfg.exportingFrame,
},
const tempCanvas = exportToCanvas(elements, appState, files, {
exportBackground,
viewBackgroundColor,
exportPadding,
exportingFrame,
});
if (type === "png") {
const blob = canvasToBlob(tempCanvas);
if (data.appState.exportEmbedScene) {
blob.then((blob) =>
let blob = canvasToBlob(tempCanvas);
if (appState.exportEmbedScene) {
blob = blob.then((blob) =>
import("./image").then(({ encodePngMetadata }) =>
encodePngMetadata({
blob,
metadata: serializeAsJSON(
data.elements,
data.appState,
data.files,
"local",
),
metadata: serializeAsJSON(elements, appState, files, "local"),
}),
),
);
@@ -185,11 +169,10 @@ export const exportAsImage = async ({
return fileSave(blob, {
description: "Export to PNG",
name: cfg.name,
// FIXME reintroduce `excalidraw.png` when most people upgrade away
// from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
fileHandle: cfg.fileHandle,
name,
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
mimeTypes: [IMAGE_MIME_TYPES.png],
fileHandle,
});
} else if (type === "clipboard") {
try {
+105
View File
@@ -0,0 +1,105 @@
import { validateLibraryUrl } from "./library";
describe("validateLibraryUrl", () => {
it("should validate hostname & pathname", () => {
// valid hostnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://www.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", [
"library.excalidraw.com",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com"]),
).toBe(true);
// valid pathnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://excalidraw.com/path", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/path/", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path/",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/other", [
"excalidraw.com/specific/path",
]),
).toBe(true);
// invalid hostnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://xexcalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://x-excalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// protocol must be https
expect(() =>
validateLibraryUrl("http://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// invalid pathnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/other/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/paths", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/path-s", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/some/specific/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
});
});
+69 -7
View File
@@ -8,8 +8,7 @@ import type {
} from "../types";
import { restoreLibraryItems } from "./restore";
import type App from "../components/App";
import { atom } from "jotai";
import { jotaiStore } from "../jotai";
import { atom, editorJotaiStore } from "../editor-jotai";
import type { ExcalidrawElement } from "../element/types";
import { getCommonBoundingBox } from "../element/bounds";
import { AbortError } from "../errors";
@@ -35,6 +34,20 @@ import type { MaybePromise } from "../utility-types";
import { Emitter } from "../emitter";
import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element";
import { toValidURL } from "./url";
/**
* format: hostname or hostname/pathname
*
* Both hostname and pathname are matched partially,
* hostname from the end, pathname from the start, with subdomain/path
* boundaries
**/
const ALLOWED_LIBRARY_URLS = [
"excalidraw.com",
// when installing from github PRs
"raw.githubusercontent.com/excalidraw/excalidraw-libraries",
];
type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */
@@ -188,13 +201,13 @@ class Library {
private notifyListeners = () => {
if (this.updateQueue.length > 0) {
jotaiStore.set(libraryItemsAtom, (s) => ({
editorJotaiStore.set(libraryItemsAtom, (s) => ({
status: "loading",
libraryItems: this.currLibraryItems,
isInitialized: s.isInitialized,
}));
} else {
jotaiStore.set(libraryItemsAtom, {
editorJotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: this.currLibraryItems,
isInitialized: true,
@@ -222,7 +235,7 @@ class Library {
destroy = () => {
this.updateQueue = [];
this.currLibraryItems = [];
jotaiStore.set(libraryItemSvgsCache, new Map());
editorJotaiStore.set(libraryItemSvgsCache, new Map());
// TODO uncomment after/if we make jotai store scoped to each excal instance
// jotaiStore.set(libraryItemsAtom, {
// status: "loading",
@@ -467,6 +480,39 @@ export const distributeLibraryItemsOnSquareGrid = (
return resElements;
};
export const validateLibraryUrl = (
libraryUrl: string,
/**
* @returns `true` if the URL is valid, throws otherwise.
*/
validator:
| ((libraryUrl: string) => boolean)
| string[] = ALLOWED_LIBRARY_URLS,
): true => {
if (
typeof validator === "function"
? validator(libraryUrl)
: validator.some((allowedUrlDef) => {
const allowedUrl = new URL(
`https://${allowedUrlDef.replace(/^https?:\/\//, "")}`,
);
const { hostname, pathname } = new URL(libraryUrl);
return (
new RegExp(`(^|\\.)${allowedUrl.hostname}$`).test(hostname) &&
new RegExp(
`^${allowedUrl.pathname.replace(/\/+$/, "")}(/+|$)`,
).test(pathname)
);
})
) {
return true;
}
throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`);
};
export const parseLibraryTokensFromUrl = () => {
const libraryUrl =
// current
@@ -608,6 +654,11 @@ const persistLibraryUpdate = async (
export const useHandleLibrary = (
opts: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
/**
* Return `true` if the library install url should be allowed.
* If not supplied, only the excalidraw.com base domain is allowed.
*/
validateLibraryUrl?: (libraryUrl: string) => boolean;
} & (
| {
/** @deprecated we recommend using `opts.adapter` instead */
@@ -650,7 +701,13 @@ export const useHandleLibrary = (
}) => {
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
try {
const request = await fetch(decodeURIComponent(libraryUrl));
libraryUrl = decodeURIComponent(libraryUrl);
libraryUrl = toValidURL(libraryUrl);
validateLibraryUrl(libraryUrl, optsRef.current.validateLibraryUrl);
const request = await fetch(libraryUrl);
const blob = await request.blob();
resolve(blob);
} catch (error: any) {
@@ -678,7 +735,12 @@ export const useHandleLibrary = (
defaultStatus: "published",
openLibraryMenu: true,
});
} catch (error) {
} catch (error: any) {
excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
throw error;
} finally {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
+7 -12
View File
@@ -1,7 +1,6 @@
import type { ExcalidrawElement } from "../element/types";
import type { AppState, BinaryFiles } from "../types";
import { prepareElementsForExport } from ".";
import { exportAsImage } from ".";
import { exportCanvas, prepareElementsForExport } from ".";
import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async (
@@ -30,16 +29,12 @@ export const resaveAsImageWithScene = async (
false,
);
await exportAsImage({
type: fileHandleType,
data: { elements: exportedElements, appState, files },
config: {
exportBackground,
viewBackgroundColor,
name,
fileHandle,
exportingFrame,
},
await exportCanvas(fileHandleType, exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
name,
fileHandle,
exportingFrame,
});
return { fileHandle };
+42 -12
View File
@@ -1,5 +1,6 @@
import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawLinearElement,
@@ -69,6 +70,7 @@ export const AllowedExcalidrawActiveTools: Record<
boolean
> = {
selection: true,
lassoSelection: true,
text: true,
rectangle: true,
diamond: true,
@@ -101,23 +103,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY;
};
const repairBinding = (
element: ExcalidrawLinearElement,
const repairBinding = <T extends ExcalidrawLinearElement>(
element: T,
binding: PointBinding | FixedPointBinding | null,
): PointBinding | FixedPointBinding | null => {
): T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null => {
if (!binding) {
return null;
}
return {
...binding,
focus: binding.focus || 0,
...(isElbowArrow(element) && isFixedPointBinding(binding)
const focus = binding.focus || 0;
if (isElbowArrow(element)) {
const fixedPointBinding:
| ExcalidrawElbowArrowElement["startBinding"]
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
? {
...binding,
focus,
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
}
: {}),
};
: null;
return fixedPointBinding;
}
return {
...binding,
focus,
} as T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null;
};
const restoreElementWithProperties = <
@@ -308,8 +325,7 @@ const restoreElement = (
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
}
// TODO: Separate arrow from linear element
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
const base = {
type: element.type,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
@@ -321,7 +337,20 @@ const restoreElement = (
y,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
});
} as const;
// TODO: Separate arrow from linear element
return isElbowArrow(element)
? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, {
...base,
elbowed: true,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
fixedSegments: element.fixedSegments,
startIsSpecial: element.startIsSpecial,
endIsSpecial: element.endIsSpecial,
})
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
}
// generic elements
@@ -639,6 +668,7 @@ export const restoreAppState = (
gridStep: getNormalizedGridStep(
isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
),
editingFrame: null,
};
};
+1 -1
View File
@@ -779,7 +779,7 @@ describe("Test Transform", () => {
elementId: "rect-1",
fixedPoint: null,
focus: 0,
gap: 205,
gap: 14,
});
expect(rect.boundElements).toStrictEqual([
{

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