Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 913921631b | |||
| ec39094933 | |||
| ef20c8b9fa | |||
| 1c55160e3f | |||
| e49db1dd3c | |||
| cd8bfb06b5 | |||
| 7762b925ae | |||
| 113dfc0023 | |||
| 96b5cfc35d | |||
| 9da26fb7e0 | |||
| 392b362118 | |||
| 0e02366dee |
+1
-15
@@ -3,20 +3,6 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"import/no-anonymous-default-export": "off",
|
"import/no-anonymous-default-export": "off",
|
||||||
"no-restricted-globals": "off",
|
"no-restricted-globals": "off",
|
||||||
"@typescript-eslint/consistent-type-imports": [
|
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"prefer": "type-imports",
|
|
||||||
"disallowTypeAnnotations": false,
|
|
||||||
"fixStyle": "separate-type-imports"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"name": "jotai",
|
|
||||||
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<h4 align="center">
|
<h4 align="center">
|
||||||
<a href="https://excalidraw.com">Excalidraw Editor</a> |
|
<a href="https://excalidraw.com">Excalidraw Editor</a> |
|
||||||
<a href="https://plus.excalidraw.com/blog">Blog</a> |
|
<a href="https://blog.excalidraw.com">Blog</a> |
|
||||||
<a href="https://docs.excalidraw.com">Documentation</a> |
|
<a href="https://docs.excalidraw.com">Documentation</a> |
|
||||||
<a href="https://plus.excalidraw.com">Excalidraw+</a>
|
<a href="https://plus.excalidraw.com">Excalidraw+</a>
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@@ -3,32 +3,31 @@
|
|||||||
All `props` are _optional_.
|
All `props` are _optional_.
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` | `null` | <code>Promise<object | null></code> | `null` | The initial data with which app loads. |
|
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` | `null` | <code>Promise<object | 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 |
|
| [`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 |
|
| [`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`. |
|
| [`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. |
|
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
|
||||||
| [`onPointerDown`](#onpointerdown) | `function` | \_ | This prop if passed gets triggered on pointer down events |
|
| [`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. |
|
| [`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 |
|
| [`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. |
|
| [`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. |
|
||||||
| [`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 |
|
| [`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 |
|
| [`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. |
|
| [`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. |
|
| [`viewModeEnabled`](#viewmodeenabled) | `boolean` | _ | This indicates if the app is in `view` mode. |
|
||||||
| [`zenModeEnabled`](#zenmodeenabled) | `boolean` | \_ | This indicates if the `zen` mode is enabled |
|
| [`zenModeEnabled`](#zenmodeenabled) | `boolean` | _ | This indicates if the `zen` mode is enabled |
|
||||||
| [`gridModeEnabled`](#gridmodeenabled) | `boolean` | \_ | This indicates if the `grid` 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 |
|
| [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
|
||||||
| [`theme`](#theme) | `"light"` | `"dark"` | `"light"` | The theme of the Excalidraw component |
|
| [`theme`](#theme) | `"light"` | `"dark"` | `"light"` | The theme of the Excalidraw component |
|
||||||
| [`name`](#name) | `string` | | Name of the drawing |
|
| [`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) |
|
| [`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. |
|
| [`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. |
|
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
|
||||||
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
|
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
|
||||||
| [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas |
|
| [`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 |
|
| [`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>` |
|
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
|
||||||
|
|
||||||
@@ -94,8 +93,9 @@ 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.
|
This prop if passed will be triggered on pointer down events and has the below signature.
|
||||||
|
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
(activeTool:{" "}
|
(activeTool:{" "}
|
||||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L115">
|
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L115">
|
||||||
{" "}
|
{" "}
|
||||||
AppState["activeTool"]
|
AppState["activeTool"]
|
||||||
@@ -143,14 +143,6 @@ 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.
|
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
|
### 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()`.
|
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()`.
|
||||||
@@ -215,7 +207,8 @@ This prop indicates whether the shows the grid. When supplied, the value takes p
|
|||||||
|
|
||||||
### libraryReturnUrl
|
### 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
|
### theme
|
||||||
|
|
||||||
@@ -227,6 +220,7 @@ 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.
|
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
|
### 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).
|
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.
|
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.
|
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,
|
height: 141.9765625,
|
||||||
},]));
|
},]));
|
||||||
return (
|
return (
|
||||||
<div style={{height:"500px", width:"500px"}}>
|
<div style={{height:"500px", width:"500px"}}>
|
||||||
<Excalidraw />
|
<Excalidraw />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default ExcalidrawWrapper;
|
export default ExcalidrawWrapper;
|
||||||
@@ -84,8 +84,8 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
|||||||
|
|
||||||
```jsx showLineNumbers
|
```jsx showLineNumbers
|
||||||
import dynamic from "next/dynamic";
|
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
|
// the excalidraw stuff dynamically with ssr false
|
||||||
|
|
||||||
const ExcalidrawWrapper = dynamic(
|
const ExcalidrawWrapper = dynamic(
|
||||||
@@ -97,7 +97,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
|||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<ExcalidrawWrapper />
|
<ExcalidrawWrapper />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -108,7 +108,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
|||||||
```jsx showLineNumbers
|
```jsx showLineNumbers
|
||||||
import dynamic from "next/dynamic";
|
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
|
// the excalidraw stuff dynamically with ssr false
|
||||||
|
|
||||||
const ExcalidrawWrapper = dynamic(
|
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"),
|
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Browser
|
## Browser
|
||||||
|
|
||||||
@@ -235,5 +235,3 @@ root.render(React.createElement(App));
|
|||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
You can try it out [here](https://codesandbox.io/p/sandbox/excalidraw-in-browser-tlqom?file=%2Findex.html%3A1%2C10).
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const config = {
|
|||||||
label: "Docs",
|
label: "Docs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "https://plus.excalidraw.com/blog",
|
to: "https://blog.excalidraw.com",
|
||||||
label: "Blog",
|
label: "Blog",
|
||||||
position: "left",
|
position: "left",
|
||||||
},
|
},
|
||||||
@@ -111,7 +111,7 @@ const config = {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Blog",
|
label: "Blog",
|
||||||
to: "https://plus.excalidraw.com/blog",
|
to: "https://blog.excalidraw.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "GitHub",
|
label: "GitHub",
|
||||||
|
|||||||
@@ -369,10 +369,12 @@ export default function ExampleApp({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await exportToClipboard({
|
await exportToClipboard({
|
||||||
elements: excalidrawAPI.getSceneElements(),
|
data: {
|
||||||
appState: excalidrawAPI.getAppState(),
|
elements: excalidrawAPI.getSceneElements(),
|
||||||
files: excalidrawAPI.getFiles(),
|
appState: excalidrawAPI.getAppState(),
|
||||||
type,
|
files: excalidrawAPI.getFiles(),
|
||||||
|
},
|
||||||
|
type: "json",
|
||||||
});
|
});
|
||||||
window.alert(`Copied to clipboard as ${type} successfully`);
|
window.alert(`Copied to clipboard as ${type} successfully`);
|
||||||
};
|
};
|
||||||
@@ -817,15 +819,17 @@ export default function ExampleApp({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const svg = await exportToSvg({
|
const svg = await exportToSvg({
|
||||||
elements: excalidrawAPI?.getSceneElements(),
|
data: {
|
||||||
appState: {
|
elements: excalidrawAPI?.getSceneElements(),
|
||||||
...initialData.appState,
|
appState: {
|
||||||
exportWithDarkMode,
|
...initialData.appState,
|
||||||
exportEmbedScene,
|
exportWithDarkMode,
|
||||||
width: 300,
|
exportEmbedScene,
|
||||||
height: 100,
|
width: 300,
|
||||||
|
height: 100,
|
||||||
|
},
|
||||||
|
files: excalidrawAPI?.getFiles(),
|
||||||
},
|
},
|
||||||
files: excalidrawAPI?.getFiles(),
|
|
||||||
});
|
});
|
||||||
appRef.current.querySelector(".export-svg").innerHTML =
|
appRef.current.querySelector(".export-svg").innerHTML =
|
||||||
svg.outerHTML;
|
svg.outerHTML;
|
||||||
@@ -841,14 +845,18 @@ export default function ExampleApp({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const blob = await exportToBlob({
|
const blob = await exportToBlob({
|
||||||
elements: excalidrawAPI?.getSceneElements(),
|
data: {
|
||||||
mimeType: "image/png",
|
elements: excalidrawAPI?.getSceneElements(),
|
||||||
appState: {
|
appState: {
|
||||||
...initialData.appState,
|
...initialData.appState,
|
||||||
exportEmbedScene,
|
exportEmbedScene,
|
||||||
exportWithDarkMode,
|
exportWithDarkMode,
|
||||||
|
},
|
||||||
|
files: excalidrawAPI?.getFiles(),
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
mimeType: "image/png",
|
||||||
},
|
},
|
||||||
files: excalidrawAPI?.getFiles(),
|
|
||||||
});
|
});
|
||||||
setBlobUrl(window.URL.createObjectURL(blob));
|
setBlobUrl(window.URL.createObjectURL(blob));
|
||||||
}}
|
}}
|
||||||
@@ -864,12 +872,14 @@ export default function ExampleApp({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const canvas = await exportToCanvas({
|
const canvas = await exportToCanvas({
|
||||||
elements: excalidrawAPI.getSceneElements(),
|
data: {
|
||||||
appState: {
|
elements: excalidrawAPI.getSceneElements(),
|
||||||
...initialData.appState,
|
appState: {
|
||||||
exportWithDarkMode,
|
...initialData.appState,
|
||||||
|
exportWithDarkMode,
|
||||||
|
},
|
||||||
|
files: excalidrawAPI.getFiles(),
|
||||||
},
|
},
|
||||||
files: excalidrawAPI.getFiles(),
|
|
||||||
});
|
});
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
ctx.font = "30px Excalifont";
|
ctx.font = "30px Excalifont";
|
||||||
@@ -885,12 +895,14 @@ export default function ExampleApp({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const canvas = await exportToCanvas({
|
const canvas = await exportToCanvas({
|
||||||
elements: excalidrawAPI.getSceneElements(),
|
data: {
|
||||||
appState: {
|
elements: excalidrawAPI.getSceneElements(),
|
||||||
...initialData.appState,
|
appState: {
|
||||||
exportWithDarkMode,
|
...initialData.appState,
|
||||||
|
exportWithDarkMode,
|
||||||
|
},
|
||||||
|
files: excalidrawAPI.getFiles(),
|
||||||
},
|
},
|
||||||
files: excalidrawAPI.getFiles(),
|
|
||||||
});
|
});
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
ctx.font = "30px Excalifont";
|
ctx.font = "30px Excalifont";
|
||||||
|
|||||||
+359
-17
@@ -25,7 +25,13 @@ import {
|
|||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
StoreAction,
|
StoreAction,
|
||||||
reconcileElements,
|
reconcileElements,
|
||||||
|
exportToCanvas,
|
||||||
|
exportToSvg,
|
||||||
} from "../packages/excalidraw";
|
} from "../packages/excalidraw";
|
||||||
|
import {
|
||||||
|
exportToBlob,
|
||||||
|
getNonDeletedElements,
|
||||||
|
} from "../packages/excalidraw/index";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawImperativeAPI,
|
||||||
@@ -90,13 +96,9 @@ import {
|
|||||||
import { AppMainMenu } from "./components/AppMainMenu";
|
import { AppMainMenu } from "./components/AppMainMenu";
|
||||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||||
import { AppFooter } from "./components/AppFooter";
|
import { AppFooter } from "./components/AppFooter";
|
||||||
import {
|
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||||
Provider,
|
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
||||||
useAtom,
|
import { appJotaiStore } from "./app-jotai";
|
||||||
useAtomValue,
|
|
||||||
useAtomWithInitialValue,
|
|
||||||
appJotaiStore,
|
|
||||||
} from "./app-jotai";
|
|
||||||
|
|
||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
import type { ResolutionType } from "../packages/excalidraw/utility-types";
|
import type { ResolutionType } from "../packages/excalidraw/utility-types";
|
||||||
@@ -121,7 +123,7 @@ import {
|
|||||||
share,
|
share,
|
||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "../packages/excalidraw/components/icons";
|
} from "../packages/excalidraw/components/icons";
|
||||||
import { useHandleAppTheme } from "./useHandleAppTheme";
|
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||||
import { useAppLangCode } from "./app-language/language-state";
|
import { useAppLangCode } from "./app-language/language-state";
|
||||||
import DebugCanvas, {
|
import DebugCanvas, {
|
||||||
@@ -131,7 +133,9 @@ import DebugCanvas, {
|
|||||||
} from "./components/DebugCanvas";
|
} from "./components/DebugCanvas";
|
||||||
import { AIComponents } from "./components/AI";
|
import { AIComponents } from "./components/AI";
|
||||||
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
||||||
import { isElementLink } from "../packages/excalidraw/element/elementLink";
|
import { fileSave } from "../packages/excalidraw/data/filesystem";
|
||||||
|
import { type ExportSceneConfig } from "../packages/excalidraw/scene/export";
|
||||||
|
import { round } from "../packages/math";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
@@ -332,7 +336,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const isCollabDisabled = isRunningInIframe();
|
const isCollabDisabled = isRunningInIframe();
|
||||||
|
|
||||||
const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme();
|
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||||
|
const { editorTheme } = useHandleAppTheme();
|
||||||
|
|
||||||
const [langCode, setLangCode] = useAppLangCode();
|
const [langCode, setLangCode] = useAppLangCode();
|
||||||
|
|
||||||
@@ -611,6 +616,19 @@ const ExcalidrawWrapper = () => {
|
|||||||
};
|
};
|
||||||
}, [excalidrawAPI]);
|
}, [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 = (
|
const onChange = (
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@@ -620,6 +638,84 @@ const ExcalidrawWrapper = () => {
|
|||||||
collabAPI.syncElements(elements);
|
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
|
// this check is redundant, but since this is a hot path, it's best
|
||||||
// not to evaludate the nested expression every time
|
// not to evaludate the nested expression every time
|
||||||
if (!LocalData.isSavePaused()) {
|
if (!LocalData.isSavePaused()) {
|
||||||
@@ -852,12 +948,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onLinkOpen={(element, event) => {
|
|
||||||
if (element.link && isElementLink(element.link)) {
|
|
||||||
event.preventDefault();
|
|
||||||
excalidrawAPI?.scrollToContent(element.link, { animate: true });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<AppMainMenu
|
<AppMainMenu
|
||||||
onCollabDialogOpen={onCollabDialogOpen}
|
onCollabDialogOpen={onCollabDialogOpen}
|
||||||
@@ -1131,6 +1221,258 @@ const ExcalidrawWrapper = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Excalidraw>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1144,7 +1486,7 @@ const ExcalidrawApp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TopErrorBoundary>
|
<TopErrorBoundary>
|
||||||
<Provider store={appJotaiStore}>
|
<Provider unstable_createStore={() => appJotaiStore}>
|
||||||
<ExcalidrawWrapper />
|
<ExcalidrawWrapper />
|
||||||
</Provider>
|
</Provider>
|
||||||
</TopErrorBoundary>
|
</TopErrorBoundary>
|
||||||
|
|||||||
@@ -1,37 +1,3 @@
|
|||||||
// eslint-disable-next-line no-restricted-imports
|
import { unstable_createStore } from "jotai";
|
||||||
import {
|
|
||||||
atom,
|
|
||||||
Provider,
|
|
||||||
useAtom,
|
|
||||||
useAtomValue,
|
|
||||||
useSetAtom,
|
|
||||||
createStore,
|
|
||||||
type PrimitiveAtom,
|
|
||||||
} from "jotai";
|
|
||||||
import { useLayoutEffect } from "react";
|
|
||||||
|
|
||||||
export const appJotaiStore = createStore();
|
export const appJotaiStore = unstable_createStore();
|
||||||
|
|
||||||
export { atom, Provider, useAtom, useAtomValue, useSetAtom };
|
|
||||||
|
|
||||||
export const useAtomWithInitialValue = <
|
|
||||||
T extends unknown,
|
|
||||||
A extends PrimitiveAtom<T>,
|
|
||||||
>(
|
|
||||||
atom: A,
|
|
||||||
initialValue: T | (() => T),
|
|
||||||
) => {
|
|
||||||
const [value, setValue] = useAtom(atom);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (typeof initialValue === "function") {
|
|
||||||
// @ts-ignore
|
|
||||||
setValue(initialValue());
|
|
||||||
} else {
|
|
||||||
setValue(initialValue);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [value, setValue] as const;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { useSetAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
||||||
import { useSetAtom } from "../app-jotai";
|
|
||||||
import { appLangCodeAtom } from "./language-state";
|
import { appLangCodeAtom } from "./language-state";
|
||||||
|
|
||||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { atom, useAtom } from "../app-jotai";
|
|
||||||
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
||||||
|
|
||||||
export const appLangCodeAtom = atom(getPreferredLanguage());
|
export const appLangCodeAtom = atom(getPreferredLanguage());
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ import { newElementWith } from "../../packages/excalidraw/element/mutateElement"
|
|||||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
import { appJotaiStore, atom } from "../app-jotai";
|
import { atom } from "jotai";
|
||||||
|
import { appJotaiStore } from "../app-jotai";
|
||||||
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
|
|||||||
import { warning } from "../../packages/excalidraw/components/icons";
|
import { warning } from "../../packages/excalidraw/components/icons";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { atom } from "../app-jotai";
|
|
||||||
|
|
||||||
import "./CollabError.scss";
|
import "./CollabError.scss";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
type ErrorIndicator = {
|
type ErrorIndicator = {
|
||||||
message: string | null;
|
message: string | null;
|
||||||
|
|||||||
@@ -21,15 +21,19 @@ export const AIComponents = ({
|
|||||||
const appState = excalidrawAPI.getAppState();
|
const appState = excalidrawAPI.getAppState();
|
||||||
|
|
||||||
const blob = await exportToBlob({
|
const blob = await exportToBlob({
|
||||||
elements: children,
|
data: {
|
||||||
appState: {
|
elements: children,
|
||||||
...appState,
|
appState: {
|
||||||
exportBackground: true,
|
...appState,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
exportBackground: true,
|
||||||
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
|
},
|
||||||
|
files: excalidrawAPI.getFiles(),
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
exportingFrame: frame,
|
||||||
|
mimeType: MIME_TYPES.jpg,
|
||||||
},
|
},
|
||||||
exportingFrame: frame,
|
|
||||||
files: excalidrawAPI.getFiles(),
|
|
||||||
mimeType: MIME_TYPES.jpg,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const dataURL = await getDataURL(blob);
|
const dataURL = await getDataURL(blob);
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const _debugRenderer = (
|
|||||||
scale,
|
scale,
|
||||||
normalizedWidth,
|
normalizedWidth,
|
||||||
normalizedHeight,
|
normalizedHeight,
|
||||||
viewBackgroundColor: "transparent",
|
canvasBackgroundColor: "transparent",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply zoom
|
// Apply zoom
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const EncryptedIcon = () => {
|
|||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className="encrypted-icon tooltip"
|
className="encrypted-icon tooltip"
|
||||||
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={t("encrypted.link")}
|
aria-label={t("encrypted.link")}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
|||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
import { getFrame } from "../../packages/excalidraw/utils";
|
import { getFrame } from "../../packages/excalidraw/utils";
|
||||||
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
|
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
|
||||||
import { uploadBytes, ref } from "firebase/storage";
|
|
||||||
|
|
||||||
export const exportToExcalidrawPlus = async (
|
export const exportToExcalidrawPlus = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
@@ -33,7 +32,7 @@ export const exportToExcalidrawPlus = async (
|
|||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
name: string,
|
name: string,
|
||||||
) => {
|
) => {
|
||||||
const storage = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
const id = `${nanoid(12)}`;
|
const id = `${nanoid(12)}`;
|
||||||
|
|
||||||
@@ -50,13 +49,15 @@ export const exportToExcalidrawPlus = async (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const storageRef = ref(storage, `/migrations/scenes/${id}`);
|
await firebase
|
||||||
await uploadBytes(storageRef, blob, {
|
.storage()
|
||||||
customMetadata: {
|
.ref(`/migrations/scenes/${id}`)
|
||||||
data: JSON.stringify({ version: 2, name }),
|
.put(blob, {
|
||||||
created: Date.now().toString(),
|
customMetadata: {
|
||||||
},
|
data: JSON.stringify({ version: 2, name }),
|
||||||
});
|
created: Date.now().toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const filesMap = new Map<FileId, BinaryFileData>();
|
const filesMap = new Map<FileId, BinaryFileData>();
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
|
|||||||
@@ -22,17 +22,9 @@ import {
|
|||||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||||
import type { SyncableExcalidrawElement } from ".";
|
import type { SyncableExcalidrawElement } from ".";
|
||||||
import { getSyncableElements } from ".";
|
import { getSyncableElements } from ".";
|
||||||
|
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
||||||
import { initializeApp } from "firebase/app";
|
|
||||||
import {
|
|
||||||
getFirestore,
|
|
||||||
doc,
|
|
||||||
getDoc,
|
|
||||||
runTransaction,
|
|
||||||
Bytes,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { getStorage, ref, uploadBytes } from "firebase/storage";
|
|
||||||
|
|
||||||
// private
|
// private
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -49,43 +41,81 @@ try {
|
|||||||
FIREBASE_CONFIG = {};
|
FIREBASE_CONFIG = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let firebaseApp: ReturnType<typeof initializeApp> | null = null;
|
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
|
||||||
let firestore: ReturnType<typeof getFirestore> | null = null;
|
null;
|
||||||
let firebaseStorage: ReturnType<typeof getStorage> | null = null;
|
let firestorePromise: Promise<any> | null | true = null;
|
||||||
|
let firebaseStoragePromise: Promise<any> | null | true = null;
|
||||||
|
|
||||||
const _initializeFirebase = () => {
|
let isFirebaseInitialized = false;
|
||||||
if (!firebaseApp) {
|
|
||||||
firebaseApp = initializeApp(FIREBASE_CONFIG);
|
const _loadFirebase = async () => {
|
||||||
|
const firebase = (
|
||||||
|
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
||||||
|
).default;
|
||||||
|
|
||||||
|
if (!isFirebaseInitialized) {
|
||||||
|
try {
|
||||||
|
firebase.initializeApp(FIREBASE_CONFIG);
|
||||||
|
} catch (error: any) {
|
||||||
|
// trying initialize again throws. Usually this is harmless, and happens
|
||||||
|
// mainly in dev (HMR)
|
||||||
|
if (error.code === "app/duplicate-app") {
|
||||||
|
console.warn(error.name, error.code);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isFirebaseInitialized = true;
|
||||||
}
|
}
|
||||||
return firebaseApp;
|
|
||||||
|
return firebase;
|
||||||
};
|
};
|
||||||
|
|
||||||
const _getFirestore = () => {
|
const _getFirebase = async (): Promise<
|
||||||
if (!firestore) {
|
typeof import("firebase/app").default
|
||||||
firestore = getFirestore(_initializeFirebase());
|
> => {
|
||||||
|
if (!firebasePromise) {
|
||||||
|
firebasePromise = _loadFirebase();
|
||||||
}
|
}
|
||||||
return firestore;
|
return firebasePromise;
|
||||||
};
|
|
||||||
|
|
||||||
const _getStorage = () => {
|
|
||||||
if (!firebaseStorage) {
|
|
||||||
firebaseStorage = getStorage(_initializeFirebase());
|
|
||||||
}
|
|
||||||
return firebaseStorage;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const loadFirebaseStorage = async () => {
|
const loadFirestore = async () => {
|
||||||
return _getStorage();
|
const firebase = await _getFirebase();
|
||||||
|
if (!firestorePromise) {
|
||||||
|
firestorePromise = import(
|
||||||
|
/* webpackChunkName: "firestore" */ "firebase/firestore"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (firestorePromise !== true) {
|
||||||
|
await firestorePromise;
|
||||||
|
firestorePromise = true;
|
||||||
|
}
|
||||||
|
return firebase;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FirebaseStoredScene = {
|
export const loadFirebaseStorage = async () => {
|
||||||
sceneVersion: number;
|
const firebase = await _getFirebase();
|
||||||
iv: Bytes;
|
if (!firebaseStoragePromise) {
|
||||||
ciphertext: Bytes;
|
firebaseStoragePromise = import(
|
||||||
|
/* webpackChunkName: "storage" */ "firebase/storage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (firebaseStoragePromise !== true) {
|
||||||
|
await firebaseStoragePromise;
|
||||||
|
firebaseStoragePromise = true;
|
||||||
|
}
|
||||||
|
return firebase;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface FirebaseStoredScene {
|
||||||
|
sceneVersion: number;
|
||||||
|
iv: firebase.default.firestore.Blob;
|
||||||
|
ciphertext: firebase.default.firestore.Blob;
|
||||||
|
}
|
||||||
|
|
||||||
const encryptElements = async (
|
const encryptElements = async (
|
||||||
key: string,
|
key: string,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -145,7 +175,7 @@ export const saveFilesToFirebase = async ({
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
files: { id: FileId; buffer: Uint8Array }[];
|
files: { id: FileId; buffer: Uint8Array }[];
|
||||||
}) => {
|
}) => {
|
||||||
const storage = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
const erroredFiles: FileId[] = [];
|
const erroredFiles: FileId[] = [];
|
||||||
const savedFiles: FileId[] = [];
|
const savedFiles: FileId[] = [];
|
||||||
@@ -153,10 +183,17 @@ export const saveFilesToFirebase = async ({
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
files.map(async ({ id, buffer }) => {
|
files.map(async ({ id, buffer }) => {
|
||||||
try {
|
try {
|
||||||
const storageRef = ref(storage, `${prefix}/${id}`);
|
await firebase
|
||||||
await uploadBytes(storageRef, buffer, {
|
.storage()
|
||||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
.ref(`${prefix}/${id}`)
|
||||||
});
|
.put(
|
||||||
|
new Blob([buffer], {
|
||||||
|
type: MIME_TYPES.binary,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
savedFiles.push(id);
|
savedFiles.push(id);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
erroredFiles.push(id);
|
erroredFiles.push(id);
|
||||||
@@ -168,6 +205,7 @@ export const saveFilesToFirebase = async ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createFirebaseSceneDocument = async (
|
const createFirebaseSceneDocument = async (
|
||||||
|
firebase: ResolutionType<typeof loadFirestore>,
|
||||||
elements: readonly SyncableExcalidrawElement[],
|
elements: readonly SyncableExcalidrawElement[],
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
) => {
|
) => {
|
||||||
@@ -175,8 +213,10 @@ const createFirebaseSceneDocument = async (
|
|||||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||||
return {
|
return {
|
||||||
sceneVersion,
|
sceneVersion,
|
||||||
ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
|
ciphertext: firebase.firestore.Blob.fromUint8Array(
|
||||||
iv: Bytes.fromUint8Array(iv),
|
new Uint8Array(ciphertext),
|
||||||
|
),
|
||||||
|
iv: firebase.firestore.Blob.fromUint8Array(iv),
|
||||||
} as FirebaseStoredScene;
|
} as FirebaseStoredScene;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,14 +236,20 @@ export const saveToFirebase = async (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firestore = _getFirestore();
|
const firebase = await loadFirestore();
|
||||||
const docRef = doc(firestore, "scenes", roomId);
|
const firestore = firebase.firestore();
|
||||||
|
|
||||||
const storedScene = await runTransaction(firestore, async (transaction) => {
|
const docRef = firestore.collection("scenes").doc(roomId);
|
||||||
|
|
||||||
|
const storedScene = await firestore.runTransaction(async (transaction) => {
|
||||||
const snapshot = await transaction.get(docRef);
|
const snapshot = await transaction.get(docRef);
|
||||||
|
|
||||||
if (!snapshot.exists()) {
|
if (!snapshot.exists) {
|
||||||
const storedScene = await createFirebaseSceneDocument(elements, roomKey);
|
const storedScene = await createFirebaseSceneDocument(
|
||||||
|
firebase,
|
||||||
|
elements,
|
||||||
|
roomKey,
|
||||||
|
);
|
||||||
|
|
||||||
transaction.set(docRef, storedScene);
|
transaction.set(docRef, storedScene);
|
||||||
|
|
||||||
@@ -223,6 +269,7 @@ export const saveToFirebase = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const storedScene = await createFirebaseSceneDocument(
|
const storedScene = await createFirebaseSceneDocument(
|
||||||
|
firebase,
|
||||||
reconciledElements,
|
reconciledElements,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
@@ -247,13 +294,15 @@ export const loadFromFirebase = async (
|
|||||||
roomKey: string,
|
roomKey: string,
|
||||||
socket: Socket | null,
|
socket: Socket | null,
|
||||||
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
||||||
const firestore = _getFirestore();
|
const firebase = await loadFirestore();
|
||||||
const docRef = doc(firestore, "scenes", roomId);
|
const db = firebase.firestore();
|
||||||
const docSnap = await getDoc(docRef);
|
|
||||||
if (!docSnap.exists()) {
|
const docRef = db.collection("scenes").doc(roomId);
|
||||||
|
const doc = await docRef.get();
|
||||||
|
if (!doc.exists) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
const storedScene = doc.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,8 +54,6 @@
|
|||||||
content="https://excalidraw.com/og-image-3.png"
|
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 -->
|
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -27,12 +27,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/random-username": "1.0.0",
|
||||||
"@sentry/browser": "9.0.1",
|
"@sentry/browser": "6.2.5",
|
||||||
"callsites": "4.2.0",
|
"@sentry/integrations": "6.2.5",
|
||||||
"firebase": "11.3.1",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.4",
|
"i18next-browser-languagedetector": "6.1.4",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "6.0.3",
|
||||||
"jotai": "2.11.0",
|
"jotai": "1.13.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"socket.io-client": "4.7.2",
|
"socket.io-client": "4.7.2",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import * as Sentry from "@sentry/browser";
|
import * as Sentry from "@sentry/browser";
|
||||||
import callsites from "callsites";
|
import * as SentryIntegrations from "@sentry/integrations";
|
||||||
|
|
||||||
const SentryEnvHostnameMap: { [key: string]: string } = {
|
const SentryEnvHostnameMap: { [key: string]: string } = {
|
||||||
"excalidraw.com": "production",
|
"excalidraw.com": "production",
|
||||||
"staging.excalidraw.com": "staging",
|
|
||||||
"vercel.app": "staging",
|
"vercel.app": "staging",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,13 +23,9 @@ Sentry.init({
|
|||||||
release: import.meta.env.VITE_APP_GIT_SHA,
|
release: import.meta.env.VITE_APP_GIT_SHA,
|
||||||
ignoreErrors: [
|
ignoreErrors: [
|
||||||
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
|
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
|
||||||
"InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.", // Not much we can do about the IndexedDB closing error
|
|
||||||
/(Failed to fetch|(fetch|loading) dynamically imported module)/i, // This is happening when a service worker tries to load an old asset
|
|
||||||
/QuotaExceededError: (The quota has been exceeded|.*setItem.*Storage)/i, // localStorage quota exceeded
|
|
||||||
"Internal error opening backing store for indexedDB.open", // Private mode and disabled indexedDB
|
|
||||||
],
|
],
|
||||||
integrations: [
|
integrations: [
|
||||||
Sentry.captureConsoleIntegration({
|
new SentryIntegrations.CaptureConsole({
|
||||||
levels: ["error"],
|
levels: ["error"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -38,44 +33,6 @@ Sentry.init({
|
|||||||
if (event.request?.url) {
|
if (event.request?.url) {
|
||||||
event.request.url = event.request.url.replace(/#.*$/, "");
|
event.request.url = event.request.url.replace(/#.*$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event.exception) {
|
|
||||||
event.exception = {
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
type: "ConsoleError",
|
|
||||||
value: event.message ?? "Unknown error",
|
|
||||||
stacktrace: {
|
|
||||||
frames: callsites()
|
|
||||||
.slice(1)
|
|
||||||
.filter(
|
|
||||||
(frame) =>
|
|
||||||
frame.getFileName() &&
|
|
||||||
!frame.getFileName()?.includes("@sentry_browser.js"),
|
|
||||||
)
|
|
||||||
.map((frame) => ({
|
|
||||||
filename: frame.getFileName() ?? undefined,
|
|
||||||
function: frame.getFunctionName() ?? undefined,
|
|
||||||
in_app: !(
|
|
||||||
frame.getFileName()?.includes("node_modules") ?? false
|
|
||||||
),
|
|
||||||
lineno: frame.getLineNumber() ?? undefined,
|
|
||||||
colno: frame.getColumnNumber() ?? undefined,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
mechanism: {
|
|
||||||
type: "instrument",
|
|
||||||
handled: true,
|
|
||||||
data: {
|
|
||||||
function: "console.error",
|
|
||||||
handler: "Sentry.beforeSend",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return event;
|
return event;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ import { TextField } from "../../packages/excalidraw/components/TextField";
|
|||||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||||
import type { CollabAPI } from "../collab/Collab";
|
import type { CollabAPI } from "../collab/Collab";
|
||||||
import { activeRoomLinkAtom } from "../collab/Collab";
|
import { activeRoomLinkAtom } from "../collab/Collab";
|
||||||
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
import { atom, useAtom, useAtomValue } from "jotai";
|
||||||
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
|
||||||
import { atom, useAtom, useAtomValue } from "../app-jotai";
|
|
||||||
|
|
||||||
import "./ShareDialog.scss";
|
import "./ShareDialog.scss";
|
||||||
|
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||||
|
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
||||||
|
|
||||||
type OnExportToBackend = () => void;
|
type OnExportToBackend = () => void;
|
||||||
type ShareDialogType = "share" | "collaborationOnly";
|
type ShareDialogType = "share" | "collaborationOnly";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useLayoutEffect, useState } from "react";
|
import { useEffect, useLayoutEffect, useState } from "react";
|
||||||
import { THEME } from "../packages/excalidraw";
|
import { THEME } from "../packages/excalidraw";
|
||||||
import { EVENT } from "../packages/excalidraw/constants";
|
import { EVENT } from "../packages/excalidraw/constants";
|
||||||
@@ -5,18 +6,18 @@ import type { Theme } from "../packages/excalidraw/element/types";
|
|||||||
import { CODES, KEYS } from "../packages/excalidraw/keys";
|
import { CODES, KEYS } from "../packages/excalidraw/keys";
|
||||||
import { STORAGE_KEYS } from "./app_constants";
|
import { STORAGE_KEYS } from "./app_constants";
|
||||||
|
|
||||||
|
export const appThemeAtom = atom<Theme | "system">(
|
||||||
|
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
|
||||||
|
| Theme
|
||||||
|
| "system"
|
||||||
|
| null) || THEME.LIGHT,
|
||||||
|
);
|
||||||
|
|
||||||
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
|
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
|
||||||
window.matchMedia?.("(prefers-color-scheme: dark)");
|
window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
export const useHandleAppTheme = () => {
|
export const useHandleAppTheme = () => {
|
||||||
const [appTheme, setAppTheme] = useState<Theme | "system">(() => {
|
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||||
return (
|
|
||||||
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
|
|
||||||
| Theme
|
|
||||||
| "system"
|
|
||||||
| null) || THEME.LIGHT
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
|
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,5 +66,5 @@ export const useHandleAppTheme = () => {
|
|||||||
}
|
}
|
||||||
}, [appTheme]);
|
}, [appTheme]);
|
||||||
|
|
||||||
return { editorTheme, appTheme, setAppTheme };
|
return { editorTheme };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import type { AppClassProperties, AppState, UIAppState } from "../types";
|
|||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const alignActionsPredicate = (
|
const alignActionsPredicate = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
|
_: unknown,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -46,7 +48,6 @@ const alignSelectedElements = (
|
|||||||
selectedElements,
|
selectedElements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
alignment,
|
alignment,
|
||||||
app.scene,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
@@ -63,8 +64,7 @@ export const actionAlignTop = register({
|
|||||||
label: "labels.alignTop",
|
label: "labels.alignTop",
|
||||||
icon: AlignTopIcon,
|
icon: AlignTopIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -79,7 +79,7 @@ export const actionAlignTop = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignTopIcon}
|
icon={AlignTopIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@@ -97,8 +97,7 @@ export const actionAlignBottom = register({
|
|||||||
label: "labels.alignBottom",
|
label: "labels.alignBottom",
|
||||||
icon: AlignBottomIcon,
|
icon: AlignBottomIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -113,7 +112,7 @@ export const actionAlignBottom = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignBottomIcon}
|
icon={AlignBottomIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@@ -131,8 +130,7 @@ export const actionAlignLeft = register({
|
|||||||
label: "labels.alignLeft",
|
label: "labels.alignLeft",
|
||||||
icon: AlignLeftIcon,
|
icon: AlignLeftIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -147,7 +145,7 @@ export const actionAlignLeft = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignLeftIcon}
|
icon={AlignLeftIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@@ -165,8 +163,7 @@ export const actionAlignRight = register({
|
|||||||
label: "labels.alignRight",
|
label: "labels.alignRight",
|
||||||
icon: AlignRightIcon,
|
icon: AlignRightIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -181,7 +178,7 @@ export const actionAlignRight = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignRightIcon}
|
icon={AlignRightIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@@ -199,8 +196,7 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
label: "labels.centerVertically",
|
label: "labels.centerVertically",
|
||||||
icon: CenterVerticallyIcon,
|
icon: CenterVerticallyIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -213,7 +209,7 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={CenterVerticallyIcon}
|
icon={CenterVerticallyIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@@ -229,8 +225,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||||||
label: "labels.centerHorizontally",
|
label: "labels.centerHorizontally",
|
||||||
icon: CenterHorizontallyIcon,
|
icon: CenterHorizontallyIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -243,7 +238,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={CenterHorizontallyIcon}
|
icon={CenterHorizontallyIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
computeBoundTextPosition,
|
computeBoundTextPosition,
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
|
measureText,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +35,6 @@ import { arrayToMap, getFontString } from "../utils";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { syncMovedIndices } from "../fractionalIndex";
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { measureText } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
export const actionUnbindText = register({
|
||||||
name: "unbindText",
|
name: "unbindText",
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ export const actionClearCanvas = register({
|
|||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
!!app.props.UIOptions.canvasActions.clearCanvas &&
|
!!app.props.UIOptions.canvasActions.clearCanvas &&
|
||||||
!appState.viewModeEnabled &&
|
!appState.viewModeEnabled
|
||||||
appState.openDialog?.name !== "elementLinkSelector"
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
readSystemClipboard,
|
readSystemClipboard,
|
||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
import { exportAsImage } from "../data/index";
|
||||||
import { getTextFromElements, isTextElement } from "../element";
|
import { getTextFromElements, isTextElement } from "../element";
|
||||||
|
import { prepareElementsForExport } from "../data/index";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isFirefox } from "../constants";
|
import { isFirefox } from "../constants";
|
||||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||||
@@ -136,17 +137,15 @@ export const actionCopyAsSvg = register({
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportAsImage({
|
||||||
"clipboard-svg",
|
type: "clipboard-svg",
|
||||||
exportedElements,
|
data: { elements: exportedElements, appState, files: app.files },
|
||||||
appState,
|
config: {
|
||||||
app.files,
|
|
||||||
{
|
|
||||||
...appState,
|
...appState,
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
name: app.getName(),
|
name: app.getName(),
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
const selectedElements = app.scene.getSelectedElements({
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
@@ -208,11 +207,16 @@ export const actionCopyAsPng = register({
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
await exportAsImage({
|
||||||
...appState,
|
type: "clipboard",
|
||||||
exportingFrame,
|
data: { elements: exportedElements, appState, files: app.files },
|
||||||
name: app.getName(),
|
config: {
|
||||||
|
...appState,
|
||||||
|
exportingFrame,
|
||||||
|
name: appState.name || app.getName(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Excalidraw, mutateElement } from "../index";
|
|
||||||
import { act, assertElements, render } from "../tests/test-utils";
|
|
||||||
import { API } from "../tests/helpers/api";
|
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
|
||||||
|
|
||||||
const { h } = window;
|
|
||||||
|
|
||||||
describe("deleting selected elements when frame selected should keep children + select them", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await render(<Excalidraw />);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame only", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const r1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([f1, r1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: r1.id, isDeleted: false, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + text container (text's frameId set)", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const r1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const t1 = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
width: 200,
|
|
||||||
height: 100,
|
|
||||||
fontSize: 20,
|
|
||||||
containerId: r1.id,
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
mutateElement(r1, {
|
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([f1, r1, t1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: r1.id, isDeleted: false, selected: true },
|
|
||||||
{ id: t1.id, isDeleted: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + text container (text's frameId not set)", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const r1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const t1 = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
width: 200,
|
|
||||||
height: 100,
|
|
||||||
fontSize: 20,
|
|
||||||
containerId: r1.id,
|
|
||||||
frameId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
mutateElement(r1, {
|
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([f1, r1, t1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: r1.id, isDeleted: false, selected: true },
|
|
||||||
{ id: t1.id, isDeleted: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + text container (text selected too)", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const r1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const t1 = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
width: 200,
|
|
||||||
height: 100,
|
|
||||||
fontSize: 20,
|
|
||||||
containerId: r1.id,
|
|
||||||
frameId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
mutateElement(r1, {
|
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([f1, r1, t1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1, t1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: r1.id, isDeleted: false, selected: true },
|
|
||||||
{ id: t1.id, isDeleted: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + labeled arrow", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const a1 = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const t1 = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
width: 200,
|
|
||||||
height: 100,
|
|
||||||
fontSize: 20,
|
|
||||||
containerId: a1.id,
|
|
||||||
frameId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
mutateElement(a1, {
|
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([f1, a1, t1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1, t1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: a1.id, isDeleted: false, selected: true },
|
|
||||||
{ id: t1.id, isDeleted: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + children selected", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
const r1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
API.setElements([f1, r1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1, r1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: r1.id, isDeleted: false, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,7 +7,7 @@ import { getNonDeletedElements } from "../element";
|
|||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import { getElementsInGroup, selectGroupsForSelectedElements } from "../groups";
|
import { getElementsInGroup } from "../groups";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
import {
|
import {
|
||||||
@@ -18,14 +18,14 @@ import {
|
|||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { getContainerElement } from "../element/textElement";
|
import { mutateElbowArrow } from "../element/routing";
|
||||||
import { getFrameChildren } from "../frame";
|
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const framesToBeDeleted = new Set(
|
const framesToBeDeleted = new Set(
|
||||||
getSelectedElements(
|
getSelectedElements(
|
||||||
elements.filter((el) => isFrameLikeElement(el)),
|
elements.filter((el) => isFrameLikeElement(el)),
|
||||||
@@ -33,141 +33,48 @@ const deleteSelectedElements = (
|
|||||||
).map((el) => el.id),
|
).map((el) => el.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
|
return {
|
||||||
|
elements: elements.map((el) => {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
if (appState.selectedElementIds[el.id]) {
|
||||||
|
if (el.boundElements) {
|
||||||
const processedElements = new Set<ExcalidrawElement["id"]>();
|
el.boundElements.forEach((candidate) => {
|
||||||
|
const bound = app.scene
|
||||||
for (const frameId of framesToBeDeleted) {
|
.getNonDeletedElementsMap()
|
||||||
const frameChildren = getFrameChildren(elements, frameId);
|
.get(candidate.id);
|
||||||
for (const el of frameChildren) {
|
if (bound && isElbowArrow(bound)) {
|
||||||
if (processedElements.has(el.id)) {
|
mutateElement(bound, {
|
||||||
continue;
|
startBinding:
|
||||||
}
|
el.id === bound.startBinding?.elementId
|
||||||
|
? null
|
||||||
if (isBoundToContainer(el)) {
|
: bound.startBinding,
|
||||||
const containerElement = getContainerElement(el, elementsMap);
|
endBinding:
|
||||||
if (containerElement) {
|
el.id === bound.endBinding?.elementId
|
||||||
selectedElementIds[containerElement.id] = true;
|
? null
|
||||||
|
: bound.endBinding,
|
||||||
|
});
|
||||||
|
mutateElbowArrow(bound, elementsMap, bound.points);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
return newElementWith(el, { isDeleted: true });
|
||||||
selectedElementIds[el.id] = true;
|
|
||||||
}
|
}
|
||||||
processedElements.add(el.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldSelectEditingGroup = true;
|
|
||||||
|
|
||||||
const nextElements = elements.map((el) => {
|
|
||||||
if (appState.selectedElementIds[el.id]) {
|
|
||||||
const boundElement = isBoundToContainer(el)
|
|
||||||
? getContainerElement(el, elementsMap)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||||
shouldSelectEditingGroup = false;
|
return newElementWith(el, { isDeleted: true });
|
||||||
selectedElementIds[el.id] = true;
|
|
||||||
return el;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
boundElement?.frameId &&
|
isBoundToContainer(el) &&
|
||||||
framesToBeDeleted.has(boundElement?.frameId)
|
appState.selectedElementIds[el.containerId]
|
||||||
) {
|
) {
|
||||||
return el;
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
|
return el;
|
||||||
if (el.boundElements) {
|
}),
|
||||||
el.boundElements.forEach((candidate) => {
|
|
||||||
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
|
||||||
if (bound && isElbowArrow(bound)) {
|
|
||||||
mutateElement(bound, {
|
|
||||||
startBinding:
|
|
||||||
el.id === bound.startBinding?.elementId
|
|
||||||
? null
|
|
||||||
: bound.startBinding,
|
|
||||||
endBinding:
|
|
||||||
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
|
|
||||||
});
|
|
||||||
mutateElement(bound, { points: bound.points });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// if deleting a frame, remove the children from it and select them
|
|
||||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
|
||||||
shouldSelectEditingGroup = false;
|
|
||||||
if (!isBoundToContainer(el)) {
|
|
||||||
selectedElementIds[el.id] = true;
|
|
||||||
}
|
|
||||||
return newElementWith(el, { frameId: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBoundToContainer(el) && appState.selectedElementIds[el.containerId]) {
|
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
|
|
||||||
let nextEditingGroupId = appState.editingGroupId;
|
|
||||||
|
|
||||||
// select next eligible element in currently editing group or supergroup
|
|
||||||
if (shouldSelectEditingGroup && appState.editingGroupId) {
|
|
||||||
const elems = getElementsInGroup(
|
|
||||||
nextElements,
|
|
||||||
appState.editingGroupId,
|
|
||||||
).filter((el) => !el.isDeleted);
|
|
||||||
if (elems.length > 1) {
|
|
||||||
if (elems[0]) {
|
|
||||||
selectedElementIds[elems[0].id] = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
nextEditingGroupId = null;
|
|
||||||
if (elems[0]) {
|
|
||||||
selectedElementIds[elems[0].id] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastElementInGroup = elems[0];
|
|
||||||
if (lastElementInGroup) {
|
|
||||||
const editingGroupIdx = lastElementInGroup.groupIds.findIndex(
|
|
||||||
(groupId) => {
|
|
||||||
return groupId === appState.editingGroupId;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const superGroupId = lastElementInGroup.groupIds[editingGroupIdx + 1];
|
|
||||||
if (superGroupId) {
|
|
||||||
const elems = getElementsInGroup(nextElements, superGroupId).filter(
|
|
||||||
(el) => !el.isDeleted,
|
|
||||||
);
|
|
||||||
if (elems.length > 1) {
|
|
||||||
nextEditingGroupId = superGroupId;
|
|
||||||
|
|
||||||
elems.forEach((el) => {
|
|
||||||
selectedElementIds[el.id] = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: nextElements,
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...selectGroupsForSelectedElements(
|
selectedElementIds: {},
|
||||||
{
|
selectedGroupIds: {},
|
||||||
selectedElementIds,
|
|
||||||
editingGroupId: nextEditingGroupId,
|
|
||||||
},
|
|
||||||
nextElements,
|
|
||||||
appState,
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -250,7 +157,11 @@ export const actionDeleteSelected = register({
|
|||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
LinearElementEditor.deletePoints(
|
||||||
|
element,
|
||||||
|
selectedPointsIndices,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
@@ -268,13 +179,11 @@ export const actionDeleteSelected = register({
|
|||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
deleteSelectedElements(elements, appState, app);
|
deleteSelectedElements(elements, appState, app);
|
||||||
|
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
nextElements,
|
nextElements,
|
||||||
nextElements.filter((el) => el.isDeleted),
|
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
||||||
|
|||||||
@@ -1,530 +0,0 @@
|
|||||||
import { Excalidraw } from "../index";
|
|
||||||
import {
|
|
||||||
act,
|
|
||||||
assertElements,
|
|
||||||
getCloneByOrigId,
|
|
||||||
render,
|
|
||||||
} from "../tests/test-utils";
|
|
||||||
import { API } from "../tests/helpers/api";
|
|
||||||
import { actionDuplicateSelection } from "./actionDuplicateSelection";
|
|
||||||
import React from "react";
|
|
||||||
import { ORIG_ID } from "../constants";
|
|
||||||
|
|
||||||
const { h } = window;
|
|
||||||
|
|
||||||
describe("actionDuplicateSelection", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await render(<Excalidraw />);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplicating frames", () => {
|
|
||||||
it("frame selected only", async () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rectangle = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle]);
|
|
||||||
API.setSelectedElements([frame]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame selected only (with text container)", async () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle, text]);
|
|
||||||
API.setSelectedElements([frame]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
},
|
|
||||||
{ [ORIG_ID]: frame.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + text container selected (order A)", async () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle, text]);
|
|
||||||
API.setSelectedElements([frame, rectangle]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: rectangle.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
[ORIG_ID]: frame.id,
|
|
||||||
selected: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + text container selected (order B)", async () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([text, rectangle, frame]);
|
|
||||||
API.setSelectedElements([rectangle, frame]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: frame.id },
|
|
||||||
{
|
|
||||||
type: "rectangle",
|
|
||||||
[ORIG_ID]: `${rectangle.id}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
[ORIG_ID]: `${text.id}`,
|
|
||||||
type: "text",
|
|
||||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
},
|
|
||||||
{ [ORIG_ID]: `${frame.id}`, type: "frame", selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplicating frame children", () => {
|
|
||||||
it("frame child selected", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rectangle = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle]);
|
|
||||||
API.setSelectedElements([rectangle]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame text container selected (rectangle selected)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle, text]);
|
|
||||||
API.setSelectedElements([rectangle]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id).id,
|
|
||||||
frameId: frame.id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame bound text selected (container not selected)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle, text]);
|
|
||||||
API.setSelectedElements([text]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id).id,
|
|
||||||
frameId: frame.id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame text container selected (text not exists)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle]);
|
|
||||||
API.setSelectedElements([rectangle]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// shouldn't happen
|
|
||||||
it("frame bound text selected (container not exists)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, text]);
|
|
||||||
API.setSelectedElements([text]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: text.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: text.id, frameId: frame.id },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame bound container selected (text has no frameId)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({
|
|
||||||
frameId: frame.id,
|
|
||||||
label: { frameId: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle, text]);
|
|
||||||
API.setSelectedElements([rectangle]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id).id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplicating multiple frames", () => {
|
|
||||||
it("multiple frames selected (no children)", () => {
|
|
||||||
const frame1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rect1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const frame2 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rect2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame2.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ellipse = API.createElement({
|
|
||||||
type: "ellipse",
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
|
|
||||||
API.setSelectedElements([frame1, frame2]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rect1.id, frameId: frame1.id },
|
|
||||||
{ id: frame1.id },
|
|
||||||
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame1.id, selected: true },
|
|
||||||
{ id: ellipse.id },
|
|
||||||
{ id: rect2.id, frameId: frame2.id },
|
|
||||||
{ id: frame2.id },
|
|
||||||
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame2.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("multiple frames selected (no children) + unrelated element", () => {
|
|
||||||
const frame1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rect1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const frame2 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rect2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame2.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ellipse = API.createElement({
|
|
||||||
type: "ellipse",
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
|
|
||||||
API.setSelectedElements([frame1, ellipse, frame2]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rect1.id, frameId: frame1.id },
|
|
||||||
{ id: frame1.id },
|
|
||||||
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame1.id, selected: true },
|
|
||||||
{ id: ellipse.id },
|
|
||||||
{ [ORIG_ID]: ellipse.id, selected: true },
|
|
||||||
{ id: rect2.id, frameId: frame2.id },
|
|
||||||
{ id: frame2.id },
|
|
||||||
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame2.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplicating containers/bound elements", () => {
|
|
||||||
it("labeled arrow (arrow selected)", () => {
|
|
||||||
const [arrow, text] = API.createLabeledArrow();
|
|
||||||
|
|
||||||
API.setElements([arrow, text]);
|
|
||||||
API.setSelectedElements([arrow]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: arrow.id },
|
|
||||||
{ id: text.id, containerId: arrow.id },
|
|
||||||
{ [ORIG_ID]: arrow.id, selected: true },
|
|
||||||
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// shouldn't happen
|
|
||||||
it("labeled arrow (text selected)", () => {
|
|
||||||
const [arrow, text] = API.createLabeledArrow();
|
|
||||||
|
|
||||||
API.setElements([arrow, text]);
|
|
||||||
API.setSelectedElements([text]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: arrow.id },
|
|
||||||
{ id: text.id, containerId: arrow.id },
|
|
||||||
{ [ORIG_ID]: arrow.id, selected: true },
|
|
||||||
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplicating groups", () => {
|
|
||||||
it("duplicate group containing frame (children don't have groupIds set)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
groupIds: ["A"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({
|
|
||||||
frameId: frame.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ellipse = API.createElement({
|
|
||||||
type: "ellipse",
|
|
||||||
groupIds: ["A"],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle, text, frame, ellipse]);
|
|
||||||
API.setSelectedElements([frame, ellipse]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, frameId: frame.id },
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: ellipse.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
|
||||||
{ [ORIG_ID]: text.id, frameId: getCloneByOrigId(frame.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame.id, selected: true },
|
|
||||||
{ [ORIG_ID]: ellipse.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplicate group containing frame (children have groupIds)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
groupIds: ["A"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({
|
|
||||||
frameId: frame.id,
|
|
||||||
groupIds: ["A"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const ellipse = API.createElement({
|
|
||||||
type: "ellipse",
|
|
||||||
groupIds: ["A"],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle, text, frame, ellipse]);
|
|
||||||
API.setSelectedElements([frame, ellipse]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, frameId: frame.id },
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: ellipse.id },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: rectangle.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
|
|
||||||
selected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
|
|
||||||
selected: true,
|
|
||||||
},
|
|
||||||
{ [ORIG_ID]: frame.id, selected: true },
|
|
||||||
{ [ORIG_ID]: ellipse.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplicating element nested in group", () => {
|
|
||||||
const ellipse = API.createElement({
|
|
||||||
type: "ellipse",
|
|
||||||
groupIds: ["B"],
|
|
||||||
});
|
|
||||||
const rect1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
groupIds: ["A", "B"],
|
|
||||||
});
|
|
||||||
const rect2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
groupIds: ["A", "B"],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([ellipse, rect1, rect2]);
|
|
||||||
API.setSelectedElements([ellipse], "B");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: ellipse.id },
|
|
||||||
{ [ORIG_ID]: ellipse.id, groupIds: ["B"], selected: true },
|
|
||||||
{ id: rect1.id, groupIds: ["A", "B"] },
|
|
||||||
{ id: rect2.id, groupIds: ["A", "B"] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,13 +5,7 @@ import { duplicateElement, getNonDeletedElements } from "../element";
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import {
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
arrayToMap,
|
|
||||||
castArray,
|
|
||||||
findLastIndex,
|
|
||||||
getShortcutKey,
|
|
||||||
invariant,
|
|
||||||
} from "../utils";
|
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import {
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
@@ -25,13 +19,8 @@ import { DEFAULT_GRID_SIZE } from "../constants";
|
|||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerElement,
|
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||||
hasBoundTextElement,
|
|
||||||
isBoundToContainer,
|
|
||||||
isFrameLikeElement,
|
|
||||||
} from "../element/typeChecks";
|
|
||||||
import { normalizeElementOrder } from "../element/sortElements";
|
import { normalizeElementOrder } from "../element/sortElements";
|
||||||
import { DuplicateIcon } from "../components/icons";
|
import { DuplicateIcon } from "../components/icons";
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +31,7 @@ import {
|
|||||||
excludeElementsInFramesFromSelection,
|
excludeElementsInFramesFromSelection,
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
} from "../scene/selection";
|
} from "../scene/selection";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
@@ -69,20 +59,8 @@ export const actionDuplicateSelection = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextState = duplicateElements(elements, appState);
|
|
||||||
|
|
||||||
if (app.props.onDuplicate && nextState.elements) {
|
|
||||||
const mappedElements = app.props.onDuplicate(
|
|
||||||
nextState.elements,
|
|
||||||
elements,
|
|
||||||
);
|
|
||||||
if (mappedElements) {
|
|
||||||
nextState.elements = mappedElements;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...nextState,
|
...duplicateElements(elements, appState),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -104,69 +82,37 @@ export const actionDuplicateSelection = register({
|
|||||||
const duplicateElements = (
|
const duplicateElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
): Partial<Exclude<ActionResult, false>> => {
|
): Partial<ActionResult> => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// step (1)
|
||||||
|
|
||||||
|
const sortedElements = normalizeElementOrder(elements);
|
||||||
const groupIdMap = new Map();
|
const groupIdMap = new Map();
|
||||||
const newElements: ExcalidrawElement[] = [];
|
const newElements: ExcalidrawElement[] = [];
|
||||||
const oldElements: ExcalidrawElement[] = [];
|
const oldElements: ExcalidrawElement[] = [];
|
||||||
const oldIdToDuplicatedId = new Map();
|
const oldIdToDuplicatedId = new Map();
|
||||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||||
|
|
||||||
const elementsMap = arrayToMap(elements);
|
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||||
|
const newElement = duplicateElement(
|
||||||
const duplicateAndOffsetElement = <
|
appState.editingGroupId,
|
||||||
T extends ExcalidrawElement | ExcalidrawElement[],
|
groupIdMap,
|
||||||
>(
|
element,
|
||||||
element: T,
|
{
|
||||||
): T extends ExcalidrawElement[]
|
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||||
? ExcalidrawElement[]
|
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||||
: ExcalidrawElement | null => {
|
|
||||||
const elements = castArray(element);
|
|
||||||
|
|
||||||
const _newElements = elements.reduce(
|
|
||||||
(acc: ExcalidrawElement[], element) => {
|
|
||||||
if (processedIds.has(element.id)) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
processedIds.set(element.id, true);
|
|
||||||
|
|
||||||
const newElement = duplicateElement(
|
|
||||||
appState.editingGroupId,
|
|
||||||
groupIdMap,
|
|
||||||
element,
|
|
||||||
{
|
|
||||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
|
||||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
processedIds.set(newElement.id, true);
|
|
||||||
|
|
||||||
duplicatedElementsMap.set(newElement.id, newElement);
|
|
||||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
|
||||||
|
|
||||||
oldElements.push(element);
|
|
||||||
newElements.push(newElement);
|
|
||||||
|
|
||||||
acc.push(newElement);
|
|
||||||
return acc;
|
|
||||||
},
|
},
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
|
duplicatedElementsMap.set(newElement.id, newElement);
|
||||||
return (
|
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||||
Array.isArray(element) ? _newElements : _newElements[0] || null
|
oldElements.push(element);
|
||||||
) as T extends ExcalidrawElement[]
|
newElements.push(newElement);
|
||||||
? ExcalidrawElement[]
|
return newElement;
|
||||||
: ExcalidrawElement | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
elements = normalizeElementOrder(elements);
|
|
||||||
|
|
||||||
const idsOfElementsToDuplicate = arrayToMap(
|
const idsOfElementsToDuplicate = arrayToMap(
|
||||||
getSelectedElements(elements, appState, {
|
getSelectedElements(sortedElements, appState, {
|
||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
includeElementsInFrames: true,
|
includeElementsInFrames: true,
|
||||||
}),
|
}),
|
||||||
@@ -184,134 +130,123 @@ const duplicateElements = (
|
|||||||
// loop over them.
|
// loop over them.
|
||||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||||
|
|
||||||
const elementsWithClones: ExcalidrawElement[] = elements.slice();
|
const markAsProcessed = (elements: ExcalidrawElement[]) => {
|
||||||
|
for (const element of elements) {
|
||||||
const insertAfterIndex = (
|
processedIds.set(element.id, true);
|
||||||
index: number,
|
|
||||||
elements: ExcalidrawElement | null | ExcalidrawElement[],
|
|
||||||
) => {
|
|
||||||
invariant(index !== -1, "targetIndex === -1 ");
|
|
||||||
|
|
||||||
if (!Array.isArray(elements) && !elements) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return elements;
|
||||||
elementsWithClones.splice(index + 1, 0, ...castArray(elements));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const frameIdsToDuplicate = new Set(
|
const elementsWithClones: ExcalidrawElement[] = [];
|
||||||
elements
|
|
||||||
.filter(
|
|
||||||
(el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
|
|
||||||
)
|
|
||||||
.map((el) => el.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const element of elements) {
|
let index = -1;
|
||||||
if (processedIds.has(element.id)) {
|
|
||||||
|
while (++index < sortedElements.length) {
|
||||||
|
const element = sortedElements[index];
|
||||||
|
|
||||||
|
if (processedIds.get(element.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idsOfElementsToDuplicate.has(element.id)) {
|
const boundTextElement = getBoundTextElement(element, arrayToMap(elements));
|
||||||
continue;
|
const isElementAFrameLike = isFrameLikeElement(element);
|
||||||
}
|
|
||||||
|
|
||||||
// groups
|
if (idsOfElementsToDuplicate.get(element.id)) {
|
||||||
// -------------------------------------------------------------------------
|
// if a group or a container/bound-text or frame, duplicate atomically
|
||||||
|
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
|
||||||
|
const groupId = getSelectedGroupForElement(appState, element);
|
||||||
|
if (groupId) {
|
||||||
|
// TODO:
|
||||||
|
// remove `.flatMap...`
|
||||||
|
// if the elements in a frame are grouped when the frame is grouped
|
||||||
|
const groupElements = getElementsInGroup(
|
||||||
|
sortedElements,
|
||||||
|
groupId,
|
||||||
|
).flatMap((element) =>
|
||||||
|
isFrameLikeElement(element)
|
||||||
|
? [...getFrameChildren(elements, element.id), element]
|
||||||
|
: [element],
|
||||||
|
);
|
||||||
|
|
||||||
const groupId = getSelectedGroupForElement(appState, element);
|
elementsWithClones.push(
|
||||||
if (groupId) {
|
...markAsProcessed([
|
||||||
const groupElements = getElementsInGroup(elements, groupId).flatMap(
|
...groupElements,
|
||||||
(element) =>
|
...groupElements.map((element) =>
|
||||||
isFrameLikeElement(element)
|
duplicateAndOffsetElement(element),
|
||||||
? [...getFrameChildren(elements, element.id), element]
|
),
|
||||||
: [element],
|
]),
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (boundTextElement) {
|
||||||
|
elementsWithClones.push(
|
||||||
|
...markAsProcessed([
|
||||||
|
element,
|
||||||
|
boundTextElement,
|
||||||
|
duplicateAndOffsetElement(element),
|
||||||
|
duplicateAndOffsetElement(boundTextElement),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isElementAFrameLike) {
|
||||||
|
const elementsInFrame = getFrameChildren(sortedElements, element.id);
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
elementsWithClones.push(
|
||||||
return el.groupIds?.includes(groupId);
|
...markAsProcessed([
|
||||||
});
|
...elementsInFrame,
|
||||||
|
element,
|
||||||
|
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
|
||||||
|
duplicateAndOffsetElement(element),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
|
continue;
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// frame duplication
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFrameLikeElement(element)) {
|
|
||||||
const frameId = element.id;
|
|
||||||
|
|
||||||
const frameChildren = getFrameChildren(elements, frameId);
|
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
|
||||||
return el.frameId === frameId || el.id === frameId;
|
|
||||||
});
|
|
||||||
|
|
||||||
insertAfterIndex(
|
|
||||||
targetIndex,
|
|
||||||
duplicateAndOffsetElement([...frameChildren, element]),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// text container
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if (hasBoundTextElement(element)) {
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
|
||||||
return (
|
|
||||||
el.id === element.id ||
|
|
||||||
("containerId" in el && el.containerId === element.id)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
insertAfterIndex(
|
|
||||||
targetIndex,
|
|
||||||
duplicateAndOffsetElement([element, boundTextElement]),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
|
|
||||||
}
|
}
|
||||||
|
// since elements in frames have a lower z-index than the frame itself,
|
||||||
continue;
|
// they will be looped first and if their frames are selected as well,
|
||||||
}
|
// they will have been copied along with the frame atomically in the
|
||||||
|
// above branch, so we must skip those elements here
|
||||||
if (isBoundToContainer(element)) {
|
//
|
||||||
const container = getContainerElement(element, elementsMap);
|
// now, for elements do not belong any frames or elements whose frames
|
||||||
|
// are selected (or elements that are left out from the above
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
// steps for whatever reason) we (should at least) duplicate them here
|
||||||
return el.id === element.id || el.id === container?.id;
|
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
|
||||||
});
|
elementsWithClones.push(
|
||||||
|
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
|
||||||
if (container) {
|
|
||||||
insertAfterIndex(
|
|
||||||
targetIndex,
|
|
||||||
duplicateAndOffsetElement([container, element]),
|
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
continue;
|
elementsWithClones.push(...markAsProcessed([element]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// default duplication (regular elements)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
insertAfterIndex(
|
|
||||||
findLastIndex(elementsWithClones, (el) => el.id === element.id),
|
|
||||||
duplicateAndOffsetElement(element),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// step (2)
|
||||||
|
|
||||||
|
// second pass to remove duplicates. We loop from the end as it's likelier
|
||||||
|
// that the last elements are in the correct order (contiguous or otherwise).
|
||||||
|
// Thus we need to reverse as the last step (3).
|
||||||
|
|
||||||
|
const finalElementsReversed: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
|
const finalElementIds = new Map<ExcalidrawElement["id"], true>();
|
||||||
|
index = elementsWithClones.length;
|
||||||
|
|
||||||
|
while (--index >= 0) {
|
||||||
|
const element = elementsWithClones[index];
|
||||||
|
if (!finalElementIds.get(element.id)) {
|
||||||
|
finalElementIds.set(element.id, true);
|
||||||
|
finalElementsReversed.push(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// step (3)
|
||||||
|
const finalElements = syncMovedIndices(
|
||||||
|
finalElementsReversed.reverse(),
|
||||||
|
arrayToMap(newElements),
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
bindTextToShapeAfterDuplication(
|
bindTextToShapeAfterDuplication(
|
||||||
@@ -325,7 +260,7 @@ const duplicateElements = (
|
|||||||
oldIdToDuplicatedId,
|
oldIdToDuplicatedId,
|
||||||
);
|
);
|
||||||
bindElementsToFramesAfterDuplication(
|
bindElementsToFramesAfterDuplication(
|
||||||
elementsWithClones,
|
finalElements,
|
||||||
oldElements,
|
oldElements,
|
||||||
oldIdToDuplicatedId,
|
oldIdToDuplicatedId,
|
||||||
);
|
);
|
||||||
@@ -334,7 +269,7 @@ const duplicateElements = (
|
|||||||
excludeElementsInFramesFromSelection(newElements);
|
excludeElementsInFramesFromSelection(newElements);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: elementsWithClones,
|
elements: finalElements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...selectGroupsForSelectedElements(
|
...selectGroupsForSelectedElements(
|
||||||
@@ -350,7 +285,7 @@ const duplicateElements = (
|
|||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
getNonDeletedElements(elementsWithClones),
|
getNonDeletedElements(finalElements),
|
||||||
appState,
|
appState,
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
@@ -10,13 +10,13 @@ import { useDevice } from "../components/App";
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { CheckboxItem } from "../components/CheckboxItem";
|
import { CheckboxItem } from "../components/CheckboxItem";
|
||||||
import { getExportSize } from "../scene/export";
|
import { getCanvasSize } from "../scene/export";
|
||||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { isImageFileHandle } from "../data/blob";
|
import { isImageFileHandle } from "../data/blob";
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||||
import type { Theme } from "../element/types";
|
import type { NonDeletedExcalidrawElement, Theme } from "../element/types";
|
||||||
|
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
@@ -58,6 +58,18 @@ export const actionChangeExportScale = register({
|
|||||||
? getSelectedElements(elements, appState)
|
? getSelectedElements(elements, appState)
|
||||||
: elements;
|
: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{EXPORT_SCALES.map((s) => {
|
{EXPORT_SCALES.map((s) => {
|
||||||
|
|||||||
@@ -49,13 +49,12 @@ describe("flipping re-centers selection", () => {
|
|||||||
},
|
},
|
||||||
startArrowhead: null,
|
startArrowhead: null,
|
||||||
endArrowhead: "arrow",
|
endArrowhead: "arrow",
|
||||||
fixedSegments: null,
|
|
||||||
points: [
|
points: [
|
||||||
pointFrom(0, 0),
|
pointFrom(0, 0),
|
||||||
pointFrom(0, -35),
|
pointFrom(0, -35),
|
||||||
pointFrom(-90, -35),
|
pointFrom(-90.9, -35),
|
||||||
pointFrom(-90, 204),
|
pointFrom(-90.9, 204.9),
|
||||||
pointFrom(66, 204),
|
pointFrom(65.1, 204.9),
|
||||||
],
|
],
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
}),
|
}),
|
||||||
@@ -71,13 +70,13 @@ describe("flipping re-centers selection", () => {
|
|||||||
API.executeAction(actionFlipHorizontal);
|
API.executeAction(actionFlipHorizontal);
|
||||||
API.executeAction(actionFlipHorizontal);
|
API.executeAction(actionFlipHorizontal);
|
||||||
|
|
||||||
const rec1 = h.elements.find((el) => el.id === "rec1")!;
|
const rec1 = h.elements.find((el) => el.id === "rec1");
|
||||||
expect(rec1.x).toBeCloseTo(100, 0);
|
expect(rec1?.x).toBeCloseTo(100);
|
||||||
expect(rec1.y).toBeCloseTo(100, 0);
|
expect(rec1?.y).toBeCloseTo(100);
|
||||||
|
|
||||||
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
const rec2 = h.elements.find((el) => el.id === "rec2");
|
||||||
expect(rec2.x).toBeCloseTo(220, 0);
|
expect(rec2?.x).toBeCloseTo(220);
|
||||||
expect(rec2.y).toBeCloseTo(250, 0);
|
expect(rec2?.y).toBeCloseTo(250);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { resizeMultipleElements } from "../element/resizeElements";
|
|||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { getCommonBoundingBox } from "../element/bounds";
|
||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElements,
|
bindOrUnbindLinearElements,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
@@ -24,9 +25,8 @@ import {
|
|||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
|
import { mutateElbowArrow } from "../element/routing";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import { deepCopyElement } from "../element/newElement";
|
|
||||||
import { getCommonBoundingBox } from "../element/bounds";
|
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
@@ -132,25 +132,18 @@ const flipElements = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { midX, midY } = getCommonBoundingBox(selectedElements);
|
const { minX, minY, maxX, maxY, midX, midY } =
|
||||||
|
getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
resizeMultipleElements(
|
resizeMultipleElements(
|
||||||
|
elementsMap,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
"nw",
|
"nw",
|
||||||
app.scene,
|
true,
|
||||||
new Map(
|
true,
|
||||||
Array.from(elementsMap.values()).map((element) => [
|
flipDirection === "horizontal" ? maxX : minX,
|
||||||
element.id,
|
flipDirection === "horizontal" ? minY : maxY,
|
||||||
deepCopyElement(element),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
{
|
|
||||||
flipByX: flipDirection === "horizontal",
|
|
||||||
flipByY: flipDirection === "vertical",
|
|
||||||
shouldResizeFromCenter: true,
|
|
||||||
shouldMaintainAspectRatio: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindLinearElements(
|
||||||
@@ -160,7 +153,6 @@ const flipElements = (
|
|||||||
app.scene,
|
app.scene,
|
||||||
isBindingEnabled(appState),
|
isBindingEnabled(appState),
|
||||||
[],
|
[],
|
||||||
appState.zoom,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -193,10 +185,16 @@ const flipElements = (
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
elbowArrows.forEach((element) =>
|
elbowArrows.forEach((element) =>
|
||||||
mutateElement(element, {
|
mutateElbowArrow(
|
||||||
x: element.x + diffX,
|
element,
|
||||||
y: element.y + diffY,
|
elementsMap,
|
||||||
}),
|
element.points,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
informMutation: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { addElementsToFrame, removeAllElementsFromFrame } from "../frame";
|
import { removeAllElementsFromFrame } from "../frame";
|
||||||
import { getFrameChildren } from "../frame";
|
import { getFrameChildren } from "../frame";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
@@ -10,10 +10,6 @@ import { register } from "./register";
|
|||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { frameToolIcon } from "../components/icons";
|
import { frameToolIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { newFrameElement } from "../element/newElement";
|
|
||||||
import { getElementsInGroup } from "../groups";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
|
|
||||||
const isSingleFrameSelected = (
|
const isSingleFrameSelected = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
@@ -148,67 +144,3 @@ export const actionSetFrameAsActiveTool = register({
|
|||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
event.key.toLocaleLowerCase() === KEYS.F,
|
event.key.toLocaleLowerCase() === KEYS.F,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionWrapSelectionInFrame = register({
|
|
||||||
name: "wrapSelectionInFrame",
|
|
||||||
label: "labels.wrapSelectionInFrame",
|
|
||||||
trackEvent: { category: "element" },
|
|
||||||
predicate: (elements, appState, _, app) => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
|
|
||||||
return (
|
|
||||||
selectedElements.length > 0 &&
|
|
||||||
!selectedElements.some((element) => isFrameLikeElement(element))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
perform: (elements, appState, _, app) => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = getCommonBounds(
|
|
||||||
selectedElements,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
const PADDING = 16;
|
|
||||||
const frame = newFrameElement({
|
|
||||||
x: x1 - PADDING,
|
|
||||||
y: y1 - PADDING,
|
|
||||||
width: x2 - x1 + PADDING * 2,
|
|
||||||
height: y2 - y1 + PADDING * 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
// for a selected partial group, we want to remove it from the remainder of the group
|
|
||||||
if (appState.editingGroupId) {
|
|
||||||
const elementsInGroup = getElementsInGroup(
|
|
||||||
selectedElements,
|
|
||||||
appState.editingGroupId,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const elementInGroup of elementsInGroup) {
|
|
||||||
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
|
|
||||||
|
|
||||||
mutateElement(
|
|
||||||
elementInGroup,
|
|
||||||
{
|
|
||||||
groupIds: elementInGroup.groupIds.slice(0, index),
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextElements = addElementsToFrame(
|
|
||||||
[...app.scene.getElementsIncludingDeleted(), frame],
|
|
||||||
selectedElements,
|
|
||||||
frame,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: nextElements,
|
|
||||||
appState: {
|
|
||||||
selectedElementIds: { [frame.id]: true },
|
|
||||||
},
|
|
||||||
storeAction: StoreAction.CAPTURE,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ import type {
|
|||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
frameAndChildrenSelectedTogether,
|
|
||||||
getElementsInResizingFrame,
|
getElementsInResizingFrame,
|
||||||
getFrameLikeElements,
|
getFrameLikeElements,
|
||||||
getRootElements,
|
|
||||||
groupByFrameLikes,
|
groupByFrameLikes,
|
||||||
removeElementsFromFrame,
|
removeElementsFromFrame,
|
||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
@@ -62,11 +60,8 @@ const enableActionGroup = (
|
|||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
selectedElements.length >= 2 &&
|
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||||
!allElementsInSameGroup(selectedElements) &&
|
|
||||||
!frameAndChildrenSelectedTogether(selectedElements)
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,12 +71,10 @@ export const actionGroup = register({
|
|||||||
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = getRootElements(
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
app.scene.getSelectedElements({
|
selectedElementIds: appState.selectedElementIds,
|
||||||
selectedElementIds: appState.selectedElementIds,
|
includeBoundTextElement: true,
|
||||||
includeBoundTextElement: true,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (selectedElements.length < 2) {
|
if (selectedElements.length < 2) {
|
||||||
// nothing to group
|
// nothing to group
|
||||||
return { appState, elements, storeAction: StoreAction.NONE };
|
return { appState, elements, storeAction: StoreAction.NONE };
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ import {
|
|||||||
sharpArrowIcon,
|
sharpArrowIcon,
|
||||||
roundArrowIcon,
|
roundArrowIcon,
|
||||||
elbowArrowIcon,
|
elbowArrowIcon,
|
||||||
ArrowheadCrowfootIcon,
|
|
||||||
ArrowheadCrowfootOneIcon,
|
|
||||||
ArrowheadCrowfootOneOrManyIcon,
|
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import {
|
||||||
ARROW_TYPE,
|
ARROW_TYPE,
|
||||||
@@ -89,7 +86,6 @@ import type {
|
|||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
VerticalAlign,
|
VerticalAlign,
|
||||||
NonDeletedSceneElementsMap,
|
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getLanguage, t } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
@@ -116,12 +112,11 @@ import {
|
|||||||
bindPointToSnapToElementOutline,
|
bindPointToSnapToElementOutline,
|
||||||
calculateFixedPointForElbowArrowBinding,
|
calculateFixedPointForElbowArrowBinding,
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
updateBoundElements,
|
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
|
import { mutateElbowArrow } from "../element/routing";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import type { LocalPoint } from "../../math";
|
import type { LocalPoint } from "../../math";
|
||||||
import { pointFrom } from "../../math";
|
import { pointFrom, vector } from "../../math";
|
||||||
import { Range } from "../components/Range";
|
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@@ -221,47 +216,33 @@ const changeFontSize = (
|
|||||||
) => {
|
) => {
|
||||||
const newFontSizes = new Set<number>();
|
const newFontSizes = new Set<number>();
|
||||||
|
|
||||||
const updatedElements = changeProperty(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(oldElement) => {
|
|
||||||
if (isTextElement(oldElement)) {
|
|
||||||
const newFontSize = getNewFontSize(oldElement);
|
|
||||||
newFontSizes.add(newFontSize);
|
|
||||||
|
|
||||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
|
||||||
fontSize: newFontSize,
|
|
||||||
});
|
|
||||||
redrawTextBoundingBox(
|
|
||||||
newElement,
|
|
||||||
app.scene.getContainerElement(oldElement),
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
|
||||||
|
|
||||||
return newElement;
|
|
||||||
}
|
|
||||||
return oldElement;
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update arrow elements after text elements have been updated
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
|
||||||
getSelectedElements(elements, appState, {
|
|
||||||
includeBoundTextElement: true,
|
|
||||||
}).forEach((element) => {
|
|
||||||
if (isTextElement(element)) {
|
|
||||||
updateBoundElements(
|
|
||||||
element,
|
|
||||||
updatedElementsMap as NonDeletedSceneElementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: updatedElements,
|
elements: changeProperty(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(oldElement) => {
|
||||||
|
if (isTextElement(oldElement)) {
|
||||||
|
const newFontSize = getNewFontSize(oldElement);
|
||||||
|
newFontSizes.add(newFontSize);
|
||||||
|
|
||||||
|
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||||
|
fontSize: newFontSize,
|
||||||
|
});
|
||||||
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||||
|
|
||||||
|
return newElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldElement;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
// update state only if we've set all select text elements to
|
// update state only if we've set all select text elements to
|
||||||
@@ -631,12 +612,25 @@ export const actionChangeOpacity = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<Range
|
<label className="control-label">
|
||||||
updateData={updateData}
|
{t("labels.opacity")}
|
||||||
elements={elements}
|
<input
|
||||||
appState={appState}
|
type="range"
|
||||||
testId="opacity"
|
min="0"
|
||||||
/>
|
max="100"
|
||||||
|
step="10"
|
||||||
|
onChange={(event) => updateData(+event.target.value)}
|
||||||
|
value={
|
||||||
|
getFormValue(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(element) => element.opacity,
|
||||||
|
true,
|
||||||
|
appState.currentItemOpacity,
|
||||||
|
) ?? undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1411,65 +1405,59 @@ const getArrowheadOptions = (flip: boolean) => {
|
|||||||
keyBinding: "w",
|
keyBinding: "w",
|
||||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "bar",
|
||||||
|
text: t("labels.arrowhead_bar"),
|
||||||
|
keyBinding: "e",
|
||||||
|
icon: <ArrowheadBarIcon flip={flip} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dot",
|
||||||
|
text: t("labels.arrowhead_circle"),
|
||||||
|
keyBinding: null,
|
||||||
|
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||||
|
showInPicker: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "circle",
|
||||||
|
text: t("labels.arrowhead_circle"),
|
||||||
|
keyBinding: "r",
|
||||||
|
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||||
|
showInPicker: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "circle_outline",
|
||||||
|
text: t("labels.arrowhead_circle_outline"),
|
||||||
|
keyBinding: null,
|
||||||
|
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||||
|
showInPicker: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "triangle",
|
value: "triangle",
|
||||||
text: t("labels.arrowhead_triangle"),
|
text: t("labels.arrowhead_triangle"),
|
||||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||||
keyBinding: "e",
|
keyBinding: "t",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "triangle_outline",
|
value: "triangle_outline",
|
||||||
text: t("labels.arrowhead_triangle_outline"),
|
text: t("labels.arrowhead_triangle_outline"),
|
||||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||||
keyBinding: "r",
|
keyBinding: null,
|
||||||
},
|
showInPicker: false,
|
||||||
{
|
|
||||||
value: "circle",
|
|
||||||
text: t("labels.arrowhead_circle"),
|
|
||||||
keyBinding: "a",
|
|
||||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "circle_outline",
|
|
||||||
text: t("labels.arrowhead_circle_outline"),
|
|
||||||
keyBinding: "s",
|
|
||||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "diamond",
|
value: "diamond",
|
||||||
text: t("labels.arrowhead_diamond"),
|
text: t("labels.arrowhead_diamond"),
|
||||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||||
keyBinding: "d",
|
keyBinding: null,
|
||||||
|
showInPicker: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "diamond_outline",
|
value: "diamond_outline",
|
||||||
text: t("labels.arrowhead_diamond_outline"),
|
text: t("labels.arrowhead_diamond_outline"),
|
||||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||||
keyBinding: "f",
|
keyBinding: null,
|
||||||
},
|
showInPicker: false,
|
||||||
{
|
|
||||||
value: "bar",
|
|
||||||
text: t("labels.arrowhead_bar"),
|
|
||||||
keyBinding: "z",
|
|
||||||
icon: <ArrowheadBarIcon flip={flip} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "crowfoot_one",
|
|
||||||
text: t("labels.arrowhead_crowfoot_one"),
|
|
||||||
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
|
||||||
keyBinding: "c",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "crowfoot_many",
|
|
||||||
text: t("labels.arrowhead_crowfoot_many"),
|
|
||||||
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
|
||||||
keyBinding: "x",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "crowfoot_one_or_many",
|
|
||||||
text: t("labels.arrowhead_crowfoot_one_or_many"),
|
|
||||||
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
|
|
||||||
keyBinding: "v",
|
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
};
|
};
|
||||||
@@ -1533,7 +1521,6 @@ export const actionChangeArrowhead = register({
|
|||||||
appState.currentItemStartArrowhead,
|
appState.currentItemStartArrowhead,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData({ position: "start", type: value })}
|
onChange={(value) => updateData({ position: "start", type: value })}
|
||||||
numberOfOptionsToAlwaysShow={4}
|
|
||||||
/>
|
/>
|
||||||
<IconPicker
|
<IconPicker
|
||||||
label="arrowhead_end"
|
label="arrowhead_end"
|
||||||
@@ -1550,7 +1537,6 @@ export const actionChangeArrowhead = register({
|
|||||||
appState.currentItemEndArrowhead,
|
appState.currentItemEndArrowhead,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData({ position: "end", type: value })}
|
onChange={(value) => updateData({ position: "end", type: value })}
|
||||||
numberOfOptionsToAlwaysShow={4}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -1563,166 +1549,150 @@ export const actionChangeArrowType = register({
|
|||||||
label: "Change arrow types",
|
label: "Change arrow types",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
const newElements = changeProperty(elements, appState, (el) => {
|
return {
|
||||||
if (!isArrowElement(el)) {
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
return el;
|
if (!isArrowElement(el)) {
|
||||||
}
|
return el;
|
||||||
const newElement = newElementWith(el, {
|
}
|
||||||
roundness:
|
const newElement = newElementWith(el, {
|
||||||
value === ARROW_TYPE.round
|
roundness:
|
||||||
? {
|
value === ARROW_TYPE.round
|
||||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
? {
|
||||||
}
|
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
: null,
|
}
|
||||||
elbowed: value === ARROW_TYPE.elbow,
|
: null,
|
||||||
points:
|
elbowed: value === ARROW_TYPE.elbow,
|
||||||
value === ARROW_TYPE.elbow || el.elbowed
|
points:
|
||||||
? [el.points[0], el.points[el.points.length - 1]]
|
value === ARROW_TYPE.elbow || el.elbowed
|
||||||
: el.points,
|
? [el.points[0], el.points[el.points.length - 1]]
|
||||||
});
|
: el.points,
|
||||||
|
|
||||||
if (isElbowArrow(newElement)) {
|
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
||||||
|
|
||||||
app.dismissLinearEditor();
|
|
||||||
|
|
||||||
const startGlobalPoint =
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
newElement,
|
|
||||||
0,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
const endGlobalPoint =
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
newElement,
|
|
||||||
-1,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
const startHoveredElement =
|
|
||||||
!newElement.startBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(startGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const endHoveredElement =
|
|
||||||
!newElement.endBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(endGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startElement = startHoveredElement
|
|
||||||
? startHoveredElement
|
|
||||||
: newElement.startBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.startBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
const endElement = endHoveredElement
|
|
||||||
? endHoveredElement
|
|
||||||
: newElement.endBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.endBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
|
|
||||||
const finalStartPoint = startHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
startGlobalPoint,
|
|
||||||
endGlobalPoint,
|
|
||||||
startHoveredElement,
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
: startGlobalPoint;
|
|
||||||
const finalEndPoint = endHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
endGlobalPoint,
|
|
||||||
startGlobalPoint,
|
|
||||||
endHoveredElement,
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
: endGlobalPoint;
|
|
||||||
|
|
||||||
startHoveredElement &&
|
|
||||||
bindLinearElement(
|
|
||||||
newElement,
|
|
||||||
startHoveredElement,
|
|
||||||
"start",
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
endHoveredElement &&
|
|
||||||
bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
|
|
||||||
|
|
||||||
mutateElement(newElement, {
|
|
||||||
points: [finalStartPoint, finalEndPoint].map(
|
|
||||||
(p): LocalPoint =>
|
|
||||||
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
|
||||||
),
|
|
||||||
...(startElement && newElement.startBinding
|
|
||||||
? {
|
|
||||||
startBinding: {
|
|
||||||
// @ts-ignore TS cannot discern check above
|
|
||||||
...newElement.startBinding!,
|
|
||||||
...calculateFixedPointForElbowArrowBinding(
|
|
||||||
newElement,
|
|
||||||
startElement,
|
|
||||||
"start",
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(endElement && newElement.endBinding
|
|
||||||
? {
|
|
||||||
endBinding: {
|
|
||||||
// @ts-ignore TS cannot discern check above
|
|
||||||
...newElement.endBinding,
|
|
||||||
...calculateFixedPointForElbowArrowBinding(
|
|
||||||
newElement,
|
|
||||||
endElement,
|
|
||||||
"end",
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
LinearElementEditor.updateEditorMidPointsCache(
|
if (isElbowArrow(newElement)) {
|
||||||
newElement,
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
elementsMap,
|
|
||||||
app.state,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newElement;
|
app.dismissLinearEditor();
|
||||||
});
|
|
||||||
|
|
||||||
const newState = {
|
const startGlobalPoint =
|
||||||
...appState,
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
currentItemArrowType: value,
|
newElement,
|
||||||
};
|
0,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const endGlobalPoint =
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
newElement,
|
||||||
|
-1,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const startHoveredElement =
|
||||||
|
!newElement.startBinding &&
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(startGlobalPoint),
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const endHoveredElement =
|
||||||
|
!newElement.endBinding &&
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(endGlobalPoint),
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const startElement = startHoveredElement
|
||||||
|
? startHoveredElement
|
||||||
|
: newElement.startBinding &&
|
||||||
|
(elementsMap.get(
|
||||||
|
newElement.startBinding.elementId,
|
||||||
|
) as ExcalidrawBindableElement);
|
||||||
|
const endElement = endHoveredElement
|
||||||
|
? endHoveredElement
|
||||||
|
: newElement.endBinding &&
|
||||||
|
(elementsMap.get(
|
||||||
|
newElement.endBinding.elementId,
|
||||||
|
) as ExcalidrawBindableElement);
|
||||||
|
|
||||||
// Change the arrow type and update any other state settings for
|
const finalStartPoint = startHoveredElement
|
||||||
// the arrow.
|
? bindPointToSnapToElementOutline(
|
||||||
const selectedId = appState.selectedLinearElement?.elementId;
|
startGlobalPoint,
|
||||||
if (selectedId) {
|
endGlobalPoint,
|
||||||
const selected = newElements.find((el) => el.id === selectedId);
|
startHoveredElement,
|
||||||
if (selected) {
|
elementsMap,
|
||||||
newState.selectedLinearElement = new LinearElementEditor(
|
)
|
||||||
selected as ExcalidrawLinearElement,
|
: startGlobalPoint;
|
||||||
);
|
const finalEndPoint = endHoveredElement
|
||||||
}
|
? bindPointToSnapToElementOutline(
|
||||||
}
|
endGlobalPoint,
|
||||||
|
startGlobalPoint,
|
||||||
|
endHoveredElement,
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
|
: endGlobalPoint;
|
||||||
|
|
||||||
return {
|
startHoveredElement &&
|
||||||
elements: newElements,
|
bindLinearElement(
|
||||||
appState: newState,
|
newElement,
|
||||||
|
startHoveredElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
endHoveredElement &&
|
||||||
|
bindLinearElement(
|
||||||
|
newElement,
|
||||||
|
endHoveredElement,
|
||||||
|
"end",
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
mutateElbowArrow(
|
||||||
|
newElement,
|
||||||
|
elementsMap,
|
||||||
|
[finalStartPoint, finalEndPoint].map(
|
||||||
|
(p): LocalPoint =>
|
||||||
|
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||||
|
),
|
||||||
|
vector(0, 0),
|
||||||
|
{
|
||||||
|
...(startElement && newElement.startBinding
|
||||||
|
? {
|
||||||
|
startBinding: {
|
||||||
|
// @ts-ignore TS cannot discern check above
|
||||||
|
...newElement.startBinding!,
|
||||||
|
...calculateFixedPointForElbowArrowBinding(
|
||||||
|
newElement,
|
||||||
|
startElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(endElement && newElement.endBinding
|
||||||
|
? {
|
||||||
|
endBinding: {
|
||||||
|
// @ts-ignore TS cannot discern check above
|
||||||
|
...newElement.endBinding,
|
||||||
|
...calculateFixedPointForElbowArrowBinding(
|
||||||
|
newElement,
|
||||||
|
endElement,
|
||||||
|
"end",
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newElement;
|
||||||
|
}),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
currentItemArrowType: value,
|
||||||
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
|||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||||
import { selectAllIcon } from "../components/icons";
|
import { selectAllIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
@@ -19,17 +20,17 @@ export const actionSelectAll = register({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedElementIds = elements
|
const selectedElementIds = excludeElementsInFramesFromSelection(
|
||||||
.filter(
|
elements.filter(
|
||||||
(element) =>
|
(element) =>
|
||||||
!element.isDeleted &&
|
!element.isDeleted &&
|
||||||
!(isTextElement(element) && element.containerId) &&
|
!(isTextElement(element) && element.containerId) &&
|
||||||
!element.locked,
|
!element.locked,
|
||||||
)
|
),
|
||||||
.reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
map[element.id] = true;
|
map[element.id] = true;
|
||||||
return map;
|
return map;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { measureText } from "../element/textMeasurements";
|
import { measureText } from "../element/textElement";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import type { AppClassProperties } from "../types";
|
import type { AppClassProperties } from "../types";
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export type ShortcutName =
|
|||||||
| "saveFileToDisk"
|
| "saveFileToDisk"
|
||||||
| "saveToActiveFile"
|
| "saveToActiveFile"
|
||||||
| "toggleShortcuts"
|
| "toggleShortcuts"
|
||||||
| "wrapSelectionInFrame"
|
|
||||||
>
|
>
|
||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "imageExport"
|
| "imageExport"
|
||||||
@@ -113,7 +112,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||||
toggleShortcuts: [getShortcutKey("?")],
|
toggleShortcuts: [getShortcutKey("?")],
|
||||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||||
wrapSelectionInFrame: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||||
|
|||||||
@@ -135,10 +135,7 @@ export type ActionName =
|
|||||||
| "autoResize"
|
| "autoResize"
|
||||||
| "elementStats"
|
| "elementStats"
|
||||||
| "searchMenu"
|
| "searchMenu"
|
||||||
| "copyElementLink"
|
| "cropEditor";
|
||||||
| "linkToElement"
|
|
||||||
| "cropEditor"
|
|
||||||
| "wrapSelectionInFrame";
|
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||||
import { mutateElement } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import type { BoundingBox } from "./element/bounds";
|
import type { BoundingBox } from "./element/bounds";
|
||||||
import { getCommonBoundingBox } from "./element/bounds";
|
import { getCommonBoundingBox } from "./element/bounds";
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getMaximumGroups } from "./groups";
|
||||||
import { updateBoundElements } from "./element/binding";
|
|
||||||
import type Scene from "./scene/Scene";
|
|
||||||
|
|
||||||
export interface Alignment {
|
export interface Alignment {
|
||||||
position: "start" | "center" | "end";
|
position: "start" | "center" | "end";
|
||||||
@@ -15,7 +13,6 @@ export const alignElements = (
|
|||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
scene: Scene,
|
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
@@ -29,18 +26,12 @@ export const alignElements = (
|
|||||||
selectionBoundingBox,
|
selectionBoundingBox,
|
||||||
alignment,
|
alignment,
|
||||||
);
|
);
|
||||||
return group.map((element) => {
|
return group.map((element) =>
|
||||||
// update element
|
newElementWith(element, {
|
||||||
const updatedEle = mutateElement(element, {
|
|
||||||
x: element.x + translation.x,
|
x: element.x + translation.x,
|
||||||
y: element.y + translation.y,
|
y: element.y + translation.y,
|
||||||
});
|
}),
|
||||||
// update bound elements
|
);
|
||||||
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
|
|
||||||
simultaneouslyUpdated: group,
|
|
||||||
});
|
|
||||||
return updatedEle;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { COLOR_PALETTE } from "./colors";
|
|
||||||
import {
|
import {
|
||||||
ARROW_TYPE,
|
ARROW_TYPE,
|
||||||
|
COLOR_WHITE,
|
||||||
DEFAULT_ELEMENT_PROPS,
|
DEFAULT_ELEMENT_PROPS,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
DEFAULT_GRID_SIZE,
|
DEFAULT_GRID_SIZE,
|
||||||
|
DEFAULT_ZOOM_VALUE,
|
||||||
EXPORT_SCALES,
|
EXPORT_SCALES,
|
||||||
STATS_PANELS,
|
STATS_PANELS,
|
||||||
THEME,
|
THEME,
|
||||||
DEFAULT_GRID_STEP,
|
DEFAULT_GRID_STEP,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import type { AppState, NormalizedZoomValue } from "./types";
|
import type { AppState } from "./types";
|
||||||
|
|
||||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||||
? devicePixelRatio
|
? devicePixelRatio
|
||||||
@@ -84,7 +85,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
scrollX: 0,
|
scrollX: 0,
|
||||||
scrollY: 0,
|
scrollY: 0,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
hoveredElementIds: {},
|
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedElementsAreBeingDragged: false,
|
selectedElementsAreBeingDragged: false,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
@@ -100,10 +100,10 @@ export const getDefaultAppState = (): Omit<
|
|||||||
editingFrame: null,
|
editingFrame: null,
|
||||||
elementsToHighlight: null,
|
elementsToHighlight: null,
|
||||||
toast: null,
|
toast: null,
|
||||||
viewBackgroundColor: COLOR_PALETTE.white,
|
viewBackgroundColor: COLOR_WHITE,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: {
|
zoom: {
|
||||||
value: 1 as NormalizedZoomValue,
|
value: DEFAULT_ZOOM_VALUE,
|
||||||
},
|
},
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
@@ -211,7 +211,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
scrollX: { browser: true, export: false, server: false },
|
scrollX: { browser: true, export: false, server: false },
|
||||||
scrollY: { browser: true, export: false, server: false },
|
scrollY: { browser: true, export: false, server: false },
|
||||||
selectedElementIds: { 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 },
|
selectedGroupIds: { browser: true, export: false, server: false },
|
||||||
selectedElementsAreBeingDragged: {
|
selectedElementsAreBeingDragged: {
|
||||||
browser: false,
|
browser: false,
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import type { Radians } from "../math";
|
import type { Radians } from "../math";
|
||||||
import { pointFrom } from "../math";
|
import { pointFrom } from "../math";
|
||||||
|
import { DEFAULT_CHART_COLOR_INDEX, getAllColorsSpecificShade } from "./colors";
|
||||||
import {
|
import {
|
||||||
COLOR_PALETTE,
|
COLOR_CHARCOAL_BLACK,
|
||||||
DEFAULT_CHART_COLOR_INDEX,
|
|
||||||
getAllColorsSpecificShade,
|
|
||||||
} from "./colors";
|
|
||||||
import {
|
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
@@ -173,7 +170,7 @@ const commonProps = {
|
|||||||
fontSize: DEFAULT_FONT_SIZE,
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
roughness: 1,
|
roughness: 1,
|
||||||
strokeColor: COLOR_PALETTE.black,
|
strokeColor: COLOR_CHARCOAL_BLACK,
|
||||||
roundness: null,
|
roundness: null,
|
||||||
strokeStyle: "solid",
|
strokeStyle: "solid",
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
@@ -324,7 +321,7 @@ const chartBaseElements = (
|
|||||||
y: y - chartHeight,
|
y: y - chartHeight,
|
||||||
width: chartWidth,
|
width: chartWidth,
|
||||||
height: chartHeight,
|
height: chartHeight,
|
||||||
strokeColor: COLOR_PALETTE.black,
|
strokeColor: COLOR_CHARCOAL_BLACK,
|
||||||
fillStyle: "solid",
|
fillStyle: "solid",
|
||||||
opacity: 6,
|
opacity: 6,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import { deepCopyElement } from "./element/newElement";
|
|||||||
import { mutateElement } from "./element/mutateElement";
|
import { mutateElement } from "./element/mutateElement";
|
||||||
import { getContainingFrame } from "./frame";
|
import { getContainingFrame } from "./frame";
|
||||||
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
||||||
import { createFile, isSupportedImageFileType } from "./data/blob";
|
|
||||||
import { ExcalidrawError } from "./errors";
|
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
@@ -41,7 +39,7 @@ export interface ClipboardData {
|
|||||||
|
|
||||||
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
||||||
|
|
||||||
type ParsedClipboardEventTextData =
|
type ParsedClipboardEvent =
|
||||||
| { type: "text"; value: string }
|
| { type: "text"; value: string }
|
||||||
| { type: "mixedContent"; value: PastedMixedContent };
|
| { type: "mixedContent"; value: PastedMixedContent };
|
||||||
|
|
||||||
@@ -77,7 +75,7 @@ export const createPasteEvent = ({
|
|||||||
types,
|
types,
|
||||||
files,
|
files,
|
||||||
}: {
|
}: {
|
||||||
types?: { [key in AllowedPasteMimeTypes]?: string | File };
|
types?: { [key in AllowedPasteMimeTypes]?: string };
|
||||||
files?: File[];
|
files?: File[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!types && !files) {
|
if (!types && !files) {
|
||||||
@@ -90,11 +88,6 @@ export const createPasteEvent = ({
|
|||||||
|
|
||||||
if (types) {
|
if (types) {
|
||||||
for (const [type, value] of Object.entries(types)) {
|
for (const [type, value] of Object.entries(types)) {
|
||||||
if (typeof value !== "string") {
|
|
||||||
files = files || [];
|
|
||||||
files.push(value);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
event.clipboardData?.setData(type, value);
|
event.clipboardData?.setData(type, value);
|
||||||
if (event.clipboardData?.getData(type) !== value) {
|
if (event.clipboardData?.getData(type) !== value) {
|
||||||
@@ -224,14 +217,14 @@ function parseHTMLTree(el: ChildNode) {
|
|||||||
const maybeParseHTMLPaste = (
|
const maybeParseHTMLPaste = (
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||||
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
const html = event.clipboardData?.getData("text/html");
|
||||||
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
|
||||||
const content = parseHTMLTree(doc.body);
|
const content = parseHTMLTree(doc.body);
|
||||||
|
|
||||||
@@ -245,44 +238,34 @@ const maybeParseHTMLPaste = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads OS clipboard programmatically. May not work on all browsers.
|
|
||||||
* Will prompt user for permission if not granted.
|
|
||||||
*/
|
|
||||||
export const readSystemClipboard = async () => {
|
export const readSystemClipboard = async () => {
|
||||||
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
|
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.readText) {
|
||||||
|
return { "text/plain": await navigator.clipboard?.readText() };
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// @ts-ignore
|
||||||
|
if (navigator.clipboard?.read) {
|
||||||
|
console.warn(
|
||||||
|
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let clipboardItems: ClipboardItems;
|
let clipboardItems: ClipboardItems;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
clipboardItems = await navigator.clipboard?.read();
|
clipboardItems = await navigator.clipboard?.read();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
try {
|
if (error.name === "DataError") {
|
||||||
if (navigator.clipboard?.readText) {
|
console.warn(
|
||||||
console.warn(
|
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
|
||||||
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
|
);
|
||||||
);
|
return types;
|
||||||
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;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -293,20 +276,10 @@ export const readSystemClipboard = async () => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
|
types[type] = await (await item.getType(type)).text();
|
||||||
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) {
|
} catch (error: any) {
|
||||||
console.warn(
|
console.warn(
|
||||||
error instanceof ExcalidrawError
|
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
||||||
? error.message
|
|
||||||
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,10 +296,10 @@ export const readSystemClipboard = async () => {
|
|||||||
/**
|
/**
|
||||||
* Parses "paste" ClipboardEvent.
|
* Parses "paste" ClipboardEvent.
|
||||||
*/
|
*/
|
||||||
const parseClipboardEventTextData = async (
|
const parseClipboardEvent = async (
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ParsedClipboardEventTextData> => {
|
): Promise<ParsedClipboardEvent> => {
|
||||||
try {
|
try {
|
||||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
||||||
|
|
||||||
@@ -335,7 +308,7 @@ const parseClipboardEventTextData = async (
|
|||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
value:
|
||||||
event.clipboardData?.getData(MIME_TYPES.text) ||
|
event.clipboardData?.getData("text/plain") ||
|
||||||
mixedContent.value
|
mixedContent.value
|
||||||
.map((item) => item.value)
|
.map((item) => item.value)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@@ -346,7 +319,7 @@ const parseClipboardEventTextData = async (
|
|||||||
return mixedContent;
|
return mixedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
const text = event.clipboardData?.getData("text/plain");
|
||||||
|
|
||||||
return { type: "text", value: (text || "").trim() };
|
return { type: "text", value: (text || "").trim() };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -355,16 +328,13 @@ const parseClipboardEventTextData = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to parse clipboard event.
|
* Attempts to parse clipboard. Prefers system clipboard.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const parsedEventData = await parseClipboardEventTextData(
|
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
|
||||||
event,
|
|
||||||
isPlainPaste,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (parsedEventData.type === "mixedContent") {
|
if (parsedEventData.type === "mixedContent") {
|
||||||
return {
|
return {
|
||||||
@@ -453,8 +423,8 @@ export const copyTextToSystemClipboard = async (
|
|||||||
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
|
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
|
||||||
try {
|
try {
|
||||||
if (clipboardEvent) {
|
if (clipboardEvent) {
|
||||||
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
|
clipboardEvent.clipboardData?.setData("text/plain", text || "");
|
||||||
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
|
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
|
||||||
throw new Error("Failed to setData on clipboardEvent");
|
throw new Error("Failed to setData on clipboardEvent");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import type { Merge } from "./utility-types";
|
import {
|
||||||
|
COLOR_WHITE,
|
||||||
// FIXME can't put to utils.ts rn because of circular dependency
|
COLOR_CHARCOAL_BLACK,
|
||||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
COLOR_TRANSPARENT,
|
||||||
source: R,
|
} from "./constants";
|
||||||
keys: K,
|
import { type Merge } from "./utility-types";
|
||||||
) => {
|
import { pick } from "./utils";
|
||||||
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 =
|
export type ColorPickerColor =
|
||||||
| Exclude<keyof oc, "indigo" | "lime">
|
| Exclude<keyof oc, "indigo" | "lime" | "black">
|
||||||
| "transparent"
|
| "transparent"
|
||||||
|
| "charcoal"
|
||||||
| "bronze";
|
| "bronze";
|
||||||
export type ColorTuple = readonly [string, string, string, string, string];
|
export type ColorTuple = readonly [string, string, string, string, string];
|
||||||
export type ColorPalette = Merge<
|
export type ColorPalette = Merge<
|
||||||
Record<ColorPickerColor, ColorTuple>,
|
Record<ColorPickerColor, ColorTuple>,
|
||||||
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
|
{
|
||||||
|
charcoal: typeof COLOR_CHARCOAL_BLACK;
|
||||||
|
white: typeof COLOR_WHITE;
|
||||||
|
transparent: typeof COLOR_TRANSPARENT;
|
||||||
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// used general type instead of specific type (ColorPalette) to support custom colors
|
// used general type instead of specific type (ColorPalette) to support custom colors
|
||||||
@@ -41,7 +39,7 @@ export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
|
|||||||
export const getSpecificColorShades = (
|
export const getSpecificColorShades = (
|
||||||
color: Exclude<
|
color: Exclude<
|
||||||
ColorPickerColor,
|
ColorPickerColor,
|
||||||
"transparent" | "white" | "black" | "bronze"
|
"transparent" | "charcoal" | "black" | "white" | "bronze"
|
||||||
>,
|
>,
|
||||||
indexArr: Readonly<ColorShadesIndexes>,
|
indexArr: Readonly<ColorShadesIndexes>,
|
||||||
) => {
|
) => {
|
||||||
@@ -49,9 +47,9 @@ export const getSpecificColorShades = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const COLOR_PALETTE = {
|
export const COLOR_PALETTE = {
|
||||||
transparent: "transparent",
|
transparent: COLOR_TRANSPARENT,
|
||||||
black: "#1e1e1e",
|
charcoal: COLOR_CHARCOAL_BLACK,
|
||||||
white: "#ffffff",
|
white: COLOR_WHITE,
|
||||||
// open-colors
|
// open-colors
|
||||||
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
|
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||||
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
|
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||||
@@ -87,7 +85,7 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
|||||||
|
|
||||||
// ORDER matters for positioning in quick picker
|
// ORDER matters for positioning in quick picker
|
||||||
export const DEFAULT_ELEMENT_STROKE_PICKS = [
|
export const DEFAULT_ELEMENT_STROKE_PICKS = [
|
||||||
COLOR_PALETTE.black,
|
COLOR_PALETTE.charcoal,
|
||||||
COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||||
COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||||
COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||||
@@ -125,7 +123,7 @@ export const DEFAULT_ELEMENT_STROKE_COLOR_PALETTE = {
|
|||||||
transparent: COLOR_PALETTE.transparent,
|
transparent: COLOR_PALETTE.transparent,
|
||||||
white: COLOR_PALETTE.white,
|
white: COLOR_PALETTE.white,
|
||||||
gray: COLOR_PALETTE.gray,
|
gray: COLOR_PALETTE.gray,
|
||||||
black: COLOR_PALETTE.black,
|
charcoal: COLOR_PALETTE.charcoal,
|
||||||
bronze: COLOR_PALETTE.bronze,
|
bronze: COLOR_PALETTE.bronze,
|
||||||
// rest
|
// rest
|
||||||
...COMMON_ELEMENT_SHADES,
|
...COMMON_ELEMENT_SHADES,
|
||||||
@@ -136,7 +134,7 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
|
|||||||
transparent: COLOR_PALETTE.transparent,
|
transparent: COLOR_PALETTE.transparent,
|
||||||
white: COLOR_PALETTE.white,
|
white: COLOR_PALETTE.white,
|
||||||
gray: COLOR_PALETTE.gray,
|
gray: COLOR_PALETTE.gray,
|
||||||
black: COLOR_PALETTE.black,
|
charcoal: COLOR_PALETTE.charcoal,
|
||||||
bronze: COLOR_PALETTE.bronze,
|
bronze: COLOR_PALETTE.bronze,
|
||||||
|
|
||||||
...COMMON_ELEMENT_SHADES,
|
...COMMON_ELEMENT_SHADES,
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import {
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
import { CLASSES } from "../constants";
|
import { CLASSES } from "../constants";
|
||||||
import { alignActionsPredicate } from "../actions/actionAlign";
|
|
||||||
|
|
||||||
export const canChangeStrokeColor = (
|
export const canChangeStrokeColor = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
@@ -91,12 +90,10 @@ export const SelectedShapeActions = ({
|
|||||||
appState,
|
appState,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
renderAction,
|
renderAction,
|
||||||
app,
|
|
||||||
}: {
|
}: {
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
app: AppClassProperties;
|
|
||||||
}) => {
|
}) => {
|
||||||
const targetElements = getTargetElements(elementsMap, appState);
|
const targetElements = getTargetElements(elementsMap, appState);
|
||||||
|
|
||||||
@@ -136,9 +133,6 @@ export const SelectedShapeActions = ({
|
|||||||
targetElements.length === 1 &&
|
targetElements.length === 1 &&
|
||||||
isImageElement(targetElements[0]);
|
isImageElement(targetElements[0]);
|
||||||
|
|
||||||
const showAlignActions =
|
|
||||||
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
<div>
|
<div>
|
||||||
@@ -206,7 +200,7 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{showAlignActions && !isSingleElementBoundContainer && (
|
{targetElements.length > 1 && !isSingleElementBoundContainer && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.align")}</legend>
|
<legend>{t("labels.align")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { atom, useAtom } from "jotai";
|
||||||
import { actionClearCanvas } from "../actions";
|
import { actionClearCanvas } from "../actions";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { atom, useAtom } from "../editor-jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import { useExcalidrawActionManager } from "./App";
|
import { useExcalidrawActionManager } from "./App";
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
|
|||||||
export const ActiveConfirmDialog = () => {
|
export const ActiveConfirmDialog = () => {
|
||||||
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
|
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
|
||||||
activeConfirmDialogAtom,
|
activeConfirmDialogAtom,
|
||||||
|
jotaiScope,
|
||||||
);
|
);
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getColor } from "./ColorPicker";
|
import { getColor } from "./ColorPicker";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import type { ColorPickerType } from "./colorPickerUtils";
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
import { eyeDropperIcon } from "../icons";
|
import { eyeDropperIcon } from "../icons";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { jotaiScope } from "../../jotai";
|
||||||
import { KEYS } from "../../keys";
|
import { KEYS } from "../../keys";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -56,7 +57,10 @@ export const ColorInput = ({
|
|||||||
}
|
}
|
||||||
}, [activeSection]);
|
}, [activeSection]);
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||||
|
activeEyeDropperAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TopPicks } from "./TopPicks";
|
|||||||
import { ButtonSeparator } from "../ButtonSeparator";
|
import { ButtonSeparator } from "../ButtonSeparator";
|
||||||
import { Picker } from "./Picker";
|
import { Picker } from "./Picker";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import type { ColorPickerType } from "./colorPickerUtils";
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
import { useExcalidrawContainer } from "../App";
|
import { useExcalidrawContainer } from "../App";
|
||||||
@@ -14,7 +15,7 @@ import PickerHeading from "./PickerHeading";
|
|||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { jotaiScope } from "../../jotai";
|
||||||
import { ColorInput } from "./ColorInput";
|
import { ColorInput } from "./ColorInput";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import { PropertiesPopover } from "../PropertiesPopover";
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
@@ -75,7 +76,10 @@ const ColorPickerPopupContent = ({
|
|||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||||
|
activeEyeDropperAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
|
|
||||||
const colorInputJSX = (
|
const colorInputJSX = (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
import HotkeyLabel from "./HotkeyLabel";
|
import HotkeyLabel from "./HotkeyLabel";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ExcalidrawElement } from "../../element/types";
|
|||||||
import { ShadeList } from "./ShadeList";
|
import { ShadeList } from "./ShadeList";
|
||||||
|
|
||||||
import PickerColorList from "./PickerColorList";
|
import PickerColorList from "./PickerColorList";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { useAtom } from "jotai";
|
||||||
import { CustomColorList } from "./CustomColorList";
|
import { CustomColorList } from "./CustomColorList";
|
||||||
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
||||||
import PickerHeading from "./PickerHeading";
|
import PickerHeading from "./PickerHeading";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
|
import { atom } from "jotai";
|
||||||
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
|
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
|
||||||
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
|
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
|
||||||
import { atom } from "../../editor-jotai";
|
|
||||||
|
|
||||||
export const getColorNameAndShadeFromColor = ({
|
export const getColorNameAndShadeFromColor = ({
|
||||||
palette,
|
palette,
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export const colorPickerKeyNavHandler = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!baseColorName) {
|
if (!baseColorName) {
|
||||||
onChange(COLOR_PALETTE.black);
|
onChange(COLOR_PALETTE.charcoal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
getShortcutKey,
|
getShortcutKey,
|
||||||
isWritableElement,
|
isWritableElement,
|
||||||
} from "../../utils";
|
} from "../../utils";
|
||||||
import { atom, useAtom, editorJotaiStore } from "../../editor-jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { deburr } from "../../deburr";
|
import { deburr } from "../../deburr";
|
||||||
import type { MarkRequired } from "../../utility-types";
|
import type { MarkRequired } from "../../utility-types";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
actionLink,
|
actionLink,
|
||||||
actionToggleSearchMenu,
|
actionToggleSearchMenu,
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
|
import { jotaiStore } from "../../jotai";
|
||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import type { CommandPaletteItem } from "./types";
|
import type { CommandPaletteItem } from "./types";
|
||||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||||
@@ -55,10 +56,6 @@ import { trackEvent } from "../../analytics";
|
|||||||
import { useStable } from "../../hooks/useStable";
|
import { useStable } from "../../hooks/useStable";
|
||||||
|
|
||||||
import "./CommandPalette.scss";
|
import "./CommandPalette.scss";
|
||||||
import {
|
|
||||||
actionCopyElementLink,
|
|
||||||
actionLinkToElement,
|
|
||||||
} from "../../actions/actionElementLink";
|
|
||||||
|
|
||||||
const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null);
|
const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null);
|
||||||
|
|
||||||
@@ -262,7 +259,6 @@ function CommandPaletteInner({
|
|||||||
actionManager.actions.cut,
|
actionManager.actions.cut,
|
||||||
actionManager.actions.copy,
|
actionManager.actions.copy,
|
||||||
actionManager.actions.deleteSelectedElements,
|
actionManager.actions.deleteSelectedElements,
|
||||||
actionManager.actions.wrapSelectionInFrame,
|
|
||||||
actionManager.actions.copyStyles,
|
actionManager.actions.copyStyles,
|
||||||
actionManager.actions.pasteStyles,
|
actionManager.actions.pasteStyles,
|
||||||
actionManager.actions.bringToFront,
|
actionManager.actions.bringToFront,
|
||||||
@@ -285,8 +281,6 @@ function CommandPaletteInner({
|
|||||||
actionManager.actions.toggleLinearEditor,
|
actionManager.actions.toggleLinearEditor,
|
||||||
actionManager.actions.cropEditor,
|
actionManager.actions.cropEditor,
|
||||||
actionLink,
|
actionLink,
|
||||||
actionCopyElementLink,
|
|
||||||
actionLinkToElement,
|
|
||||||
].map((action: Action) =>
|
].map((action: Action) =>
|
||||||
actionToCommand(
|
actionToCommand(
|
||||||
action,
|
action,
|
||||||
@@ -348,7 +342,7 @@ function CommandPaletteInner({
|
|||||||
keywords: ["delete", "destroy"],
|
keywords: ["delete", "destroy"],
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: () => {
|
perform: () => {
|
||||||
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { flushSync } from "react-dom";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { DialogProps } from "./Dialog";
|
import type { DialogProps } from "./Dialog";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
|
|
||||||
import "./ConfirmDialog.scss";
|
import "./ConfirmDialog.scss";
|
||||||
import DialogActionButton from "./DialogActionButton";
|
import DialogActionButton from "./DialogActionButton";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||||
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
|
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
|
||||||
import { useSetAtom } from "../editor-jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
|
|
||||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@@ -26,7 +26,7 @@ const ConfirmDialog = (props: Props) => {
|
|||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,14 +43,7 @@ const ConfirmDialog = (props: Props) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppState({ openMenu: null });
|
setAppState({ openMenu: null });
|
||||||
setIsLibraryMenuOpen(false);
|
setIsLibraryMenuOpen(false);
|
||||||
// flush any pending updates synchronously,
|
onCancel();
|
||||||
// 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();
|
container?.focus();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -59,14 +52,7 @@ const ConfirmDialog = (props: Props) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppState({ openMenu: null });
|
setAppState({ openMenu: null });
|
||||||
setIsLibraryMenuOpen(false);
|
setIsLibraryMenuOpen(false);
|
||||||
// flush any pending updates synchronously,
|
onConfirm();
|
||||||
// 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();
|
container?.focus();
|
||||||
}}
|
}}
|
||||||
actionType="danger"
|
actionType="danger"
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import "./Dialog.scss";
|
|||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { queryFocusableElements } from "../utils";
|
import { queryFocusableElements } from "../utils";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||||
import { useSetAtom } from "../editor-jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CloseIcon } from "./icons";
|
import { CloseIcon } from "./icons";
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
}, [islandNode, props.autofocus]);
|
}, [islandNode, props.autofocus]);
|
||||||
|
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
setAppState({ openMenu: null });
|
setAppState({ openMenu: null });
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import { TextField } from "./TextField";
|
|
||||||
import type { AppProps, AppState, UIAppState } from "../types";
|
|
||||||
import DialogActionButton from "./DialogActionButton";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import {
|
|
||||||
defaultGetElementLinkFromSelection,
|
|
||||||
getLinkIdAndTypeFromSelection,
|
|
||||||
} from "../element/elementLink";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "../element/types";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { TrashIcon } from "./icons";
|
|
||||||
import { KEYS } from "../keys";
|
|
||||||
|
|
||||||
import "./ElementLinkDialog.scss";
|
|
||||||
import { normalizeLink } from "../data/url";
|
|
||||||
|
|
||||||
const ElementLinkDialog = ({
|
|
||||||
sourceElementId,
|
|
||||||
onClose,
|
|
||||||
elementsMap,
|
|
||||||
appState,
|
|
||||||
generateLinkForSelection = defaultGetElementLinkFromSelection,
|
|
||||||
}: {
|
|
||||||
sourceElementId: ExcalidrawElement["id"];
|
|
||||||
elementsMap: ElementsMap;
|
|
||||||
appState: UIAppState;
|
|
||||||
onClose?: () => void;
|
|
||||||
generateLinkForSelection: AppProps["generateLinkForSelection"];
|
|
||||||
}) => {
|
|
||||||
const originalLink = elementsMap.get(sourceElementId)?.link ?? null;
|
|
||||||
|
|
||||||
const [nextLink, setNextLink] = useState<string | null>(originalLink);
|
|
||||||
const [linkEdited, setLinkEdited] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const selectedElements = getSelectedElements(elementsMap, appState);
|
|
||||||
let nextLink = originalLink;
|
|
||||||
|
|
||||||
if (selectedElements.length > 0 && generateLinkForSelection) {
|
|
||||||
const idAndType = getLinkIdAndTypeFromSelection(
|
|
||||||
selectedElements,
|
|
||||||
appState as AppState,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (idAndType) {
|
|
||||||
nextLink = normalizeLink(
|
|
||||||
generateLinkForSelection(idAndType.id, idAndType.type),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setNextLink(nextLink);
|
|
||||||
}, [
|
|
||||||
elementsMap,
|
|
||||||
appState,
|
|
||||||
appState.selectedElementIds,
|
|
||||||
originalLink,
|
|
||||||
generateLinkForSelection,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleConfirm = useCallback(() => {
|
|
||||||
if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) {
|
|
||||||
const elementToLink = elementsMap.get(sourceElementId);
|
|
||||||
elementToLink &&
|
|
||||||
mutateElement(elementToLink, {
|
|
||||||
link: nextLink,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nextLink && linkEdited && sourceElementId) {
|
|
||||||
const elementToLink = elementsMap.get(sourceElementId);
|
|
||||||
elementToLink &&
|
|
||||||
mutateElement(elementToLink, {
|
|
||||||
link: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose?.();
|
|
||||||
}, [sourceElementId, nextLink, elementsMap, linkEdited, onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (
|
|
||||||
appState.openDialog?.name === "elementLinkSelector" &&
|
|
||||||
event.key === KEYS.ENTER
|
|
||||||
) {
|
|
||||||
handleConfirm();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
appState.openDialog?.name === "elementLinkSelector" &&
|
|
||||||
event.key === KEYS.ESCAPE
|
|
||||||
) {
|
|
||||||
onClose?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [appState, onClose, handleConfirm]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ElementLinkDialog">
|
|
||||||
<div className="ElementLinkDialog__header">
|
|
||||||
<h2>{t("elementLink.title")}</h2>
|
|
||||||
<p>{t("elementLink.desc")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ElementLinkDialog__input">
|
|
||||||
<TextField
|
|
||||||
value={nextLink ?? ""}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (!linkEdited) {
|
|
||||||
setLinkEdited(true);
|
|
||||||
}
|
|
||||||
setNextLink(value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === KEYS.ENTER) {
|
|
||||||
handleConfirm();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="ElementLinkDialog__input-field"
|
|
||||||
selectOnRender
|
|
||||||
/>
|
|
||||||
|
|
||||||
{originalLink && nextLink && (
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.remove")}
|
|
||||||
aria-label={t("buttons.remove")}
|
|
||||||
label={t("buttons.remove")}
|
|
||||||
onClick={() => {
|
|
||||||
// removes the link from the input
|
|
||||||
// but doesn't update the element
|
|
||||||
|
|
||||||
// when confirmed, will remove the link from the element
|
|
||||||
setNextLink(null);
|
|
||||||
setLinkEdited(true);
|
|
||||||
}}
|
|
||||||
className="ElementLinkDialog__remove"
|
|
||||||
icon={TrashIcon}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ElementLinkDialog__actions">
|
|
||||||
<DialogActionButton
|
|
||||||
label={t("buttons.cancel")}
|
|
||||||
onClick={() => {
|
|
||||||
onClose?.();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
marginRight: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogActionButton
|
|
||||||
label={t("buttons.confirm")}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
actionType="primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ElementLinkDialog;
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { rgbToHex } from "../colors";
|
import { rgbToHex } from "../colors";
|
||||||
@@ -13,7 +14,6 @@ import { useStable } from "../hooks/useStable";
|
|||||||
import "./EyeDropper.scss";
|
import "./EyeDropper.scss";
|
||||||
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { atom } from "../editor-jotai";
|
|
||||||
|
|
||||||
export type EyeDropperProperties = {
|
export type EyeDropperProperties = {
|
||||||
keepOpenOnAlt: boolean;
|
keepOpenOnAlt: boolean;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
top: var(--editor-container-padding);
|
top: var(--editor-container-padding);
|
||||||
right: var(--editor-container-padding);
|
right: var(--editor-container-padding);
|
||||||
bottom: var(--editor-container-padding);
|
bottom: var(--editor-container-padding);
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.FixedSideContainer_side_top.zen-mode {
|
.FixedSideContainer_side_top.zen-mode {
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
@import "../css/variables.module.scss";
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
.picker-container {
|
||||||
|
display: inline-block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
padding: 0.5rem;
|
|
||||||
background: var(--popup-bg-color);
|
background: var(--popup-bg-color);
|
||||||
border: 0 solid transparentize($oc-white, 0.75);
|
border: 0 solid transparentize($oc-white, 0.75);
|
||||||
box-shadow: var(--shadow-island);
|
// ˇˇ yeah, i dunno, open to suggestions here :D
|
||||||
|
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
:root[dir="rtl"] & {
|
|
||||||
padding: 0.4rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-container button,
|
.picker-container button,
|
||||||
@@ -52,16 +55,47 @@
|
|||||||
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-content {
|
.picker-triangle {
|
||||||
display: grid;
|
width: 0;
|
||||||
grid-template-columns: repeat(4, auto);
|
height: 0;
|
||||||
grid-gap: 0.5rem;
|
position: relative;
|
||||||
border-radius: 4px;
|
top: -10px;
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0 9px 10px;
|
||||||
|
border-color: transparent transparent transparentize($oc-black, 0.9);
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0 9px 10px;
|
||||||
|
border-color: transparent transparent var(--popup-bg-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-collapsible {
|
.picker-content {
|
||||||
font-size: 0.75rem;
|
padding: 0.5rem;
|
||||||
padding: 0.5rem 0;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, auto);
|
||||||
|
grid-gap: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-keybinding {
|
.picker-keybinding {
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import { Popover } from "./Popover";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
|
||||||
import { getLanguage, t } from "../i18n";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Collapsible from "./Stats/Collapsible";
|
|
||||||
import { atom, useAtom } from "../editor-jotai";
|
|
||||||
import { useDevice } from "./App";
|
|
||||||
|
|
||||||
import "./IconPicker.scss";
|
import "./IconPicker.scss";
|
||||||
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
const moreOptionsAtom = atom(false);
|
import { getLanguage } from "../i18n";
|
||||||
|
import clsx from "clsx";
|
||||||
type Option<T> = {
|
|
||||||
value: T;
|
|
||||||
text: string;
|
|
||||||
icon: JSX.Element;
|
|
||||||
keyBinding: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Picker<T>({
|
function Picker<T>({
|
||||||
options,
|
options,
|
||||||
@@ -24,16 +12,30 @@ function Picker<T>({
|
|||||||
label,
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
numberOfOptionsToAlwaysShow = options.length,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
options: readonly Option<T>[];
|
options: {
|
||||||
|
value: T;
|
||||||
|
text: string;
|
||||||
|
icon: JSX.Element;
|
||||||
|
keyBinding: string | null;
|
||||||
|
}[];
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
numberOfOptionsToAlwaysShow?: number;
|
|
||||||
}) {
|
}) {
|
||||||
const device = useDevice();
|
const rFirstItem = React.useRef<HTMLButtonElement>();
|
||||||
|
const rActiveItem = React.useRef<HTMLButtonElement>();
|
||||||
|
const rGallery = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// After the component is first mounted focus on first input
|
||||||
|
if (rActiveItem.current) {
|
||||||
|
rActiveItem.current.focus();
|
||||||
|
} else if (rGallery.current) {
|
||||||
|
rGallery.current.focus();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
const pressedOption = options.find(
|
const pressedOption = options.find(
|
||||||
@@ -42,19 +44,28 @@ function Picker<T>({
|
|||||||
|
|
||||||
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
||||||
// Keybinding navigation
|
// Keybinding navigation
|
||||||
onChange(pressedOption.value);
|
const index = options.indexOf(pressedOption);
|
||||||
|
(rGallery!.current!.children![index] as any).focus();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.TAB) {
|
} else if (event.key === KEYS.TAB) {
|
||||||
const index = options.findIndex((option) => option.value === value);
|
// Tab navigation cycle through options. If the user tabs
|
||||||
const nextIndex = event.shiftKey
|
// away from the picker, close the picker. We need to use
|
||||||
? (options.length + index - 1) % options.length
|
// a timeout here to let the stack clear before checking.
|
||||||
: (index + 1) % options.length;
|
setTimeout(() => {
|
||||||
onChange(options[nextIndex].value);
|
const active = rActiveItem.current;
|
||||||
|
const docActive = document.activeElement;
|
||||||
|
if (active !== docActive) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
} else if (isArrowKey(event.key)) {
|
} else if (isArrowKey(event.key)) {
|
||||||
// Arrow navigation
|
// Arrow navigation
|
||||||
|
const { activeElement } = document;
|
||||||
const isRTL = getLanguage().rtl;
|
const isRTL = getLanguage().rtl;
|
||||||
const index = options.findIndex((option) => option.value === value);
|
const index = Array.prototype.indexOf.call(
|
||||||
|
rGallery!.current!.children,
|
||||||
|
activeElement,
|
||||||
|
);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const length = options.length;
|
const length = options.length;
|
||||||
let nextIndex = index;
|
let nextIndex = index;
|
||||||
@@ -62,26 +73,19 @@ function Picker<T>({
|
|||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
// Select the next option
|
// Select the next option
|
||||||
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
||||||
|
case KEYS.ARROW_DOWN: {
|
||||||
nextIndex = (index + 1) % length;
|
nextIndex = (index + 1) % length;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
// Select the previous option
|
// Select the previous option
|
||||||
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
||||||
nextIndex = (length + index - 1) % length;
|
|
||||||
break;
|
|
||||||
// Go the next row
|
|
||||||
case KEYS.ARROW_DOWN: {
|
|
||||||
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Go the previous row
|
|
||||||
case KEYS.ARROW_UP: {
|
case KEYS.ARROW_UP: {
|
||||||
nextIndex =
|
nextIndex = (length + index - 1) % length;
|
||||||
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(options[nextIndex].value);
|
(rGallery.current!.children![nextIndex] as any).focus();
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||||
@@ -93,26 +97,15 @@ function Picker<T>({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
|
return (
|
||||||
|
<div
|
||||||
const alwaysVisibleOptions = React.useMemo(
|
className={`picker`}
|
||||||
() => options.slice(0, numberOfOptionsToAlwaysShow),
|
role="dialog"
|
||||||
[options, numberOfOptionsToAlwaysShow],
|
aria-modal="true"
|
||||||
);
|
aria-label={label}
|
||||||
const moreOptions = React.useMemo(
|
onKeyDown={handleKeyDown}
|
||||||
() => options.slice(numberOfOptionsToAlwaysShow),
|
>
|
||||||
[options, numberOfOptionsToAlwaysShow],
|
<div className="picker-content" ref={rGallery}>
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
|
|
||||||
setShowMoreOptions(true);
|
|
||||||
}
|
|
||||||
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
|
|
||||||
|
|
||||||
const renderOptions = (options: Option<T>[]) => {
|
|
||||||
return (
|
|
||||||
<div className="picker-content">
|
|
||||||
{options.map((option, i) => (
|
{options.map((option, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -120,6 +113,7 @@ function Picker<T>({
|
|||||||
active: value === option.value,
|
active: value === option.value,
|
||||||
})}
|
})}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
(event.currentTarget as HTMLButtonElement).focus();
|
||||||
onChange(option.value);
|
onChange(option.value);
|
||||||
}}
|
}}
|
||||||
title={`${option.text} ${
|
title={`${option.text} ${
|
||||||
@@ -128,13 +122,16 @@ function Picker<T>({
|
|||||||
aria-label={option.text || "none"}
|
aria-label={option.text || "none"}
|
||||||
aria-keyshortcuts={option.keyBinding || undefined}
|
aria-keyshortcuts={option.keyBinding || undefined}
|
||||||
key={option.text}
|
key={option.text}
|
||||||
ref={(ref) => {
|
ref={(el) => {
|
||||||
if (value === option.value) {
|
if (el && i === 0) {
|
||||||
// Use a timeout here to render focus properly
|
rFirstItem.current = el;
|
||||||
setTimeout(() => {
|
|
||||||
ref?.focus();
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
if (el && option.value === value) {
|
||||||
|
rActiveItem.current = el;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
onChange(option.value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
@@ -144,43 +141,7 @@ function Picker<T>({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover.Content
|
|
||||||
side={
|
|
||||||
device.editor.isMobile && !device.viewport.isLandscape
|
|
||||||
? "top"
|
|
||||||
: "bottom"
|
|
||||||
}
|
|
||||||
align="start"
|
|
||||||
sideOffset={12}
|
|
||||||
style={{ zIndex: "var(--zIndex-popup)" }}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`picker`}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={label}
|
|
||||||
>
|
|
||||||
{renderOptions(alwaysVisibleOptions)}
|
|
||||||
|
|
||||||
{moreOptions.length > 0 && (
|
|
||||||
<Collapsible
|
|
||||||
label={t("labels.more_options")}
|
|
||||||
open={showMoreOptions}
|
|
||||||
openTrigger={() => {
|
|
||||||
setShowMoreOptions((value) => !value);
|
|
||||||
}}
|
|
||||||
className="picker-collapsible"
|
|
||||||
>
|
|
||||||
{renderOptions(moreOptions)}
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Popover.Content>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +151,6 @@ export function IconPicker<T>({
|
|||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
group = "",
|
group = "",
|
||||||
numberOfOptionsToAlwaysShow,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
@@ -199,40 +159,51 @@ export function IconPicker<T>({
|
|||||||
text: string;
|
text: string;
|
||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
keyBinding: string | null;
|
keyBinding: string | null;
|
||||||
|
showInPicker?: boolean;
|
||||||
}[];
|
}[];
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
numberOfOptionsToAlwaysShow?: number;
|
|
||||||
group?: string;
|
group?: string;
|
||||||
}) {
|
}) {
|
||||||
const [isActive, setActive] = React.useState(false);
|
const [isActive, setActive] = React.useState(false);
|
||||||
const rPickerButton = React.useRef<any>(null);
|
const rPickerButton = React.useRef<any>(null);
|
||||||
|
const isRTL = getLanguage().rtl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
|
<button
|
||||||
<Popover.Trigger
|
name={group}
|
||||||
name={group}
|
type="button"
|
||||||
type="button"
|
className={isActive ? "active" : ""}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => setActive(!isActive)}
|
onClick={() => setActive(!isActive)}
|
||||||
ref={rPickerButton}
|
ref={rPickerButton}
|
||||||
className={isActive ? "active" : ""}
|
>
|
||||||
>
|
{options.find((option) => option.value === value)?.icon}
|
||||||
{options.find((option) => option.value === value)?.icon}
|
</button>
|
||||||
</Popover.Trigger>
|
<React.Suspense fallback="">
|
||||||
{isActive && (
|
{isActive ? (
|
||||||
<Picker
|
<>
|
||||||
options={options}
|
<Popover
|
||||||
value={value}
|
onCloseRequest={(event) =>
|
||||||
label={label}
|
event.target !== rPickerButton.current && setActive(false)
|
||||||
onChange={onChange}
|
}
|
||||||
onClose={() => {
|
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
|
||||||
setActive(false);
|
>
|
||||||
}}
|
<Picker
|
||||||
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
|
options={options.filter((opt) => opt.showInPicker !== false)}
|
||||||
/>
|
value={value}
|
||||||
)}
|
label={label}
|
||||||
</Popover.Root>
|
onChange={onChange}
|
||||||
|
onClose={() => {
|
||||||
|
setActive(false);
|
||||||
|
rPickerButton.current?.focus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<div className="picker-triangle" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</React.Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { nativeFileSystemSupported } from "../data/filesystem";
|
|||||||
import type { NonDeletedExcalidrawElement } from "../element/types";
|
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas } from "../../utils/export";
|
|
||||||
|
|
||||||
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
@@ -36,6 +35,7 @@ import { FilledButton } from "./FilledButton";
|
|||||||
import { cloneJSON } from "../utils";
|
import { cloneJSON } from "../utils";
|
||||||
import { prepareElementsForExport } from "../data";
|
import { prepareElementsForExport } from "../data";
|
||||||
import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
||||||
|
import { exportToCanvas } from "../scene/export";
|
||||||
|
|
||||||
const supportsContextFilters =
|
const supportsContextFilters =
|
||||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||||
@@ -123,19 +123,25 @@ const ImageExportModal = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
exportToCanvas({
|
exportToCanvas({
|
||||||
elements: exportedElements,
|
data: {
|
||||||
appState: {
|
elements: exportedElements,
|
||||||
...appStateSnapshot,
|
appState: {
|
||||||
name: projectName,
|
...appStateSnapshot,
|
||||||
exportBackground: exportWithBackground,
|
name: projectName,
|
||||||
exportWithDarkMode: exportDarkMode,
|
exportEmbedScene: embedScene,
|
||||||
exportScale,
|
},
|
||||||
exportEmbedScene: embedScene,
|
files,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
canvasBackgroundColor: !exportWithBackground
|
||||||
|
? false
|
||||||
|
: appStateSnapshot.viewBackgroundColor,
|
||||||
|
padding: DEFAULT_EXPORT_PADDING,
|
||||||
|
theme: exportDarkMode ? "dark" : "light",
|
||||||
|
scale: exportScale,
|
||||||
|
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
|
||||||
|
exportingFrame,
|
||||||
},
|
},
|
||||||
files,
|
|
||||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
|
||||||
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
|
|
||||||
exportingFrame,
|
|
||||||
})
|
})
|
||||||
.then((canvas) => {
|
.then((canvas) => {
|
||||||
setRenderError(null);
|
setRenderError(null);
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ import { trackEvent } from "../analytics";
|
|||||||
import { useDevice } from "./App";
|
import { useDevice } from "./App";
|
||||||
import Footer from "./footer/Footer";
|
import Footer from "./footer/Footer";
|
||||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||||
import { useAtom, useAtomValue } from "../editor-jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
|
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||||
import MainMenu from "./main-menu/MainMenu";
|
import MainMenu from "./main-menu/MainMenu";
|
||||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||||
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
|
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
|
||||||
@@ -59,7 +60,6 @@ import { LaserPointerButton } from "./LaserPointerButton";
|
|||||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
import { actionToggleStats } from "../actions";
|
import { actionToggleStats } from "../actions";
|
||||||
import ElementLinkDialog from "./ElementLinkDialog";
|
|
||||||
|
|
||||||
import "./LayerUI.scss";
|
import "./LayerUI.scss";
|
||||||
import "./Toolbar.scss";
|
import "./Toolbar.scss";
|
||||||
@@ -84,7 +84,6 @@ interface LayerUIProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
generateLinkForSelection?: AppProps["generateLinkForSelection"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultMainMenu: React.FC<{
|
const DefaultMainMenu: React.FC<{
|
||||||
@@ -142,14 +141,14 @@ const LayerUI = ({
|
|||||||
children,
|
children,
|
||||||
app,
|
app,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
generateLinkForSelection,
|
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const tunnels = useInitializeTunnels();
|
const tunnels = useInitializeTunnels();
|
||||||
|
|
||||||
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||||
|
activeEyeDropperAtom,
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
jotaiScope,
|
||||||
|
);
|
||||||
|
|
||||||
const renderJSONExportDialog = () => {
|
const renderJSONExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.export) {
|
if (!UIOptions.canvasActions.export) {
|
||||||
@@ -219,7 +218,6 @@ const LayerUI = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
app={app}
|
|
||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -234,8 +232,7 @@ const LayerUI = ({
|
|||||||
const shouldShowStats =
|
const shouldShowStats =
|
||||||
appState.stats.open &&
|
appState.stats.open &&
|
||||||
!appState.zenModeEnabled &&
|
!appState.zenModeEnabled &&
|
||||||
!appState.viewModeEnabled &&
|
!appState.viewModeEnabled;
|
||||||
appState.openDialog?.name !== "elementLinkSelector";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
@@ -244,91 +241,90 @@ const LayerUI = ({
|
|||||||
{renderCanvasActions()}
|
{renderCanvasActions()}
|
||||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
{!appState.viewModeEnabled &&
|
{!appState.viewModeEnabled && (
|
||||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
<Section heading="shapes" className="shapes-section">
|
||||||
<Section heading="shapes" className="shapes-section">
|
{(heading: React.ReactNode) => (
|
||||||
{(heading: React.ReactNode) => (
|
<div style={{ position: "relative" }}>
|
||||||
<div style={{ position: "relative" }}>
|
{renderWelcomeScreen && (
|
||||||
{renderWelcomeScreen && (
|
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
)}
|
||||||
)}
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Row
|
||||||
<Stack.Row
|
gap={1}
|
||||||
gap={1}
|
className={clsx("App-toolbar-container", {
|
||||||
className={clsx("App-toolbar-container", {
|
"zen-mode": appState.zenModeEnabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Island
|
||||||
|
padding={1}
|
||||||
|
className={clsx("App-toolbar", {
|
||||||
"zen-mode": appState.zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Island
|
<HintViewer
|
||||||
padding={1}
|
appState={appState}
|
||||||
className={clsx("App-toolbar", {
|
isMobile={device.editor.isMobile}
|
||||||
"zen-mode": appState.zenModeEnabled,
|
device={device}
|
||||||
})}
|
app={app}
|
||||||
>
|
/>
|
||||||
<HintViewer
|
{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}
|
appState={appState}
|
||||||
isMobile={device.editor.isMobile}
|
activeTool={appState.activeTool}
|
||||||
device={device}
|
UIOptions={UIOptions}
|
||||||
app={app}
|
app={app}
|
||||||
/>
|
/>
|
||||||
{heading}
|
</Stack.Row>
|
||||||
<Stack.Row gap={1}>
|
</Island>
|
||||||
<PenModeButton
|
{isCollaborating && (
|
||||||
zenModeEnabled={appState.zenModeEnabled}
|
<Island
|
||||||
checked={appState.penMode}
|
style={{
|
||||||
onChange={() => onPenModeToggle(null)}
|
marginLeft: 8,
|
||||||
title={t("toolBar.penMode")}
|
alignSelf: "center",
|
||||||
penDetected={appState.penDetected}
|
height: "fit-content",
|
||||||
/>
|
}}
|
||||||
<LockButton
|
>
|
||||||
checked={appState.activeTool.locked}
|
<LaserPointerButton
|
||||||
onChange={onLockToggle}
|
title={t("toolBar.laser")}
|
||||||
title={t("toolBar.lock")}
|
checked={
|
||||||
/>
|
appState.activeTool.type === TOOL_TYPE.laser
|
||||||
|
}
|
||||||
<div className="App-toolbar__divider" />
|
onChange={() =>
|
||||||
|
app.setActiveTool({ type: TOOL_TYPE.laser })
|
||||||
<HandButton
|
}
|
||||||
checked={isHandToolActive(appState)}
|
isMobile
|
||||||
onChange={() => onHandToolToggle()}
|
/>
|
||||||
title={t("toolBar.hand")}
|
|
||||||
isMobile
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ShapesSwitcher
|
|
||||||
appState={appState}
|
|
||||||
activeTool={appState.activeTool}
|
|
||||||
UIOptions={UIOptions}
|
|
||||||
app={app}
|
|
||||||
/>
|
|
||||||
</Stack.Row>
|
|
||||||
</Island>
|
</Island>
|
||||||
{isCollaborating && (
|
)}
|
||||||
<Island
|
</Stack.Row>
|
||||||
style={{
|
</Stack.Col>
|
||||||
marginLeft: 8,
|
</div>
|
||||||
alignSelf: "center",
|
)}
|
||||||
height: "fit-content",
|
</Section>
|
||||||
}}
|
)}
|
||||||
>
|
|
||||||
<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
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||||
@@ -345,7 +341,6 @@ const LayerUI = ({
|
|||||||
)}
|
)}
|
||||||
{renderTopRightUI?.(device.editor.isMobile, appState)}
|
{renderTopRightUI?.(device.editor.isMobile, appState)}
|
||||||
{!appState.viewModeEnabled &&
|
{!appState.viewModeEnabled &&
|
||||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
|
||||||
// hide button when sidebar docked
|
// hide button when sidebar docked
|
||||||
(!isSidebarDocked ||
|
(!isSidebarDocked ||
|
||||||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
||||||
@@ -381,7 +376,7 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
|
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
|
||||||
|
|
||||||
const layerUIJSX = (
|
const layerUIJSX = (
|
||||||
<>
|
<>
|
||||||
@@ -476,19 +471,6 @@ const LayerUI = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ActiveConfirmDialog />
|
<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 />
|
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
||||||
{renderImageExportDialog()}
|
{renderImageExportDialog()}
|
||||||
{renderJSONExportDialog()}
|
{renderJSONExportDialog()}
|
||||||
@@ -565,11 +547,11 @@ const LayerUI = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<UIAppStateContext.Provider value={appState}>
|
<UIAppStateContext.Provider value={appState}>
|
||||||
<TunnelsJotaiProvider>
|
<Provider scope={tunnels.jotaiScope}>
|
||||||
<TunnelsContext.Provider value={tunnels}>
|
<TunnelsContext.Provider value={tunnels}>
|
||||||
{layerUIJSX}
|
{layerUIJSX}
|
||||||
</TunnelsContext.Provider>
|
</TunnelsContext.Provider>
|
||||||
</TunnelsJotaiProvider>
|
</Provider>
|
||||||
</UIAppStateContext.Provider>
|
</UIAppStateContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import React, {
|
import React, { useState, useCallback, useMemo, useRef } from "react";
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useEffect,
|
|
||||||
memo,
|
|
||||||
useRef,
|
|
||||||
} from "react";
|
|
||||||
import type Library from "../data/library";
|
import type Library from "../data/library";
|
||||||
import {
|
import {
|
||||||
distributeLibraryItemsOnSquareGrid,
|
distributeLibraryItemsOnSquareGrid,
|
||||||
@@ -18,11 +11,11 @@ import type {
|
|||||||
LibraryItem,
|
LibraryItem,
|
||||||
ExcalidrawProps,
|
ExcalidrawProps,
|
||||||
UIAppState,
|
UIAppState,
|
||||||
AppClassProperties,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import LibraryMenuItems from "./LibraryMenuItems";
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { atom, useAtom } from "../editor-jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { jotaiScope } from "../jotai";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import {
|
import {
|
||||||
useApp,
|
useApp,
|
||||||
@@ -35,12 +28,9 @@ import { useUIAppState } from "../context/ui-appState";
|
|||||||
|
|
||||||
import "./LibraryMenu.scss";
|
import "./LibraryMenu.scss";
|
||||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||||
import type {
|
|
||||||
ExcalidrawElement,
|
|
||||||
NonDeletedExcalidrawElement,
|
|
||||||
} from "../element/types";
|
|
||||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
|
||||||
import { isShallowEqual } from "../utils";
|
import { isShallowEqual } from "../utils";
|
||||||
|
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
|
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||||
|
|
||||||
export const isLibraryMenuOpenAtom = atom(false);
|
export const isLibraryMenuOpenAtom = atom(false);
|
||||||
|
|
||||||
@@ -48,215 +38,156 @@ const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||||||
return <div className="layer-ui__library">{children}</div>;
|
return <div className="layer-ui__library">{children}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LibraryMenuContent = memo(
|
export const LibraryMenuContent = ({
|
||||||
({
|
onInsertLibraryItems,
|
||||||
onInsertLibraryItems,
|
pendingElements,
|
||||||
pendingElements,
|
onAddToLibrary,
|
||||||
onAddToLibrary,
|
setAppState,
|
||||||
setAppState,
|
libraryReturnUrl,
|
||||||
libraryReturnUrl,
|
library,
|
||||||
library,
|
id,
|
||||||
id,
|
theme,
|
||||||
theme,
|
selectedItems,
|
||||||
selectedItems,
|
onSelectItems,
|
||||||
onSelectItems,
|
}: {
|
||||||
}: {
|
pendingElements: LibraryItem["elements"];
|
||||||
pendingElements: LibraryItem["elements"];
|
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
onAddToLibrary: () => void;
|
||||||
onAddToLibrary: () => void;
|
setAppState: React.Component<any, UIAppState>["setState"];
|
||||||
setAppState: React.Component<any, UIAppState>["setState"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
library: Library;
|
||||||
library: Library;
|
id: string;
|
||||||
id: string;
|
theme: UIAppState["theme"];
|
||||||
theme: UIAppState["theme"];
|
selectedItems: LibraryItem["id"][];
|
||||||
selectedItems: LibraryItem["id"][];
|
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
}) => {
|
||||||
}) => {
|
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
|
||||||
|
|
||||||
const _onAddToLibrary = useCallback(
|
const _onAddToLibrary = useCallback(
|
||||||
(elements: LibraryItem["elements"]) => {
|
(elements: LibraryItem["elements"]) => {
|
||||||
const addToLibrary = async (
|
const addToLibrary = async (
|
||||||
processedElements: LibraryItem["elements"],
|
processedElements: LibraryItem["elements"],
|
||||||
libraryItems: LibraryItems,
|
libraryItems: LibraryItems,
|
||||||
) => {
|
) => {
|
||||||
trackEvent("element", "addToLibrary", "ui");
|
trackEvent("element", "addToLibrary", "ui");
|
||||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||||
if (processedElements.some((element) => element.type === type)) {
|
if (processedElements.some((element) => element.type === type)) {
|
||||||
return setAppState({
|
return setAppState({
|
||||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const nextItems: LibraryItems = [
|
}
|
||||||
{
|
const nextItems: LibraryItems = [
|
||||||
status: "unpublished",
|
{
|
||||||
elements: processedElements,
|
status: "unpublished",
|
||||||
id: randomId(),
|
elements: processedElements,
|
||||||
created: Date.now(),
|
id: randomId(),
|
||||||
},
|
created: Date.now(),
|
||||||
...libraryItems,
|
},
|
||||||
];
|
...libraryItems,
|
||||||
onAddToLibrary();
|
];
|
||||||
library.setLibrary(nextItems).catch(() => {
|
onAddToLibrary();
|
||||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
library.setLibrary(nextItems).catch(() => {
|
||||||
});
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
};
|
});
|
||||||
addToLibrary(elements, libraryItemsData.libraryItems);
|
};
|
||||||
},
|
addToLibrary(elements, libraryItemsData.libraryItems);
|
||||||
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
|
},
|
||||||
);
|
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
|
||||||
|
);
|
||||||
|
|
||||||
const libraryItems = useMemo(
|
const libraryItems = useMemo(
|
||||||
() => libraryItemsData.libraryItems,
|
() => libraryItemsData.libraryItems,
|
||||||
[libraryItemsData],
|
[libraryItemsData],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
|
||||||
libraryItemsData.status === "loading" &&
|
|
||||||
!libraryItemsData.isInitialized
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<LibraryMenuWrapper>
|
|
||||||
<div className="layer-ui__library-message">
|
|
||||||
<div>
|
|
||||||
<Spinner size="2em" />
|
|
||||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LibraryMenuWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const showBtn =
|
|
||||||
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
|
|
||||||
|
|
||||||
|
if (
|
||||||
|
libraryItemsData.status === "loading" &&
|
||||||
|
!libraryItemsData.isInitialized
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<LibraryMenuWrapper>
|
<LibraryMenuWrapper>
|
||||||
<LibraryMenuItems
|
<div className="layer-ui__library-message">
|
||||||
isLoading={libraryItemsData.status === "loading"}
|
<div>
|
||||||
libraryItems={libraryItems}
|
<Spinner size="2em" />
|
||||||
onAddToLibrary={_onAddToLibrary}
|
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||||
onInsertLibraryItems={onInsertLibraryItems}
|
</div>
|
||||||
pendingElements={pendingElements}
|
</div>
|
||||||
|
</LibraryMenuWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showBtn =
|
||||||
|
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LibraryMenuWrapper>
|
||||||
|
<LibraryMenuItems
|
||||||
|
isLoading={libraryItemsData.status === "loading"}
|
||||||
|
libraryItems={libraryItems}
|
||||||
|
onAddToLibrary={_onAddToLibrary}
|
||||||
|
onInsertLibraryItems={onInsertLibraryItems}
|
||||||
|
pendingElements={pendingElements}
|
||||||
|
id={id}
|
||||||
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
|
theme={theme}
|
||||||
|
onSelectItems={onSelectItems}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
/>
|
||||||
|
{showBtn && (
|
||||||
|
<LibraryMenuControlButtons
|
||||||
|
className="library-menu-control-buttons--at-bottom"
|
||||||
|
style={{ padding: "16px 12px 0 12px" }}
|
||||||
id={id}
|
id={id}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onSelectItems={onSelectItems}
|
|
||||||
selectedItems={selectedItems}
|
|
||||||
/>
|
/>
|
||||||
{showBtn && (
|
)}
|
||||||
<LibraryMenuControlButtons
|
</LibraryMenuWrapper>
|
||||||
className="library-menu-control-buttons--at-bottom"
|
);
|
||||||
style={{ padding: "16px 12px 0 12px" }}
|
};
|
||||||
id={id}
|
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
|
||||||
theme={theme}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</LibraryMenuWrapper>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const getPendingElements = (
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
selectedElementIds: UIAppState["selectedElementIds"],
|
|
||||||
) => ({
|
|
||||||
elements,
|
|
||||||
pending: getSelectedElements(
|
|
||||||
elements,
|
|
||||||
{ selectedElementIds },
|
|
||||||
{
|
|
||||||
includeBoundTextElement: true,
|
|
||||||
includeElementsInFrames: true,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
selectedElementIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
const usePendingElementsMemo = (
|
const usePendingElementsMemo = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
app: AppClassProperties,
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
const elements = useExcalidrawElements();
|
const create = () =>
|
||||||
const [state, setState] = useState(() =>
|
getSelectedElements(elements, appState, {
|
||||||
getPendingElements(elements, appState.selectedElementIds),
|
includeBoundTextElement: true,
|
||||||
);
|
includeElementsInFrames: true,
|
||||||
|
});
|
||||||
|
const val = useRef(create());
|
||||||
|
const prevAppState = useRef<UIAppState>(appState);
|
||||||
|
const prevElements = useRef(elements);
|
||||||
|
|
||||||
const selectedElementVersions = useRef(
|
if (
|
||||||
new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(),
|
!isShallowEqual(
|
||||||
);
|
appState.selectedElementIds,
|
||||||
|
prevAppState.current.selectedElementIds,
|
||||||
useEffect(() => {
|
) ||
|
||||||
for (const element of state.pending) {
|
!isShallowEqual(elements, prevElements.current)
|
||||||
selectedElementVersions.current.set(element.id, element.version);
|
) {
|
||||||
}
|
val.current = create();
|
||||||
}, [state.pending]);
|
prevAppState.current = appState;
|
||||||
|
prevElements.current = elements;
|
||||||
useEffect(() => {
|
}
|
||||||
if (
|
return val.current;
|
||||||
// Only update once pointer is released.
|
|
||||||
// Reading directly from app.state to make it clear it's not reactive
|
|
||||||
// (hence, there's potential for stale state)
|
|
||||||
app.state.cursorButton === "up" &&
|
|
||||||
app.state.activeTool.type === "selection"
|
|
||||||
) {
|
|
||||||
setState((prev) => {
|
|
||||||
// if selectedElementIds changed, we don't have to compare versions
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
if (
|
|
||||||
!isShallowEqual(prev.selectedElementIds, appState.selectedElementIds)
|
|
||||||
) {
|
|
||||||
selectedElementVersions.current.clear();
|
|
||||||
return getPendingElements(elements, appState.selectedElementIds);
|
|
||||||
}
|
|
||||||
// otherwise we need to check whether selected elements changed
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
||||||
for (const id of Object.keys(appState.selectedElementIds)) {
|
|
||||||
const currVersion = elementsMap.get(id)?.version;
|
|
||||||
if (
|
|
||||||
currVersion &&
|
|
||||||
currVersion !== selectedElementVersions.current.get(id)
|
|
||||||
) {
|
|
||||||
// we can't update the selectedElementVersions in here
|
|
||||||
// because of double render in StrictMode which would overwrite
|
|
||||||
// the state in the second pass with the old `prev` state.
|
|
||||||
// Thus, we update versions in a separate effect. May create
|
|
||||||
// a race condition since current effect is not fully reactive.
|
|
||||||
return getPendingElements(elements, appState.selectedElementIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// nothing changed
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
app,
|
|
||||||
app.state.cursorButton,
|
|
||||||
app.state.activeTool.type,
|
|
||||||
appState.selectedElementIds,
|
|
||||||
elements,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return state.pending;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
|
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
|
||||||
* <DefaultSidebar/> or host apps Sidebar components.
|
* <DefaultSidebar/> or host apps Sidebar components.
|
||||||
*/
|
*/
|
||||||
export const LibraryMenu = memo(() => {
|
export const LibraryMenu = () => {
|
||||||
const app = useApp();
|
const { library, id, onInsertElements } = useApp();
|
||||||
const { onInsertElements } = app;
|
|
||||||
const appProps = useAppProps();
|
const appProps = useAppProps();
|
||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
const elements = useExcalidrawElements();
|
||||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
const memoizedLibrary = useMemo(() => app.library, [app.library]);
|
const memoizedLibrary = useMemo(() => library, [library]);
|
||||||
const pendingElements = usePendingElementsMemo(appState, app);
|
// BUG: pendingElements are still causing some unnecessary rerenders because clicking into canvas returns some ids even when no element is selected.
|
||||||
|
const pendingElements = usePendingElementsMemo(appState, elements);
|
||||||
|
|
||||||
const onInsertLibraryItems = useCallback(
|
const onInsertLibraryItems = useCallback(
|
||||||
(libraryItems: LibraryItems) => {
|
(libraryItems: LibraryItems) => {
|
||||||
@@ -281,10 +212,10 @@ export const LibraryMenu = memo(() => {
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
libraryReturnUrl={appProps.libraryReturnUrl}
|
libraryReturnUrl={appProps.libraryReturnUrl}
|
||||||
library={memoizedLibrary}
|
library={memoizedLibrary}
|
||||||
id={app.id}
|
id={id}
|
||||||
theme={appState.theme}
|
theme={appState.theme}
|
||||||
selectedItems={selectedItems}
|
selectedItems={selectedItems}
|
||||||
onSelectItems={setSelectedItems}
|
onSelectItems={setSelectedItems}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import Trans from "./Trans";
|
import Trans from "./Trans";
|
||||||
import { useAtom } from "../editor-jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import type { LibraryItem, LibraryItems, UIAppState } from "../types";
|
import type { LibraryItem, LibraryItems, UIAppState } from "../types";
|
||||||
import { useApp, useExcalidrawSetAppState } from "./App";
|
import { useApp, useExcalidrawSetAppState } from "./App";
|
||||||
import { saveLibraryAsJSON } from "../data/json";
|
import { saveLibraryAsJSON } from "../data/json";
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { fileOpen } from "../data/filesystem";
|
import { fileOpen } from "../data/filesystem";
|
||||||
import { muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import PublishLibrary from "./PublishLibrary";
|
import PublishLibrary from "./PublishLibrary";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
@@ -50,9 +51,10 @@ export const LibraryDropdownMenuButton: React.FC<{
|
|||||||
appState,
|
appState,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||||
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
||||||
isLibraryMenuOpenAtom,
|
isLibraryMenuOpenAtom,
|
||||||
|
jotaiScope,
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRemoveLibAlert = () => {
|
const renderRemoveLibAlert = () => {
|
||||||
@@ -284,7 +286,7 @@ export const LibraryDropdownMenu = ({
|
|||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||||
|
|
||||||
const removeFromLibrary = async (libraryItems: LibraryItems) => {
|
const removeFromLibrary = async (libraryItems: LibraryItems) => {
|
||||||
const nextItems = libraryItems.filter(
|
const nextItems = libraryItems.filter(
|
||||||
|
|||||||
@@ -91,10 +91,9 @@ export const MobileMenu = ({
|
|||||||
</Island>
|
</Island>
|
||||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||||
<div className="mobile-misc-tools-container">
|
<div className="mobile-misc-tools-container">
|
||||||
{!appState.viewModeEnabled &&
|
{!appState.viewModeEnabled && (
|
||||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
<DefaultSidebarTriggerTunnel.Out />
|
||||||
<DefaultSidebarTriggerTunnel.Out />
|
)}
|
||||||
)}
|
|
||||||
<PenModeButton
|
<PenModeButton
|
||||||
checked={appState.penMode}
|
checked={appState.penMode}
|
||||||
onChange={() => onPenModeToggle(null)}
|
onChange={() => onPenModeToggle(null)}
|
||||||
@@ -130,10 +129,7 @@ export const MobileMenu = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderAppToolbar = () => {
|
const renderAppToolbar = () => {
|
||||||
if (
|
if (appState.viewModeEnabled) {
|
||||||
appState.viewModeEnabled ||
|
|
||||||
appState.openDialog?.name === "elementLinkSelector"
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<div className="App-toolbar-content">
|
<div className="App-toolbar-content">
|
||||||
<MainMenuTunnel.Out />
|
<MainMenuTunnel.Out />
|
||||||
@@ -145,14 +141,12 @@ export const MobileMenu = ({
|
|||||||
<div className="App-toolbar-content">
|
<div className="App-toolbar-content">
|
||||||
<MainMenuTunnel.Out />
|
<MainMenuTunnel.Out />
|
||||||
{actionManager.renderAction("toggleEditMenu")}
|
{actionManager.renderAction("toggleEditMenu")}
|
||||||
|
{actionManager.renderAction("undo")}
|
||||||
|
{actionManager.renderAction("redo")}
|
||||||
{actionManager.renderAction(
|
{actionManager.renderAction(
|
||||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||||
)}
|
)}
|
||||||
{actionManager.renderAction("deleteSelectedElements")}
|
{actionManager.renderAction("deleteSelectedElements")}
|
||||||
<div>
|
|
||||||
{actionManager.renderAction("undo")}
|
|
||||||
{actionManager.renderAction("redo")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -160,9 +154,7 @@ export const MobileMenu = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderSidebars()}
|
{renderSidebars()}
|
||||||
{!appState.viewModeEnabled &&
|
{!appState.viewModeEnabled && renderToolbar()}
|
||||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
|
||||||
renderToolbar()}
|
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
style={{
|
style={{
|
||||||
@@ -174,14 +166,12 @@ export const MobileMenu = ({
|
|||||||
<Island padding={0}>
|
<Island padding={0}>
|
||||||
{appState.openMenu === "shape" &&
|
{appState.openMenu === "shape" &&
|
||||||
!appState.viewModeEnabled &&
|
!appState.viewModeEnabled &&
|
||||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
|
||||||
showSelectedShapeActions(appState, elements) ? (
|
showSelectedShapeActions(appState, elements) ? (
|
||||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||||
<SelectedShapeActions
|
<SelectedShapeActions
|
||||||
appState={appState}
|
appState={appState}
|
||||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
app={app}
|
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
import { useTunnels } from "../../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { jotaiScope } from "../../jotai";
|
||||||
import { Dialog } from "../Dialog";
|
import { Dialog } from "../Dialog";
|
||||||
import { withInternalFallback } from "../hoc/withInternalFallback";
|
import { withInternalFallback } from "../hoc/withInternalFallback";
|
||||||
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
|
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
|
||||||
@@ -22,6 +23,7 @@ const OverwriteConfirmDialog = Object.assign(
|
|||||||
const { OverwriteConfirmDialogTunnel } = useTunnels();
|
const { OverwriteConfirmDialogTunnel } = useTunnels();
|
||||||
const [overwriteConfirmState, setState] = useAtom(
|
const [overwriteConfirmState, setState] = useAtom(
|
||||||
overwriteConfirmStateAtom,
|
overwriteConfirmStateAtom,
|
||||||
|
jotaiScope,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!overwriteConfirmState.active) {
|
if (!overwriteConfirmState.active) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { atom, editorJotaiStore } from "../../editor-jotai";
|
import { atom } from "jotai";
|
||||||
|
import { jotaiStore } from "../../jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
export type OverwriteConfirmState =
|
export type OverwriteConfirmState =
|
||||||
@@ -31,7 +32,7 @@ export async function openConfirmModal({
|
|||||||
color: "danger" | "warning";
|
color: "danger" | "warning";
|
||||||
}) {
|
}) {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
editorJotaiStore.set(overwriteConfirmStateAtom, {
|
jotaiStore.set(overwriteConfirmStateAtom, {
|
||||||
active: true,
|
active: true,
|
||||||
onConfirm: () => resolve(true),
|
onConfirm: () => resolve(true),
|
||||||
onClose: () => resolve(false),
|
onClose: () => resolve(false),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import oc from "open-color";
|
|
||||||
import React, { useLayoutEffect, useRef, useState } from "react";
|
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import type { ChartElements, Spreadsheet } from "../charts";
|
import type { ChartElements, Spreadsheet } from "../charts";
|
||||||
import { renderSpreadsheet } from "../charts";
|
import { renderSpreadsheet } from "../charts";
|
||||||
import type { ChartType } from "../element/types";
|
import type { ChartType } from "../element/types";
|
||||||
|
import { COLOR_WHITE } from "../constants";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import type { UIAppState } from "../types";
|
import type { UIAppState } from "../types";
|
||||||
@@ -41,17 +41,16 @@ const ChartPreviewBtn = (props: {
|
|||||||
const previewNode = previewRef.current!;
|
const previewNode = previewRef.current!;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
svg = await exportToSvg(
|
svg = await exportToSvg({
|
||||||
elements,
|
data: {
|
||||||
{
|
elements,
|
||||||
exportBackground: false,
|
appState: {
|
||||||
viewBackgroundColor: oc.white,
|
exportBackground: false,
|
||||||
|
viewBackgroundColor: COLOR_WHITE,
|
||||||
|
},
|
||||||
|
files: null,
|
||||||
},
|
},
|
||||||
null, // files
|
});
|
||||||
{
|
|
||||||
skipInliningFonts: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
svg.querySelector(".style-fonts")?.remove();
|
svg.querySelector(".style-fonts")?.remove();
|
||||||
previewNode.replaceChildren();
|
previewNode.replaceChildren();
|
||||||
previewNode.appendChild(svg);
|
previewNode.appendChild(svg);
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { t } from "../i18n";
|
|||||||
import Trans from "./Trans";
|
import Trans from "./Trans";
|
||||||
|
|
||||||
import type { LibraryItems, LibraryItem, UIAppState } from "../types";
|
import type { LibraryItems, LibraryItem, UIAppState } from "../types";
|
||||||
import { exportToCanvas, exportToSvg } from "../../utils/export";
|
|
||||||
import {
|
import {
|
||||||
|
COLOR_WHITE,
|
||||||
EDITOR_LS_KEYS,
|
EDITOR_LS_KEYS,
|
||||||
EXPORT_DATA_TYPES,
|
EXPORT_DATA_TYPES,
|
||||||
EXPORT_SOURCE,
|
EXPORT_SOURCE,
|
||||||
@@ -24,6 +24,7 @@ import { ToolButton } from "./ToolButton";
|
|||||||
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
||||||
|
|
||||||
import "./PublishLibrary.scss";
|
import "./PublishLibrary.scss";
|
||||||
|
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||||
|
|
||||||
interface PublishLibraryDataParams {
|
interface PublishLibraryDataParams {
|
||||||
authorName: string;
|
authorName: string;
|
||||||
@@ -55,16 +56,20 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
|
|||||||
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
ctx.fillStyle = OpenColor.white;
|
ctx.fillStyle = COLOR_WHITE;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
// draw items
|
// draw items
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
for (const [index, item] of libraryItems.entries()) {
|
for (const [index, item] of libraryItems.entries()) {
|
||||||
const itemCanvas = await exportToCanvas({
|
const itemCanvas = await exportToCanvas({
|
||||||
elements: item.elements,
|
data: {
|
||||||
files: null,
|
elements: item.elements,
|
||||||
maxWidthOrHeight: BOX_SIZE,
|
files: null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
maxWidthOrHeight: BOX_SIZE,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { width, height } = itemCanvas;
|
const { width, height } = itemCanvas;
|
||||||
@@ -126,14 +131,15 @@ const SingleLibraryItem = ({
|
|||||||
}
|
}
|
||||||
(async () => {
|
(async () => {
|
||||||
const svg = await exportToSvg({
|
const svg = await exportToSvg({
|
||||||
elements: libItem.elements,
|
data: {
|
||||||
appState: {
|
elements: libItem.elements,
|
||||||
...appState,
|
appState: {
|
||||||
viewBackgroundColor: OpenColor.white,
|
...appState,
|
||||||
exportBackground: true,
|
viewBackgroundColor: COLOR_WHITE,
|
||||||
|
exportBackground: true,
|
||||||
|
},
|
||||||
|
files: null,
|
||||||
},
|
},
|
||||||
files: null,
|
|
||||||
skipInliningFonts: true,
|
|
||||||
});
|
});
|
||||||
node.innerHTML = svg.outerHTML;
|
node.innerHTML = svg.outerHTML;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
@import "../css/variables.module.scss";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
--slider-thumb-size: 16px;
|
|
||||||
|
|
||||||
.range-wrapper {
|
|
||||||
position: relative;
|
|
||||||
padding-top: 10px;
|
|
||||||
padding-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-input {
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
background: var(--color-slider-track);
|
|
||||||
border-radius: 2px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-input::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: var(--slider-thumb-size);
|
|
||||||
height: var(--slider-thumb-size);
|
|
||||||
background: var(--color-slider-thumb);
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-input::-moz-range-thumb {
|
|
||||||
width: var(--slider-thumb-size);
|
|
||||||
height: var(--slider-thumb-size);
|
|
||||||
background: var(--color-slider-thumb);
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-bubble {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.zero-label {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-primary-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import { getFormValue } from "../actions/actionProperties";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import "./Range.scss";
|
|
||||||
|
|
||||||
export type RangeProps = {
|
|
||||||
updateData: (value: number) => void;
|
|
||||||
appState: any;
|
|
||||||
elements: any;
|
|
||||||
testId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Range = ({
|
|
||||||
updateData,
|
|
||||||
appState,
|
|
||||||
elements,
|
|
||||||
testId,
|
|
||||||
}: RangeProps) => {
|
|
||||||
const rangeRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
const valueRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const value = getFormValue(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(element) => element.opacity,
|
|
||||||
true,
|
|
||||||
appState.currentItemOpacity,
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (rangeRef.current && valueRef.current) {
|
|
||||||
const rangeElement = rangeRef.current;
|
|
||||||
const valueElement = valueRef.current;
|
|
||||||
const inputWidth = rangeElement.offsetWidth;
|
|
||||||
const thumbWidth = 15; // 15 is the width of the thumb
|
|
||||||
const position =
|
|
||||||
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
|
|
||||||
valueElement.style.left = `${position}px`;
|
|
||||||
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className="control-label">
|
|
||||||
{t("labels.opacity")}
|
|
||||||
<div className="range-wrapper">
|
|
||||||
<input
|
|
||||||
ref={rangeRef}
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="10"
|
|
||||||
onChange={(event) => {
|
|
||||||
updateData(+event.target.value);
|
|
||||||
}}
|
|
||||||
value={value}
|
|
||||||
className="range-input"
|
|
||||||
data-testid={testId}
|
|
||||||
/>
|
|
||||||
<div className="value-bubble" ref={valueRef}>
|
|
||||||
{value !== 0 ? value : null}
|
|
||||||
</div>
|
|
||||||
<div className="zero-label">0</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -7,10 +7,12 @@ import { debounce } from "lodash";
|
|||||||
import type { AppClassProperties } from "../types";
|
import type { AppClassProperties } from "../types";
|
||||||
import { isTextElement, newTextElement } from "../element";
|
import { isTextElement, newTextElement } from "../element";
|
||||||
import type { ExcalidrawTextElement } from "../element/types";
|
import type { ExcalidrawTextElement } from "../element/types";
|
||||||
|
import { measureText } from "../element/textElement";
|
||||||
import { addEventListener, getFontString } from "../utils";
|
import { addEventListener, getFontString } from "../utils";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { atom, useAtom } from "../editor-jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { jotaiScope } from "../jotai";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
|
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
|
||||||
import { randomInteger } from "../random";
|
import { randomInteger } from "../random";
|
||||||
@@ -19,7 +21,6 @@ import { useStable } from "../hooks/useStable";
|
|||||||
|
|
||||||
import "./SearchMenu.scss";
|
import "./SearchMenu.scss";
|
||||||
import { round } from "../../math";
|
import { round } from "../../math";
|
||||||
import { measureText } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
const searchQueryAtom = atom<string>("");
|
const searchQueryAtom = atom<string>("");
|
||||||
export const searchItemInFocusAtom = atom<number | null>(null);
|
export const searchItemInFocusAtom = atom<number | null>(null);
|
||||||
@@ -57,7 +58,7 @@ export const SearchMenu = () => {
|
|||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useAtom(searchQueryAtom);
|
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
|
||||||
const searchQuery = inputValue.trim() as SearchQuery;
|
const searchQuery = inputValue.trim() as SearchQuery;
|
||||||
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
@@ -69,7 +70,10 @@ export const SearchMenu = () => {
|
|||||||
const searchedQueryRef = useRef<SearchQuery | null>(null);
|
const searchedQueryRef = useRef<SearchQuery | null>(null);
|
||||||
const lastSceneNonceRef = useRef<number | undefined>(undefined);
|
const lastSceneNonceRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
const [focusIndex, setFocusIndex] = useAtom(searchItemInFocusAtom);
|
const [focusIndex, setFocusIndex] = useAtom(
|
||||||
|
searchItemInFocusAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -607,6 +611,7 @@ const getMatchedLines = (
|
|||||||
textToStart,
|
textToStart,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
textElement.lineHeight,
|
textElement.lineHeight,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// measureText returns a non-zero width for the empty string
|
// measureText returns a non-zero width for the empty string
|
||||||
@@ -620,6 +625,7 @@ const getMatchedLines = (
|
|||||||
lineIndexRange.line,
|
lineIndexRange.line,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
textElement.lineHeight,
|
textElement.lineHeight,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const spaceToStart =
|
const spaceToStart =
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Island } from "../Island";
|
import { Island } from "../Island";
|
||||||
import { atom, useSetAtom } from "../../editor-jotai";
|
import { atom, useSetAtom } from "jotai";
|
||||||
|
import { jotaiScope } from "../../jotai";
|
||||||
import type { SidebarProps, SidebarPropsContextValue } from "./common";
|
import type { SidebarProps, SidebarPropsContextValue } from "./common";
|
||||||
import { SidebarPropsContext } from "./common";
|
import { SidebarPropsContext } from "./common";
|
||||||
import { SidebarHeader } from "./SidebarHeader";
|
import { SidebarHeader } from "./SidebarHeader";
|
||||||
@@ -57,7 +58,7 @@ export const SidebarInner = forwardRef(
|
|||||||
|
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom);
|
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setIsSidebarDockedAtom(!!docked);
|
setIsSidebarDockedAtom(!!docked);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ interface CollapsibleProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
openTrigger: () => void;
|
openTrigger: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Collapsible = ({
|
const Collapsible = ({
|
||||||
@@ -17,7 +16,6 @@ const Collapsible = ({
|
|||||||
open,
|
open,
|
||||||
openTrigger,
|
openTrigger,
|
||||||
children,
|
children,
|
||||||
className,
|
|
||||||
}: CollapsibleProps) => {
|
}: CollapsibleProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -28,7 +26,6 @@ const Collapsible = ({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
className={className}
|
|
||||||
onClick={openTrigger}
|
onClick={openTrigger}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import type { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
|
||||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||||
import { resizeSingleElement } from "../../element/resizeElements";
|
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState } from "../../types";
|
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 {
|
interface DimensionDragInputProps {
|
||||||
property: "width" | "height";
|
property: "width" | "height";
|
||||||
@@ -31,124 +23,20 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
> = ({
|
> = ({
|
||||||
accumulatedChange,
|
accumulatedChange,
|
||||||
originalElements,
|
originalElements,
|
||||||
originalElementsMap,
|
|
||||||
shouldKeepAspectRatio,
|
shouldKeepAspectRatio,
|
||||||
shouldChangeByStepSize,
|
shouldChangeByStepSize,
|
||||||
nextValue,
|
nextValue,
|
||||||
property,
|
property,
|
||||||
originalAppState,
|
|
||||||
instantChange,
|
|
||||||
scene,
|
scene,
|
||||||
}) => {
|
}) => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const elements = scene.getNonDeletedElements();
|
||||||
const origElement = originalElements[0];
|
const origElement = originalElements[0];
|
||||||
const latestElement = elementsMap.get(origElement.id);
|
if (origElement) {
|
||||||
if (origElement && latestElement) {
|
|
||||||
const keepAspectRatio =
|
const keepAspectRatio =
|
||||||
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
|
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
|
||||||
const aspectRatio = origElement.width / origElement.height;
|
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) {
|
if (nextValue !== undefined) {
|
||||||
const nextWidth = Math.max(
|
const nextWidth = Math.max(
|
||||||
property === "width"
|
property === "width"
|
||||||
@@ -167,17 +55,14 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
MIN_WIDTH_OR_HEIGHT,
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
);
|
);
|
||||||
|
|
||||||
resizeSingleElement(
|
resizeElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
latestElement,
|
keepAspectRatio,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
originalElementsMap,
|
elements,
|
||||||
property === "width" ? "e" : "s",
|
scene,
|
||||||
{
|
|
||||||
shouldMaintainAspectRatio: keepAspectRatio,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -214,17 +99,14 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
|
|
||||||
resizeSingleElement(
|
resizeElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
latestElement,
|
keepAspectRatio,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
originalElementsMap,
|
elements,
|
||||||
property === "width" ? "e" : "s",
|
scene,
|
||||||
{
|
|
||||||
shouldMaintainAspectRatio: keepAspectRatio,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -235,25 +117,9 @@ const DimensionDragInput = ({
|
|||||||
scene,
|
scene,
|
||||||
appState,
|
appState,
|
||||||
}: DimensionDragInputProps) => {
|
}: DimensionDragInputProps) => {
|
||||||
let value = round(property === "width" ? element.width : element.height, 2);
|
const value =
|
||||||
|
Math.round((property === "width" ? element.width : element.height) * 100) /
|
||||||
if (
|
100;
|
||||||
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 (
|
return (
|
||||||
<DragInput
|
<DragInput
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { useMemo } from "react";
|
|||||||
import { getCommonBounds, isTextElement } from "../../element";
|
import { getCommonBounds, isTextElement } from "../../element";
|
||||||
import { updateBoundElements } from "../../element/binding";
|
import { updateBoundElements } from "../../element/binding";
|
||||||
import { mutateElement } from "../../element/mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import {
|
import { rescalePointsInElement } from "../../element/resizeElements";
|
||||||
rescalePointsInElement,
|
|
||||||
resizeSingleElement,
|
|
||||||
} from "../../element/resizeElements";
|
|
||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
@@ -20,7 +17,7 @@ import type { AppState } from "../../types";
|
|||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
import { getElementsInAtomicUnit } from "./utils";
|
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
||||||
import type { AtomicUnit } from "./utils";
|
import type { AtomicUnit } from "./utils";
|
||||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||||
import { pointFrom, type GlobalPoint } from "../../../math";
|
import { pointFrom, type GlobalPoint } from "../../../math";
|
||||||
@@ -153,6 +150,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
property,
|
property,
|
||||||
}) => {
|
}) => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const elements = scene.getNonDeletedElements();
|
||||||
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
||||||
if (nextValue !== undefined) {
|
if (nextValue !== undefined) {
|
||||||
for (const atomicUnit of atomicUnits) {
|
for (const atomicUnit of atomicUnits) {
|
||||||
@@ -225,17 +223,15 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
|
||||||
resizeSingleElement(
|
resizeElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
latestElement,
|
false,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
originalElementsMap,
|
elements,
|
||||||
property === "width" ? "e" : "s",
|
scene,
|
||||||
{
|
false,
|
||||||
shouldInformMutation: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,17 +324,14 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
|
||||||
resizeSingleElement(
|
resizeElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
latestElement,
|
false,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
originalElementsMap,
|
elements,
|
||||||
property === "width" ? "e" : "s",
|
scene,
|
||||||
{
|
|
||||||
shouldInformMutation: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,7 +237,6 @@ const MultiPosition = ({
|
|||||||
const [x1, y1] = getCommonBounds(elementsInUnit);
|
const [x1, y1] = getCommonBounds(elementsInUnit);
|
||||||
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
|
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [el] = elementsInUnit;
|
const [el] = elementsInUnit;
|
||||||
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,7 @@ import type { DragInputCallbackType } from "./DragInput";
|
|||||||
import { getStepSizedValue, moveElement } from "./utils";
|
import { getStepSizedValue, moveElement } from "./utils";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import { clamp, pointFrom, pointRotateRads, round } from "../../../math";
|
import { pointFrom, pointRotateRads } from "../../../math";
|
||||||
import { isImageElement } from "../../element/typeChecks";
|
|
||||||
import {
|
|
||||||
getFlipAdjustedCropPosition,
|
|
||||||
getUncroppedWidthAndHeight,
|
|
||||||
} from "../../element/cropElement";
|
|
||||||
import { mutateElement } from "../../element/mutateElement";
|
|
||||||
|
|
||||||
interface PositionProps {
|
interface PositionProps {
|
||||||
property: "x" | "y";
|
property: "x" | "y";
|
||||||
@@ -24,14 +18,12 @@ const STEP_SIZE = 10;
|
|||||||
|
|
||||||
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
||||||
accumulatedChange,
|
accumulatedChange,
|
||||||
instantChange,
|
|
||||||
originalElements,
|
originalElements,
|
||||||
originalElementsMap,
|
originalElementsMap,
|
||||||
shouldChangeByStepSize,
|
shouldChangeByStepSize,
|
||||||
nextValue,
|
nextValue,
|
||||||
property,
|
property,
|
||||||
scene,
|
scene,
|
||||||
originalAppState,
|
|
||||||
}) => {
|
}) => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const elements = scene.getNonDeletedElements();
|
const elements = scene.getNonDeletedElements();
|
||||||
@@ -46,82 +38,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
|||||||
origElement.angle,
|
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) {
|
if (nextValue !== undefined) {
|
||||||
const newTopLeftX = property === "x" ? nextValue : topLeftX;
|
const newTopLeftX = property === "x" ? nextValue : topLeftX;
|
||||||
const newTopLeftY = property === "y" ? nextValue : topLeftY;
|
const newTopLeftY = property === "y" ? nextValue : topLeftY;
|
||||||
@@ -181,22 +97,8 @@ const Position = ({
|
|||||||
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
|
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
let value = round(property === "x" ? topLeftX : topLeftY, 2);
|
const value =
|
||||||
|
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
||||||
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 (
|
return (
|
||||||
<StatsDragInput
|
<StatsDragInput
|
||||||
|
|||||||
@@ -23,15 +23,12 @@ import Collapsible from "./Collapsible";
|
|||||||
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
||||||
import { getAtomicUnits } from "./utils";
|
import { getAtomicUnits } from "./utils";
|
||||||
import { STATS_PANELS } from "../../constants";
|
import { STATS_PANELS } from "../../constants";
|
||||||
import { isElbowArrow, isImageElement } from "../../element/typeChecks";
|
import { isElbowArrow } from "../../element/typeChecks";
|
||||||
import CanvasGrid from "./CanvasGrid";
|
import CanvasGrid from "./CanvasGrid";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import "./Stats.scss";
|
import "./Stats.scss";
|
||||||
import { isGridModeEnabled } from "../../snapping";
|
import { isGridModeEnabled } from "../../snapping";
|
||||||
import { getUncroppedWidthAndHeight } from "../../element/cropElement";
|
|
||||||
import { round } from "../../../math";
|
|
||||||
import { frameAndChildrenSelectedTogether } from "../../frame";
|
|
||||||
|
|
||||||
interface StatsProps {
|
interface StatsProps {
|
||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
@@ -131,13 +128,6 @@ export const StatsInner = memo(
|
|||||||
const multipleElements =
|
const multipleElements =
|
||||||
selectedElements.length > 1 ? selectedElements : null;
|
selectedElements.length > 1 ? selectedElements : null;
|
||||||
|
|
||||||
const cropMode =
|
|
||||||
appState.croppingElementId && isImageElement(singleElement);
|
|
||||||
|
|
||||||
const unCroppedDimension = cropMode
|
|
||||||
? getUncroppedWidthAndHeight(singleElement)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const [sceneDimension, setSceneDimension] = useState<{
|
const [sceneDimension, setSceneDimension] = useState<{
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -171,10 +161,6 @@ export const StatsInner = memo(
|
|||||||
return getAtomicUnits(selectedElements, appState);
|
return getAtomicUnits(selectedElements, appState);
|
||||||
}, [selectedElements, appState]);
|
}, [selectedElements, appState]);
|
||||||
|
|
||||||
const _frameAndChildrenSelectedTogether = useMemo(() => {
|
|
||||||
return frameAndChildrenSelectedTogether(selectedElements);
|
|
||||||
}, [selectedElements]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="exc-stats">
|
<div className="exc-stats">
|
||||||
<Island padding={3}>
|
<Island padding={3}>
|
||||||
@@ -231,7 +217,7 @@ export const StatsInner = memo(
|
|||||||
{renderCustomStats?.(elements, appState)}
|
{renderCustomStats?.(elements, appState)}
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
|
{selectedElements.length > 0 && (
|
||||||
<div
|
<div
|
||||||
id="elementStats"
|
id="elementStats"
|
||||||
style={{
|
style={{
|
||||||
@@ -258,34 +244,8 @@ export const StatsInner = memo(
|
|||||||
<StatsRows>
|
<StatsRows>
|
||||||
{singleElement && (
|
{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">
|
<StatsRow heading data-testid="stats-element-type">
|
||||||
{appState.croppingElementId
|
{t(`element.${singleElement.type}`)}
|
||||||
? t("labels.imageCropping")
|
|
||||||
: t(`element.${singleElement.type}`)}
|
|
||||||
</StatsRow>
|
</StatsRow>
|
||||||
|
|
||||||
<StatsRow>
|
<StatsRow>
|
||||||
@@ -427,8 +387,7 @@ export const StatsInner = memo(
|
|||||||
prev.selectedElements === next.selectedElements &&
|
prev.selectedElements === next.selectedElements &&
|
||||||
prev.appState.stats.panels === next.appState.stats.panels &&
|
prev.appState.stats.panels === next.appState.stats.panels &&
|
||||||
prev.gridModeEnabled === next.gridModeEnabled &&
|
prev.gridModeEnabled === next.gridModeEnabled &&
|
||||||
prev.appState.gridStep === next.appState.gridStep &&
|
prev.appState.gridStep === next.appState.gridStep
|
||||||
prev.appState.croppingElementId === next.appState.croppingElementId
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,17 @@ import {
|
|||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
} from "../../element/binding";
|
} from "../../element/binding";
|
||||||
import { mutateElement } from "../../element/mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import { getBoundTextElement } from "../../element/textElement";
|
import {
|
||||||
|
measureFontSizeFromWidth,
|
||||||
|
rescalePointsInElement,
|
||||||
|
} from "../../element/resizeElements";
|
||||||
|
import {
|
||||||
|
getApproxMinLineHeight,
|
||||||
|
getApproxMinLineWidth,
|
||||||
|
getBoundTextElement,
|
||||||
|
getBoundTextMaxWidth,
|
||||||
|
handleBindTextResize,
|
||||||
|
} from "../../element/textElement";
|
||||||
import {
|
import {
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
@@ -24,6 +34,7 @@ import {
|
|||||||
} from "../../groups";
|
} from "../../groups";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
import { getFontString } from "../../utils";
|
||||||
|
|
||||||
export type StatsInputProperty =
|
export type StatsInputProperty =
|
||||||
| "x"
|
| "x"
|
||||||
@@ -110,6 +121,95 @@ 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 = (
|
export const moveElement = (
|
||||||
newTopLeftX: number,
|
newTopLeftX: number,
|
||||||
newTopLeftY: number,
|
newTopLeftY: number,
|
||||||
@@ -200,7 +300,6 @@ export const updateBindings = (
|
|||||||
options?: {
|
options?: {
|
||||||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||||
newSize?: { width: number; height: number };
|
newSize?: { width: number; height: number };
|
||||||
zoom?: AppState["zoom"];
|
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (isLinearElement(latestElement)) {
|
if (isLinearElement(latestElement)) {
|
||||||
@@ -211,7 +310,6 @@ export const updateBindings = (
|
|||||||
scene,
|
scene,
|
||||||
true,
|
true,
|
||||||
[],
|
[],
|
||||||
options?.zoom,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updateBoundElements(latestElement, elementsMap, options);
|
updateBoundElements(latestElement, elementsMap, options);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import type { BinaryFiles } from "../../types";
|
|||||||
import { ArrowRightIcon } from "../icons";
|
import { ArrowRightIcon } from "../icons";
|
||||||
|
|
||||||
import "./TTDDialog.scss";
|
import "./TTDDialog.scss";
|
||||||
import { atom, useAtom } from "../../editor-jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
||||||
|
|||||||
@@ -91,12 +91,16 @@ export const convertMermaidToExcalidraw = async ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canvas = await exportToCanvas({
|
const canvas = await exportToCanvas({
|
||||||
elements: data.current.elements,
|
data: {
|
||||||
files: data.current.files,
|
elements: data.current.elements,
|
||||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
files: data.current.files,
|
||||||
maxWidthOrHeight:
|
},
|
||||||
Math.max(parent.offsetWidth, parent.offsetHeight) *
|
config: {
|
||||||
window.devicePixelRatio,
|
padding: DEFAULT_EXPORT_PADDING,
|
||||||
|
maxWidthOrHeight:
|
||||||
|
Math.max(parent.offsetWidth, parent.offsetHeight) *
|
||||||
|
window.devicePixelRatio,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// if converting to blob fails, there's some problem that will
|
// if converting to blob fails, there's some problem that will
|
||||||
// likely prevent preview and export (e.g. canvas too big)
|
// likely prevent preview and export (e.g. canvas too big)
|
||||||
|
|||||||
@@ -55,152 +55,146 @@ type ToolButtonProps =
|
|||||||
onPointerDown?(data: { pointerType: PointerType }): void;
|
onPointerDown?(data: { pointerType: PointerType }): void;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ToolButton = React.forwardRef(
|
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||||
(
|
const { id: excalId } = useExcalidrawContainer();
|
||||||
{
|
const innerRef = React.useRef(null);
|
||||||
size = "medium",
|
React.useImperativeHandle(ref, () => innerRef.current);
|
||||||
visible = true,
|
const sizeCn = `ToolIcon_size_${props.size}`;
|
||||||
className = "",
|
|
||||||
...props
|
|
||||||
}: ToolButtonProps,
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const { id: excalId } = useExcalidrawContainer();
|
|
||||||
const innerRef = React.useRef(null);
|
|
||||||
React.useImperativeHandle(ref, () => innerRef.current);
|
|
||||||
const sizeCn = `ToolIcon_size_${size}`;
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
const onClick = async (event: React.MouseEvent) => {
|
const onClick = async (event: React.MouseEvent) => {
|
||||||
const ret = "onClick" in props && props.onClick?.(event);
|
const ret = "onClick" in props && props.onClick?.(event);
|
||||||
|
|
||||||
if (isPromiseLike(ret)) {
|
if (isPromiseLike(ret)) {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await ret;
|
await ret;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (!(error instanceof AbortError)) {
|
if (!(error instanceof AbortError)) {
|
||||||
throw error;
|
throw error;
|
||||||
} else {
|
} else {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMountedRef.current = true;
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
|
||||||
|
|
||||||
if (
|
|
||||||
props.type === "button" ||
|
|
||||||
props.type === "icon" ||
|
|
||||||
props.type === "submit"
|
|
||||||
) {
|
|
||||||
const type = (props.type === "icon" ? "button" : props.type) as
|
|
||||||
| "button"
|
|
||||||
| "submit";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon_type_button",
|
|
||||||
sizeCn,
|
|
||||||
className,
|
|
||||||
visible && !props.hidden
|
|
||||||
? "ToolIcon_type_button--show"
|
|
||||||
: "ToolIcon_type_button--hide",
|
|
||||||
{
|
|
||||||
ToolIcon: !props.hidden,
|
|
||||||
"ToolIcon--selected": props.selected,
|
|
||||||
"ToolIcon--plain": props.type === "icon",
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
style={props.style}
|
|
||||||
data-testid={props["data-testid"]}
|
|
||||||
hidden={props.hidden}
|
|
||||||
title={props.title}
|
|
||||||
aria-label={props["aria-label"]}
|
|
||||||
type={type}
|
|
||||||
onClick={onClick}
|
|
||||||
ref={innerRef}
|
|
||||||
disabled={isLoading || props.isLoading || !!props.disabled}
|
|
||||||
>
|
|
||||||
{(props.icon || props.label) && (
|
|
||||||
<div
|
|
||||||
className="ToolIcon__icon"
|
|
||||||
aria-hidden="true"
|
|
||||||
aria-disabled={!!props.disabled}
|
|
||||||
>
|
|
||||||
{props.icon || props.label}
|
|
||||||
{props.keyBindingLabel && (
|
|
||||||
<span className="ToolIcon__keybinding">
|
|
||||||
{props.keyBindingLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{props.isLoading && <Spinner />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{props.showAriaLabel && (
|
|
||||||
<div className="ToolIcon__label">
|
|
||||||
{props["aria-label"]} {isLoading && <Spinner />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{props.children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.type === "button" ||
|
||||||
|
props.type === "icon" ||
|
||||||
|
props.type === "submit"
|
||||||
|
) {
|
||||||
|
const type = (props.type === "icon" ? "button" : props.type) as
|
||||||
|
| "button"
|
||||||
|
| "submit";
|
||||||
return (
|
return (
|
||||||
<label
|
<button
|
||||||
className={clsx("ToolIcon", className)}
|
className={clsx(
|
||||||
|
"ToolIcon_type_button",
|
||||||
|
sizeCn,
|
||||||
|
props.className,
|
||||||
|
props.visible && !props.hidden
|
||||||
|
? "ToolIcon_type_button--show"
|
||||||
|
: "ToolIcon_type_button--hide",
|
||||||
|
{
|
||||||
|
ToolIcon: !props.hidden,
|
||||||
|
"ToolIcon--selected": props.selected,
|
||||||
|
"ToolIcon--plain": props.type === "icon",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={props.style}
|
||||||
|
data-testid={props["data-testid"]}
|
||||||
|
hidden={props.hidden}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
onPointerDown={(event) => {
|
aria-label={props["aria-label"]}
|
||||||
lastPointerTypeRef.current = event.pointerType || null;
|
type={type}
|
||||||
props.onPointerDown?.({ pointerType: event.pointerType || null });
|
onClick={onClick}
|
||||||
}}
|
ref={innerRef}
|
||||||
onPointerUp={() => {
|
disabled={isLoading || props.isLoading || !!props.disabled}
|
||||||
requestAnimationFrame(() => {
|
|
||||||
lastPointerTypeRef.current = null;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input
|
{(props.icon || props.label) && (
|
||||||
className={`ToolIcon_type_radio ${sizeCn}`}
|
<div
|
||||||
type="radio"
|
className="ToolIcon__icon"
|
||||||
name={props.name}
|
aria-hidden="true"
|
||||||
aria-label={props["aria-label"]}
|
aria-disabled={!!props.disabled}
|
||||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
>
|
||||||
data-testid={props["data-testid"]}
|
{props.icon || props.label}
|
||||||
id={`${excalId}-${props.id}`}
|
{props.keyBindingLabel && (
|
||||||
onChange={() => {
|
<span className="ToolIcon__keybinding">
|
||||||
props.onChange?.({ pointerType: lastPointerTypeRef.current });
|
{props.keyBindingLabel}
|
||||||
}}
|
</span>
|
||||||
checked={props.checked}
|
)}
|
||||||
ref={innerRef}
|
{props.isLoading && <Spinner />}
|
||||||
/>
|
</div>
|
||||||
<div className="ToolIcon__icon">
|
)}
|
||||||
{props.icon}
|
{props.showAriaLabel && (
|
||||||
{props.keyBindingLabel && (
|
<div className="ToolIcon__label">
|
||||||
<span className="ToolIcon__keybinding">
|
{props["aria-label"]} {isLoading && <Spinner />}
|
||||||
{props.keyBindingLabel}
|
</div>
|
||||||
</span>
|
)}
|
||||||
)}
|
{props.children}
|
||||||
</div>
|
</button>
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={clsx("ToolIcon", props.className)}
|
||||||
|
title={props.title}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
lastPointerTypeRef.current = event.pointerType || null;
|
||||||
|
props.onPointerDown?.({ pointerType: event.pointerType || null });
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
lastPointerTypeRef.current = null;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={`ToolIcon_type_radio ${sizeCn}`}
|
||||||
|
type="radio"
|
||||||
|
name={props.name}
|
||||||
|
aria-label={props["aria-label"]}
|
||||||
|
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||||
|
data-testid={props["data-testid"]}
|
||||||
|
id={`${excalId}-${props.id}`}
|
||||||
|
onChange={() => {
|
||||||
|
props.onChange?.({ pointerType: lastPointerTypeRef.current });
|
||||||
|
}}
|
||||||
|
checked={props.checked}
|
||||||
|
ref={innerRef}
|
||||||
|
/>
|
||||||
|
<div className="ToolIcon__icon">
|
||||||
|
{props.icon}
|
||||||
|
{props.keyBindingLabel && (
|
||||||
|
<span className="ToolIcon__keybinding">{props.keyBindingLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ToolButton.defaultProps = {
|
||||||
|
visible: true,
|
||||||
|
className: "",
|
||||||
|
size: "medium",
|
||||||
|
};
|
||||||
|
|
||||||
ToolButton.displayName = "ToolButton";
|
ToolButton.displayName = "ToolButton";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import fallbackLangData from "../locales/en.json";
|
|||||||
|
|
||||||
import Trans from "./Trans";
|
import Trans from "./Trans";
|
||||||
import type { TranslationKeys } from "../i18n";
|
import type { TranslationKeys } from "../i18n";
|
||||||
import { EditorJotaiProvider } from "../editor-jotai";
|
|
||||||
|
|
||||||
describe("Test <Trans/>", () => {
|
describe("Test <Trans/>", () => {
|
||||||
it("should translate the the strings correctly", () => {
|
it("should translate the the strings correctly", () => {
|
||||||
@@ -18,7 +17,7 @@ describe("Test <Trans/>", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { getByTestId } = render(
|
const { getByTestId } = render(
|
||||||
<EditorJotaiProvider>
|
<>
|
||||||
<div data-testid="test1">
|
<div data-testid="test1">
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey={"transTest.key1" as unknown as TranslationKeys}
|
i18nKey={"transTest.key1" as unknown as TranslationKeys}
|
||||||
@@ -52,7 +51,7 @@ describe("Test <Trans/>", () => {
|
|||||||
connect-link={(el) => <a href="https://example.com">{el}</a>}
|
connect-link={(el) => <a href="https://example.com">{el}</a>}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</EditorJotaiProvider>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
|
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ const getRelevantAppStateProps = (
|
|||||||
width: appState.width,
|
width: appState.width,
|
||||||
height: appState.height,
|
height: appState.height,
|
||||||
viewModeEnabled: appState.viewModeEnabled,
|
viewModeEnabled: appState.viewModeEnabled,
|
||||||
openDialog: appState.openDialog,
|
|
||||||
editingGroupId: appState.editingGroupId,
|
editingGroupId: appState.editingGroupId,
|
||||||
editingLinearElement: appState.editingLinearElement,
|
editingLinearElement: appState.editingLinearElement,
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
|||||||
@@ -92,14 +92,11 @@ const getRelevantAppStateProps = (
|
|||||||
width: appState.width,
|
width: appState.width,
|
||||||
height: appState.height,
|
height: appState.height,
|
||||||
viewModeEnabled: appState.viewModeEnabled,
|
viewModeEnabled: appState.viewModeEnabled,
|
||||||
openDialog: appState.openDialog,
|
|
||||||
hoveredElementIds: appState.hoveredElementIds,
|
|
||||||
offsetLeft: appState.offsetLeft,
|
offsetLeft: appState.offsetLeft,
|
||||||
offsetTop: appState.offsetTop,
|
offsetTop: appState.offsetTop,
|
||||||
theme: appState.theme,
|
theme: appState.theme,
|
||||||
pendingImageElementId: appState.pendingImageElementId,
|
pendingImageElementId: appState.pendingImageElementId,
|
||||||
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
|
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
|
||||||
exportScale: appState.exportScale,
|
exportScale: appState.exportScale,
|
||||||
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
|
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { atom, useAtom } from "jotai";
|
||||||
import React, { useLayoutEffect, useRef } from "react";
|
import React, { useLayoutEffect, useRef } from "react";
|
||||||
import { useTunnels } from "../../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
import { atom } from "../../editor-jotai";
|
|
||||||
|
|
||||||
export const withInternalFallback = <P,>(
|
export const withInternalFallback = <P,>(
|
||||||
componentName: string,
|
componentName: string,
|
||||||
@@ -13,11 +13,9 @@ export const withInternalFallback = <P,>(
|
|||||||
__fallback?: boolean;
|
__fallback?: boolean;
|
||||||
}
|
}
|
||||||
> = (props) => {
|
> = (props) => {
|
||||||
const {
|
const { jotaiScope } = useTunnels();
|
||||||
tunnelsJotai: { useAtom },
|
|
||||||
} = useTunnels();
|
|
||||||
// for rerenders
|
// for rerenders
|
||||||
const [, setCounter] = useAtom(renderAtom);
|
const [, setCounter] = useAtom(renderAtom, jotaiScope);
|
||||||
// for initial & subsequent renders. Tracked as component state
|
// for initial & subsequent renders. Tracked as component state
|
||||||
// due to excalidraw multi-instance scanerios.
|
// due to excalidraw multi-instance scanerios.
|
||||||
const metaRef = useRef({
|
const metaRef = useRef({
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
|
|
||||||
import { ToolButton } from "../ToolButton";
|
import { ToolButton } from "../ToolButton";
|
||||||
import { FreedrawIcon, TrashIcon, elementLinkIcon } from "../icons";
|
import { FreedrawIcon, TrashIcon } from "../icons";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -30,19 +30,18 @@ import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip";
|
|||||||
import { getSelectedElements } from "../../scene";
|
import { getSelectedElements } from "../../scene";
|
||||||
import { hitElementBoundingBox } from "../../element/collision";
|
import { hitElementBoundingBox } from "../../element/collision";
|
||||||
import { isLocalLink, normalizeLink } from "../../data/url";
|
import { isLocalLink, normalizeLink } from "../../data/url";
|
||||||
|
|
||||||
|
import "./Hyperlink.scss";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
import { useAppProps, useDevice, useExcalidrawAppState } from "../App";
|
import { useAppProps, useExcalidrawAppState } from "../App";
|
||||||
import { isEmbeddableElement } from "../../element/typeChecks";
|
import { isEmbeddableElement } from "../../element/typeChecks";
|
||||||
import { getLinkHandleFromCoords } from "./helpers";
|
import { getLinkHandleFromCoords } from "./helpers";
|
||||||
import { pointFrom, type GlobalPoint } from "../../../math";
|
import { pointFrom, type GlobalPoint } from "../../../math";
|
||||||
import { isElementLink } from "../../element/elementLink";
|
|
||||||
|
|
||||||
import "./Hyperlink.scss";
|
const CONTAINER_WIDTH = 320;
|
||||||
|
|
||||||
const POPUP_WIDTH = 380;
|
|
||||||
const POPUP_HEIGHT = 42;
|
|
||||||
const POPUP_PADDING = 5;
|
|
||||||
const SPACE_BOTTOM = 85;
|
const SPACE_BOTTOM = 85;
|
||||||
|
const CONTAINER_PADDING = 5;
|
||||||
|
const CONTAINER_HEIGHT = 42;
|
||||||
const AUTO_HIDE_TIMEOUT = 500;
|
const AUTO_HIDE_TIMEOUT = 500;
|
||||||
|
|
||||||
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
|
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
|
||||||
@@ -74,7 +73,6 @@ export const Hyperlink = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const appState = useExcalidrawAppState();
|
const appState = useExcalidrawAppState();
|
||||||
const appProps = useAppProps();
|
const appProps = useAppProps();
|
||||||
const device = useDevice();
|
|
||||||
|
|
||||||
const linkVal = element.link || "";
|
const linkVal = element.link || "";
|
||||||
|
|
||||||
@@ -170,19 +168,8 @@ export const Hyperlink = ({
|
|||||||
};
|
};
|
||||||
}, [handleSubmit]);
|
}, [handleSubmit]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
isEditing &&
|
|
||||||
inputRef?.current &&
|
|
||||||
!(device.viewport.isMobile || device.isTouchScreen)
|
|
||||||
) {
|
|
||||||
inputRef.current.select();
|
|
||||||
}
|
|
||||||
}, [isEditing, device.viewport.isMobile, device.isTouchScreen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeoutId: number | null = null;
|
let timeoutId: number | null = null;
|
||||||
|
|
||||||
const handlePointerMove = (event: PointerEvent) => {
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return;
|
return;
|
||||||
@@ -214,8 +201,11 @@ export const Hyperlink = ({
|
|||||||
const handleRemove = useCallback(() => {
|
const handleRemove = useCallback(() => {
|
||||||
trackEvent("hyperlink", "delete");
|
trackEvent("hyperlink", "delete");
|
||||||
mutateElement(element, { link: null });
|
mutateElement(element, { link: null });
|
||||||
|
if (isEditing) {
|
||||||
|
inputRef.current!.value = "";
|
||||||
|
}
|
||||||
setAppState({ showHyperlinkPopup: false });
|
setAppState({ showHyperlinkPopup: false });
|
||||||
}, [setAppState, element]);
|
}, [setAppState, element, isEditing]);
|
||||||
|
|
||||||
const onEdit = () => {
|
const onEdit = () => {
|
||||||
trackEvent("hyperlink", "edit", "popup-ui");
|
trackEvent("hyperlink", "edit", "popup-ui");
|
||||||
@@ -239,14 +229,19 @@ export const Hyperlink = ({
|
|||||||
style={{
|
style={{
|
||||||
top: `${y}px`,
|
top: `${y}px`,
|
||||||
left: `${x}px`,
|
left: `${x}px`,
|
||||||
width: POPUP_WIDTH,
|
width: CONTAINER_WIDTH,
|
||||||
padding: POPUP_PADDING,
|
padding: CONTAINER_PADDING,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!element.link && !isEditing) {
|
||||||
|
setAppState({ showHyperlinkPopup: "editor" });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
<input
|
||||||
className={clsx("excalidraw-hyperlinkContainer-input")}
|
className={clsx("excalidraw-hyperlinkContainer-input")}
|
||||||
placeholder={t("labels.link.hint")}
|
placeholder="Type or paste your link here"
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={inputVal}
|
value={inputVal}
|
||||||
onChange={(event) => setInputVal(event.target.value)}
|
onChange={(event) => setInputVal(event.target.value)}
|
||||||
@@ -307,21 +302,6 @@ export const Hyperlink = ({
|
|||||||
icon={FreedrawIcon}
|
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) && (
|
{linkVal && !isEmbeddableElement(element) && (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -348,7 +328,7 @@ const getCoordsForPopover = (
|
|||||||
{ sceneX: x1 + element.width / 2, sceneY: y1 },
|
{ sceneX: x1 + element.width / 2, sceneY: y1 },
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
const x = viewportX - appState.offsetLeft - POPUP_WIDTH / 2;
|
const x = viewportX - appState.offsetLeft - CONTAINER_WIDTH / 2;
|
||||||
const y = viewportY - appState.offsetTop - SPACE_BOTTOM;
|
const y = viewportY - appState.offsetTop - SPACE_BOTTOM;
|
||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
@@ -358,10 +338,12 @@ export const getContextMenuLabel = (
|
|||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
const label = isEmbeddableElement(selectedElements[0])
|
const label = selectedElements[0]?.link
|
||||||
? "labels.link.editEmbed"
|
? isEmbeddableElement(selectedElements[0])
|
||||||
: selectedElements[0]?.link
|
? "labels.link.editEmbed"
|
||||||
? "labels.link.edit"
|
: "labels.link.edit"
|
||||||
|
: isEmbeddableElement(selectedElements[0])
|
||||||
|
? "labels.link.createEmbed"
|
||||||
: "labels.link.create";
|
: "labels.link.create";
|
||||||
return label;
|
return label;
|
||||||
};
|
};
|
||||||
@@ -394,9 +376,7 @@ const renderTooltip = (
|
|||||||
|
|
||||||
tooltipDiv.classList.add("excalidraw-tooltip--visible");
|
tooltipDiv.classList.add("excalidraw-tooltip--visible");
|
||||||
tooltipDiv.style.maxWidth = "20rem";
|
tooltipDiv.style.maxWidth = "20rem";
|
||||||
tooltipDiv.textContent = isElementLink(element.link)
|
tooltipDiv.textContent = element.link;
|
||||||
? t("labels.link.goToElement")
|
|
||||||
: element.link;
|
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
|
||||||
@@ -470,9 +450,9 @@ const shouldHideLinkPopup = (
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
clientX >= popoverX - threshold &&
|
clientX >= popoverX - threshold &&
|
||||||
clientX <= popoverX + POPUP_WIDTH + POPUP_PADDING * 2 + threshold &&
|
clientX <= popoverX + CONTAINER_WIDTH + CONTAINER_PADDING * 2 + threshold &&
|
||||||
clientY >= popoverY - threshold &&
|
clientY >= popoverY - threshold &&
|
||||||
clientY <= popoverY + threshold + POPUP_PADDING * 2 + POPUP_HEIGHT
|
clientY <= popoverY + threshold + CONTAINER_PADDING * 2 + CONTAINER_HEIGHT
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ 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>`,
|
`<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 = (
|
export const getLinkHandleFromCoords = (
|
||||||
[x1, y1, x2, y2]: Bounds,
|
[x1, y1, x2, y2]: Bounds,
|
||||||
angle: Radians,
|
angle: Radians,
|
||||||
|
|||||||
@@ -1216,12 +1216,11 @@ export const EdgeRoundIcon = createIcon(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ArrowheadNoneIcon = createIcon(
|
export const ArrowheadNoneIcon = createIcon(
|
||||||
<g stroke="currentColor" opacity={0.3} strokeWidth={2}>
|
<path d="M6 10H34" stroke="currentColor" strokeWidth={2} fill="none" />,
|
||||||
<path d="M12 12l9 0" />
|
{
|
||||||
<path d="M3 9l6 6" />
|
width: 40,
|
||||||
<path d="M3 15l6 -6" />
|
height: 20,
|
||||||
</g>,
|
},
|
||||||
tablerIconProps,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ArrowheadArrowIcon = React.memo(
|
export const ArrowheadArrowIcon = React.memo(
|
||||||
@@ -1353,54 +1352,6 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ArrowheadCrowfootIcon = React.memo(
|
|
||||||
({ flip = false }: { flip?: boolean }) =>
|
|
||||||
createIcon(
|
|
||||||
<g
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="none"
|
|
||||||
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
|
|
||||||
</g>,
|
|
||||||
{ width: 40, height: 20 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ArrowheadCrowfootOneIcon = React.memo(
|
|
||||||
({ flip = false }: { flip?: boolean }) =>
|
|
||||||
createIcon(
|
|
||||||
<g
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="none"
|
|
||||||
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
|
|
||||||
</g>,
|
|
||||||
{ width: 40, height: 20 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
|
|
||||||
({ flip = false }: { flip?: boolean }) =>
|
|
||||||
createIcon(
|
|
||||||
<g
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="none"
|
|
||||||
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
|
|
||||||
</g>,
|
|
||||||
{ width: 40, height: 20 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const FontSizeSmallIcon = createIcon(
|
export const FontSizeSmallIcon = createIcon(
|
||||||
<>
|
<>
|
||||||
<g clipPath="url(#a)">
|
<g clipPath="url(#a)">
|
||||||
@@ -2205,18 +2156,3 @@ export const cropIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const elementLinkIcon = createIcon(
|
|
||||||
<g>
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
|
||||||
<path d="M5 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
|
||||||
<path d="M19 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
|
||||||
<path d="M5 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
|
||||||
<path d="M19 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
|
||||||
<path d="M5 7l0 10" />
|
|
||||||
<path d="M7 5l10 0" />
|
|
||||||
<path d="M7 19l10 0" />
|
|
||||||
<path d="M19 7l0 10" />
|
|
||||||
</g>,
|
|
||||||
tablerIconProps,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ import {
|
|||||||
actionToggleTheme,
|
actionToggleTheme,
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import { useSetAtom } from "../../editor-jotai";
|
import { jotaiScope } from "../../jotai";
|
||||||
import { useUIAppState } from "../../context/ui-appState";
|
import { useUIAppState } from "../../context/ui-appState";
|
||||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||||
import Trans from "../Trans";
|
import Trans from "../Trans";
|
||||||
@@ -188,7 +189,10 @@ Help.displayName = "Help";
|
|||||||
export const ClearCanvas = () => {
|
export const ClearCanvas = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
|
const setActiveConfirmDialog = useSetAtom(
|
||||||
|
activeConfirmDialogAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
|
||||||
if (!actionManager.isActionEnabled(actionClearCanvas)) {
|
if (!actionManager.isActionEnabled(actionClearCanvas)) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.excalifont {
|
.excalifont {
|
||||||
font-family: "Excalifont", "Xiaolai";
|
font-family: "Excalifont";
|
||||||
}
|
}
|
||||||
|
|
||||||
// WelcomeSreen common
|
// WelcomeSreen common
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import cssVariables from "./css/variables.module.scss";
|
import cssVariables from "./css/variables.module.scss";
|
||||||
import type { AppProps, AppState } from "./types";
|
import type { AppProps, AppState } from "./types";
|
||||||
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
|
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
|
||||||
import { COLOR_PALETTE } from "./colors";
|
import type { NormalizedZoomValue } from "./types";
|
||||||
|
|
||||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||||
export const isWindows = /^Win/.test(navigator.platform);
|
export const isWindows = /^Win/.test(navigator.platform);
|
||||||
@@ -108,7 +108,6 @@ export const YOUTUBE_STATES = {
|
|||||||
|
|
||||||
export const ENV = {
|
export const ENV = {
|
||||||
TEST: "test",
|
TEST: "test",
|
||||||
DEVELOPMENT: "development",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CLASSES = {
|
export const CLASSES = {
|
||||||
@@ -184,6 +183,14 @@ export const DEFAULT_TEXT_ALIGN = "left";
|
|||||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||||
export const DEFAULT_VERSION = "{version}";
|
export const DEFAULT_VERSION = "{version}";
|
||||||
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
|
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;
|
export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
|
||||||
// a small epsilon to make side resizing always take precedence
|
// a small epsilon to make side resizing always take precedence
|
||||||
@@ -192,8 +199,6 @@ const EPSILON = 0.00001;
|
|||||||
export const DEFAULT_COLLISION_THRESHOLD =
|
export const DEFAULT_COLLISION_THRESHOLD =
|
||||||
2 * SIDE_RESIZING_THRESHOLD - EPSILON;
|
2 * SIDE_RESIZING_THRESHOLD - EPSILON;
|
||||||
|
|
||||||
export const COLOR_WHITE = "#ffffff";
|
|
||||||
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
|
|
||||||
// keep this in sync with CSS
|
// keep this in sync with CSS
|
||||||
export const COLOR_VOICE_CALL = "#a2f1a6";
|
export const COLOR_VOICE_CALL = "#a2f1a6";
|
||||||
|
|
||||||
@@ -214,9 +219,9 @@ export const IMAGE_MIME_TYPES = {
|
|||||||
jfif: "image/jfif",
|
jfif: "image/jfif",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
|
||||||
|
|
||||||
export const MIME_TYPES = {
|
export const MIME_TYPES = {
|
||||||
text: "text/plain",
|
|
||||||
html: "text/html",
|
|
||||||
json: "application/json",
|
json: "application/json",
|
||||||
// excalidraw data
|
// excalidraw data
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
@@ -230,12 +235,6 @@ export const MIME_TYPES = {
|
|||||||
...IMAGE_MIME_TYPES,
|
...IMAGE_MIME_TYPES,
|
||||||
} as const;
|
} 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 = {
|
export const EXPORT_IMAGE_TYPES = {
|
||||||
png: "png",
|
png: "png",
|
||||||
svg: "svg",
|
svg: "svg",
|
||||||
@@ -310,6 +309,7 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
|||||||
|
|
||||||
export const EXPORT_SCALES = [1, 2, 3];
|
export const EXPORT_SCALES = [1, 2, 3];
|
||||||
export const DEFAULT_EXPORT_PADDING = 10; // px
|
export const DEFAULT_EXPORT_PADDING = 10; // px
|
||||||
|
export const DEFAULT_SMALLEST_EXPORT_SIZE = 20; // px
|
||||||
|
|
||||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||||
|
|
||||||
@@ -390,8 +390,8 @@ export const DEFAULT_ELEMENT_PROPS: {
|
|||||||
opacity: ExcalidrawElement["opacity"];
|
opacity: ExcalidrawElement["opacity"];
|
||||||
locked: ExcalidrawElement["locked"];
|
locked: ExcalidrawElement["locked"];
|
||||||
} = {
|
} = {
|
||||||
strokeColor: COLOR_PALETTE.black,
|
strokeColor: COLOR_CHARCOAL_BLACK,
|
||||||
backgroundColor: COLOR_PALETTE.transparent,
|
backgroundColor: COLOR_TRANSPARENT,
|
||||||
fillStyle: "solid",
|
fillStyle: "solid",
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
strokeStyle: "solid",
|
strokeStyle: "solid",
|
||||||
@@ -455,9 +455,3 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
|
|||||||
round: "round",
|
round: "round",
|
||||||
elbow: "elbow",
|
elbow: "elbow",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
|
|
||||||
export const ELEMENT_LINK_KEY = "element";
|
|
||||||
|
|
||||||
/** used in tests */
|
|
||||||
export const ORIG_ID = Symbol.for("__test__originalId__");
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import tunnel from "tunnel-rat";
|
import tunnel from "tunnel-rat";
|
||||||
import { createIsolation } from "jotai-scope";
|
|
||||||
|
|
||||||
export type Tunnel = ReturnType<typeof tunnel>;
|
export type Tunnel = ReturnType<typeof tunnel>;
|
||||||
|
|
||||||
@@ -15,17 +14,13 @@ type TunnelsContextValue = {
|
|||||||
DefaultSidebarTabTriggersTunnel: Tunnel;
|
DefaultSidebarTabTriggersTunnel: Tunnel;
|
||||||
OverwriteConfirmDialogTunnel: Tunnel;
|
OverwriteConfirmDialogTunnel: Tunnel;
|
||||||
TTDDialogTriggerTunnel: Tunnel;
|
TTDDialogTriggerTunnel: Tunnel;
|
||||||
// this can be removed once we create jotai stores per each editor
|
jotaiScope: symbol;
|
||||||
// instance
|
|
||||||
tunnelsJotai: ReturnType<typeof createIsolation>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
|
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
|
||||||
|
|
||||||
export const useTunnels = () => React.useContext(TunnelsContext);
|
export const useTunnels = () => React.useContext(TunnelsContext);
|
||||||
|
|
||||||
const tunnelsJotai = createIsolation();
|
|
||||||
|
|
||||||
export const useInitializeTunnels = () => {
|
export const useInitializeTunnels = () => {
|
||||||
return React.useMemo((): TunnelsContextValue => {
|
return React.useMemo((): TunnelsContextValue => {
|
||||||
return {
|
return {
|
||||||
@@ -39,7 +34,7 @@ export const useInitializeTunnels = () => {
|
|||||||
DefaultSidebarTabTriggersTunnel: tunnel(),
|
DefaultSidebarTabTriggersTunnel: tunnel(),
|
||||||
OverwriteConfirmDialogTunnel: tunnel(),
|
OverwriteConfirmDialogTunnel: tunnel(),
|
||||||
TTDDialogTriggerTunnel: tunnel(),
|
TTDDialogTriggerTunnel: tunnel(),
|
||||||
tunnelsJotai,
|
jotaiScope: Symbol(),
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user