Compare commits

..

19 Commits

Author SHA1 Message Date
dwelle 0e76cd4230 debug 2025-01-02 17:15:21 +01:00
dwelle e5a9786af3 wip 2025-01-02 16:59:14 +01:00
dwelle 55be6de5ec fix: caret not showing in Safari for empty wysiwyg 2024-12-29 21:19:31 +01:00
dwelle 42faafe138 fix: UI offset when typing into out-of-viewport wysiwyg 2024-12-29 21:19:31 +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
110 changed files with 4322 additions and 3427 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false
# The port the run the dev server
VITE_APP_PORT=3000
VITE_APP_PORT=3001
#Debug flags
+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";
+7 -352
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,
@@ -133,9 +127,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();
@@ -616,19 +608,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 +617,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 +849,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 +1128,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>
);
};
+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
@@ -169,7 +169,7 @@ export default defineConfig(({ mode }) => {
},
],
start_url: "/",
id:"excalidraw",
id: "excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",
+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,
@@ -161,6 +161,7 @@ export const actionDeleteSelected = register({
element,
selectedPointsIndices,
elementsMap,
appState.zoom,
);
return {
@@ -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) => {
+9 -13
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,
@@ -27,6 +26,7 @@ import {
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getCommonBoundingBox } from "../element/bounds";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@@ -132,19 +132,14 @@ 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,
);
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, {
flipByX: flipDirection === "horizontal",
flipByY: flipDirection === "vertical",
shouldResizeFromCenter: true,
shouldMaintainAspectRatio: true,
});
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
@@ -153,6 +148,7 @@ const flipElements = (
app.scene,
isBindingEnabled(appState),
[],
appState.zoom,
);
// ---------------------------------------------------------------------------
@@ -1591,6 +1591,7 @@ export const actionChangeArrowType = register({
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
appState.zoom,
true,
);
const endHoveredElement =
@@ -1599,6 +1600,7 @@ export const actionChangeArrowType = register({
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
true,
);
const startElement = startHoveredElement
+2
View File
@@ -135,6 +135,8 @@ export type ActionName =
| "autoResize"
| "elementStats"
| "searchMenu"
| "copyElementLink"
| "linkToElement"
| "cropEditor";
export type PanelComponentProps = {
+6 -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,
@@ -211,6 +211,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,
+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,
+271 -54
View File
@@ -49,7 +49,6 @@ import {
} from "../appState";
import type { PastedMixedContent } from "../clipboard";
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
import { ARROW_TYPE, isSafari, type EXPORT_IMAGE_TYPES } from "../constants";
import {
APP_NAME,
CURSOR_TYPE,
@@ -88,9 +87,13 @@ import {
supportsResizeObserver,
DEFAULT_COLLISION_THRESHOLD,
DEFAULT_TEXT_ALIGN,
ARROW_TYPE,
DEFAULT_REDUCED_GLOBAL_ALPHA,
isSafari,
type EXPORT_IMAGE_TYPES,
} from "../constants";
import type { ExportedElements } from "../data";
import { exportAsImage, loadFromBlob } from "../data";
import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore";
import {
@@ -461,6 +464,8 @@ import {
} from "../../math";
import { cropElement } from "../element/cropElement";
import { wrapText } from "../element/textWrapping";
import { actionCopyElementLink } from "../actions/actionElementLink";
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -1202,6 +1207,9 @@ class App extends React.Component<AppProps, AppState> {
getContainingFrame(el, this.scene.getNonDeletedElementsMap()),
this.elementsPendingErasure,
null,
this.state.openDialog?.name === "elementLinkSelector"
? DEFAULT_REDUCED_GLOBAL_ALPHA
: 1,
),
["--embeddable-radius" as string]: `${getCornerRadius(
Math.min(el.width, el.height),
@@ -1333,8 +1341,18 @@ class App extends React.Component<AppProps, AppState> {
_cache: new Map(),
};
private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => {
if (frame) {
mutateElement(frame, { name: frame.name?.trim() || null });
}
this.setState({ editingFrame: null });
};
private renderFrameNames = () => {
if (!this.state.frameRendering.enabled || !this.state.frameRendering.name) {
if (this.state.editingFrame) {
this.resetEditingFrame(null);
}
return null;
}
@@ -1356,6 +1374,9 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
)
) {
if (this.state.editingFrame === f.id) {
this.resetEditingFrame(f);
}
// if frame not visible, don't render its name
return null;
}
@@ -1367,11 +1388,6 @@ class App extends React.Component<AppProps, AppState> {
const FRAME_NAME_EDIT_PADDING = 6;
const reset = () => {
mutateElement(f, { name: f.name?.trim() || null });
this.setState({ editingFrame: null });
};
let frameNameJSX;
const frameName = getFrameLikeTitle(f);
@@ -1389,13 +1405,13 @@ class App extends React.Component<AppProps, AppState> {
});
}}
onFocus={(e) => e.target.select()}
onBlur={() => reset()}
onBlur={() => this.resetEditingFrame(f)}
onKeyDown={(event) => {
// for some inexplicable reason, `onBlur` triggered on ESC
// does not reset `state.editingFrame` despite being called,
// and we need to reset it here as well
if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
reset();
this.resetEditingFrame(f);
}
}}
style={{
@@ -1520,7 +1536,9 @@ class App extends React.Component<AppProps, AppState> {
return (
<div
className={clsx("excalidraw excalidraw-container", {
"excalidraw--view-mode": this.state.viewModeEnabled,
"excalidraw--view-mode":
this.state.viewModeEnabled ||
this.state.openDialog?.name === "elementLinkSelector",
"excalidraw--mobile": this.device.editor.isMobile,
})}
style={{
@@ -1579,6 +1597,9 @@ class App extends React.Component<AppProps, AppState> {
}
app={this}
isCollaborating={this.props.isCollaborating}
generateLinkForSelection={
this.props.generateLinkForSelection
}
>
{this.props.children}
</LayerUI>
@@ -1590,6 +1611,8 @@ class App extends React.Component<AppProps, AppState> {
trails={[this.laserTrails, this.eraserTrail]}
/>
{selectedElements.length === 1 &&
this.state.openDialog?.name !==
"elementLinkSelector" &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={firstSelectedElement.id}
@@ -1815,20 +1838,18 @@ class App extends React.Component<AppProps, AppState> {
opts: { exportingFrame: ExcalidrawFrameLikeElement | null },
) => {
trackEvent("export", type, "ui");
const fileHandle = await exportAsImage({
const fileHandle = await exportCanvas(
type,
data: {
elements,
appState: this.state,
files: this.files,
},
config: {
elements,
this.state,
this.files,
{
exportBackground: this.state.exportBackground,
name: this.getName(),
viewBackgroundColor: this.state.viewBackgroundColor,
exportingFrame: opts.exportingFrame,
},
})
)
.catch(muteFSAbortError)
.catch((error) => {
console.error(error);
@@ -2327,6 +2348,10 @@ class App extends React.Component<AppProps, AppState> {
this.fonts.loadSceneFonts().then((fontFaces) => {
this.fonts.onLoaded(fontFaces);
});
if (isElementLink(window.location.href)) {
this.scrollToContent(window.location.href, { animate: false });
}
};
private isMobileBreakpoint = (width: number, height: number) => {
@@ -2558,6 +2583,21 @@ class App extends React.Component<AppProps, AppState> {
this.handleWheel,
{ passive: false },
),
addEventListener(window, "focusin", (event) => {
console.log("%c@@@@@@ focusin:", "color:lime", event.target);
const target = event.target;
if (
event.target instanceof HTMLElement &&
this.state.editingTextElement
) {
if (event.target.tagName !== "TEXTAREA") {
this.focusContainer();
}
}
}),
addEventListener(window, "focusout", (event) => {
console.log("%c@@@@@@ focusout:", "color:red", event.target);
}),
addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
addEventListener(document, EVENT.POINTER_UP, this.removePointer, {
passive: false,
@@ -2763,6 +2803,18 @@ class App extends React.Component<AppProps, AppState> {
this.deselectElements();
}
// cleanup
if (
(prevState.openDialog?.name === "elementLinkSelector" ||
this.state.openDialog?.name === "elementLinkSelector") &&
prevState.openDialog?.name !== this.state.openDialog?.name
) {
this.deselectElements();
this.setState({
hoveredElementIds: {},
});
}
if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
this.setState({ zenModeEnabled: !!this.props.zenModeEnabled });
}
@@ -3178,6 +3230,10 @@ class App extends React.Component<AppProps, AppState> {
),
),
[el.points[0], el.points[el.points.length - 1]],
undefined,
{
zoom: this.state.zoom,
},
),
};
}
@@ -3625,7 +3681,14 @@ class App extends React.Component<AppProps, AppState> {
private cancelInProgressAnimation: (() => void) | null = null;
scrollToContent = (
/**
* target to scroll to
*
* - string - id of element or group, or url containing elementLink
* - ExcalidrawElement | ExcalidrawElement[] - element(s) objects
*/
target:
| string
| ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: (
@@ -3652,6 +3715,34 @@ class App extends React.Component<AppProps, AppState> {
canvasOffsets?: Offsets;
},
) => {
if (typeof target === "string") {
let id: string | null;
if (isElementLink(target)) {
id = parseElementLinkFromURL(target);
} else {
id = target;
}
if (id) {
const elements = this.scene.getElementsFromId(id);
if (elements?.length) {
this.scrollToContent(elements, {
fitToContent: opts?.fitToContent ?? true,
animate: opts?.animate ?? true,
});
} else if (isElementLink(target)) {
this.setState({
toast: {
message: t("elementLink.notFound"),
duration: 3000,
closable: true,
},
});
}
}
return;
}
this.cancelInProgressAnimation?.();
// convert provided target into ExcalidrawElement[] if necessary
@@ -3806,14 +3897,18 @@ class App extends React.Component<AppProps, AppState> {
nextFiles[fileData.id] = fileData;
if (fileData.mimeType === MIME_TYPES.svg) {
const restoredDataURL = getDataURL_sync(
normalizeSVG(dataURLToString(fileData.dataURL)),
MIME_TYPES.svg,
);
if (fileData.dataURL !== restoredDataURL) {
// bump version so persistence layer can update the store
fileData.version = (fileData.version ?? 1) + 1;
fileData.dataURL = restoredDataURL;
try {
const restoredDataURL = getDataURL_sync(
normalizeSVG(dataURLToString(fileData.dataURL)),
MIME_TYPES.svg,
);
if (fileData.dataURL !== restoredDataURL) {
// bump version so persistence layer can update the store
fileData.version = (fileData.version ?? 1) + 1;
fileData.dataURL = restoredDataURL;
}
} catch (error) {
console.error(error);
}
}
}
@@ -4216,6 +4311,10 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (this.state.openDialog?.name === "elementLinkSelector") {
return;
}
if (this.actionManager.handleKeyDown(event)) {
return;
}
@@ -4292,6 +4391,7 @@ class App extends React.Component<AppProps, AppState> {
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: selectedElements,
zoom: this.state.zoom,
});
});
@@ -4301,6 +4401,7 @@ class App extends React.Component<AppProps, AppState> {
(element) => element.id !== elbowArrow?.id || step !== 0,
),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
),
});
@@ -4487,7 +4588,10 @@ class App extends React.Component<AppProps, AppState> {
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
if (event.key === KEYS.SPACE) {
if (this.state.viewModeEnabled) {
if (
this.state.viewModeEnabled ||
this.state.openDialog?.name === "elementLinkSelector"
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.activeTool.type === "selection") {
resetCursor(this.interactiveCanvas);
@@ -4513,6 +4617,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene,
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.zoom,
);
this.setState({ suggestedBindings: [] });
}
@@ -4755,6 +4860,12 @@ class App extends React.Component<AppProps, AppState> {
) {
const elementsMap = this.scene.getElementsMapIncludingDeleted();
// flushSync(() => {
// this.setState({
// editingTextElement: element,
// });
// });
const updateElement = (nextOriginalText: string, isDeleted: boolean) => {
this.scene.replaceAllElements([
// Not sure why we include deleted elements as well hence using deleted elements map
@@ -5010,7 +5121,11 @@ class App extends React.Component<AppProps, AppState> {
isImageElement(element) ? 0 : this.getElementHitThreshold(),
);
return isPointInShape(pointFrom(x, y), selectionShape);
// if hitting the bounding box, return early
// but if not, we should check for other cases as well (e.g. frame name)
if (isPointInShape(pointFrom(x, y), selectionShape)) {
return true;
}
}
// take bound text element into consideration for hit collision as well
@@ -5374,18 +5489,17 @@ class App extends React.Component<AppProps, AppState> {
scenePointer: Readonly<{ x: number; y: number }>,
hitElement: NonDeletedExcalidrawElement | null,
): ExcalidrawElement | undefined => {
// Reversing so we traverse the elements in decreasing order
// of z-index
const elements = this.scene.getNonDeletedElements().slice().reverse();
let hitElementIndex = Infinity;
const elements = this.scene.getNonDeletedElements();
let hitElementIndex = -1;
return elements.find((element, index) => {
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (hitElement && element.id === hitElement.id) {
hitElementIndex = index;
}
return (
if (
element.link &&
index <= hitElementIndex &&
index >= hitElementIndex &&
isPointHittingLink(
element,
this.scene.getNonDeletedElementsMap(),
@@ -5393,8 +5507,10 @@ class App extends React.Component<AppProps, AppState> {
pointFrom(scenePointer.x, scenePointer.y),
this.device.editor.isMobile,
)
);
});
) {
return element;
}
}
};
private redirectToLink = (
@@ -5411,12 +5527,7 @@ class App extends React.Component<AppProps, AppState> {
this.lastPointerUpEvent!.clientY,
),
);
if (
!this.hitLinkElement ||
// For touch screen allow dragging threshold else strict check
(isTouchScreen && draggedDistance > DRAGGING_THRESHOLD) ||
(!isTouchScreen && draggedDistance !== 0)
) {
if (!this.hitLinkElement || draggedDistance > DRAGGING_THRESHOLD) {
return;
}
const lastPointerDownCoords = viewportCoordsToSceneCoords(
@@ -5443,6 +5554,7 @@ class App extends React.Component<AppProps, AppState> {
this.device.editor.isMobile,
);
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
hideHyperlinkToolip();
let url = this.hitLinkElement.link;
if (url) {
url = normalizeLink(url);
@@ -5770,6 +5882,7 @@ class App extends React.Component<AppProps, AppState> {
{
isDragging: true,
informMutation: false,
zoom: this.state.zoom,
},
);
} else {
@@ -5829,6 +5942,7 @@ class App extends React.Component<AppProps, AppState> {
if (
(!this.state.selectedLinearElement ||
this.state.selectedLinearElement.hoverPointIndex === -1) &&
this.state.openDialog?.name !== "elementLinkSelector" &&
!(selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
) {
const elementWithTransformHandleType =
@@ -5853,7 +5967,11 @@ class App extends React.Component<AppProps, AppState> {
return;
}
}
} else if (selectedElements.length > 1 && !isOverScrollBar) {
} else if (
selectedElements.length > 1 &&
!isOverScrollBar &&
this.state.openDialog?.name !== "elementLinkSelector"
) {
const transformHandleType = getTransformHandleTypeFromCoords(
getCommonBounds(selectedElements),
scenePointerX,
@@ -5912,6 +6030,8 @@ class App extends React.Component<AppProps, AppState> {
);
} else if (this.state.viewModeEnabled) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.openDialog?.name === "elementLinkSelector") {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if (isOverScrollBar) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if (this.state.selectedLinearElement) {
@@ -5957,6 +6077,32 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
}
}
if (this.state.openDialog?.name === "elementLinkSelector" && hitElement) {
this.setState((prevState) => {
return {
hoveredElementIds: updateStable(
prevState.hoveredElementIds,
selectGroupsForSelectedElements(
{
editingGroupId: prevState.editingGroupId,
selectedElementIds: { [hitElement.id]: true },
},
this.scene.getNonDeletedElements(),
prevState,
this,
).selectedElementIds,
),
};
});
} else if (
this.state.openDialog?.name === "elementLinkSelector" &&
!hitElement
) {
this.setState((prevState) => ({
hoveredElementIds: updateStable(prevState.hoveredElementIds, {}),
}));
}
};
private handleEraser = (
@@ -6153,6 +6299,11 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>,
) => {
console.log("(1)", document.activeElement);
console.time();
this.focusContainer();
console.timeEnd();
console.log("(2)", document.activeElement);
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser();
@@ -6214,7 +6365,10 @@ class App extends React.Component<AppProps, AppState> {
this.state,
),
},
storeAction: StoreAction.UPDATE,
storeAction:
this.state.openDialog?.name === "elementLinkSelector"
? StoreAction.NONE
: StoreAction.UPDATE,
});
return;
}
@@ -6594,17 +6748,16 @@ class App extends React.Component<AppProps, AppState> {
}
isPanning = true;
// due to event.preventDefault below, container wouldn't get focus
// automatically
this.focusContainer();
// preventing defualt while text editing messes with cursor/focus
if (!this.state.editingTextElement) {
// necessary to prevent browser from scrolling the page if excalidraw
// not full-page #4489
//
// as such, the above is broken when panning canvas while in wysiwyg
// note, this fix won't work when panning canvas while in wysiwyg since
// we don't execute it while in wysiwyg
event.preventDefault();
// focus explicitly due to the event.preventDefault above
this.focusContainer();
}
let nextPastePrevented = false;
@@ -6941,6 +7094,15 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin.y,
);
this.hitLinkElement = this.getElementLinkAtPosition(
pointerDownState.origin,
pointerDownState.hit.element,
);
if (this.hitLinkElement) {
return true;
}
if (
this.state.croppingElementId &&
pointerDownState.hit.element?.id !== this.state.croppingElementId
@@ -7034,7 +7196,7 @@ class App extends React.Component<AppProps, AppState> {
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
) {
this.setState((prevState) => {
const nextSelectedElementIds: { [id: string]: true } = {
let nextSelectedElementIds: { [id: string]: true } = {
...prevState.selectedElementIds,
[hitElement.id]: true,
};
@@ -7105,6 +7267,23 @@ class App extends React.Component<AppProps, AppState> {
}
}
// Finally, in shape selection mode, we'd like to
// keep only one shape or group selected at a time.
// This means, if the hitElement is a different shape or group
// than the previously selected ones, we deselect the previous ones
// and select the hitElement
if (prevState.openDialog?.name === "elementLinkSelector") {
if (
!hitElement.groupIds.some(
(gid) => prevState.selectedGroupIds[gid],
)
) {
nextSelectedElementIds = {
[hitElement.id]: true,
};
}
}
return {
...selectGroupsForSelectedElements(
{
@@ -7255,6 +7434,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
this.setState({
@@ -7552,6 +7732,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isElbowArrow(element),
);
@@ -7749,6 +7930,9 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState: PointerDownState,
) {
return withBatchedUpdatesThrottled((event: PointerEvent) => {
if (this.state.openDialog?.name === "elementLinkSelector") {
return;
}
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
const lastPointerCoords =
this.lastPointerMoveCoords ?? pointerDownState.origin;
@@ -8127,6 +8311,7 @@ class App extends React.Component<AppProps, AppState> {
suggestedBindings: getSuggestedBindingsForArrows(
selectedElements,
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
),
});
}
@@ -8295,6 +8480,7 @@ class App extends React.Component<AppProps, AppState> {
{
isDragging: true,
informMutation: false,
zoom: this.state.zoom,
},
);
} else if (points.length === 2) {
@@ -9259,6 +9445,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene,
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.zoom,
);
}
@@ -9751,6 +9938,7 @@ class App extends React.Component<AppProps, AppState> {
pointerCoords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
this.setState({
suggestedBindings:
@@ -9779,6 +9967,7 @@ class App extends React.Component<AppProps, AppState> {
coords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isArrowElement(linearElement) && isElbowArrow(linearElement),
);
if (
@@ -10219,7 +10408,7 @@ class App extends React.Component<AppProps, AppState> {
const [x, y] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y,
this.getEffectiveGridSize(),
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
const croppingElement = this.scene
@@ -10245,6 +10434,28 @@ class App extends React.Component<AppProps, AppState> {
image &&
!(image instanceof Promise)
) {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
const dragOffset = {
x: gridX - pointerDownState.originInGrid.x,
y: gridY - pointerDownState.originInGrid.y,
};
this.maybeCacheReferenceSnapPoints(event, [croppingElement]);
const { snapOffset, snapLines } = snapResizingElements(
[croppingElement],
[croppingAtStateStart],
this,
event,
dragOffset,
transformHandleType,
);
mutateElement(
croppingElement,
cropElement(
@@ -10252,8 +10463,8 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType,
image.naturalWidth,
image.naturalHeight,
x,
y,
x + snapOffset.x,
y + snapOffset.y,
event.shiftKey
? croppingAtStateStart.width / croppingAtStateStart.height
: undefined,
@@ -10273,6 +10484,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
isCropping: transformHandleType && transformHandleType !== "rotation",
snapLines,
});
}
@@ -10383,6 +10595,7 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType,
selectedElements,
this.scene.getElementsMapIncludingDeleted(),
this.scene,
shouldRotateWithDiscreteAngle(event),
shouldResizeFromCenter(event),
selectedElements.some((element) => isImageElement(element))
@@ -10397,6 +10610,7 @@ class App extends React.Component<AppProps, AppState> {
const suggestedBindings = getSuggestedBindingsForArrows(
selectedElements,
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
const elementsToHighlight = new Set<ExcalidrawElement>();
@@ -10500,7 +10714,10 @@ class App extends React.Component<AppProps, AppState> {
actionFlipVertical,
CONTEXT_MENU_SEPARATOR,
actionToggleLinearEditor,
CONTEXT_MENU_SEPARATOR,
actionLink,
actionCopyElementLink,
CONTEXT_MENU_SEPARATOR,
actionDuplicateSelection,
actionToggleElementLock,
CONTEXT_MENU_SEPARATOR,
@@ -19,6 +19,7 @@ import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import { CLASSES } from "../../constants";
import "./ColorPicker.scss";
@@ -186,9 +187,13 @@ const ColorPickerTrigger = ({
return (
<Popover.Trigger
type="button"
className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": color === "transparent" || !color,
})}
className={clsx(
"color-picker__button active-color",
CLASSES.PROPERTIES_POPOVER_TRIGGER,
{
"is-transparent": color === "transparent" || !color,
},
)}
aria-label={label}
style={color ? { "--swatch-color": color } : undefined}
title={
@@ -1,6 +1,7 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import { useUIAppState } from "../../context/ui-appState";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
@@ -17,6 +18,7 @@ export const CustomColorList = ({
onChange,
label,
}: CustomColorListProps) => {
const appState = useUIAppState();
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
@@ -54,7 +56,9 @@ export const CustomColorList = ({
key={i}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
{!appState.editingTextElement && (
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
)}
</button>
);
})}
@@ -10,6 +10,7 @@ import HotkeyLabel from "./HotkeyLabel";
import type { ColorPaletteCustom } from "../../colors";
import type { TranslationKeys } from "../../i18n";
import { t } from "../../i18n";
import { useUIAppState } from "../../context/ui-appState";
interface PickerColorListProps {
palette: ColorPaletteCustom;
@@ -26,6 +27,8 @@ const PickerColorList = ({
label,
activeShade,
}: PickerColorListProps) => {
const appState = useUIAppState();
const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent",
palette,
@@ -80,7 +83,9 @@ const PickerColorList = ({
key={key}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={keybinding} />
{!appState.editingTextElement && (
<HotkeyLabel color={color} keyLabel={keybinding} />
)}
</button>
);
})}
@@ -8,6 +8,7 @@ import {
import HotkeyLabel from "./HotkeyLabel";
import { t } from "../../i18n";
import type { ColorPaletteCustom } from "../../colors";
import { useUIAppState } from "../../context/ui-appState";
interface ShadeListProps {
hex: string;
@@ -16,6 +17,8 @@ interface ShadeListProps {
}
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
const appState = useUIAppState();
const colorObj = getColorNameAndShadeFromColor({
color: hex || "transparent",
palette,
@@ -31,7 +34,7 @@ export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
if (btnRef.current && activeColorPickerSection === "shades") {
btnRef.current.focus();
}
}, [colorObj, activeColorPickerSection]);
}, [colorObj?.colorName, colorObj?.shade, activeColorPickerSection]);
if (colorObj) {
const { colorName, shade } = colorObj;
@@ -64,7 +67,9 @@ export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
}}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
{!appState.editingTextElement && (
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
)}
</button>
))}
</div>
@@ -56,6 +56,7 @@ export const TopPicks = ({
title={color}
onClick={() => onChange(color)}
data-testid={`color-top-pick-${color}`}
tabIndex={-1}
>
<div className="color-picker__button-outline" />
</button>
@@ -204,7 +204,7 @@ export const colorPickerKeyNavHandler = ({
});
if (!baseColorName) {
onChange(COLOR_PALETTE.charcoal);
onChange(COLOR_PALETTE.black);
}
}
@@ -56,6 +56,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);
@@ -281,6 +285,8 @@ function CommandPaletteInner({
actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionLink,
actionCopyElementLink,
actionLinkToElement,
].map((action: Action) =>
actionToCommand(
action,
@@ -1,3 +1,4 @@
import { flushSync } from "react-dom";
import { t } from "../i18n";
import type { DialogProps } from "./Dialog";
import { Dialog } from "./Dialog";
@@ -43,7 +44,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 +60,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"
@@ -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;
@@ -250,6 +250,10 @@ export const FontPickerList = React.memo(
onClose={onClose}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
onFocusOutside={(event) => {
// so we don't close when refocusing wysiwyg while editing
event.preventDefault();
}}
>
<QuickSearch
ref={inputRef}
@@ -5,6 +5,7 @@ import { TextIcon } from "../icons";
import type { FontFamilyValues } from "../../element/types";
import { t } from "../../i18n";
import { isDefaultFont } from "./FontPicker";
import { CLASSES } from "../../constants";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
@@ -26,7 +27,7 @@ export const FontPickerTrigger = ({
standalone
icon={TextIcon}
title={t("labels.showFonts")}
className="properties-trigger"
className={CLASSES.PROPERTIES_POPOVER_TRIGGER}
testId={"font-family-show-fonts"}
active={isTriggerActive}
// no-op
@@ -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);
+97 -78
View File
@@ -60,6 +60,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 +85,7 @@ interface LayerUIProps {
children?: React.ReactNode;
app: AppClassProperties;
isCollaborating: boolean;
generateLinkForSelection?: AppProps["generateLinkForSelection"];
}
const DefaultMainMenu: React.FC<{
@@ -141,6 +143,7 @@ const LayerUI = ({
children,
app,
isCollaborating,
generateLinkForSelection,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@@ -232,7 +235,8 @@ const LayerUI = ({
const shouldShowStats =
appState.stats.open &&
!appState.zenModeEnabled &&
!appState.viewModeEnabled;
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector";
return (
<FixedSideContainer side="top">
@@ -241,90 +245,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 +346,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) && (
@@ -471,6 +477,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()}
+12 -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,6 +172,7 @@ 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
@@ -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);
@@ -5,6 +5,7 @@ import * as Popover from "@radix-ui/react-popover";
import { useDevice } from "./App";
import { Island } from "./Island";
import { isInteractive } from "../utils";
import { CLASSES } from "../constants";
interface PropertiesPopoverProps {
className?: string;
@@ -42,7 +43,11 @@ export const PropertiesPopover = React.forwardRef<
<Popover.Portal container={container}>
<Popover.Content
ref={ref}
className={clsx("focus-visible-none", className)}
className={clsx(
"focus-visible-none",
CLASSES.PROPERTIES_POPOVER,
className,
)}
data-prevent-outside-click
side={
device.editor.isMobile && !device.viewport.isLandscape
@@ -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;
})();
@@ -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,
},
);
}
}
@@ -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
+39 -3
View File
@@ -23,12 +23,14 @@ 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";
interface StatsProps {
app: AppClassProperties;
@@ -128,6 +130,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;
@@ -244,8 +253,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 +422,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);
@@ -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)
@@ -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,
@@ -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 || "";
@@ -170,6 +172,15 @@ export const Hyperlink = ({
useEffect(() => {
let timeoutId: number | null = null;
if (
inputRef &&
inputRef.current &&
!(device.viewport.isMobile || device.isTouchScreen)
) {
inputRef.current.select();
}
const handlePointerMove = (event: PointerEvent) => {
if (isEditing) {
return;
@@ -196,16 +207,21 @@ export const Hyperlink = ({
clearTimeout(timeoutId);
}
};
}, [appState, element, isEditing, setAppState, elementsMap]);
}, [
appState,
element,
isEditing,
setAppState,
elementsMap,
device.viewport.isMobile,
device.isTouchScreen,
]);
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 +245,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 +313,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 +354,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 +364,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 +400,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 +476,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,
+15
View File
@@ -2156,3 +2156,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,
);
+19 -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,12 +108,15 @@ export const YOUTUBE_STATES = {
export const ENV = {
TEST: "test",
DEVELOPMENT: "development",
};
export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
PROPERTIES_POPOVER: "properties-popover",
PROPERTIES_POPOVER_TRIGGER: "properties-popover-trigger",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@@ -183,14 +186,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 +194,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 +216,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 +232,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 +312,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 +392,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",
@@ -455,3 +457,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";
+5
View File
@@ -759,3 +759,8 @@ body.excalidraw-cursor-resize * {
font-family: "Assistant";
}
}
.excalidraw-textEditorContainer {
position: fixed;
z-index: var(--zIndex-wysiwyg);
}
@@ -95,7 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 35,
"height": 33.519031369643244,
"id": Any<String>,
"index": "a2",
"isDeleted": false,
@@ -109,8 +109,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0.5,
],
[
394.5,
34.5,
382.47606040672997,
34.019031369643244,
],
],
"roughness": 1,
@@ -128,9 +128,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 7,
"versionNonce": Any<Number>,
"width": 395,
"width": 381.97606040672997,
"x": 247,
"y": 420,
}
@@ -167,7 +167,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0,
],
[
399.5,
389.5,
0,
],
],
@@ -186,10 +186,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 400,
"x": 227,
"width": 390,
"x": 237,
"y": 450,
}
`;
@@ -319,7 +319,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"verticalAlign": "top",
"width": 100,
"x": 560,
"y": 226.5,
"y": 236.95454545454544,
}
`;
@@ -339,13 +339,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": {
"elementId": "text-2",
"fixedPoint": null,
"focus": 0,
"gap": 205,
"focus": 1.625925925925924,
"gap": 14,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"height": 18.278619528619487,
"id": Any<String>,
"index": "a2",
"isDeleted": false,
@@ -356,11 +356,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"points": [
[
0.5,
0,
-0.5,
],
[
99.5,
0,
357.2037037037038,
-17.778619528619487,
],
],
"roughness": 1,
@@ -378,11 +378,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
"y": 239,
"width": 357.7037037037038,
"x": 171,
"y": 249.45454545454544,
}
`;
@@ -482,7 +482,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
@@ -660,7 +660,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
@@ -1505,7 +1505,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
0,
],
[
272.485,
270.98528125,
0,
],
],
@@ -1526,10 +1526,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 7,
"versionNonce": Any<Number>,
"width": 272.985,
"x": 111.262,
"width": 270.48528125,
"x": 112.76171875,
"y": 57,
}
`;
@@ -1587,11 +1587,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 0,
"x": 77.017,
"y": 79,
"x": 83.015625,
"y": 81.5,
}
`;
+6 -2
View File
@@ -106,11 +106,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 +333,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,
+45 -63
View File
@@ -81,53 +81,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 +128,9 @@ 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",
fileHandle,
},
);
} else if (type === "clipboard-svg") {
@@ -151,33 +144,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 +167,11 @@ export const exportAsImage = async ({
return fileSave(blob, {
description: "Export to PNG",
name: cfg.name,
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,
fileHandle,
});
} else if (type === "clipboard") {
try {
+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 };
+1
View File
@@ -639,6 +639,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([
{
+82 -12
View File
@@ -40,7 +40,6 @@ import {
isBoundToContainer,
isElbowArrow,
isFixedPointBinding,
isFrameLikeElement,
isLinearElement,
isRectangularElement,
isTextElement,
@@ -97,6 +96,8 @@ export const isBindingEnabled = (appState: AppState): boolean => {
};
export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = (
scene: Scene,
@@ -213,6 +214,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
@@ -223,7 +225,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap)
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
@@ -235,12 +237,14 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) =>
getOriginalBindingIfStillCloseOfLinearElementEdge(
linearElement,
edge as "start" | "end",
elementsMap,
zoom,
),
);
@@ -250,6 +254,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
draggingPoints: readonly number[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const startIdx = 0;
const endIdx = selectedElement.points.length - 1;
@@ -262,6 +267,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start",
elementsMap,
elements,
zoom,
)
: null // If binding is disabled and start is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind
@@ -270,6 +276,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start",
elementsMap,
elements,
zoom,
);
const end = endDragged
? isBindingEnabled
@@ -278,6 +285,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end",
elementsMap,
elements,
zoom,
)
: null // If binding is disabled and end is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind
@@ -286,6 +294,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end",
elementsMap,
elements,
zoom,
);
return [start, end];
@@ -296,10 +305,12 @@ const getBindingStrategyForDraggingArrowOrJoints = (
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
isBindingEnabled: boolean,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
selectedElement,
elementsMap,
zoom,
);
const start = startIsClose
? isBindingEnabled
@@ -308,6 +319,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"start",
elementsMap,
elements,
zoom,
)
: null
: null;
@@ -318,6 +330,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"end",
elementsMap,
elements,
zoom,
)
: null
: null;
@@ -332,6 +345,7 @@ export const bindOrUnbindLinearElements = (
scene: Scene,
isBindingEnabled: boolean,
draggingPoints: readonly number[] | null,
zoom?: AppState["zoom"],
): void => {
selectedElements.forEach((selectedElement) => {
const [start, end] = draggingPoints?.length
@@ -342,6 +356,7 @@ export const bindOrUnbindLinearElements = (
draggingPoints ?? [],
elementsMap,
elements,
zoom,
)
: // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints(
@@ -349,6 +364,7 @@ export const bindOrUnbindLinearElements = (
elementsMap,
elements,
isBindingEnabled,
zoom,
);
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
@@ -358,6 +374,7 @@ export const bindOrUnbindLinearElements = (
export const getSuggestedBindingsForArrows = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
zoom: AppState["zoom"],
): SuggestedBinding[] => {
// HOT PATH: Bail out if selected elements list is too large
if (selectedElements.length > 50) {
@@ -368,7 +385,7 @@ export const getSuggestedBindingsForArrows = (
selectedElements
.filter(isLinearElement)
.flatMap((element) =>
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom),
)
.filter(
(element): element is NonDeleted<ExcalidrawBindableElement> =>
@@ -406,6 +423,7 @@ export const maybeBindLinearElement = (
pointerCoords,
elements,
elementsMap,
appState.zoom,
isElbowArrow(linearElement) && isElbowArrow(linearElement),
);
@@ -422,6 +440,26 @@ export const maybeBindLinearElement = (
}
};
const normalizePointBinding = (
binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement,
) => {
let gap = binding.gap;
const maxGap = maxBindingGap(
hoveredElement,
hoveredElement.width,
hoveredElement.height,
);
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
@@ -433,11 +471,14 @@ export const bindLinearElement = (
}
const binding: PointBinding = {
elementId: hoveredElement.id,
...calculateFocusAndGap(
linearElement,
...normalizePointBinding(
calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
hoveredElement,
startOrEnd,
elementsMap,
),
...(isElbowArrow(linearElement)
? calculateFixedPointForElbowArrowBinding(
@@ -462,6 +503,12 @@ export const bindLinearElement = (
}),
});
}
// update bound elements to make sure the binding tips are in sync with
// the normalized gap from above
if (!isElbowArrow(linearElement)) {
updateBoundElements(hoveredElement, elementsMap);
}
};
// Don't bind both ends of a simple segment
@@ -514,6 +561,7 @@ export const getHoveredElementForBinding = (
},
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition(
@@ -524,11 +572,13 @@ export const getHoveredElementForBinding = (
element,
pointerCoords,
elementsMap,
zoom,
// disable fullshape snapping for frame elements so we
// can bind to frame children
fullShape && !isFrameLikeElement(element),
fullShape,
),
);
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
};
@@ -578,9 +628,11 @@ export const updateBoundElements = (
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>;
zoom?: AppState["zoom"];
},
) => {
const { newSize, simultaneouslyUpdated, changedElements } = options ?? {};
const { newSize, simultaneouslyUpdated, changedElements, zoom } =
options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
@@ -670,6 +722,7 @@ export const updateBoundElements = (
},
{
changedElements,
zoom,
},
);
@@ -703,6 +756,7 @@ export const getHeadingForElbowArrowSnap = (
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: GlobalPoint,
zoom?: AppState["zoom"],
): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@@ -714,6 +768,7 @@ export const getHeadingForElbowArrowSnap = (
origPoint,
bindableElement,
elementsMap,
zoom,
);
if (!distance) {
@@ -737,6 +792,7 @@ const getDistanceForBinding = (
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
) => {
const distance = distanceToBindableElement(
bindableElement,
@@ -747,6 +803,7 @@ const getDistanceForBinding = (
bindableElement,
bindableElement.width,
bindableElement.height,
zoom,
);
return distance > bindDistance ? null : distance;
@@ -1174,11 +1231,13 @@ const getElligibleElementForBindingElement = (
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawBindableElement> | null => {
return getHoveredElementForBinding(
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
elements,
elementsMap,
zoom,
);
};
@@ -1341,9 +1400,11 @@ export const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
{ x, y }: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean,
): boolean => {
const threshold = maxBindingGap(element, element.width, element.height);
const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shape = getElementShape(element, elementsMap);
return (
isPointOnShape(pointFrom(x, y), shape, threshold) ||
@@ -1356,12 +1417,21 @@ export const maxBindingGap = (
element: ExcalidrawElement,
elementWidth: number,
elementHeight: number,
zoom?: AppState["zoom"],
): number => {
const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1;
// Aligns diamonds with rectangles
const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
// We make the bindable boundary bigger for bigger elements
return Math.max(16, Math.min(0.25 * smallerDimension, 32));
return Math.max(
16,
// bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
);
};
export const distanceToBindableElement = (
+39 -1
View File
@@ -26,7 +26,7 @@ import {
getResizedElementAbsoluteCoords,
} from "./bounds";
const MINIMAL_CROP_SIZE = 10;
export const MINIMAL_CROP_SIZE = 10;
export const cropElement = (
element: ExcalidrawImageElement,
@@ -585,3 +585,41 @@ const adjustCropPosition = (
cropY,
};
};
export const getFlipAdjustedCropPosition = (
element: ExcalidrawImageElement,
natural = false,
) => {
const crop = element.crop;
if (!crop) {
return null;
}
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
let cropX = crop.x;
let cropY = crop.y;
if (isFlippedByX) {
cropX = crop.naturalWidth - crop.width - crop.x;
}
if (isFlippedByY) {
cropY = crop.naturalHeight - crop.height - crop.y;
}
if (natural) {
return {
x: cropX,
y: cropY,
};
}
const { width, height } = getUncroppedWidthAndHeight(element);
return {
x: cropX / (crop.naturalWidth / width),
y: cropY / (crop.naturalHeight / height),
};
};
+102
View File
@@ -0,0 +1,102 @@
/**
* Create and link between shapes.
*/
import { ELEMENT_LINK_KEY } from "../constants";
import { normalizeLink } from "../data/url";
import { elementsAreInSameGroup } from "../groups";
import type { AppProps, AppState } from "../types";
import type { ExcalidrawElement } from "./types";
export const defaultGetElementLinkFromSelection: Exclude<
AppProps["generateLinkForSelection"],
undefined
> = (id, type) => {
const url = window.location.href;
try {
const link = new URL(url);
link.searchParams.set(ELEMENT_LINK_KEY, id);
return normalizeLink(link.toString());
} catch (error) {
console.error(error);
}
return normalizeLink(url);
};
export const getLinkIdAndTypeFromSelection = (
selectedElements: ExcalidrawElement[],
appState: AppState,
): {
id: string;
type: "element" | "group";
} | null => {
if (
selectedElements.length > 0 &&
canCreateLinkFromElements(selectedElements)
) {
if (selectedElements.length === 1) {
return {
id: selectedElements[0].id,
type: "element",
};
}
if (selectedElements.length > 1) {
const selectedGroupId = Object.keys(appState.selectedGroupIds)[0];
if (selectedGroupId) {
return {
id: selectedGroupId,
type: "group",
};
}
return {
id: selectedElements[0].groupIds[0],
type: "group",
};
}
}
return null;
};
export const canCreateLinkFromElements = (
selectedElements: ExcalidrawElement[],
) => {
if (selectedElements.length === 1) {
return true;
}
if (selectedElements.length > 1 && elementsAreInSameGroup(selectedElements)) {
return true;
}
return false;
};
export const isElementLink = (url: string) => {
try {
const _url = new URL(url);
return (
_url.searchParams.has(ELEMENT_LINK_KEY) &&
_url.host === window.location.host
);
} catch (error) {
return false;
}
};
export const parseElementLinkFromURL = (url: string) => {
try {
const { searchParams } = new URL(url);
if (searchParams.has(ELEMENT_LINK_KEY)) {
const id = searchParams.get(ELEMENT_LINK_KEY);
return id;
}
} catch {}
return null;
};
+27 -5
View File
@@ -105,20 +105,42 @@ export const normalizeSVG = (SVGString: string) => {
svg.setAttribute("xmlns", SVG_NS);
}
if (!svg.hasAttribute("width") || !svg.hasAttribute("height")) {
const viewBox = svg.getAttribute("viewBox");
let width = svg.getAttribute("width") || "50";
let height = svg.getAttribute("height") || "50";
let width = svg.getAttribute("width");
let height = svg.getAttribute("height");
// Do not use % or auto values for width/height
// to avoid scaling issues when rendering at different sizes/zoom levels
if (width?.includes("%") || width === "auto") {
width = null;
}
if (height?.includes("%") || height === "auto") {
height = null;
}
const viewBox = svg.getAttribute("viewBox");
if (!width || !height) {
width = width || "50";
height = height || "50";
if (viewBox) {
const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
const match = viewBox.match(
/\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/,
);
if (match) {
[, width, height] = match;
}
}
svg.setAttribute("width", width);
svg.setAttribute("height", height);
}
// Make sure viewBox is set
if (!viewBox) {
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
}
return svg.outerHTML;
}
};
@@ -448,6 +448,7 @@ export class LinearElementEditor {
),
elements,
elementsMap,
appState.zoom,
)
: null;
@@ -787,6 +788,7 @@ export class LinearElementEditor {
scenePointer,
elements,
elementsMap,
app.state.zoom,
),
};
@@ -911,6 +913,7 @@ export class LinearElementEditor {
element,
[points.length - 1],
elementsMap,
app.state.zoom,
);
}
return {
@@ -964,6 +967,7 @@ export class LinearElementEditor {
element,
[{ point: newPoint }],
elementsMap,
app.state.zoom,
);
}
return {
@@ -1218,6 +1222,7 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) {
let offsetX = 0;
let offsetY = 0;
@@ -1260,6 +1265,7 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) {
const offsetX = 0;
const offsetY = 0;
@@ -1285,6 +1291,7 @@ export class LinearElementEditor {
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
zoom?: AppState["zoom"];
},
) {
const { points } = element;
@@ -1337,6 +1344,7 @@ export class LinearElementEditor {
false,
),
changedElements: options?.changedElements,
zoom: options?.zoom,
},
);
}
@@ -1451,6 +1459,7 @@ export class LinearElementEditor {
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
zoom?: AppState["zoom"];
},
) {
if (isElbowArrow(element)) {
@@ -1487,6 +1496,7 @@ export class LinearElementEditor {
bindings,
{
isDragging: options?.isDragging,
zoom: options?.zoom,
},
);
} else {
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -14,6 +14,7 @@ import {
import BinaryHeap from "../binaryheap";
import { getSizeFromPoints } from "../points";
import { aabbForElement, pointInsideBounds } from "../shapes";
import type { AppState } from "../types";
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
import {
bindPointToSnapToElementOutline,
@@ -79,6 +80,7 @@ export const mutateElbowArrow = (
options?: {
isDragging?: boolean;
informMutation?: boolean;
zoom?: AppState["zoom"];
},
) => {
const update = updateElbowArrow(
@@ -112,6 +114,7 @@ export const updateElbowArrow = (
isDragging?: boolean;
disableBinding?: boolean;
informMutation?: boolean;
zoom?: AppState["zoom"];
},
): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
const origStartGlobalPoint: GlobalPoint = pointTranslate(
@@ -136,7 +139,12 @@ export const updateElbowArrow = (
arrow.endBinding &&
getBindableElementForId(arrow.endBinding.elementId, elementsMap);
const [hoveredStartElement, hoveredEndElement] = options?.isDragging
? getHoveredElements(origStartGlobalPoint, origEndGlobalPoint, elementsMap)
? getHoveredElements(
origStartGlobalPoint,
origEndGlobalPoint,
elementsMap,
options?.zoom,
)
: [startElement, endElement];
const startGlobalPoint = getGlobalPoint(
arrow.startBinding?.fixedPoint,
@@ -1072,6 +1080,7 @@ const getHoveredElements = (
origStartGlobalPoint: GlobalPoint,
origEndGlobalPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom?: AppState["zoom"],
) => {
// TODO: Might be a performance bottleneck and the Map type
// remembers the insertion order anyway...
@@ -1084,12 +1093,14 @@ const getHoveredElements = (
tupleToCoors(origStartGlobalPoint),
elements,
nonDeletedSceneElementsMap,
zoom,
true,
),
getHoveredElementForBinding(
tupleToCoors(origEndGlobalPoint),
elements,
nonDeletedSceneElementsMap,
zoom,
true,
),
];
@@ -8,6 +8,7 @@ export const showSelectedShapeActions = (
) =>
Boolean(
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
((appState.activeTool.type !== "custom" &&
(appState.editingTextElement ||
(appState.activeTool.type !== "selection" &&
+85 -45
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, POINTER_BUTTON } from "../constants";
import { CLASSES, EVENT, isSafari, POINTER_BUTTON } from "../constants";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -50,6 +50,8 @@ import {
originalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
import { activeEyeDropperAtom } from "../components/EyeDropper";
import { jotaiStore } from "../jotai";
const getTransform = (
width: number,
@@ -289,6 +291,8 @@ export const textWysiwyg = ({
editable.dataset.type = "wysiwyg";
// prevent line wrapping on Safari
editable.wrap = "off";
// set &nbsp; placeholder fix for Safari not showing caret for empty textarea
editable.placeholder = "\u00A0";
editable.classList.add("excalidraw-wysiwyg");
let whiteSpace = "pre";
@@ -522,6 +526,7 @@ export const textWysiwyg = ({
// so that we don't need to create separate a callback for event handlers
let submittedViaKeyboard = false;
const handleSubmit = () => {
console.warn("handleSubmit");
// prevent double submit
if (isDestroyed) {
return;
@@ -579,62 +584,96 @@ export const textWysiwyg = ({
});
};
const onBlur = () => {
console.warn("onBlur", document.activeElement);
const isColorPicking = jotaiStore.get(activeEyeDropperAtom);
if (isColorPicking) {
focusEditable(null);
} else if (document.activeElement !== editable) {
handleSubmit();
}
};
const cleanup = () => {
// remove events to ensure they don't late-fire
editable.onblur = null;
editable.oninput = null;
editable.onkeydown = null;
editable.onpointerdown = null;
if (observer) {
observer.disconnect();
}
window.removeEventListener("resize", updateWysiwygStyle);
window.removeEventListener("wheel", stopEvent, true);
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", bindBlurEvent);
window.removeEventListener("blur", handleSubmit);
window.removeEventListener("beforeunload", handleSubmit);
window.removeEventListener(EVENT.RESIZE, updateWysiwygStyle);
window.removeEventListener(EVENT.WHEEL, stopEvent, true);
window.removeEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
window.removeEventListener(EVENT.BLUR, onBlur);
window.removeEventListener(EVENT.BEFORE_UNLOAD, handleSubmit);
unbindUpdate();
unbindOnScroll();
editable.remove();
};
const bindBlurEvent = (event?: MouseEvent) => {
window.removeEventListener("pointerup", bindBlurEvent);
// Deferred so that the pointerdown that initiates the wysiwyg doesn't
// trigger the blur on ensuing pointerup.
// Also to handle cases such as picking a color which would trigger a blur
// in that same tick.
const focusEditable = (event: MouseEvent | FocusEvent | null) => {
const target = event?.target;
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
const shouldSkipRefocus =
target &&
// don't steal focus if user is focusing an input such as HEX input
((isWritableElement(target) && document.activeElement !== editable) ||
// refocusing while clicking on popver breaks safari
(isSafari &&
target instanceof HTMLElement &&
target.classList.contains(CLASSES.PROPERTIES_POPOVER_TRIGGER)));
setTimeout(() => {
editable.onblur = handleSubmit;
// case: clicking on the same property → no change → no update → no focus
if (!isPropertiesTrigger) {
editable.focus();
}
});
if (!shouldSkipRefocus) {
// Deferred so that the pointerdown that initiates the wysiwyg doesn't
// trigger the blur on ensuing pointerup.
// Also to handle cases such as picking a color which would trigger a blur
// in that same tick.
setTimeout(() => {
// double deferred because on onUpdate/color picker shennanings
setTimeout(() => {
editable.focus();
});
});
}
};
const temporarilyDisableSubmit = () => {
const onPointerUp = (event: PointerEvent | FocusEvent) => {
window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
window.removeEventListener(EVENT.FOCUS, onPointerUp);
// needs to be deferred due to Safari
setTimeout(() => {
editable.onblur = onBlur;
});
focusEditable(event);
};
const disableBlurUntilNextPointerUp = () => {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
// handle edge-case where pointerup doesn't fire e.g. due to user
// alt-tabbing away
window.addEventListener("blur", handleSubmit);
window.addEventListener(EVENT.FOCUS, onPointerUp);
};
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
const target = event?.target;
// ugly hack to close popups such as color picker when clicking back
// into the wysiwyg editor (it won't autoclose as blur won't trigger
// since we perpetually keep focus inside the wysiwyg)
if (target === editable && app.state.openPopup) {
app.setState({ openPopup: null });
}
// panning canvas
if (event.button === POINTER_BUTTON.WHEEL) {
// trying to pan by clicking inside text area itself -> handle here
@@ -642,24 +681,18 @@ export const textWysiwyg = ({
event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
}
temporarilyDisableSubmit();
disableBlurUntilNextPointerUp();
return;
}
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
if (
((event.target instanceof HTMLElement ||
(event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) &&
!isWritableElement(event.target)) ||
isPropertiesTrigger
event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}, .${CLASSES.PROPERTIES_POPOVER}`,
)
) {
temporarilyDisableSubmit();
disableBlurUntilNextPointerUp();
} else if (
event.target instanceof HTMLCanvasElement &&
// Vitest simply ignores stopPropagation, capture-mode, or rAF
@@ -682,9 +715,11 @@ export const textWysiwyg = ({
const unbindUpdate = app.scene.onUpdate(() => {
updateWysiwygStyle();
const isPopupOpened = !!document.activeElement?.closest(
".properties-content",
CLASSES.PROPERTIES_POPOVER,
);
if (!isPopupOpened) {
// we need to keep this code path for safari (iPadOS) bs reasons
// (also Vitest)
editable.focus();
}
});
@@ -702,8 +737,11 @@ export const textWysiwyg = ({
// because we need it to happen *after* the blur event from `pointerdown`)
editable.select();
}
bindBlurEvent();
focusEditable(null);
setTimeout(() => {
editable.onblur = onBlur;
});
console.log(">>>>>>>>", app.state.editingTextElement);
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
// is preferred so we catch changes from host, where window may not resize.
let observer: ResizeObserver | null = null;
@@ -713,7 +751,7 @@ export const textWysiwyg = ({
});
observer.observe(canvas);
} else {
window.addEventListener("resize", updateWysiwygStyle);
window.addEventListener(EVENT.RESIZE, updateWysiwygStyle);
}
editable.onpointerdown = (event) => event.stopPropagation();
@@ -721,9 +759,11 @@ export const textWysiwyg = ({
// rAF (+ capture to by doubly sure) so we don't catch te pointerdown that
// triggered the wysiwyg
requestAnimationFrame(() => {
window.addEventListener("pointerdown", onPointerDown, { capture: true });
window.addEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
});
window.addEventListener("beforeunload", handleSubmit);
window.addEventListener(EVENT.BEFORE_UNLOAD, handleSubmit);
excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);
@@ -2,8 +2,8 @@ import { atom, useAtom } from "jotai";
import { useEffect, useState } from "react";
import { COLOR_PALETTE } from "../colors";
import { jotaiScope } from "../jotai";
import { exportToSvg } from "../../utils/export";
import type { LibraryItem } from "../types";
import { exportToSvg } from "../scene/export";
export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>;
@@ -11,14 +11,14 @@ export const libraryItemSvgsCache = atom<SvgCache>(new Map());
const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
return await exportToSvg({
data: {
elements,
appState: {
exportBackground: false,
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
elements,
appState: {
exportBackground: false,
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
renderEmbeddables: false,
skipInliningFonts: true,
});
};
+14 -14
View File
@@ -1,6 +1,5 @@
import { exportToCanvas } from "./scene/export";
import { getDefaultAppState } from "./appState";
import { COLOR_WHITE } from "./constants";
const { registerFont, createCanvas } = require("canvas");
@@ -58,21 +57,22 @@ const elements = [
registerFont("./public/Virgil.woff2", { family: "Virgil" });
registerFont("./public/Cascadia.woff2", { family: "Cascadia" });
const canvas = exportToCanvas({
data: {
elements: elements as any,
appState: {
...getDefaultAppState(),
width: 0,
height: 0,
},
files: {}, // files
const canvas = exportToCanvas(
elements as any,
{
...getDefaultAppState(),
offsetTop: 0,
offsetLeft: 0,
width: 0,
height: 0,
},
config: {
canvasBackgroundColor: COLOR_WHITE,
createCanvas,
{}, // files
{
exportBackground: true,
viewBackgroundColor: "#ffffff",
},
});
createCanvas,
);
const fs = require("fs");
const out = fs.createWriteStream("test.png");
+10 -7
View File
@@ -43,6 +43,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
autoFocus = false,
generateIdForFile,
onLinkOpen,
generateLinkForSelection,
onPointerDown,
onPointerUp,
onScrollChange,
@@ -132,6 +133,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
autoFocus={autoFocus}
generateIdForFile={generateIdForFile}
onLinkOpen={onLinkOpen}
generateLinkForSelection={generateLinkForSelection}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onScrollChange={onScrollChange}
@@ -225,6 +227,13 @@ export {
export { reconcileElements } from "./data/reconcile";
export {
exportToCanvas,
exportToBlob,
exportToSvg,
exportToClipboard,
} from "../utils/export";
export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
export {
loadFromBlob,
@@ -267,13 +276,6 @@ export { WelcomeScreen };
export { LiveCollaborationTrigger };
export { Stats } from "./components/Stats";
export {
exportToCanvas,
exportToBlob,
exportToClipboard,
exportToSvg,
} from "./scene/export";
export { DefaultSidebar } from "./components/DefaultSidebar";
export { TTDDialog } from "./components/TTDDialog/TTDDialog";
export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger";
@@ -291,3 +293,4 @@ export {
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
export { getDataURL } from "./data/blob";
export { isElementLink } from "./element/elementLink";
+17 -6
View File
@@ -125,12 +125,13 @@
"createContainerFromText": "Wrap text in a container",
"link": {
"edit": "Edit link",
"editEmbed": "Edit link & embed",
"create": "Create link",
"createEmbed": "Create link & embed",
"editEmbed": "Edit embeddable link",
"create": "Add link",
"label": "Link",
"labelEmbed": "Link & embed",
"empty": "No link is set"
"empty": "No link is set",
"hint": "Type or paste your link here",
"goToElement": "Go to target element"
},
"lineEditor": {
"edit": "Edit line",
@@ -155,7 +156,16 @@
"zoomToFitSelection": "Zoom to fit selection",
"zoomToFit": "Zoom to fit all elements",
"installPWA": "Install Excalidraw locally (PWA)",
"autoResize": "Enable text auto-resizing"
"autoResize": "Enable text auto-resizing",
"imageCropping": "Image cropping",
"unCroppedDimension": "Uncropped dimension",
"copyElementLink": "Copy link to object",
"linkToElement": "Link to object"
},
"elementLink": {
"title": "Link to object",
"desc": "Click on a shape on canvas or paste a link.",
"notFound": "Linked object wasn't found on canvas."
},
"library": {
"noItems": "No items added yet...",
@@ -501,7 +511,8 @@
"selection": "selection",
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor",
"unableToEmbed": "Embedding this url is currently not allowed. Raise an issue on GitHub to request the url whitelisted",
"unrecognizedLinkFormat": "The link you embedded does not match the expected format. Please try to paste the 'embed' string provided by the source site"
"unrecognizedLinkFormat": "The link you embedded does not match the expected format. Please try to paste the 'embed' string provided by the source site",
"elementLinkCopied": "Link copied to clipboard"
},
"colors": {
"transparent": "Transparent",
+9 -10
View File
@@ -1,4 +1,4 @@
import type { AppState } from "../types";
import type { StaticCanvasAppState, AppState } from "../types";
import type { StaticCanvasRenderConfig } from "../scene/types";
@@ -34,16 +34,15 @@ export const bootstrapCanvas = ({
normalizedHeight,
theme,
isExporting,
canvasBackgroundColor,
viewBackgroundColor,
}: {
canvas: HTMLCanvasElement;
scale: number;
normalizedWidth: number;
normalizedHeight: number;
theme?: AppState["theme"];
// static canvas only
isExporting?: StaticCanvasRenderConfig["isExporting"];
canvasBackgroundColor?: string | null;
viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"];
}): CanvasRenderingContext2D => {
const context = canvas.getContext("2d")!;
@@ -55,17 +54,17 @@ export const bootstrapCanvas = ({
}
// Paint background
if (typeof canvasBackgroundColor === "string") {
if (typeof viewBackgroundColor === "string") {
const hasTransparence =
canvasBackgroundColor === "transparent" ||
canvasBackgroundColor.length === 5 || // #RGBA
canvasBackgroundColor.length === 9 || // #RRGGBBA
/(hsla|rgba)\(/.test(canvasBackgroundColor);
viewBackgroundColor === "transparent" ||
viewBackgroundColor.length === 5 || // #RGBA
viewBackgroundColor.length === 9 || // #RRGGBBA
/(hsla|rgba)\(/.test(viewBackgroundColor);
if (hasTransparence) {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
context.save();
context.fillStyle = canvasBackgroundColor;
context.fillStyle = viewBackgroundColor;
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
} else {
@@ -43,7 +43,11 @@ import type {
SuggestedBinding,
SuggestedPointBinding,
} from "../element/binding";
import { maxBindingGap } from "../element/binding";
import {
BINDING_HIGHLIGHT_OFFSET,
BINDING_HIGHLIGHT_THICKNESS,
maxBindingGap,
} from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
bootstrapCanvas,
@@ -217,17 +221,18 @@ const renderBindingHighlightForBindableElement = (
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
const thickness = 10;
// So that we don't overlap the element itself
const strokeOffset = 4;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = thickness - strokeOffset;
const padding = strokeOffset / 2 + thickness / 2;
// When zooming out, make line width greater for visibility
const zoomValue = zoom.value < 1 ? zoom.value : 1;
context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue;
// To ensure the binding highlight doesn't overlap the element itself
const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET;
const radius = getCornerRadius(
Math.min(element.width, element.height),
@@ -285,6 +290,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
context: CanvasRenderingContext2D,
suggestedBinding: SuggestedPointBinding,
elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => {
const [element, startOrEnd, bindableElement] = suggestedBinding;
@@ -292,6 +298,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
bindableElement,
bindableElement.width,
bindableElement.height,
zoom,
);
context.strokeStyle = "rgba(0,0,0,0)";
@@ -390,7 +397,7 @@ const renderBindingHighlight = (
context.save();
context.translate(appState.scrollX, appState.scrollY);
renderHighlight(context, suggestedBinding as any, elementsMap);
renderHighlight(context, suggestedBinding as any, elementsMap, appState.zoom);
context.restore();
};
+11 -1
View File
@@ -40,6 +40,7 @@ import type {
import { getDefaultAppState } from "../appState";
import {
BOUND_TEXT_PADDING,
DEFAULT_REDUCED_GLOBAL_ALPHA,
ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE,
MIME_TYPES,
@@ -109,10 +110,13 @@ export const getRenderOpacity = (
containingFrame: ExcalidrawFrameLikeElement | null,
elementsPendingErasure: ElementsPendingErasure,
pendingNodes: Readonly<PendingExcalidrawElements> | null,
globalAlpha: number = 1,
) => {
// multiplying frame opacity with element opacity to combine them
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
let opacity = ((containingFrame?.opacity ?? 100) * element.opacity) / 10000;
let opacity =
(((containingFrame?.opacity ?? 100) * element.opacity) / 10000) *
globalAlpha;
// if pending erasure, multiply again to combine further
// (so that erasing always results in lower opacity than original)
@@ -700,11 +704,17 @@ export const renderElement = (
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
const reduceAlphaForSelection =
appState.openDialog?.name === "elementLinkSelector" &&
!appState.selectedElementIds[element.id] &&
!appState.hoveredElementIds[element.id];
context.globalAlpha = getRenderOpacity(
element,
getContainingFrame(element, elementsMap),
renderConfig.elementsPendingErasure,
renderConfig.pendingFlowchartNodes,
reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1,
);
switch (element.type) {
+42 -25
View File
@@ -25,11 +25,13 @@ import type {
} from "../scene/types";
import {
EXTERNAL_LINK_IMG,
ELEMENT_LINK_IMG,
getLinkHandleFromCoords,
} from "../components/hyperlink/helpers";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { throttleRAF } from "../utils";
import { getBoundTextElement } from "../element/textElement";
import { isElementLink } from "../element/elementLink";
const GridLineColor = {
Bold: "#dddddd",
@@ -133,7 +135,16 @@ const frameClip = (
);
};
let linkCanvasCache: any;
type LinkIconCanvas = HTMLCanvasElement & { zoom: number };
const linkIconCanvasCache: {
regularLink: LinkIconCanvas | null;
elementLink: LinkIconCanvas | null;
} = {
regularLink: null,
elementLink: null,
};
const renderLinkIcon = (
element: NonDeletedExcalidrawElement,
context: CanvasRenderingContext2D,
@@ -153,38 +164,44 @@ const renderLinkIcon = (
context.translate(appState.scrollX + centerX, appState.scrollY + centerY);
context.rotate(element.angle);
if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) {
linkCanvasCache = document.createElement("canvas");
linkCanvasCache.zoom = appState.zoom.value;
linkCanvasCache.width =
width * window.devicePixelRatio * appState.zoom.value;
linkCanvasCache.height =
const canvasKey = isElementLink(element.link)
? "elementLink"
: "regularLink";
let linkCanvas = linkIconCanvasCache[canvasKey];
if (!linkCanvas || linkCanvas.zoom !== appState.zoom.value) {
linkCanvas = Object.assign(document.createElement("canvas"), {
zoom: appState.zoom.value,
});
linkCanvas.width = width * window.devicePixelRatio * appState.zoom.value;
linkCanvas.height =
height * window.devicePixelRatio * appState.zoom.value;
const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!;
linkIconCanvasCache[canvasKey] = linkCanvas;
const linkCanvasCacheContext = linkCanvas.getContext("2d")!;
linkCanvasCacheContext.scale(
window.devicePixelRatio * appState.zoom.value,
window.devicePixelRatio * appState.zoom.value,
);
linkCanvasCacheContext.fillStyle = "#fff";
linkCanvasCacheContext.fillRect(0, 0, width, height);
linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height);
if (canvasKey === "elementLink") {
linkCanvasCacheContext.drawImage(ELEMENT_LINK_IMG, 0, 0, width, height);
} else {
linkCanvasCacheContext.drawImage(
EXTERNAL_LINK_IMG,
0,
0,
width,
height,
);
}
linkCanvasCacheContext.restore();
context.drawImage(
linkCanvasCache,
x - centerX,
y - centerY,
width,
height,
);
} else {
context.drawImage(
linkCanvasCache,
x - centerX,
y - centerY,
width,
height,
);
}
context.drawImage(linkCanvas, x - centerX, y - centerY, width, height);
context.restore();
}
};
@@ -216,7 +233,7 @@ const _renderStaticScene = ({
normalizedHeight,
theme: appState.theme,
isExporting,
canvasBackgroundColor: renderConfig.canvasBackgroundColor,
viewBackgroundColor: appState.viewBackgroundColor,
});
// Apply zoom
+13
View File
@@ -25,6 +25,7 @@ import {
import { arrayToMap } from "../utils";
import { toBrandedType } from "../utils";
import { ENV } from "../constants";
import { getElementsInGroup } from "../groups";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
@@ -437,6 +438,18 @@ class Scene {
}
return null;
};
getElementsFromId = (id: string): ExcalidrawElement[] => {
const elementsMap = this.getNonDeletedElementsMap();
// first check if the id is an element
const el = elementsMap.get(id);
if (el) {
return [el];
}
// then, check if the id is a group
return getElementsInGroup(elementsMap, id);
};
}
export default Scene;
+2 -4
View File
@@ -164,10 +164,8 @@ const getArrowheadShapes = (
arrowhead: Arrowhead,
generator: RoughGenerator,
options: Options,
canvasBackgroundColor: string | null,
canvasBackgroundColor: string,
) => {
canvasBackgroundColor = canvasBackgroundColor || "transparent";
const arrowheadPoints = getArrowheadPoints(
element,
shape,
@@ -295,7 +293,7 @@ export const _generateElementShape = (
embedsValidationStatus,
}: {
isExporting: boolean;
canvasBackgroundColor: string | null;
canvasBackgroundColor: string;
embedsValidationStatus: EmbedsValidationStatus | null;
},
): Drawable | Drawable[] | null => {
+2 -3
View File
@@ -8,8 +8,7 @@ import { elementWithCanvasCache } from "../renderer/renderElement";
import { _generateElementShape } from "./Shape";
import type { ElementShape, ElementShapes } from "./types";
import { COLOR_PALETTE } from "../colors";
import type { EmbedsValidationStatus } from "../types";
import type { StaticCanvasRenderConfig } from "./types";
import type { AppState, EmbedsValidationStatus } from "../types";
export class ShapeCache {
private static rg = new RoughGenerator();
@@ -51,7 +50,7 @@ export class ShapeCache {
element: T,
renderConfig: {
isExporting: boolean;
canvasBackgroundColor: StaticCanvasRenderConfig["canvasBackgroundColor"];
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
} | null,
) => {
+142 -622
View File
@@ -5,7 +5,6 @@ import type {
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Theme,
} from "../element/types";
import type { Bounds } from "../element/bounds";
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
@@ -13,15 +12,12 @@ import { renderSceneToSvg } from "../renderer/staticSvgScene";
import { arrayToMap, distance, getFontString, toBrandedType } from "../utils";
import type { AppState, BinaryFiles } from "../types";
import {
COLOR_WHITE,
DEFAULT_ZOOM_VALUE,
DEFAULT_EXPORT_PADDING,
FRAME_STYLE,
FONT_FAMILY,
SVG_NS,
THEME,
THEME_FILTER,
MIME_TYPES,
DEFAULT_SMALLEST_EXPORT_SIZE,
} from "../constants";
import { getDefaultAppState } from "../appState";
import { serializeAsJSON } from "../data/json";
@@ -29,7 +25,6 @@ import {
getInitializedImageElements,
updateImageCache,
} from "../element/image";
import { restoreAppState } from "../data/restore";
import {
getElementsOverlappingFrame,
getFrameLikeElements,
@@ -44,12 +39,6 @@ import type { RenderableElementsMap } from "./types";
import { syncInvalidIndices } from "../fractionalIndex";
import { renderStaticScene } from "../renderer/staticScene";
import { Fonts } from "../fonts";
import { encodePngMetadata } from "../data/image";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
copyToClipboard,
} from "../clipboard";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@@ -160,199 +149,36 @@ const prepareElementsForRender = ({
return nextElements;
};
type ExportToCanvasAppState = Partial<
Omit<AppState, "offsetTop" | "offsetLeft">
>;
export type ExportSceneData = {
elements: readonly NonDeletedExcalidrawElement[];
appState?: ExportToCanvasAppState;
files: BinaryFiles | null;
};
export type ExportSceneConfig = {
theme?: Theme;
/**
* Canvas background. Valid values are:
*
* - `undefined` - the background of "appState.viewBackgroundColor" is used.
* - `false` - no background is used (set to "transparent").
* - `string` - should be a valid CSS color.
*
* @default undefined
*/
canvasBackgroundColor?: string | false;
/**
* Canvas padding in pixels. Affected by `scale`.
*
* When `fit` is set to `none`, padding is added to the content bounding box
* (including if you set `width` or `height` or `maxWidthOrHeight` or
* `widthOrHeight`).
*
* When `fit` set to `contain`, padding is subtracted from the content
* bounding box (ensuring the size doesn't exceed the supplied values, with
* the exeception of using alongside `scale` as noted above), and the padding
* serves as a minimum distance between the content and the canvas edges, as
* it may exceed the supplied padding value from one side or the other in
* order to maintain the aspect ratio. It is recommended to set `position`
* to `center` when using `fit=contain`.
*
* When `fit` is set to `none` and either `width` or `height` or
* `maxWidthOrHeight` is set, padding is simply adding to the bounding box
* and the content may overflow the canvas, thus right or bottom padding
* may be ignored.
*
* @default 0
*/
padding?: number;
// -------------------------------------------------------------------------
/**
* Makes sure the canvas content fits into a frame of width/height no larger
* than this value, while maintaining the aspect ratio.
*
* Final dimensions can get smaller/larger if used in conjunction with
* `scale`.
*/
maxWidthOrHeight?: number;
/**
* Scale the canvas content to be excatly this many pixels wide/tall,
* maintaining the aspect ratio.
*
* Cannot be used in conjunction with `maxWidthOrHeight`.
*
* Final dimensions can get smaller/larger if used in conjunction with
* `scale`.
*/
widthOrHeight?: number;
// -------------------------------------------------------------------------
/**
* Width of the frame. Supply `x` or `y` if you want to ofsset the canvas
* content.
*
* If `width` omitted but `height` supplied, `width` is calculated from the
* the content's bounding box to preserve the aspect ratio.
*
* Defaults to the content bounding box width when both `width` and `height`
* are omitted.
*/
width?: number;
/**
* Height of the frame.
*
* If `height` omitted but `width` supplied, `height` is calculated from the
* content's bounding box to preserve the aspect ratio.
*
* Defaults to the content bounding box height when both `width` and `height`
* are omitted.
*/
height?: number;
/**
* Left canvas offset. By default the coordinate is relative to the canvas.
* You can switch to content coordinates by setting `origin` to `content`.
*
* Defaults to the `x` postion of the content bounding box.
*/
x?: number;
/**
* Top canvas offset. By default the coordinate is relative to the canvas.
* You can switch to content coordinates by setting `origin` to `content`.
*
* Defaults to the `y` postion of the content bounding box.
*/
y?: number;
/**
* Indicates the coordinate system of the `x` and `y` values.
*
* - `canvas` - `x` and `y` are relative to the canvas [0, 0] position.
* - `content` - `x` and `y` are relative to the content bounding box.
*
* @default "canvas"
*/
origin?: "canvas" | "content";
/**
* If dimensions specified and `x` and `y` are not specified, this indicates
* how the canvas should be scaled.
*
* Behavior aligns with the `object-fit` CSS property.
*
* - `none` - no scaling.
* - `contain` - scale to fit the frame. Includes `padding`.
*
* If `maxWidthOrHeight` or `widthOrHeight` is set, `fit` is ignored.
*
* @default "contain" unless `width`, `height`, `maxWidthOrHeight`, or
* `widthOrHeight` is specified in which case `none` is the default (can be
* changed). If `x` or `y` are specified, `none` is forced.
*/
fit?: "none" | "contain";
/**
* When either `x` or `y` are not specified, indicates how the canvas should
* be aligned on the respective axis.
*
* - `none` - canvas aligned to top left.
* - `center` - canvas is centered on the axis which is not specified
* (or both).
*
* If `maxWidthOrHeight` or `widthOrHeight` is set, `position` is ignored.
*
* @default "center"
*/
position?: "center" | "topLeft";
// -------------------------------------------------------------------------
/**
* A multiplier to increase/decrease the frame dimensions
* (content resolution).
*
* For example, if your canvas is 300x150 and you set scale to 2, the
* resulting size will be 600x300.
*
* @default 1
*/
scale?: number;
/**
* If you need to suply your own canvas, e.g. in test environments or in
* Node.js.
*
* Do not set `canvas.width/height` or modify the canvas context as that's
* handled by Excalidraw.
*
* Defaults to `document.createElement("canvas")`.
*/
createCanvas?: () => HTMLCanvasElement;
/**
* If you want to supply `width`/`height` dynamically (or derive from the
* content bounding box), you can use this function.
*
* Ignored if `maxWidthOrHeight`, `width`, or `height` is set.
*/
getDimensions?: (
export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
{
exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING,
viewBackgroundColor,
exportingFrame,
}: {
exportBackground: boolean;
exportPadding?: number;
viewBackgroundColor: string;
exportingFrame?: ExcalidrawFrameLikeElement | null;
},
createCanvas: (
width: number,
height: number,
) => { width: number; height: number; scale?: number };
exportingFrame?: ExcalidrawFrameLikeElement | null;
loadFonts?: () => Promise<void>;
};
const configExportDimension = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
// clone
const cfg = Object.assign({}, config);
const { exportingFrame } = cfg;
const elements = data.elements;
// initialize defaults
// ---------------------------------------------------------------------------
const appState = restoreAppState(data.appState, null);
) => { canvas: HTMLCanvasElement; scale: number } = (width, height) => {
const canvas = document.createElement("canvas");
canvas.width = width * appState.exportScale;
canvas.height = height * appState.exportScale;
return { canvas, scale: appState.exportScale };
},
loadFonts: () => Promise<void> = async () => {
await Fonts.loadElementsFonts(elements);
},
) => {
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
await loadFonts();
const frameRendering = getFrameRenderingConfig(
exportingFrame ?? null,
@@ -372,240 +198,24 @@ const configExportDimension = async ({
});
if (exportingFrame) {
cfg.padding = 0;
exportPadding = 0;
}
cfg.fit =
cfg.fit ??
(cfg.width != null ||
cfg.height != null ||
cfg.maxWidthOrHeight != null ||
cfg.widthOrHeight != null
? "contain"
: "none");
cfg.padding = cfg.padding ?? 0;
cfg.scale = cfg.scale ?? 1;
cfg.origin = cfg.origin ?? "canvas";
cfg.position = cfg.position ?? "center";
if (cfg.maxWidthOrHeight != null && cfg.widthOrHeight != null) {
if (!import.meta.env.PROD) {
console.warn("`maxWidthOrHeight` is ignored when `widthOrHeight` is set");
}
cfg.maxWidthOrHeight = undefined;
}
if (
(cfg.maxWidthOrHeight != null || cfg.width != null || cfg.height != null) &&
cfg.getDimensions
) {
if (!import.meta.env.PROD) {
console.warn(
"`getDimensions` is ignored when `width`, `height`, or `maxWidthOrHeight` is set",
);
}
cfg.getDimensions = undefined;
}
// ---------------------------------------------------------------------------
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
if (cfg.loadFonts) {
await cfg.loadFonts();
} else {
await Fonts.loadElementsFonts(elements);
}
// value used to scale the canvas context. By default, we use this to
// make the canvas fit into the frame (e.g. for `cfg.fit` set to `contain`).
// If `cfg.scale` is set, we multiply the resulting canvasScale by it to
// scale the output further.
let exportScale = 1;
const origCanvasSize = getCanvasSize(
const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
);
// cfg.x = undefined;
// cfg.y = undefined;
const { canvas, scale = 1 } = createCanvas(width, height);
// variables for original content bounding box
const [origX, origY, origWidth, origHeight] = origCanvasSize;
// variables for target bounding box
let [x, y, width, height] = origCanvasSize;
x = cfg.x ?? x;
y = cfg.y ?? y;
width = cfg.width ?? width;
height = cfg.height ?? height;
if (cfg.fit === "contain" || cfg.widthOrHeight || cfg.maxWidthOrHeight) {
cfg.padding =
cfg.padding && cfg.padding > 0
? Math.min(
cfg.padding,
(width - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
(height - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
)
: 0;
if (cfg.getDimensions != null) {
const ret = cfg.getDimensions(width, height);
width = ret.width;
height = ret.height;
cfg.padding = Math.min(
cfg.padding,
(width - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
(height - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
} else if (cfg.widthOrHeight != null) {
cfg.padding = Math.min(
cfg.padding,
(cfg.widthOrHeight - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
} else if (cfg.maxWidthOrHeight != null) {
cfg.padding = Math.min(
cfg.padding,
(cfg.maxWidthOrHeight - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
}
}
if (cfg.maxWidthOrHeight != null || cfg.widthOrHeight != null) {
if (cfg.padding) {
if (cfg.maxWidthOrHeight != null) {
cfg.maxWidthOrHeight -= cfg.padding * 2;
} else if (cfg.widthOrHeight != null) {
cfg.widthOrHeight -= cfg.padding * 2;
}
}
const max = Math.max(width, height);
if (cfg.widthOrHeight != null) {
// calculate by how much do we need to scale the canvas to fit into the
// target dimension (e.g. target: max 50px, actual: 70x100px => scale: 0.5)
exportScale = cfg.widthOrHeight / max;
} else if (cfg.maxWidthOrHeight != null) {
exportScale = cfg.maxWidthOrHeight < max ? cfg.maxWidthOrHeight / max : 1;
}
width *= exportScale;
height *= exportScale;
} else if (cfg.getDimensions) {
const ret = cfg.getDimensions(width, height);
width = ret.width;
height = ret.height;
cfg.scale = ret.scale ?? cfg.scale;
} else if (cfg.fit === "contain") {
width -= cfg.padding * 2;
height -= cfg.padding * 2;
const wRatio = width / origWidth;
const hRatio = height / origHeight;
// scale the orig canvas to fit in the target region
exportScale = Math.min(wRatio, hRatio);
}
x = cfg.x ?? origX;
y = cfg.y ?? origY;
// if we switch to "content" coords, we need to offset cfg-supplied
// coords by the x/y of content bounding box
if (cfg.origin === "content") {
if (cfg.x != null) {
x += origX;
}
if (cfg.y != null) {
y += origY;
}
}
// Centering the content to the frame.
// We divide width/height by canvasScale so that we calculate in the original
// aspect ratio dimensions.
if (cfg.position === "center") {
x -=
width / exportScale / 2 -
(cfg.x == null ? origWidth : width + cfg.padding * 2) / 2;
y -=
height / exportScale / 2 -
(cfg.y == null ? origHeight : height + cfg.padding * 2) / 2;
}
// rescale padding based on current canvasScale factor so that the resulting
// padding is kept the same as supplied by user (with the exception of
// `cfg.scale` being set, which also scales the padding)
const normalizedPadding = cfg.padding / exportScale;
// scale the whole frame by cfg.scale (on top of whatever canvasScale we
// calculated above)
exportScale *= cfg.scale;
width *= cfg.scale;
height *= cfg.scale;
const exportWidth = width + cfg.padding * 2 * cfg.scale;
const exportHeight = height + cfg.padding * 2 * cfg.scale;
return {
config: cfg,
normalizedPadding,
contentWidth: width,
contentHeight: height,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
};
};
/**
* This API is usually used as a precursor to searializing to Blob or PNG,
* but can also be used to create a canvas for other purposes.
*/
export const exportToCanvas = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
const {
config: cfg,
normalizedPadding,
contentWidth: width,
contentHeight: height,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
} = await configExportDimension({ data, config });
const canvas = cfg.createCanvas
? cfg.createCanvas()
: document.createElement("canvas");
canvas.width = exportWidth;
canvas.height = exportHeight;
const defaultAppState = getDefaultAppState();
const { imageCache } = await updateImageCache({
imageCache: new Map(),
fileIds: getInitializedImageElements(elementsForRender).map(
(element) => element.fileId,
),
files: data.files || {},
files,
});
renderStaticScene({
@@ -615,32 +225,22 @@ export const exportToCanvas = async ({
arrayToMap(elementsForRender),
),
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
arrayToMap(syncInvalidIndices(data.elements)),
arrayToMap(syncInvalidIndices(elements)),
),
visibleElements: elementsForRender,
scale,
appState: {
...appState,
frameRendering,
width,
height,
offsetLeft: 0,
offsetTop: 0,
scrollX: -x + normalizedPadding,
scrollY: -y + normalizedPadding,
zoom: { value: DEFAULT_ZOOM_VALUE },
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + exportPadding,
scrollY: -minY + exportPadding,
zoom: defaultAppState.zoom,
shouldCacheIgnoreZoom: false,
theme: cfg.theme || THEME.LIGHT,
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
},
scale: exportScale,
renderConfig: {
canvasBackgroundColor:
cfg.canvasBackgroundColor === false
? // null indicates transparent background
null
: cfg.canvasBackgroundColor ||
appState.viewBackgroundColor ||
COLOR_WHITE,
canvasBackgroundColor: viewBackgroundColor,
imageCache,
renderGrid: false,
isExporting: true,
@@ -654,91 +254,95 @@ export const exportToCanvas = async ({
return canvas;
};
type ExportToSvgConfig = Pick<
ExportSceneConfig,
"canvasBackgroundColor" | "padding" | "theme" | "exportingFrame"
> & {
/**
* if true, all embeddables passed in will be rendered when possible.
*/
renderEmbeddables?: boolean;
skipInliningFonts?: true;
reuseImages?: boolean;
};
export const exportToSvg = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
const {
config: cfg,
normalizedPadding,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
} = await configExportDimension({ data, config });
const offsetX = -(x - normalizedPadding);
const offsetY = -(y - normalizedPadding);
const { elements } = data;
// initialize SVG root
const svgRoot = document.createElementNS(SVG_NS, "svg");
svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute(
"viewBox",
`0 0 ${exportWidth / exportScale} ${exportHeight / exportScale}`,
export const exportToSvg = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: {
exportBackground: boolean;
exportPadding?: number;
exportScale?: number;
viewBackgroundColor: string;
exportWithDarkMode?: boolean;
exportEmbedScene?: boolean;
frameRendering?: AppState["frameRendering"];
},
files: BinaryFiles | null,
opts?: {
/**
* if true, all embeddables passed in will be rendered when possible.
*/
renderEmbeddables?: boolean;
exportingFrame?: ExcalidrawFrameLikeElement | null;
skipInliningFonts?: true;
reuseImages?: boolean;
},
): Promise<SVGSVGElement> => {
const frameRendering = getFrameRenderingConfig(
opts?.exportingFrame ?? null,
appState.frameRendering ?? null,
);
svgRoot.setAttribute("width", `${exportWidth}`);
svgRoot.setAttribute("height", `${exportHeight}`);
if (cfg.theme === THEME.DARK) {
svgRoot.setAttribute("filter", THEME_FILTER);
let {
exportPadding = DEFAULT_EXPORT_PADDING,
exportWithDarkMode = false,
viewBackgroundColor,
exportScale = 1,
exportEmbedScene,
} = appState;
const { exportingFrame = null } = opts || {};
const elementsForRender = prepareElementsForRender({
elements,
exportingFrame,
exportWithDarkMode,
frameRendering,
});
if (exportingFrame) {
exportPadding = 0;
}
const fontFaces = cfg.loadFonts
? await Fonts.generateFontFaceDeclarations(elements)
: [];
const delimiter = "\n "; // 6 spaces
let metadata = "";
// we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids
if (appState.exportEmbedScene) {
if (exportEmbedScene) {
try {
metadata = (await import("../data/image")).encodeSvgMetadata({
// when embedding scene, we want to embed the origionally supplied
// elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra.
text: serializeAsJSON(elements, appState, data.files || {}, "local"),
text: serializeAsJSON(elements, appState, files || {}, "local"),
});
} catch (error: any) {
console.error(error);
}
}
let exportContentClipPath = "";
if (cfg.width != null && cfg.height != null) {
exportContentClipPath = `<clipPath id="content">
<rect x="${offsetX}" y="${offsetY}" width="${exportWidth}" height="${exportWidth}"></rect>
</clipPath>`;
const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
);
// initialize SVG root
const svgRoot = document.createElementNS(SVG_NS, "svg");
svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
svgRoot.setAttribute("width", `${width * exportScale}`);
svgRoot.setAttribute("height", `${height * exportScale}`);
if (exportWithDarkMode) {
svgRoot.setAttribute("filter", THEME_FILTER);
}
const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding;
const frameElements = getFrameLikeElements(elements);
let exportingFrameClipPath = "";
const elementsMap = arrayToMap(elements);
const frameElements = getFrameLikeElements(elements);
for (const frame of frameElements) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
const cx = (x2 - x1) / 2 - (frame.x - x1);
@@ -751,7 +355,7 @@ export const exportToSvg = async ({
width="${frame.width}"
height="${frame.height}"
${
cfg.exportingFrame
exportingFrame
? ""
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
}
@@ -760,56 +364,59 @@ export const exportToSvg = async ({
</clipPath>`;
}
const fontFaces = !opts?.skipInliningFonts
? await Fonts.generateFontFaceDeclarations(elements)
: [];
const delimiter = "\n "; // 6 spaces
svgRoot.innerHTML = `
${SVG_EXPORT_TAG}
${metadata}
<defs>
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}</style>
${exportContentClipPath}
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}
</style>
${exportingFrameClipPath}
</defs>
`;
// render background rect
if (appState.exportBackground && appState.viewBackgroundColor) {
if (appState.exportBackground && viewBackgroundColor) {
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", "0");
rect.setAttribute("y", "0");
rect.setAttribute("width", `${exportWidth / exportScale}`);
rect.setAttribute("height", `${exportHeight / exportScale}`);
rect.setAttribute(
"fill",
cfg.canvasBackgroundColor || appState.viewBackgroundColor,
);
rect.setAttribute("width", `${width}`);
rect.setAttribute("height", `${height}`);
rect.setAttribute("fill", viewBackgroundColor);
svgRoot.appendChild(rect);
}
const rsvg = rough.svg(svgRoot);
// const renderEmbeddables = appState.embe ?? false;
const renderEmbeddables = opts?.renderEmbeddables ?? false;
renderSceneToSvg(
elementsForRender,
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
rsvg,
svgRoot,
data.files || {},
files || {},
{
offsetX,
offsetY,
isExporting: true,
exportWithDarkMode: cfg.theme === THEME.DARK,
renderEmbeddables: false,
exportWithDarkMode,
renderEmbeddables,
frameRendering,
canvasBackgroundColor: appState.viewBackgroundColor,
embedsValidationStatus: false
canvasBackgroundColor: viewBackgroundColor,
embedsValidationStatus: renderEmbeddables
? new Map(
elementsForRender
.filter((element) => isFrameLikeElement(element))
.map((element) => [element.id, true]),
)
: new Map(),
reuseImages: true,
reuseImages: opts?.reuseImages ?? true,
},
);
@@ -817,112 +424,25 @@ export const exportToSvg = async ({
};
// calculate smallest area to fit the contents in
export const getCanvasSize = (
const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number,
): Bounds => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const width = distance(minX, maxX);
const height = distance(minY, maxY);
const width = distance(minX, maxX) + exportPadding * 2;
const height = distance(minY, maxY) + exportPadding * 2;
return [minX, minY, width, height];
};
export { MIME_TYPES };
export const getExportSize = (
elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number,
scale: number,
): [number, number] => {
const [, , width, height] = getCanvasSize(elements, exportPadding).map(
(dimension) => Math.trunc(dimension * scale),
);
type ExportToBlobConfig = ExportSceneConfig & {
mimeType?: string;
quality?: number;
};
export const exportToBlob = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportToBlobConfig;
}): Promise<Blob> => {
let { mimeType = MIME_TYPES.png, quality } = config || {};
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
}
// typo in MIME type (should be "jpeg")
if (mimeType === "image/jpg") {
mimeType = MIME_TYPES.jpg;
}
if (mimeType === MIME_TYPES.jpg && !config?.canvasBackgroundColor === false) {
console.warn(
`Defaulting "exportBackground" to "true" for "${MIME_TYPES.jpg}" mimeType`,
);
config = {
...config,
canvasBackgroundColor: data.appState?.viewBackgroundColor || COLOR_WHITE,
};
}
const canvas = await exportToCanvas({ data, config });
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => {
canvas.toBlob(
async (blob) => {
if (!blob) {
return reject(new Error("couldn't export to blob"));
}
if (
blob &&
mimeType === MIME_TYPES.png &&
data.appState?.exportEmbedScene
) {
blob = await encodePngMetadata({
blob,
metadata: serializeAsJSON(
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable
data.elements,
data.appState,
data.files || {},
"local",
),
});
}
resolve(blob);
},
mimeType,
quality,
);
});
};
export const exportToClipboard = async ({
type,
data,
config,
}: {
data: ExportSceneData;
} & (
| { type: "png"; config?: ExportToBlobConfig }
| { type: "svg"; config?: ExportToSvgConfig }
| { type: "json"; config?: never }
)) => {
if (type === "svg") {
const svg = await exportToSvg({
data: {
...data,
appState: restoreAppState(data.appState, null),
},
config,
});
await copyTextToSystemClipboard(svg.outerHTML);
} else if (type === "png") {
await copyBlobToClipboardAsPng(exportToBlob({ data, config }));
} else if (type === "json") {
await copyToClipboard(data.elements, data.files);
} else {
throw new Error("Invalid export type");
}
return [width, height];
};
+1 -9
View File
@@ -24,6 +24,7 @@ export type RenderableElementsMap = NonDeletedElementsMap &
MakeBrand<"RenderableElementsMap">;
export type StaticCanvasRenderConfig = {
canvasBackgroundColor: AppState["viewBackgroundColor"];
// extra options passed to the renderer
// ---------------------------------------------------------------------------
imageCache: AppClassProperties["imageCache"];
@@ -31,8 +32,6 @@ export type StaticCanvasRenderConfig = {
/** when exporting the behavior is slightly different (e.g. we can't use
CSS filters), and we disable render optimizations for best output */
isExporting: boolean;
/** null indicates transparent bg */
canvasBackgroundColor: string | null;
embedsValidationStatus: EmbedsValidationStatus;
elementsPendingErasure: ElementsPendingErasure;
pendingFlowchartNodes: PendingExcalidrawElements | null;
@@ -82,13 +81,6 @@ export type StaticSceneRenderConfig = {
elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[];
/**
* canvas scale factor. Not related to zoom. In browsers, it's the
* devicePixelRatio. For export, it's the `appState.exportScale`
* (user setting) or whatever scale you want to use when exporting elsewhere.
*
* Bigger the scale, the more pixels (=quality).
*/
scale: number;
appState: StaticCanvasAppState;
renderConfig: StaticCanvasRenderConfig;
@@ -6,7 +6,7 @@ exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active too
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone]
C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="300" height="0" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="89" height="158" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
@@ -728,6 +728,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@@ -762,6 +763,42 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"category": "hyperlink",
},
},
{
"icon": <svg
aria-hidden="true"
className=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
>
<React.Fragment>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"
/>
<path
d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"
/>
</React.Fragment>
</svg>,
"label": "labels.copyElementLink",
"name": "copyElementLink",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@@ -880,6 +917,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -1088,6 +1126,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -1306,6 +1345,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -1639,6 +1679,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -1972,6 +2013,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -2190,6 +2232,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -2432,6 +2475,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -2735,6 +2779,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -3106,6 +3151,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -3583,6 +3629,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -3908,6 +3955,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -4233,6 +4281,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -5313,6 +5362,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@@ -5347,6 +5397,42 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"category": "hyperlink",
},
},
{
"icon": <svg
aria-hidden="true"
className=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
>
<React.Fragment>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"
/>
<path
d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"
/>
</React.Fragment>
</svg>,
"label": "labels.copyElementLink",
"name": "copyElementLink",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@@ -5465,6 +5551,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -6486,6 +6573,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@@ -6520,6 +6608,42 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"category": "hyperlink",
},
},
{
"icon": <svg
aria-hidden="true"
className=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
>
<React.Fragment>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"
/>
<path
d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"
/>
</React.Fragment>
</svg>,
"label": "labels.copyElementLink",
"name": "copyElementLink",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@@ -6638,6 +6762,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -7575,6 +7700,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -8381,6 +8507,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@@ -8415,6 +8542,42 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "hyperlink",
},
},
{
"icon": <svg
aria-hidden="true"
className=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
>
<React.Fragment>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"
/>
<path
d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"
/>
</React.Fragment>
</svg>,
"label": "labels.copyElementLink",
"name": "copyElementLink",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@@ -8533,6 +8696,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -9321,6 +9485,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@@ -9355,6 +9520,42 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "hyperlink",
},
},
{
"icon": <svg
aria-hidden="true"
className=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
>
<React.Fragment>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"
/>
<path
d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"
/>
</React.Fragment>
</svg>,
"label": "labels.copyElementLink",
"name": "copyElementLink",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@@ -9473,6 +9674,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -635,7 +635,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-expanded="false"
aria-haspopup="dialog"
aria-label="Canvas background"
class="color-picker__button active-color properties-trigger"
class="color-picker__button active-color properties-popover-trigger"
data-state="closed"
style="--swatch-color: #ffffff;"
title="Show background color picker"
File diff suppressed because one or more lines are too long
@@ -53,6 +53,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -196,7 +197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 99,
"height": 125,
"id": "id166",
"index": "a2",
"isDeleted": false,
@@ -210,8 +211,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"98.20800",
99,
125,
125,
],
],
"roughness": 1,
@@ -225,9 +226,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 40,
"width": "98.20800",
"x": 1,
"version": 47,
"width": 125,
"x": 0,
"y": 0,
}
`;
@@ -297,7 +298,7 @@ History {
"focus": "0.00990",
"gap": 1,
},
"height": "0.98017",
"height": "0.98000",
"points": [
[
0,
@@ -305,7 +306,7 @@ History {
],
[
98,
"-0.98017",
"-0.98000",
],
],
"startBinding": {
@@ -319,10 +320,10 @@ History {
"endBinding": {
"elementId": "id165",
"fixedPoint": null,
"focus": "-0.02000",
"focus": "-0.02040",
"gap": 1,
},
"height": "0.00169",
"height": "0.02000",
"points": [
[
0,
@@ -330,13 +331,13 @@ History {
],
[
98,
"0.00169",
"0.02000",
],
],
"startBinding": {
"elementId": "id164",
"fixedPoint": null,
"focus": "0.02000",
"focus": "0.01959",
"gap": 1,
},
},
@@ -392,18 +393,20 @@ History {
"focus": 0,
"gap": 1,
},
"height": 99,
"height": 125,
"points": [
[
0,
0,
],
[
"98.20800",
99,
125,
125,
],
],
"startBinding": null,
"width": 125,
"x": 0,
"y": 0,
},
"inserted": {
@@ -413,7 +416,7 @@ History {
"focus": "0.00990",
"gap": 1,
},
"height": "0.98161",
"height": "0.98000",
"points": [
[
0,
@@ -421,7 +424,7 @@ History {
],
[
98,
"-0.98161",
"-0.98000",
],
],
"startBinding": {
@@ -430,7 +433,9 @@ History {
"focus": "0.02970",
"gap": 1,
},
"y": "0.99245",
"width": 98,
"x": 1,
"y": "0.99000",
},
},
"id169" => Delta {
@@ -657,6 +662,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -821,9 +827,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 30,
"width": 0,
"x": 200,
"version": 37,
"width": 100,
"x": 150,
"y": 0,
}
`;
@@ -860,6 +866,8 @@ History {
0,
],
],
"width": 0,
"x": 149,
},
"inserted": {
"points": [
@@ -868,10 +876,12 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
"width": "98.00000",
"x": "1.00000",
},
},
},
@@ -928,6 +938,8 @@ History {
],
],
"startBinding": null,
"width": 100,
"x": 150,
},
"inserted": {
"endBinding": {
@@ -952,6 +964,8 @@ History {
"focus": 0,
"gap": 1,
},
"width": 0,
"x": 149,
},
},
},
@@ -1165,6 +1179,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -1535,6 +1550,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -1906,6 +1922,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -2175,6 +2192,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -2357,9 +2375,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 10,
"version": 12,
"width": 498,
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -2498,7 +2516,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -2517,8 +2535,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -2617,6 +2635,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -2918,6 +2937,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -3204,6 +3224,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -3500,6 +3521,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -3788,6 +3810,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -4025,6 +4048,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -4286,6 +4310,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -4561,6 +4586,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -4794,6 +4820,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -5027,6 +5054,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -5258,6 +5286,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -5489,6 +5518,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -5750,6 +5780,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -6083,6 +6114,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -6510,6 +6542,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -6890,6 +6923,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -7211,6 +7245,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -7511,6 +7546,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -7742,6 +7778,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -8099,6 +8136,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -8456,6 +8494,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -8862,6 +8901,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -9151,6 +9191,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -9418,6 +9459,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -9684,6 +9726,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -9917,6 +9960,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -10220,6 +10264,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -10562,6 +10607,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -10799,6 +10845,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -11254,6 +11301,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -11510,6 +11558,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -11751,6 +11800,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -11994,6 +12044,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -12397,6 +12448,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -12646,6 +12698,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -12889,6 +12942,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -13132,6 +13186,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -13381,6 +13436,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -13715,6 +13771,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -13889,6 +13946,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -14179,6 +14237,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -14448,6 +14507,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -14725,6 +14785,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -14888,6 +14949,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -15117,9 +15179,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 10,
"version": 12,
"width": "98.00000",
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -15158,7 +15220,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -15171,7 +15233,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -15467,7 +15529,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -15486,8 +15548,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -15586,6 +15648,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -15815,9 +15878,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 10,
"version": 12,
"width": "98.00000",
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -16089,7 +16152,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -16108,8 +16171,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -16208,6 +16271,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -16437,9 +16501,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 10,
"version": 12,
"width": "98.00000",
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -16711,7 +16775,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -16730,8 +16794,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -16830,6 +16894,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -17057,9 +17122,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 10,
"version": 12,
"width": "98.00000",
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -17115,7 +17180,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -17133,7 +17198,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -17402,7 +17467,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -17421,8 +17486,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -17544,6 +17609,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -17774,9 +17840,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 11,
"version": 13,
"width": "98.00000",
"x": 1,
"x": "1.00000",
"y": 0,
}
`;
@@ -17847,7 +17913,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -17866,7 +17932,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -18135,7 +18201,7 @@ History {
0,
],
[
100,
"98.00000",
0,
],
],
@@ -18154,8 +18220,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@@ -18296,6 +18362,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -18772,6 +18839,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -19296,6 +19364,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -19754,6 +19823,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -17,6 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
placeholder=" "
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
tabindex="0"
wrap="off"
@@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"type": "rectangle",
"updated": 1,
"version": 7,
"versionNonce": 745419401,
"versionNonce": 2066753033,
"width": 300,
"x": 201,
"y": 2,
@@ -232,8 +232,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 11,
"versionNonce": 1996028265,
"version": 15,
"versionNonce": 271613161,
"width": 81,
"x": 110,
"y": 50,
@@ -53,6 +53,7 @@ exports[`given element A and group of elements B and given both are selected whe
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -467,6 +468,7 @@ exports[`given element A and group of elements B and given both are selected whe
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -872,6 +874,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": false,
"isCropping": false,
"isLoading": false,
@@ -1416,6 +1419,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -1619,6 +1623,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -1993,6 +1998,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -2232,6 +2238,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -2411,6 +2418,7 @@ exports[`regression tests > can drag element that covers another element, while
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -2730,6 +2738,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -2975,6 +2984,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -3217,6 +3227,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -3446,6 +3457,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -3701,6 +3713,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -4011,6 +4024,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -4424,6 +4438,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -4706,6 +4721,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -4958,6 +4974,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -5167,6 +5184,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -5365,6 +5383,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -5746,6 +5765,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -6035,6 +6055,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -6842,6 +6863,7 @@ exports[`regression tests > given a group of selected elements with an element t
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -7171,6 +7193,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -7446,6 +7469,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -7679,6 +7703,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -7915,6 +7940,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -8094,6 +8120,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -8273,6 +8300,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -8452,6 +8480,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -8674,6 +8703,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -8895,6 +8925,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -9088,6 +9119,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -9310,6 +9342,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -9489,6 +9522,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -9710,6 +9744,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -9889,6 +9924,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -10082,6 +10118,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -10261,6 +10298,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -10774,6 +10812,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -11050,6 +11089,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -11175,6 +11215,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -11373,6 +11414,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -11683,6 +11725,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -12094,6 +12137,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -12706,6 +12750,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -12834,6 +12879,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -13417,6 +13463,7 @@ exports[`regression tests > switches from group of selected elements to another
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -13754,6 +13801,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -14018,6 +14066,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -14143,6 +14192,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -14521,6 +14571,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@@ -14646,6 +14697,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
+11 -8
View File
@@ -22,6 +22,7 @@ import { copiedStyles } from "../actions/actionStyles";
import { API } from "./helpers/api";
import { setDateTimeForTests } from "../utils";
import { vi } from "vitest";
import type { ActionName } from "../actions/types";
const checkpoint = (name: string) => {
expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
@@ -115,7 +116,7 @@ describe("contextMenu element", () => {
const contextMenu = UI.queryContextMenu();
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
const expectedContextMenuItems: ActionName[] = [
"cut",
"copy",
"paste",
@@ -131,14 +132,15 @@ describe("contextMenu element", () => {
"bringToFront",
"duplicateSelection",
"hyperlink",
"copyElementLink",
"toggleElementLock",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(contextMenuOptions?.length).toBe(expectedContextMenuItems.length);
expectedContextMenuItems.forEach((item) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
contextMenu?.querySelector(`li[data-testid="${item}"]`),
).not.toBeNull();
});
});
@@ -263,13 +265,14 @@ describe("contextMenu element", () => {
const contextMenu = UI.queryContextMenu();
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
const expectedContextMenuItems: ActionName[] = [
"cut",
"copy",
"paste",
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"copyElementLink",
"ungroup",
"addToLibrary",
"flipHorizontal",
@@ -283,10 +286,10 @@ describe("contextMenu element", () => {
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(contextMenuOptions?.length).toBe(expectedContextMenuItems.length);
expectedContextMenuItems.forEach((item) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
contextMenu?.querySelector(`li[data-testid="${item}"]`),
).not.toBeNull();
});
});
+11 -19
View File
@@ -186,14 +186,14 @@ describe("Crop an image", () => {
// 50 x 50 square
UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]);
UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true);
expect(image.width).toEqual(image.height);
expect(image.width).toBeCloseTo(image.height);
// image is at the corner, not space to its right to expand, should not be able to resize
expect(image.height).toBeCloseTo(50);
UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true);
expect(image.width).toEqual(image.height);
expect(image.width).toBeCloseTo(image.height);
// max height should be reached
expect(image.height).toEqual(initialHeight);
expect(image.height).toBeCloseTo(initialHeight);
expect(image.width).toBe(initialHeight);
});
});
@@ -315,28 +315,20 @@ describe("Cropping and other features", async () => {
const widthToHeightRatio = image.width / image.height;
const canvas = await exportToCanvas({
data: {
elements: [image],
appState: h.state,
files: h.app.files,
},
config: {
padding: 0,
},
elements: [image],
appState: h.state,
files: h.app.files,
exportPadding: 0,
});
const exportedCanvasRatio = canvas.width / canvas.height;
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
const svg = await exportToSvg({
data: {
elements: [image],
appState: h.state,
files: h.app.files,
},
config: {
padding: 0,
},
elements: [image],
appState: h.state,
files: h.app.files,
exportPadding: 0,
});
const svgWidth = svg.getAttribute("width");
const svgHeight = svg.getAttribute("height");
+1 -1
View File
@@ -163,7 +163,7 @@ describe("export", () => {
},
} as const;
const svg = await exportToSvg({ data: { elements, appState, files } });
const svg = await exportToSvg(elements, appState, files);
const svgText = svg.outerHTML;
+19 -4
View File
@@ -38,6 +38,8 @@ import { pointFrom, pointRotateRads } from "../../../math";
import { cropElement } from "../../element/cropElement";
import type { ToolType } from "../../types";
const TEXT_EDITOR_SELECTOR = ".excalidraw-textEditorContainer > textarea";
// so that window.h is available when App.tsx is not imported as well.
createTestHook();
@@ -477,12 +479,20 @@ export class UI {
pointFrom(0, 0),
pointFrom(width, height),
];
UI.clickTool(type);
if (type === "text") {
mouse.reset();
mouse.click(x, y);
const openedEditor =
document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
// NOTE this is a hack to make sure the editor is focused on edit
// which for some reason doesn't work in tests after latest changes.
// This means that a regression in wysiwyg editor might not be caught
// tests.
openedEditor?.focus();
} else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => {
mouse.reset();
@@ -518,20 +528,25 @@ export class UI {
static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) {
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const openedEditor =
document.querySelector<HTMLTextAreaElement>(textEditorSelector);
document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
if (!openedEditor) {
mouse.select(element);
Keyboard.keyPress(KEYS.ENTER);
}
const editor = await getTextEditor(textEditorSelector);
const editor = await getTextEditor();
if (!editor) {
throw new Error("Can't find wysiwyg text editor in the dom");
}
// NOTE this is a hack to make sure the editor is focused on edit
// which for some reason doesn't work in tests after latest changes.
// This means that a regression in wysiwyg editor might not be caught
// tests.
editor.focus();
fireEvent.input(editor, { target: { value: text } });
act(() => {
editor.blur();
+6 -14
View File
@@ -15,11 +15,7 @@ import { getDefaultAppState } from "../appState";
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import {
COLOR_CHARCOAL_BLACK,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "../constants";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import type { AppState } from "../types";
import { arrayToMap } from "../utils";
import {
@@ -81,7 +77,7 @@ const checkpoint = (name: string) => {
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
const transparent = COLOR_PALETTE.transparent;
const black = COLOR_CHARCOAL_BLACK;
const black = COLOR_PALETTE.black;
const red = COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
const blue = COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
const yellow = COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
@@ -4789,21 +4785,17 @@ describe("history", () => {
expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({
id: arrowId,
points: [
[0, 0],
[100, 0],
],
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
focus: 0,
gap: 1,
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
focus: 0,
gap: 1,
}),
isDeleted: true,
}),
@@ -24,7 +24,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
</button>
<a
class="dropdown-menu-item dropdown-menu-item-base"
href="blog.excalidaw.com"
href="https://plus.excalidraw.com/blog"
rel="noreferrer"
target="_blank"
>
+4 -1
View File
@@ -1,7 +1,10 @@
import { waitFor } from "@testing-library/dom";
import { fireEvent } from "@testing-library/react";
export const getTextEditor = async (selector: string, waitForEditor = true) => {
export const getTextEditor = async (
selector = ".excalidraw-textEditorContainer > textarea",
waitForEditor = true,
) => {
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
if (waitForEditor) {
await waitFor(() => expect(query()).not.toBe(null));
+148 -5
View File
@@ -18,6 +18,8 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { arrayToMap } from "../utils";
import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math";
import { resizeSingleElement } from "../element/resizeElements";
import { getSizeFromPoints } from "../points";
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -235,7 +237,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
};
it("resizes", async () => {
const element = UI.createElement(type, { points: points[type] });
const element = UI.createElement("freedraw", { points: points.freedraw });
const bounds = getBoundsFromPoints(element);
UI.resize(element, "ne", [30, -60]);
@@ -249,7 +251,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
});
it("flips while resizing", async () => {
const element = UI.createElement(type, { points: points[type] });
const element = UI.createElement("freedraw", { points: points.freedraw });
const bounds = getBoundsFromPoints(element);
UI.resize(element, "sw", [140, -80]);
@@ -263,7 +265,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
});
it("resizes with locked aspect ratio", async () => {
const element = UI.createElement(type, { points: points[type] });
const element = UI.createElement("freedraw", { points: points.freedraw });
const bounds = getBoundsFromPoints(element);
UI.resize(element, "ne", [30, -60], { shift: true });
@@ -280,7 +282,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
});
it("resizes from center", async () => {
const element = UI.createElement(type, { points: points[type] });
const element = UI.createElement("freedraw", { points: points.freedraw });
const bounds = getBoundsFromPoints(element);
UI.resize(element, "nw", [-20, -30], { alt: true });
@@ -294,6 +296,147 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => {
});
});
describe("line element", () => {
const points: LocalPoint[] = [
pointFrom(0, 0),
pointFrom(60, -20),
pointFrom(20, 40),
pointFrom(-40, 0),
];
it("resizes", async () => {
UI.createElement("line", { points });
const element = h.elements[0] as ExcalidrawLinearElement;
const {
x: prevX,
y: prevY,
width: prevWidth,
height: prevHeight,
} = element;
const nextWidth = prevWidth + 30;
const nextHeight = prevHeight + 30;
resizeSingleElement(
nextWidth,
nextHeight,
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
"ne",
);
expect(element.x).not.toBe(prevX);
expect(element.y).not.toBe(prevY);
expect(element.width).toBe(nextWidth);
expect(element.height).toBe(nextHeight);
expect(element.points[0]).toEqual([0, 0]);
const { width, height } = getSizeFromPoints(element.points);
expect(width).toBe(element.width);
expect(height).toBe(element.height);
});
it("flips while resizing", async () => {
UI.createElement("line", { points });
const element = h.elements[0] as ExcalidrawLinearElement;
const {
width: prevWidth,
height: prevHeight,
points: prevPoints,
} = element;
const nextWidth = prevWidth * -1;
const nextHeight = prevHeight * -1;
resizeSingleElement(
nextWidth,
nextHeight,
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
"se",
);
expect(element.width).toBe(prevWidth);
expect(element.height).toBe(prevHeight);
element.points.forEach((point, idx) => {
expect(point[0]).toBeCloseTo(prevPoints[idx][0] * -1);
expect(point[1]).toBeCloseTo(prevPoints[idx][1] * -1);
});
});
it("resizes with locked aspect ratio", async () => {
UI.createElement("line", { points });
const element = h.elements[0] as ExcalidrawLinearElement;
const { width: prevWidth, height: prevHeight } = element;
UI.resize(element, "ne", [30, -60], { shift: true });
const scaleHeight = element.width / prevWidth;
const scaleWidth = element.height / prevHeight;
expect(scaleHeight).toBeCloseTo(scaleWidth);
});
it("resizes from center", async () => {
UI.createElement("line", {
points: [
pointFrom(0, 0),
pointFrom(338.05644048727373, -180.4761618151104),
pointFrom(338.05644048727373, 180.4761618151104),
pointFrom(-338.05644048727373, 180.4761618151104),
pointFrom(-338.05644048727373, -180.4761618151104),
],
});
const element = h.elements[0] as ExcalidrawLinearElement;
const {
x: prevX,
y: prevY,
width: prevWidth,
height: prevHeight,
} = element;
const prevSmallestX = Math.min(...element.points.map((p) => p[0]));
const prevBiggestX = Math.max(...element.points.map((p) => p[0]));
resizeSingleElement(
prevWidth + 20,
prevHeight,
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
"e",
{
shouldResizeFromCenter: true,
},
);
expect(element.width).toBeCloseTo(prevWidth + 20);
expect(element.height).toBeCloseTo(prevHeight);
expect(element.x).toBeCloseTo(prevX);
expect(element.y).toBeCloseTo(prevY);
const smallestX = Math.min(...element.points.map((p) => p[0]));
const biggestX = Math.max(...element.points.map((p) => p[0]));
expect(prevSmallestX - smallestX).toBeCloseTo(10);
expect(biggestX - prevBiggestX).toBeCloseTo(10);
});
});
describe("arrow element", () => {
it("resizes with a label", async () => {
const arrow = UI.createElement("arrow", {
@@ -983,7 +1126,7 @@ describe("multiple selection", () => {
expect(bottomArrowLabel.fontSize).toBeCloseTo(28 * scale);
});
it("resizes with text elements", async () => {
it.only("resizes with text elements", async () => {
const topText = UI.createElement("text", { position: 0 });
await UI.editText(topText, "lorem ipsum");

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