Compare commits

..

1 Commits

Author SHA1 Message Date
Daniel J. Geiger 7958b7144a feat: Support subtypes for ExcalidrawTextElement 2023-10-14 17:46:57 -05:00
217 changed files with 4882 additions and 11999 deletions
+3
View File
@@ -0,0 +1,3 @@
## 2020-10-13
- Added ability to embed scene source into exported PNG/SVG files so you can import the scene from them (open via `Load` button or drag & drop). #2219
-3
View File
@@ -25,9 +25,6 @@
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
</a>
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
</a>
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
</a>
@@ -34,7 +34,7 @@ Open the `Menu` in the below playground and you will see the `custom footer` ren
```jsx live noInline
const MobileFooter = ({}) => {
const device = useDevice();
if (device.editor.isMobile) {
if (device.isMobile) {
return (
<Footer>
<button
@@ -80,7 +80,7 @@ A given tab trigger button that switches to a given sidebar tab. It must be rend
| `className` | `string` | No | |
| `style` | `React.CSSProperties` | No | |
You can use the [`ref.toggleSidebar({ name: "custom" })`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api#toggleSidebar) api to control the sidebar, but we export a trigger button to make UI use cases easier.
You can use the [`ref.toggleSidebar({ name: "custom" })`](/docs/@excalidraw/excalidraw/api/props/ref#toggleSidebar) api to control the sidebar, but we export a trigger button to make UI use cases easier.
## Example
@@ -10,17 +10,13 @@ The [`ExcalidrawElementSkeleton`](https://github.com/excalidraw/excalidraw/blob/
**_Signature_**
```ts
convertToExcalidrawElements(
elements: ExcalidrawElementSkeleton,
opts?: { regenerateIds: boolean }
): ExcalidrawElement[]
```
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `elements` | [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L137) | | The Excalidraw element Skeleton which needs to be converted to Excalidraw elements. |
| `opts` | `{ regenerateIds: boolean }` | ` {regenerateIds: true}` | By default `id` will be regenerated for all the elements irrespective of whether you pass the `id` so if you don't want the ids to regenerated, you can set this attribute to `false`. |
<pre>
convertToExcalidrawElements(elements:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L133">
ExcalidrawElementSkeleton
</a>
)
</pre>
**_How to use_**
@@ -28,13 +24,13 @@ convertToExcalidrawElements(
import { convertToExcalidrawElements } from "@excalidraw/excalidraw";
```
This function converts the Excalidraw Element Skeleton to excalidraw elements which could be then rendered on the canvas. Hence calling this function is necessary before passing it to APIs like [`initialData`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/initialdata), [`updateScene`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api#updatescene) if you are using the Skeleton API
This function converts the Excalidraw Element Skeleton to excalidraw elements which could be then rendered on the canvas. Hence calling this function is necessary before passing it to APIs like [`initialData`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/initialdata), [`updateScene`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#updatescene) if you are using the Skeleton API
## Supported Features
### Rectangle, Ellipse, and Diamond
To create these shapes you need to pass its `type` and `x` and `y` coordinates for position. The rest of the attributes are optional\_.
To create these shapes you need to pass its `type` and `x` and `y` coordinates for position. The rest of the attributes are optional_.
For the Skeleton API to work, `convertToExcalidrawElements` needs to be called before passing it to Excalidraw Component via initialData, updateScene or any such API.
@@ -431,45 +427,3 @@ convertToExcalidrawElements([
```
![image](https://github.com/excalidraw/excalidraw/assets/11256141/a8b047c8-2eed-4aea-82a2-e1e6bbddb8d4)
### Frames
To create a frame, you need to pass `type`, `children` (list of Excalidraw element ids). The rest of the attributes are optional.
```ts
{
type: "frame";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawFrameElement>);
```
```ts
convertToExcalidrawElements([
{
"type": "rectangle",
"x": 10,
"y": 10,
"strokeWidth": 2,
"id": "1"
},
{
"type": "diamond",
"x": 120,
"y": 20,
"backgroundColor": "#fff3bf",
"strokeWidth": 2,
"label": {
"text": "HELLO EXCALIDRAW",
"strokeColor": "#099268",
"fontSize": 30
},
"id": "2"
},
{
"type": "frame",
"children": ["1", "2"],
"name": "My frame"
}]
}
```
@@ -1,11 +1,11 @@
# Props
All `props` are _optional_.
All `props` are *optional*.
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; null></code> | `null` | The initial data with which app loads. |
| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | _ | Callback triggered with the excalidraw api once rendered |
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; null></code> | `null` | The initial data with which app loads. |
| [`ref`](/docs/@excalidraw/excalidraw/api/props/ref) | `object` | _ | `Ref` to be passed to Excalidraw |
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
@@ -37,7 +37,7 @@ Beyond attributes that Excalidraw elements already support, you can store `custo
You can use this to add any extra information you need to keep track of.
You can add `customData` to elements when passing them as [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata), or using [`updateScene`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api#updatescene) / [`updateLibrary`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api#updatelibrary) afterwards.
You can add `customData` to elements when passing them as [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata), or using [`updateScene`](/docs/@excalidraw/excalidraw/api/props/ref#updatescene) / [`updateLibrary`](/docs/@excalidraw/excalidraw/api/props/ref#updatelibrary) afterwards.
```js showLineNumbers
{
@@ -93,16 +93,8 @@ This callback is triggered when mouse pointer is updated.
This prop if passed will be triggered on pointer down events and has the below signature.
<pre>
(activeTool:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L115">
{" "}
AppState["activeTool"]
</a>
, pointerDownState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L424">
PointerDownState
</a>) => void
(activeTool: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L115"> AppState["activeTool"]</a>, pointerDownState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L424">PointerDownState</a>) => void
</pre>
### onScrollChange
@@ -118,11 +110,7 @@ This prop if passed will be triggered when canvas is scrolled and has the below
This callback is triggered if passed when something is pasted into the scene. You can use this callback in case you want to do something additional when the paste event occurs.
<pre>
(data:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L18">
ClipboardData
</a>
, event: ClipboardEvent &#124; null) => boolean
(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L18">ClipboardData</a>, event: ClipboardEvent &#124; null) => boolean
</pre>
This callback must return a `boolean` value or a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise) which resolves to a boolean value.
@@ -148,11 +136,8 @@ It is invoked with empty items when user clears the library. You can use this ca
This prop if passed will be triggered when clicked on `link`. To handle the redirect yourself (such as when using your own router for internal links), you must call `event.preventDefault()`.
<pre>
(element:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
ExcalidrawElement
</a>
, event: CustomEvent&lt;&#123; nativeEvent: MouseEvent }&gt;) => void
(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement</a>,
event: CustomEvent&lt;&#123; nativeEvent: MouseEvent }&gt;) => void
</pre>
Example:
@@ -195,30 +180,30 @@ import { defaultLang, languages } from "@excalidraw/excalidraw";
### viewModeEnabled
This prop indicates whether the app is in `view mode`. When supplied, the value takes precedence over _intialData.appState.viewModeEnabled_, the `view mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
This prop indicates whether the app is in `view mode`. When supplied, the value takes precedence over *intialData.appState.viewModeEnabled*, the `view mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
### zenModeEnabled
This prop indicates whether the app is in `zen mode`. When supplied, the value takes precedence over _intialData.appState.zenModeEnabled_, the `zen mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
This prop indicates whether the app is in `zen mode`. When supplied, the value takes precedence over *intialData.appState.zenModeEnabled*, the `zen mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
### gridModeEnabled
This prop indicates whether the shows the grid. When supplied, the value takes precedence over _intialData.appState.gridModeEnabled_, the grid will be fully controlled by the host app, and users won't be able to toggle it from within the app.
This prop indicates whether the shows the grid. When supplied, the value takes precedence over *intialData.appState.gridModeEnabled*, the grid will be fully controlled by the host app, and users won't be able to toggle it from within the app.
### libraryReturnUrl
If supplied, this URL will be used when user tries to install a library from [libraries.excalidraw.com](https://libraries.excalidraw.com).
Defaults to _window.location.origin + window.location.pathname_. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab.
Defaults to *window.location.origin + window.location.pathname*. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab.
### theme
This prop controls Excalidraw's theme. When supplied, the value takes precedence over _intialData.appState.theme_, the theme will be fully controlled by the host app, and users won't be able to toggle it from within the app unless _UIOptions.canvasActions.toggleTheme_ is set to `true`, in which case the `theme` prop will control Excalidraw's default theme with ability to allow theme switching (you must take care of updating the `theme` prop when you detect a change to `appState.theme` from the [onChange](#onchange) callback).
This prop controls Excalidraw's theme. When supplied, the value takes precedence over *intialData.appState.theme*, the theme will be fully controlled by the host app, and users won't be able to toggle it from within the app unless *UIOptions.canvasActions.toggleTheme* is set to `true`, in which case the `theme` prop will control Excalidraw's default theme with ability to allow theme switching (you must take care of updating the `theme` prop when you detect a change to `appState.theme` from the [onChange](#onchange) callback).
You can use [`THEME`](/docs/@excalidraw/excalidraw/api/utils#theme) to specify the theme.
### name
This prop sets the `name` of the drawing which will be used when exporting the drawing. When supplied, the value takes precedence over _intialData.appState.name_, the `name` will be fully controlled by host app and the users won't be able to edit from within Excalidraw.
This prop sets the `name` of the drawing which will be used when exporting the drawing. When supplied, the value takes precedence over *intialData.appState.name*, the `name` will be fully controlled by host app and the users won't be able to edit from within Excalidraw.
### detectScroll
@@ -251,4 +236,4 @@ validateEmbeddable?: boolean | string[] | RegExp | RegExp[] | ((link: string) =>
This is an optional property. By default we support a handful of well-known sites. You may allow additional sites or disallow the default ones by supplying a custom validator. If you pass `true`, all URLs will be allowed. You can also supply a list of hostnames, RegExp (or list of RegExp objects), or a function. If the function returns `undefined`, the built-in validator will be used.
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.
@@ -1,26 +1,27 @@
# excalidrawAPI
# ref
<pre>
(api:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L616">
ExcalidrawAPI
</a>
) => void;
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">
createRef
</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
callbackRef
</a>{" "}
&#124; <br />
&#123; current: &#123; readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
resolvablePromise
</a> } }
</pre>
Once the callback is triggered, you will need to store the api in state to access it later.
```jsx showLineNumbers
export default function App() {
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />;
}
```
You can use this prop when you want to access some [Excalidraw APIs](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L616). We expose the below APIs :point_down:
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs:
| API | Signature | Usage |
| --- | --- | --- |
| ready | `boolean` | This is set to true once Excalidraw is rendered |
| [readyPromise](#readypromise) | `function` | This promise will be resolved with the api once excalidraw has rendered. This will be helpful when you want do some action on the host app once this promise resolves. For this to work you will have to pass ref as shown [here](#readypromise) |
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData |
| [addFiles](#addfiles) | `function` | add files data to the appState |
@@ -38,15 +39,54 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
| [setCursor](#setcursor) | `function` | This API can be used to set customise the mouse cursor on the canvas |
| [resetCursor](#resetcursor) | `function` | This API can be used to reset to default mouse cursor on the canvas |
| [toggleMenu](#togglemenu) | `function` | Toggles specific menus on/off |
| [onChange](#onChange) | `function` | Subscribes to change events |
| [onPointerDown](#onPointerDown) | `function` | Subscribes to `pointerdown` events |
| [onPointerUp](#onPointerUp) | `function` | Subscribes to `pointerup` events |
:::info The `Ref` support has been removed in v0.17.0 so if you are using refs, please update the integration to use the `excalidrawAPI`.
## readyPromise
Additionally `ready` and `readyPromise` from the API have been discontinued. These APIs were found to be superfluous, and as part of the effort to streamline the APIs and maintain simplicity, they were removed in version v0.17.0.
<pre>
const excalidrawRef = &#123; current:&#123; readyPromise:
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
&nbsp;resolvablePromise
</a>
&nbsp;&#125; &#125;
</pre>
:::
Since plain object is passed as a `ref`, the `readyPromise` is resolved as soon as the component is mounted. Most of the time you will not need this unless you have a specific use case where you can't pass the `ref` in the react way and want to do some action on the host when this promise resolves.
```jsx showLineNumbers
const resolvablePromise = () => {
let resolve;
let reject;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
promise.resolve = resolve;
promise.reject = reject;
return promise;
};
const App = () => {
const excalidrawRef = useMemo(
() => ({
current: {
readyPromise: resolvablePromise(),
},
}),
[],
);
useEffect(() => {
excalidrawRef.current.readyPromise.then((api) => {
console.log("loaded", api);
});
}, [excalidrawRef]);
return (
<div style={{ height: "500px" }}>
<Excalidraw ref={excalidrawRef} />
</div>
);
};
```
## updateScene
@@ -347,25 +387,14 @@ This API can be used to get the files present in the scene. It may contain files
This API has the below signature. It sets the `tool` passed in param as the active tool.
```ts
(
tool: (
| (
| { type: Exclude<ToolType, "image"> }
| {
type: Extract<ToolType, "image">;
insertOnCanvasDirectly?: boolean;
}
)
| { type: "custom"; customType: string }
) & { locked?: boolean },
) => {};
```
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
<pre>
(tool: <br /> &#123; type:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
SHAPES
</a>
[number]["value"]&#124; "eraser" &#125; &#124;
<br /> &#123; type: "custom"; customType: string &#125;) => void
</pre>
## setCursor
@@ -392,51 +421,3 @@ This API is especially useful when you render a custom [`<Sidebar/>`](/docs/@exc
```
This API can be used to reset to default mouse cursor.
## onChange
```tsx
(
callback: (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => void
) => () => void
```
Subscribes to change events, similar to [`props.onChange`](/docs/@excalidraw/excalidraw/api/props#onchange).
Returns an unsubscribe function.
## onPointerDown
```tsx
(
callback: (
activeTool: AppState["activeTool"],
pointerDownState: PointerDownState,
event: React.PointerEvent<HTMLElement>,
) => void,
) => () => void
```
Subscribes to canvas `pointerdown` events.
Returns an unsubscribe function.
## onPointerUp
```tsx
(
callback: (
activeTool: AppState["activeTool"],
pointerDownState: PointerDownState,
event: PointerEvent,
) => void,
) => () => void
```
Subscribes to canvas `pointerup` events.
Returns an unsubscribe function.
@@ -1,6 +1,6 @@
# UIOptions
This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasactions), [`dockedSidebarBreakpoint`](#dockedsidebarbreakpoint) [`welcomeScreen`](#welcmescreen) and [`tools`](#tools).
This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasactions), [`dockedSidebarBreakpoint`](#dockedsidebarbreakpoint) and [`welcomeScreen`](#welcmescreen).
<pre>
&#123;
@@ -70,12 +70,3 @@ function App() {
);
}
```
## tools
This `prop ` controls the visibility of the tools in the editor.
Currently you can control the visibility of `image` tool via this prop.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| image | boolean | true | Decides whether `image` tool should be visible.
@@ -129,7 +129,7 @@ if (contents.type === MIME_TYPES.excalidraw) {
<pre>
loadSceneOrLibraryFromBlob(<br/>&nbsp;
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,<br/>&nbsp;
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,
localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a> | null,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null,<br/>&nbsp;
fileHandle?: FileSystemHandle | null<br/>
@@ -164,9 +164,9 @@ import { isLinearElement } from "@excalidraw/excalidraw";
**Signature**
<pre>
```tsx
isLinearElement(elementType?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L80">ExcalidrawElement</a>): boolean
</pre>
```
### getNonDeletedElements
@@ -195,10 +195,8 @@ import { mergeLibraryItems } from "@excalidraw/excalidraw";
**_Signature_**
<pre>
mergeLibraryItems(<br/>&nbsp;
localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>,<br/>&nbsp;
otherItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a><br/>
): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>
mergeLibraryItems(localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>,<br/>&nbsp;
otherItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>
</pre>
### parseLibraryTokensFromUrl
@@ -301,7 +299,7 @@ Open the `main menu` in the below example to view the footer.
```jsx live noInline
const MobileFooter = ({}) => {
const device = useDevice();
if (device.editor.isMobile) {
if (device.isMobile) {
return (
<Footer>
<button
@@ -333,15 +331,14 @@ const App = () => (
render(<App />);
```
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
The `device` has the following `attributes`
| Name | Type | Description |
| --- | --- | --- |
| `viewport.isMobile` | `boolean` | Set to `true` when viewport is in `mobile` breakpoint |
| `viewport.isLandscape` | `boolean` | Set to `true` when the viewport is in `landscape` mode |
| `editor.canFitSidebar` | `boolean` | Set to `true` if there's enough space to fit the `sidebar` |
| `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint |
| `isTouchScreen` | `boolean` | Set to `true` for `touch` when touch event detected |
| `isSmScreen` | `boolean` | Set to `true` when the device small screen is small (Width < `640px` ) |
| `isMobile` | `boolean` | Set to `true` when the device is `mobile` |
| `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
| `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |
### i18n
@@ -386,94 +383,3 @@ function App() {
);
}
```
### getCommonBounds
This util can be used to get the common bounds of the passed elements.
**_Signature_**
```ts
getCommonBounds(
elements: readonly ExcalidrawElement[]
): readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
]
```
**_How to use_**
```js
import { getCommonBounds } from "@excalidraw/excalidraw";
```
### elementsOverlappingBBox
To filter `elements` that are inside, overlap, or contain the `bounds` rectangle.
The bounds check is approximate and does not precisely follow the element's shape. You can also supply `errorMargin` which effectively makes the `bounds` larger by that amount.
This API has 3 `type`s of operation: `overlap`, `contain`, and `inside`:
- `overlap` - filters elements that are overlapping or inside bounds.
- `contain` - filters elements that are inside bounds or bounds inside elements.
- `inside` - filters elements that are inside bounds.
**_Signature_**
<pre>
elementsOverlappingBBox(<br/>&nbsp;
elements: readonly NonDeletedExcalidrawElement[];<br/>&nbsp;
bounds: <a href="https://github.com/excalidraw/excalidraw/blob/9c425224c789d083bf16e0597ce4a429b9ee008e/src/element/bounds.ts#L37-L42">Bounds</a> | ExcalidrawElement;<br/>&nbsp;
errorMargin?: number;<br/>&nbsp;
type: "overlap" | "contain" | "inside";<br/>
): NonDeletedExcalidrawElement[];
</pre>
**_How to use_**
```js
import { elementsOverlappingBBox } from "@excalidraw/excalidraw";
```
### isElementInsideBBox
Lower-level API than `elementsOverlappingBBox` to check if a single `element` is inside `bounds`. If `eitherDirection=true`, returns `true` if `element` is fully inside `bounds` rectangle, or vice versa. When `false`, it returns `true` only for the former case.
**_Signature_**
<pre>
isElementInsideBBox(<br/>&nbsp;
element: NonDeletedExcalidrawElement,<br/>&nbsp;
bounds: <a href="https://github.com/excalidraw/excalidraw/blob/9c425224c789d083bf16e0597ce4a429b9ee008e/src/element/bounds.ts#L37-L42">Bounds</a>,<br/>&nbsp;
eitherDirection = false,<br/>
): boolean
</pre>
**_How to use_**
```js
import { isElementInsideBBox } from "@excalidraw/excalidraw";
```
### elementPartiallyOverlapsWithOrContainsBBox
Checks if `element` is overlapping the `bounds` rectangle, or is fully inside.
**_Signature_**
<pre>
elementPartiallyOverlapsWithOrContainsBBox(<br/>&nbsp;
element: NonDeletedExcalidrawElement,<br/>&nbsp;
bounds: <a href="https://github.com/excalidraw/excalidraw/blob/9c425224c789d083bf16e0597ce4a429b9ee008e/src/element/bounds.ts#L37-L42">Bounds</a>,<br/>
): boolean
</pre>
**_How to use_**
```js
import { elementPartiallyOverlapsWithOrContainsBBox } from "@excalidraw/excalidraw";
```
@@ -43,7 +43,7 @@ Once the version is released `@excalibot` will post a comment with the release v
To release the next stable version follow the below steps:
```bash
yarn prerelease:excalidraw
yarn prerelease version
```
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
@@ -51,7 +51,7 @@ You need to pass the `version` for which you want to create the release. This wi
The next step is to run the `release` script:
```bash
yarn release:excalidraw
yarn release
```
This will publish the package.
@@ -31,17 +31,6 @@ We strongly recommend turning it off. You can follow the steps below on how to d
If disabling this setting doesn't fix the display of text elements, please consider opening an [issue](https://github.com/excalidraw/excalidraw/issues/new) on our GitHub, or message us on [Discord](https://discord.gg/UexuTaE).
### ReferenceError: process is not defined
When using `vite` or any build tools, you will have to make sure the `process` is accessible as we are accessing `process.env.IS_PREACT` to decide whether to use `preact` build.
Since Vite removes env variables by default, you can update the vite config to ensure its available :point_down:
```
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
},
```
## Need help?
@@ -30,74 +30,25 @@ function App() {
}
```
### Next.js
### Rendering Excalidraw only on client
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
Here are two ways on how you can render **Excalidraw** on **Next.js**.
1. Using **Next.js Dynamic** import [Recommended].
Since Excalidraw doesn't support server side rendering so you can also use `dynamic import` to render by setting `ssr` to `false`.
```jsx showLineNumbers
import dynamic from "next/dynamic";
const Excalidraw = dynamic(
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
{
ssr: false,
},
);
export default function App() {
return <Excalidraw />;
}
```
Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-dynamic-k8yjq2).
2. Importing Excalidraw once **client** is rendered.
The following workflow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
```jsx showLineNumbers
import { useState, useEffect } from "react";
export default function App() {
const [Excalidraw, setExcalidraw] = useState(null);
useEffect(() => {
import("@excalidraw/excalidraw").then((comp) =>
setExcalidraw(comp.Excalidraw),
);
import("@excalidraw/excalidraw").then((comp) => setExcalidraw(comp.Excalidraw));
}, []);
return <>{Excalidraw && <Excalidraw />}</>;
}
```
Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-5xb3d)
The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm)
### Preact
Since we support `umd` build ships with `react/jsx-runtime` and `react-dom/client` inlined with the package. This conflicts with `Preact` and hence the build doesn't work directly with `Preact`.
However we have shipped a separate build for `Preact` so if you are using `Preact` you need to set `process.env.IS_PREACT` to `true` to use the `Preact` build.
Once the above `env` variable is set, you will be able to use the package in `Preact` as well.
:::info
When using `vite` or any build tools, you will have to make sure the `process` is accessible as we are accessing `process.env.IS_PREACT` to decide whether to use `preact` build.
Since Vite removes env variables by default, you can update the vite config to ensure its available :point_down:
```
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
},
```
:::
## Browser
To use it in a browser directly:
+1 -1
View File
@@ -19,4 +19,4 @@ Frames should be ordered where frame children come first, followed by the frame
]
```
If not ordered correctly, the editor will still function, but the elements may not be rendered and clipped correctly. Further, the renderer relies on this ordering for performance optimizations.
If not oredered correctly, the editor will still function, but the elements may not be rendered and clipped correctly. Further, the renderer relies on this ordering for performance optimizations.
+1 -14
View File
@@ -1,11 +1,6 @@
// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion
// Set the env variable to false so the excalidraw npm package doesn't throw
// process undefined as docusaurus doesn't expose env variables by default
process.env.IS_PREACT = "false";
/** @type {import('@docusaurus/types').Config} */
const config = {
title: "Excalidraw developer docs",
@@ -144,15 +139,7 @@ const config = {
},
}),
themes: ["@docusaurus/theme-live-codeblock"],
plugins: [
"docusaurus-plugin-sass",
[
"docusaurus2-dotenv",
{
systemvars: true,
},
],
],
plugins: ["docusaurus-plugin-sass"],
};
module.exports = config;
+1 -2
View File
@@ -18,7 +18,7 @@
"@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.17.0",
"@excalidraw/excalidraw": "0.15.2-eb020d0",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3",
@@ -30,7 +30,6 @@
"devDependencies": {
"@docusaurus/module-type-aliases": "2.0.0-rc.1",
"@tsconfig/docusaurus": "^1.0.5",
"docusaurus2-dotenv": "1.4.0",
"typescript": "^4.7.4"
},
"browserslist": {
+1 -1
View File
@@ -53,7 +53,7 @@ const sidebars = {
},
items: [
"@excalidraw/excalidraw/api/props/initialdata",
"@excalidraw/excalidraw/api/props/excalidraw-api",
"@excalidraw/excalidraw/api/props/ref",
"@excalidraw/excalidraw/api/props/render-props",
"@excalidraw/excalidraw/api/props/ui-options",
],
+15 -146
View File
@@ -145,14 +145,6 @@
dependencies:
"@babel/highlight" "^7.18.6"
"@babel/code-frame@^7.22.13":
version "7.22.13"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
dependencies:
"@babel/highlight" "^7.22.13"
chalk "^2.4.2"
"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8":
version "7.18.8"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d"
@@ -210,16 +202,6 @@
"@jridgewell/gen-mapping" "^0.3.2"
jsesc "^2.5.1"
"@babel/generator@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.0.tgz#df5c386e2218be505b34837acbcb874d7a983420"
integrity sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==
dependencies:
"@babel/types" "^7.23.0"
"@jridgewell/gen-mapping" "^0.3.2"
"@jridgewell/trace-mapping" "^0.3.17"
jsesc "^2.5.1"
"@babel/helper-annotate-as-pure@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb"
@@ -283,11 +265,6 @@
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be"
integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==
"@babel/helper-environment-visitor@^7.22.20":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167"
integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==
"@babel/helper-explode-assignable-expression@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz#41f8228ef0a6f1a036b8dfdfec7ce94f9a6bc096"
@@ -303,14 +280,6 @@
"@babel/template" "^7.18.6"
"@babel/types" "^7.18.9"
"@babel/helper-function-name@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759"
integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==
dependencies:
"@babel/template" "^7.22.15"
"@babel/types" "^7.23.0"
"@babel/helper-hoist-variables@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678"
@@ -318,13 +287,6 @@
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-hoist-variables@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb"
integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==
dependencies:
"@babel/types" "^7.22.5"
"@babel/helper-member-expression-to-functions@^7.18.9":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz#1531661e8375af843ad37ac692c132841e2fd815"
@@ -412,28 +374,11 @@
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-split-export-declaration@^7.22.6":
version "7.22.6"
resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz#322c61b7310c0997fe4c323955667f18fcefb91c"
integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==
dependencies:
"@babel/types" "^7.22.5"
"@babel/helper-string-parser@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f"
integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==
"@babel/helper-validator-identifier@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
"@babel/helper-validator-identifier@^7.22.20":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
"@babel/helper-validator-option@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8"
@@ -467,25 +412,11 @@
chalk "^2.0.0"
js-tokens "^4.0.0"
"@babel/highlight@^7.22.13":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
dependencies:
"@babel/helper-validator-identifier" "^7.22.20"
chalk "^2.4.2"
js-tokens "^4.0.0"
"@babel/parser@^7.12.7", "@babel/parser@^7.18.6", "@babel/parser@^7.18.8", "@babel/parser@^7.18.9":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.9.tgz#f2dde0c682ccc264a9a8595efd030a5cc8fd2539"
integrity sha512-9uJveS9eY9DJ0t64YbIBZICtJy8a5QrDEVdiLCG97fVLpDTpGX7t8mMSb6OWw6Lrnjqj4O8zwjELX3dhoMgiBg==
"@babel/parser@^7.22.15", "@babel/parser@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719"
integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2"
@@ -1216,28 +1147,19 @@
"@babel/parser" "^7.18.6"
"@babel/types" "^7.18.6"
"@babel/template@^7.22.15":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==
dependencies:
"@babel/code-frame" "^7.22.13"
"@babel/parser" "^7.22.15"
"@babel/types" "^7.22.15"
"@babel/traverse@^7.12.9", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9":
version "7.23.2"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8"
integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.9.tgz#deeff3e8f1bad9786874cb2feda7a2d77a904f98"
integrity sha512-LcPAnujXGwBgv3/WHv01pHtb2tihcyW1XuL9wd7jqh1Z8AQkTd+QVjMrMijrln0T7ED3UXLIy36P9Ao7W75rYg==
dependencies:
"@babel/code-frame" "^7.22.13"
"@babel/generator" "^7.23.0"
"@babel/helper-environment-visitor" "^7.22.20"
"@babel/helper-function-name" "^7.23.0"
"@babel/helper-hoist-variables" "^7.22.5"
"@babel/helper-split-export-declaration" "^7.22.6"
"@babel/parser" "^7.23.0"
"@babel/types" "^7.23.0"
"@babel/code-frame" "^7.18.6"
"@babel/generator" "^7.18.9"
"@babel/helper-environment-visitor" "^7.18.9"
"@babel/helper-function-name" "^7.18.9"
"@babel/helper-hoist-variables" "^7.18.6"
"@babel/helper-split-export-declaration" "^7.18.6"
"@babel/parser" "^7.18.9"
"@babel/types" "^7.18.9"
debug "^4.1.0"
globals "^11.1.0"
@@ -1249,15 +1171,6 @@
"@babel/helper-validator-identifier" "^7.18.6"
to-fast-properties "^2.0.0"
"@babel/types@^7.22.15", "@babel/types@^7.22.5", "@babel/types@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==
dependencies:
"@babel/helper-string-parser" "^7.22.5"
"@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0"
"@colors/colors@1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
@@ -1718,10 +1631,10 @@
url-loader "^4.1.1"
webpack "^5.73.0"
"@excalidraw/excalidraw@0.17.0":
version "0.17.0"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725"
integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==
"@excalidraw/excalidraw@0.15.2-eb020d0":
version "0.15.2-eb020d0"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2-eb020d0.tgz#25bd61e6f23da7c084fb16a3e0fe0dd9ad8e6533"
integrity sha512-TKGLzpOVqFQcwK1GFKTDXgg1s2U6tc5KE3qXuv87osbzOtftQn3x4+VH61vwdj11l00nEN80SMdXUC43T9uJqQ==
"@hapi/hoek@^9.0.0":
version "9.3.0"
@@ -1757,11 +1670,6 @@
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
"@jridgewell/resolve-uri@^3.1.0":
version "3.1.1"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721"
integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==
"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
@@ -1780,19 +1688,6 @@
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
"@jridgewell/sourcemap-codec@^1.4.14":
version "1.4.15"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
"@jridgewell/trace-mapping@^0.3.17":
version "0.3.20"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz#72e45707cf240fa6b081d0366f8265b0cd10197f"
integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==
dependencies:
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@jridgewell/trace-mapping@^0.3.7", "@jridgewell/trace-mapping@^0.3.9":
version "0.3.14"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed"
@@ -3567,13 +3462,6 @@ docusaurus-plugin-sass@0.2.3:
dependencies:
sass-loader "^10.1.1"
docusaurus2-dotenv@1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/docusaurus2-dotenv/-/docusaurus2-dotenv-1.4.0.tgz#9ab900e29de9081f9f1f28f7224ff63760385641"
integrity sha512-iWqem5fnBAyeBBtX75Fxp71uUAnwFaXzOmade8zAhN4vL3RG9m27sLSRwjJGVVgIkEo3esjGyCcTGTiCjfi+sg==
dependencies:
dotenv-webpack "1.7.0"
dom-converter@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768"
@@ -3659,25 +3547,6 @@ dot-prop@^5.2.0:
dependencies:
is-obj "^2.0.0"
dotenv-defaults@^1.0.2:
version "1.1.1"
resolved "https://registry.yarnpkg.com/dotenv-defaults/-/dotenv-defaults-1.1.1.tgz#032c024f4b5906d9990eb06d722dc74cc60ec1bd"
integrity sha512-6fPRo9o/3MxKvmRZBD3oNFdxODdhJtIy1zcJeUSCs6HCy4tarUpd+G67UTU9tF6OWXeSPqsm4fPAB+2eY9Rt9Q==
dependencies:
dotenv "^6.2.0"
dotenv-webpack@1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/dotenv-webpack/-/dotenv-webpack-1.7.0.tgz#4384d8c57ee6f405c296278c14a9f9167856d3a1"
integrity sha512-wwNtOBW/6gLQSkb8p43y0Wts970A3xtNiG/mpwj9MLUhtPCQG6i+/DSXXoNN7fbPCU/vQ7JjwGmgOeGZSSZnsw==
dependencies:
dotenv-defaults "^1.0.2"
dotenv@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064"
integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==
duplexer3@^0.1.4:
version "0.1.5"
resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.5.tgz#0b5e4d7bad5de8901ea4440624c8e1d20099217e"
@@ -80,8 +80,7 @@ export const ExportToExcalidrawPlus: React.FC<{
appState: Partial<AppState>;
files: BinaryFiles;
onError: (error: Error) => void;
onSuccess: () => void;
}> = ({ elements, appState, files, onError, onSuccess }) => {
}> = ({ elements, appState, files, onError }) => {
const { t } = useI18n();
return (
<Card color="primary">
@@ -108,7 +107,6 @@ export const ExportToExcalidrawPlus: React.FC<{
try {
trackEvent("export", "eplus", `ui (${getFrame()})`);
await exportToExcalidrawPlus(elements, appState, files);
onSuccess();
} catch (error: any) {
console.error(error);
if (error.name !== "AbortError") {
+1 -1
View File
@@ -6,7 +6,7 @@
*
* - DataState refers to full state of the app: appState, elements, images,
* though some state is saved separately (collab username, library) for one
* reason or another. We also save different data to different storage
* reason or another. We also save different data to different sotrage
* (localStorage, indexedDB).
*/
+1 -1
View File
@@ -131,5 +131,5 @@ export class Debug {
};
};
}
//@ts-ignore
window.debug = Debug;
+4 -9
View File
@@ -608,7 +608,7 @@ const ExcalidrawWrapper = () => {
canvas: HTMLCanvasElement,
) => {
if (exportedElements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas"));
return window.alert(t("alerts.cannotExportEmptyCanvas"));
}
if (canvas) {
try {
@@ -624,7 +624,7 @@ const ExcalidrawWrapper = () => {
);
if (errorMessage) {
throw new Error(errorMessage);
setErrorMessage(errorMessage);
}
if (url) {
@@ -634,7 +634,7 @@ const ExcalidrawWrapper = () => {
if (error.name !== "AbortError") {
const { width, height } = canvas;
console.error(error, { width, height });
throw new Error(error.message);
setErrorMessage(error.message);
}
}
}
@@ -691,7 +691,7 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
ref={excalidrawRefCallback}
onChange={onChange}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
@@ -714,11 +714,6 @@ const ExcalidrawWrapper = () => {
},
});
}}
onSuccess={() => {
excalidrawAPI?.updateScene({
appState: { openDialog: null },
});
}}
/>
);
},
+6 -12
View File
@@ -17,10 +17,8 @@ describe("Test MobileMenu", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
// @ts-ignore
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
//@ts-ignore
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
});
afterAll(() => {
@@ -30,15 +28,11 @@ describe("Test MobileMenu", () => {
it("should set device correctly", () => {
expect(h.app.device).toMatchInlineSnapshot(`
{
"editor": {
"canFitSidebar": false,
"isMobile": true,
},
"canDeviceFitSidebar": false,
"isLandscape": true,
"isMobile": true,
"isSmScreen": false,
"isTouchScreen": false,
"viewport": {
"isLandscape": false,
"isMobile": true,
},
}
`);
});
+6 -5
View File
@@ -8,7 +8,6 @@ import {
} from "../../excalidraw-app/collab/reconciliation";
import { randomInteger } from "../../src/random";
import { AppState } from "../../src/types";
import { cloneJSON } from "../../src/utils";
type Id = string;
type ElementLike = {
@@ -94,6 +93,8 @@ const cleanElements = (elements: ReconciledElements) => {
});
};
const cloneDeep = (data: any) => JSON.parse(JSON.stringify(data));
const test = <U extends `${string}:${"L" | "R"}`>(
local: (Id | ElementLike)[],
remote: (Id | ElementLike)[],
@@ -114,15 +115,15 @@ const test = <U extends `${string}:${"L" | "R"}`>(
"remote reconciliation",
);
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
const __local = cleanElements(cloneDeep(_remote));
const __remote = addParents(cleanElements(cloneDeep(remoteReconciled)));
if (bidirectional) {
try {
expect(
cleanElements(
reconcileElements(
cloneJSON(__local),
cloneJSON(__remote),
cloneDeep(__local),
cloneDeep(__remote),
{} as AppState,
),
),
+6 -7
View File
@@ -21,8 +21,7 @@
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.2.0",
"@excalidraw/mermaid-to-excalidraw": "0.1.2",
"@excalidraw/random-username": "1.1.0",
"@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
"@sentry/browser": "6.2.5",
@@ -50,11 +49,11 @@
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
"points-on-curve": "1.0.1",
"points-on-curve": "0.2.0",
"pwacompat": "2.0.17",
"react": "18.2.0",
"react-dom": "18.2.0",
"roughjs": "4.6.4",
"roughjs": "4.5.2",
"sass": "1.51.0",
"socket.io-client": "2.3.1",
"tunnel-rat": "0.1.2"
@@ -126,10 +125,10 @@
"test": "yarn test:app",
"test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui --coverage.enabled=true",
"test:ui": "yarn test --ui",
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"prerelease": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
"release:excalidraw": "node scripts/release.js"
"release": "node scripts/release.js"
}
}
+6 -6
View File
@@ -10,7 +10,7 @@ import {
computeBoundTextPosition,
computeContainerDimensionForBoundText,
getBoundTextElement,
measureText,
measureTextElement,
redrawTextBoundingBox,
} from "../element/textElement";
import {
@@ -31,7 +31,6 @@ import {
} from "../element/types";
import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionUnbindText = register({
@@ -48,10 +47,11 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
const { width, height, baseline } = measureTextElement(
boundTextElement,
{
text: boundTextElement.originalText,
},
);
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
+1 -2
View File
@@ -438,6 +438,5 @@ export const actionToggleHandTool = register({
commitToHistory: true,
};
},
keyTest: (event) =>
!event.altKey && !event[KEYS.CTRL_OR_CMD] && event.key === KEYS.H,
keyTest: (event) => event.key === KEYS.H,
});
+35 -86
View File
@@ -3,43 +3,33 @@ import { register } from "./register";
import {
copyTextToSystemClipboard,
copyToClipboard,
createPasteEvent,
probablySupportsClipboardBlob,
probablySupportsClipboardWriteText,
readSystemClipboard,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
perform: (elements, appState, _, app) => {
const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
try {
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
},
};
}
copyToClipboard(elementsToCopy, app.files);
return {
commitToHistory: false,
};
},
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
@@ -48,55 +38,15 @@ export const actionCopy = register({
export const actionPaste = register({
name: "paste",
trackEvent: { category: "element" },
perform: async (elements, appState, data, app) => {
let types;
try {
types = await readSystemClipboard();
} catch (error: any) {
if (error.name === "AbortError" || error.name === "NotAllowedError") {
// user probably aborted the action. Though not 100% sure, it's best
// to not annoy them with an error message.
return false;
}
console.error(`actionPaste ${error.name}: ${error.message}`);
if (isFirefox) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
},
};
}
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
},
};
}
try {
app.pasteFromClipboard(createPasteEvent({ types }));
} catch (error: any) {
console.error(error);
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
},
};
}
perform: (elements: any, appStates: any, data, app) => {
app.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
@@ -105,10 +55,13 @@ export const actionPaste = register({
export const actionCut = register({
name: "cut",
trackEvent: { category: "element" },
perform: (elements, appState, event: ClipboardEvent | null, app) => {
actionCopy.perform(elements, appState, event, app);
perform: (elements, appState, data, app) => {
actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState);
},
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
});
@@ -122,23 +75,20 @@ export const actionCopyAsSvg = register({
commitToHistory: false,
};
}
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
true,
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
try {
await exportCanvas(
"clipboard-svg",
exportedElements,
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.files,
{
...appState,
exportingFrame,
},
appState,
);
return {
commitToHistory: false,
@@ -174,17 +124,16 @@ export const actionCopyAsPng = register({
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
true,
);
try {
await exportCanvas("clipboard", exportedElements, appState, app.files, {
...appState,
exportingFrame,
});
await exportCanvas(
"clipboard",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.files,
appState,
);
return {
appState: {
...appState,
-1
View File
@@ -46,7 +46,6 @@ const deleteSelectedElements = (
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
},
};
};
+3 -3
View File
@@ -25,7 +25,7 @@ import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons";
import {
bindElementsToFramesAfterDuplication,
getFrameChildren,
getFrameElements,
} from "../frame";
import {
excludeElementsInFramesFromSelection,
@@ -155,7 +155,7 @@ const duplicateElements = (
groupId,
).flatMap((element) =>
isFrameElement(element)
? [...getFrameChildren(elements, element.id), element]
? [...getFrameElements(elements, element.id), element]
: [element],
);
@@ -181,7 +181,7 @@ const duplicateElements = (
continue;
}
if (isElementAFrame) {
const elementsInFrame = getFrameChildren(sortedElements, element.id);
const elementsInFrame = getFrameElements(sortedElements, element.id);
elementsWithClones.push(
...markAsProcessed([
+2 -10
View File
@@ -191,15 +191,7 @@ export const actionSaveFileToDisk = register({
},
app.files,
);
return {
commitToHistory: false,
appState: {
...appState,
openDialog: null,
fileHandle,
toast: { message: t("toast.fileSaved") },
},
};
return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error: any) {
if (error?.name !== "AbortError") {
console.error(error);
@@ -217,7 +209,7 @@ export const actionSaveFileToDisk = register({
icon={saveAs}
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useDevice().editor.isMobile}
showAriaLabel={useDevice().isMobile}
hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)}
data-testid="save-as-button"
+2 -2
View File
@@ -1,7 +1,7 @@
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameChildren } from "../frame";
import { getFrameElements } from "../frame";
import { KEYS } from "../keys";
import { AppClassProperties, AppState } from "../types";
import { updateActiveTool } from "../utils";
@@ -21,7 +21,7 @@ export const actionSelectAllElementsInFrame = register({
const selectedFrame = app.scene.getSelectedElements(appState)[0];
if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameChildren(
const elementsInFrame = getFrameElements(
getNonDeletedElements(elements),
selectedFrame.id,
).filter((element) => !(element.type === "text" && element.containerId));
+13 -15
View File
@@ -17,12 +17,15 @@ import {
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawTextElement,
} from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
getFrameElements,
groupByFrames,
removeElementsFromFrame,
replaceAllElementsInFrame,
@@ -187,6 +190,13 @@ export const actionUngroup = register({
let nextElements = [...elements];
const selectedElements = app.scene.getSelectedElements(appState);
const frames = selectedElements
.filter((element) => element.frameId)
.map((element) =>
app.scene.getElement(element.frameId!),
) as ExcalidrawFrameElement[];
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
nextElements = nextElements.map((element) => {
if (isBoundToContainer(element)) {
@@ -211,19 +221,7 @@ export const actionUngroup = register({
null,
);
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElementFrameIds = new Set(
selectedElements
.filter((element) => element.frameId)
.map((element) => element.frameId!),
);
const targetFrames = getFrameElements(elements).filter((frame) =>
selectedElementFrameIds.has(frame.id),
);
targetFrames.forEach((frame) => {
frames.forEach((frame) => {
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,
+18
View File
@@ -3,6 +3,7 @@ import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { KEYS } from "../keys";
export const actionToggleCanvasMenu = register({
@@ -51,6 +52,23 @@ export const actionToggleEditMenu = register({
),
});
export const actionFullScreen = register({
name: "toggleFullScreen",
viewMode: true,
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => {
if (!isFullScreen()) {
allowFullScreen();
}
if (isFullScreen()) {
exitFullScreen();
}
return {
commitToHistory: false,
};
},
});
export const actionShortcuts = register({
name: "toggleShortcuts",
viewMode: true,
-167
View File
@@ -1,167 +0,0 @@
import { Excalidraw } from "../packages/excalidraw/index";
import { queryByTestId } from "@testing-library/react";
import { render } from "../tests/test-utils";
import { UI } from "../tests/helpers/ui";
import { API } from "../tests/helpers/api";
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
const { h } = window;
describe("element locking", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
describe("properties when tool selected", () => {
it("should show active background top picks", () => {
UI.clickTool("rectangle");
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({
currentItemBackgroundColor: color,
});
const activeColor = queryByTestId(
document.body,
`color-top-pick-${color}`,
);
expect(activeColor).toHaveClass("active");
});
it("should show fill style when background non-transparent", () => {
UI.clickTool("rectangle");
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({
currentItemBackgroundColor: color,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toHaveClass("active");
h.setState({
currentItemFillStyle: "solid",
});
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
expect(solidFillStyle).toHaveClass("active");
});
it("should not show fill style when background transparent", () => {
UI.clickTool("rectangle");
h.setState({
currentItemBackgroundColor: COLOR_PALETTE.transparent,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toBe(null);
});
it("should show horizontal text align for text tool", () => {
UI.clickTool("text");
h.setState({
currentItemTextAlign: "right",
});
const centerTextAlign = queryByTestId(document.body, `align-right`);
expect(centerTextAlign).toBeChecked();
});
});
describe("properties when elements selected", () => {
it("should show active styles when single element selected", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: "red",
fillStyle: "cross-hatch",
});
h.elements = [rect];
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
expect(crossHatchButton).toHaveClass("active");
});
it("should not show fill style selected element's background is transparent", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "cross-hatch",
});
h.elements = [rect];
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
expect(crossHatchButton).toBe(null);
});
it("should highlight common stroke width of selected elements", () => {
const rect1 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]);
const thinStrokeWidthButton = queryByTestId(
document.body,
`strokeWidth-thin`,
);
expect(thinStrokeWidthButton).toBeChecked();
});
it("should not highlight any stroke width button if no common style", () => {
const rect1 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
});
h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]);
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
expect(
queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-extraBold`),
).not.toBeChecked();
});
it("should show properties of different element types when selected", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
});
const text = API.createElement({
type: "text",
fontFamily: FONT_FAMILY.Cascadia,
});
h.elements = [rect, text];
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
});
});
});
+28 -85
View File
@@ -1,4 +1,4 @@
import { AppState, Primitive } from "../../src/types";
import { AppState } from "../../src/types";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
@@ -51,7 +51,6 @@ import {
DEFAULT_FONT_SIZE,
FONT_FAMILY,
ROUNDNESS,
STROKE_WIDTH,
VERTICAL_ALIGN,
} from "../constants";
import {
@@ -83,6 +82,7 @@ import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
import { randomInteger } from "../random";
import {
canChangeRoundness,
canHaveArrowheads,
getCommonAttributeOfSelectedElements,
getSelectedElements,
@@ -118,44 +118,25 @@ export const changeProperty = (
});
};
export const getFormValue = function <T extends Primitive>(
export const getFormValue = function <T>(
elements: readonly ExcalidrawElement[],
appState: AppState,
getAttribute: (element: ExcalidrawElement) => T,
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
defaultValue: T,
): T {
const editingElement = appState.editingElement;
const nonDeletedElements = getNonDeletedElements(elements);
let ret: T | null = null;
if (editingElement) {
ret = getAttribute(editingElement);
}
if (!ret) {
const hasSelection = isSomeElementSelected(nonDeletedElements, appState);
if (hasSelection) {
ret =
getCommonAttributeOfSelectedElements(
isRelevantElement === true
? nonDeletedElements
: nonDeletedElements.filter((el) => isRelevantElement(el)),
return (
(editingElement && getAttribute(editingElement)) ??
(isSomeElementSelected(nonDeletedElements, appState)
? getCommonAttributeOfSelectedElements(
nonDeletedElements,
appState,
getAttribute,
) ??
(typeof defaultValue === "function"
? defaultValue(true)
: defaultValue);
} else {
ret =
typeof defaultValue === "function" ? defaultValue(false) : defaultValue;
}
}
return ret;
)
: defaultValue) ??
defaultValue
);
};
const offsetElementAfterFontResize = (
@@ -266,7 +247,6 @@ export const actionChangeStrokeColor = register({
elements,
appState,
(element) => element.strokeColor,
true,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
@@ -309,7 +289,6 @@ export const actionChangeBackgroundColor = register({
elements,
appState,
(element) => element.backgroundColor,
true,
appState.currentItemBackgroundColor,
)}
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
@@ -328,7 +307,7 @@ export const actionChangeFillStyle = register({
trackEvent(
"element",
"changeFillStyle",
`${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
`${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
);
return {
elements: changeProperty(elements, appState, (el) =>
@@ -359,28 +338,23 @@ export const actionChangeFillStyle = register({
} (${getShortcutKey("Alt-Click")})`,
icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
active: allElementsZigZag ? true : undefined,
testId: `fill-hachure`,
},
{
value: "cross-hatch",
text: t("labels.crossHatch"),
icon: FillCrossHatchIcon,
testId: `fill-cross-hatch`,
},
{
value: "solid",
text: t("labels.solid"),
icon: FillSolidIcon,
testId: `fill-solid`,
},
]}
value={getFormValue(
elements,
appState,
(element) => element.fillStyle,
(element) => element.hasOwnProperty("fillStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemFillStyle,
appState.currentItemFillStyle,
)}
onClick={(value, event) => {
const nextValue =
@@ -419,31 +393,26 @@ export const actionChangeStrokeWidth = register({
group="stroke-width"
options={[
{
value: STROKE_WIDTH.thin,
value: 1,
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: STROKE_WIDTH.bold,
value: 2,
text: t("labels.bold"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
},
{
value: STROKE_WIDTH.extraBold,
value: 4,
text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
},
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeWidth,
(element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidth,
appState.currentItemStrokeWidth,
)}
onChange={(value) => updateData(value)}
/>
@@ -492,9 +461,7 @@ export const actionChangeSloppiness = register({
elements,
appState,
(element) => element.roughness,
(element) => element.hasOwnProperty("roughness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoughness,
appState.currentItemRoughness,
)}
onChange={(value) => updateData(value)}
/>
@@ -542,9 +509,7 @@ export const actionChangeStrokeStyle = register({
elements,
appState,
(element) => element.strokeStyle,
(element) => element.hasOwnProperty("strokeStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeStyle,
appState.currentItemStrokeStyle,
)}
onChange={(value) => updateData(value)}
/>
@@ -584,7 +549,6 @@ export const actionChangeOpacity = register({
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
) ?? undefined
}
@@ -643,12 +607,7 @@ export const actionChangeFontSize = register({
}
return null;
},
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
/>
@@ -733,25 +692,21 @@ export const actionChangeFontFamily = register({
value: FontFamilyValues;
text: string;
icon: JSX.Element;
testId: string;
}[] = [
{
value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"),
icon: FreedrawIcon,
testId: "font-family-virgil",
},
{
value: FONT_FAMILY.Helvetica,
text: t("labels.normal"),
icon: FontFamilyNormalIcon,
testId: "font-family-normal",
},
{
value: FONT_FAMILY.Cascadia,
text: t("labels.code"),
icon: FontFamilyCodeIcon,
testId: "font-family-code",
},
];
@@ -774,12 +729,7 @@ export const actionChangeFontFamily = register({
}
return null;
},
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
/>
@@ -856,10 +806,7 @@ export const actionChangeTextAlign = register({
}
return null;
},
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign,
appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
/>
@@ -935,9 +882,7 @@ export const actionChangeVerticalAlign = register({
}
return null;
},
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
VERTICAL_ALIGN.MIDDLE,
)}
onChange={(value) => updateData(value)}
/>
@@ -1002,9 +947,9 @@ export const actionChangeRoundness = register({
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(element) => element.hasOwnProperty("roundness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoundness,
(canChangeRoundness(appState.activeTool.type) &&
appState.currentItemRoundness) ||
null,
)}
onChange={(value) => updateData(value)}
/>
@@ -1098,7 +1043,6 @@ export const actionChangeArrowhead = register({
isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead
: appState.currentItemStartArrowhead,
true,
appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
@@ -1145,7 +1089,6 @@ export const actionChangeArrowhead = register({
isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead
: appState.currentItemEndArrowhead,
true,
appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
+5 -13
View File
@@ -21,10 +21,8 @@ import {
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
isFrameElement,
isArrowElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
import { ExcalidrawTextElement } from "../element/types";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@@ -101,19 +99,16 @@ export const actionPasteStyles = register({
if (isTextElement(newElement)) {
const fontSize =
(elementStylesToCopyFrom as ExcalidrawTextElement).fontSize ||
DEFAULT_FONT_SIZE;
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
const fontFamily =
(elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily ||
DEFAULT_FONT_FAMILY;
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
newElement = newElementWith(newElement, {
fontSize,
fontFamily,
textAlign:
(elementStylesToCopyFrom as ExcalidrawTextElement).textAlign ||
DEFAULT_TEXT_ALIGN,
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
lineHeight:
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
elementStylesToCopyFrom.lineHeight ||
getDefaultLineHeight(fontFamily),
});
let container = null;
@@ -128,10 +123,7 @@ export const actionPasteStyles = register({
redrawTextBoundingBox(newElement, container);
}
if (
newElement.type === "arrow" &&
isArrowElement(elementStylesToCopyFrom)
) {
if (newElement.type === "arrow") {
newElement = newElementWith(newElement, {
startArrowhead: elementStylesToCopyFrom.startArrowhead,
endArrowhead: elementStylesToCopyFrom.endArrowhead,
+1
View File
@@ -44,6 +44,7 @@ export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
export {
actionToggleCanvasMenu,
actionToggleEditMenu,
actionFullScreen,
actionShortcuts,
} from "./actionMenu";
+4 -4
View File
@@ -29,7 +29,7 @@ const trackAction = (
trackEvent(
action.trackEvent.category,
action.trackEvent.action || action.name,
`${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
);
}
}
@@ -119,10 +119,10 @@ export class ActionManager {
return true;
}
executeAction<T extends Action>(
action: T,
executeAction(
action: Action,
source: ActionSource = "api",
value: Parameters<T["perform"]>[2] = null,
value: any = null,
) {
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
+19 -188
View File
@@ -1,196 +1,27 @@
import {
createPasteEvent,
parseClipboard,
serializeAsClipboardJSON,
} from "./clipboard";
import { API } from "./tests/helpers/api";
import { parseClipboard } from "./clipboard";
describe("parseClipboard()", () => {
it("should parse JSON as plaintext if not excalidraw-api/clipboard data", async () => {
let text;
let clipboardData;
// -------------------------------------------------------------------------
describe("Test parseClipboard", () => {
it("should parse valid json correctly", async () => {
let text = "123";
let clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
text = "123";
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
);
expect(clipboardData.text).toBe(text);
// -------------------------------------------------------------------------
text = "[123]";
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
);
clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
expect(clipboardData.text).toBe(text);
// -------------------------------------------------------------------------
text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
);
expect(clipboardData.text).toBe(text);
});
it("should parse valid excalidraw JSON if inside text/plain", async () => {
const rect = API.createElement({ type: "rectangle" });
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/plain": json,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
});
it("should parse valid excalidraw JSON if inside text/html", async () => {
const rect = API.createElement({ type: "rectangle" });
let json;
let clipboardData;
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": json,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<div> ${json}</div>`,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
});
it("should parse <image> `src` urls out of text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<img src="https://example.com/image.png" />`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "imageUrl",
value: "https://example.com/image.png",
},
]);
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "imageUrl",
value: "https://example.com/image.png",
},
{
type: "imageUrl",
value: "https://example.com/image2.png",
},
]);
});
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "text",
// trimmed
value: "hello",
},
{
type: "imageUrl",
value: "https://example.com/image.png",
},
{
type: "text",
value: "my friend!",
},
]);
});
it("should parse spreadsheet from either text/plain and text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<html>
<body>
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
</body>
</html>`,
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
});
});
+85 -257
View File
@@ -3,18 +3,14 @@ import {
NonDeletedExcalidrawElement,
} from "./element/types";
import { BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import {
ALLOWED_PASTE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "./constants";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { isMemberOf, isPromiseLike } from "./utils";
import { t } from "./i18n";
import { isPromiseLike, isTestEnv } from "./utils";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -22,23 +18,17 @@ type ElementsClipboard = {
files: BinaryFiles | undefined;
};
export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
export interface ClipboardData {
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
files?: BinaryFiles;
text?: string;
mixedContent?: PastedMixedContent;
errorMessage?: string;
programmaticAPI?: boolean;
}
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
type ParsedClipboardEvent =
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent };
let CLIPBOARD = "";
let PREFER_APP_CLIPBOARD = false;
export const probablySupportsClipboardReadText =
"clipboard" in navigator && "readText" in navigator.clipboard;
@@ -68,61 +58,10 @@ const clipboardContainsElements = (
return false;
};
export const createPasteEvent = ({
types,
files,
}: {
types?: { [key in AllowedPasteMimeTypes]?: string };
files?: File[];
}) => {
if (!types && !files) {
console.warn("createPasteEvent: no types or files provided");
}
const event = new ClipboardEvent("paste", {
clipboardData: new DataTransfer(),
});
if (types) {
for (const [type, value] of Object.entries(types)) {
try {
event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`);
}
} catch (error: any) {
throw new Error(error.message);
}
}
}
if (files) {
let idx = -1;
for (const file of files) {
idx++;
try {
event.clipboardData?.items.add(file);
if (event.clipboardData?.files[idx] !== file) {
throw new Error(
`Failed to set file "${file.name}" as clipboardData item`,
);
}
} catch (error: any) {
throw new Error(error.message);
}
}
}
return event;
};
export const serializeAsClipboardJSON = ({
elements,
files,
}: {
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}) => {
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
files: BinaryFiles | null,
) => {
const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"),
);
@@ -144,7 +83,7 @@ export const serializeAsClipboardJSON = ({
);
}
// select bound text elements when copying
// select binded text elements when copying
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: elements.map((element) => {
@@ -163,20 +102,34 @@ export const serializeAsClipboardJSON = ({
}),
files: files ? _files : undefined,
};
const json = JSON.stringify(contents);
return JSON.stringify(contents);
if (isTestEnv()) {
return json;
}
CLIPBOARD = json;
try {
PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json);
} catch (error: any) {
PREFER_APP_CLIPBOARD = true;
console.error(error);
}
};
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
files: BinaryFiles | null,
/** supply if available to make the operation more certain to succeed */
clipboardEvent?: ClipboardEvent | null,
) => {
await copyTextToSystemClipboard(
serializeAsClipboardJSON({ elements, files }),
clipboardEvent,
);
const getAppClipboard = (): Partial<ElementsClipboard> => {
if (!CLIPBOARD) {
return {};
}
try {
return JSON.parse(CLIPBOARD);
} catch (error: any) {
console.error(error);
return {};
}
};
const parsePotentialSpreadsheet = (
@@ -189,137 +142,22 @@ const parsePotentialSpreadsheet = (
return null;
};
/** internal, specific to parsing paste events. Do not reuse. */
function parseHTMLTree(el: ChildNode) {
let result: PastedMixedContent = [];
for (const node of el.childNodes) {
if (node.nodeType === 3) {
const text = node.textContent?.trim();
if (text) {
result.push({ type: "text", value: text });
}
} else if (node instanceof HTMLImageElement) {
const url = node.getAttribute("src");
if (url && url.startsWith("http")) {
result.push({ type: "imageUrl", value: url });
}
} else {
result = result.concat(parseHTMLTree(node));
}
}
return result;
}
const maybeParseHTMLPaste = (
event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData("text/html");
if (!html) {
return null;
}
try {
const doc = new DOMParser().parseFromString(html, "text/html");
const content = parseHTMLTree(doc.body);
if (content.length) {
return { type: "mixedContent", value: content };
}
} catch (error: any) {
console.error(`error in parseHTMLFromPaste: ${error.message}`);
}
return null;
};
export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
try {
if (navigator.clipboard?.readText) {
return { "text/plain": await navigator.clipboard?.readText() };
}
} catch (error: any) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
throw error;
}
}
let clipboardItems: ClipboardItems;
try {
clipboardItems = await navigator.clipboard?.read();
} catch (error: any) {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
}
throw error;
}
for (const item of clipboardItems) {
for (const type of item.types) {
if (!isMemberOf(ALLOWED_PASTE_MIME_TYPES, type)) {
continue;
}
try {
types[type] = await (await item.getType(type)).text();
} catch (error: any) {
console.warn(
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
);
}
}
}
if (Object.keys(types).length === 0) {
console.warn("No clipboard data found from clipboard.read().");
return types;
}
return types;
};
/**
* Parses "paste" ClipboardEvent.
* Retrieves content from system clipboard (either from ClipboardEvent or
* via async clipboard API if supported)
*/
const parseClipboardEvent = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ParsedClipboardEvent> => {
export const getSystemClipboard = async (
event: ClipboardEvent | null,
): Promise<string> => {
try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
const text = event
? event.clipboardData?.getData("text/plain")
: probablySupportsClipboardReadText &&
(await navigator.clipboard.readText());
if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) {
return {
type: "text",
value:
event.clipboardData?.getData("text/plain") ||
mixedContent.value
.map((item) => item.value)
.join("\n")
.trim(),
};
}
return mixedContent;
}
const text = event.clipboardData?.getData("text/plain");
return { type: "text", value: (text || "").trim() };
return (text || "").trim();
} catch {
return { type: "text", value: "" };
return "";
}
};
@@ -327,32 +165,34 @@ const parseClipboardEvent = async (
* Attempts to parse clipboard. Prefers system clipboard.
*/
export const parseClipboard = async (
event: ClipboardEvent,
event: ClipboardEvent | null,
isPlainPaste = false,
): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
const systemClipboard = await getSystemClipboard(event);
if (parsedEventData.type === "mixedContent") {
return {
mixedContent: parsedEventData.value,
};
// if system clipboard empty, couldn't be resolved, or contains previously
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
// elements
if (
!systemClipboard ||
(!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
) {
return getAppClipboard();
}
try {
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
const spreadsheetResult =
!isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
const spreadsheetResult =
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
if (spreadsheetResult) {
return spreadsheetResult;
}
} catch (error: any) {
console.error(error);
if (spreadsheetResult) {
return spreadsheetResult;
}
const appClipboardData = getAppClipboard();
try {
const systemClipboardData = JSON.parse(parsedEventData.value);
const systemClipboardData = JSON.parse(systemClipboard);
const programmaticAPI =
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
if (clipboardContainsElements(systemClipboardData)) {
@@ -365,9 +205,18 @@ export const parseClipboard = async (
programmaticAPI,
};
}
} catch {}
return { text: parsedEventData.value };
} catch (e) {}
// system clipboard doesn't contain excalidraw elements → return plaintext
// unless we set a flag to prefer in-app clipboard because browser didn't
// support storing to system clipboard on copy
return PREFER_APP_CLIPBOARD && appClipboardData.elements
? {
...appClipboardData,
text: isPlainPaste
? JSON.stringify(appClipboardData.elements, null, 2)
: undefined,
}
: { text: systemClipboard };
};
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
@@ -400,49 +249,28 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
}
};
export const copyTextToSystemClipboard = async (
text: string | null,
clipboardEvent?: ClipboardEvent | null,
) => {
// (1) first try using Async Clipboard API
export const copyTextToSystemClipboard = async (text: string | null) => {
let copied = false;
if (probablySupportsClipboardWriteText) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
await navigator.clipboard.writeText(text || "");
return;
copied = true;
} catch (error: any) {
console.error(error);
}
}
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
try {
if (clipboardEvent) {
clipboardEvent.clipboardData?.setData("text/plain", text || "");
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
throw new Error("Failed to setData on clipboardEvent");
}
return;
}
} catch (error: any) {
console.error(error);
}
// (3) if that fails, use document.execCommand
if (!copyTextViaExecCommand(text)) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
// Note that execCommand doesn't allow copying empty strings, so if we're
// clearing clipboard using this API, we must copy at least an empty char
if (!copied && !copyTextViaExecCommand(text || " ")) {
throw new Error("couldn't copy");
}
};
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
const copyTextViaExecCommand = (text: string | null) => {
// execCommand doesn't allow copying empty strings, so if we're
// clearing clipboard using this API, we must copy at least an empty char
if (!text) {
text = " ";
}
const copyTextViaExecCommand = (text: string) => {
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const textarea = document.createElement("textarea");
+118 -81
View File
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n";
import { useDevice } from "../components/App";
import {
@@ -11,15 +11,16 @@ import {
hasBackground,
hasStrokeStyle,
hasStrokeWidth,
hasText,
} from "../scene";
import { SHAPES } from "../shapes";
import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { AppClassProperties, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
import { hasBoundTextElement } from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
import { Tooltip } from "./Tooltip";
@@ -34,7 +35,6 @@ import {
EmbedIcon,
extraToolsIcon,
frameToolIcon,
mermaidLogoIcon,
laserPointerToolIcon,
} from "./icons";
import { KEYS } from "../keys";
@@ -66,8 +66,7 @@ export const SelectedShapeActions = ({
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons =
(hasBackground(appState.activeTool.type) &&
!isTransparent(appState.currentItemBackgroundColor)) ||
hasBackground(appState.activeTool.type) ||
targetElements.some(
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
@@ -124,15 +123,14 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</>
)}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
{(hasText(appState.activeTool.type) ||
targetElements.some((element) => hasText(element.type))) && (
<>
{renderAction("changeFontSize")}
{renderAction("changeFontFamily")}
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements)) &&
{suppportsHorizontalAlign(targetElements) &&
renderAction("changeTextAlign")}
</>
)}
@@ -202,8 +200,8 @@ export const SelectedShapeActions = ({
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{!device.editor.isMobile && renderAction("duplicateSelection")}
{!device.editor.isMobile && renderAction("deleteSelectedElements")}
{!device.isMobile && renderAction("duplicateSelection")}
{!device.isMobile && renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
@@ -215,17 +213,20 @@ export const SelectedShapeActions = ({
};
export const ShapesSwitcher = ({
interactiveCanvas,
activeTool,
onImageAction,
appState,
app,
UIOptions,
}: {
interactiveCanvas: HTMLCanvasElement | null;
activeTool: UIAppState["activeTool"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: UIAppState;
app: AppClassProperties;
UIOptions: AppProps["UIOptions"];
}) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const device = useDevice();
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
@@ -234,14 +235,6 @@ export const ShapesSwitcher = ({
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
if (
UIOptions.tools?.[
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
] === false
) {
return null;
}
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
@@ -270,76 +263,120 @@ export const ShapesSwitcher = ({
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
app.setActiveTool({ type: value });
if (value === "image") {
app.setActiveTool({
type: value,
insertOnCanvasDirectly: pointerType !== "mouse",
});
} else {
app.setActiveTool({ type: value });
onImageAction({ pointerType });
}
}}
/>
);
})}
<div className="App-toolbar__divider" />
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className={clsx("App-toolbar__extra-tools-trigger", {
"App-toolbar__extra-tools-trigger--selected":
frameToolSelected ||
embeddableToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
// on top of it
(laserToolSelected && !app.props.isCollaborating),
})}
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "frame" })}
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
selected={frameToolSelected}
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "embeddable" })}
checked={activeTool.type === "frame"}
name="editor-current-shape"
title={`${capitalizeString(
t("toolBar.frame"),
)} ${KEYS.F.toLocaleUpperCase()}`}
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
aria-label={capitalizeString(t("toolBar.frame"))}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "frame", "ui");
app.setActiveTool({ type: "frame" });
}}
selected={activeTool.type === "frame"}
/>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={EmbedIcon}
data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
checked={activeTool.type === "embeddable"}
name="editor-current-shape"
title={capitalizeString(t("toolBar.embeddable"))}
aria-label={capitalizeString(t("toolBar.embeddable"))}
data-testid={`toolbar-embeddable`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "embeddable", "ui");
app.setActiveTool({ type: "embeddable" });
}}
selected={activeTool.type === "embeddable"}
/>
</>
) : (
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className={clsx("App-toolbar__extra-tools-trigger", {
"App-toolbar__extra-tools-trigger--selected":
frameToolSelected ||
embeddableToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
// on top of it
(laserToolSelected && !app.props.isCollaborating),
})}
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "laser" })}
icon={laserPointerToolIcon}
data-testid="toolbar-laser"
selected={laserToolSelected}
shortcut={KEYS.K.toLocaleUpperCase()}
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setOpenDialog("mermaid")}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "frame" });
}}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
selected={frameToolSelected}
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "embeddable" });
}}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "laser" });
}}
icon={laserPointerToolIcon}
data-testid="toolbar-laser"
selected={laserToolSelected}
shortcut={KEYS.K.toLocaleUpperCase()}
>
{t("toolBar.laser")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
</>
);
};
+162 -399
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -98,7 +98,7 @@ export const ColorInput = ({
}}
/>
{/* TODO reenable on mobile with a better UX */}
{!device.editor.isMobile && (
{!device.isMobile && (
<>
<div
style={{
+3 -11
View File
@@ -80,7 +80,7 @@ const ColorPickerPopupContent = ({
);
const { container } = useExcalidrawContainer();
const device = useDevice();
const { isMobile, isLandscape } = useDevice();
const colorInputJSX = (
<div>
@@ -136,16 +136,8 @@ const ColorPickerPopupContent = ({
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
side={isMobile && !isLandscape ? "bottom" : "right"}
align={isMobile && !isLandscape ? "center" : "start"}
alignOffset={-16}
sideOffset={20}
style={{
-1
View File
@@ -55,7 +55,6 @@ export const TopPicks = ({
type="button"
title={color}
onClick={() => onChange(color)}
data-testid={`color-top-pick-${color}`}
>
<div className="color-picker__button-outline" />
</button>
+9 -7
View File
@@ -9,7 +9,11 @@ import {
} from "../actions/shortcuts";
import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager";
import { useExcalidrawAppState, useExcalidrawElements } from "./App";
import {
useExcalidrawAppState,
useExcalidrawElements,
useExcalidrawSetAppState,
} from "./App";
import React from "react";
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
@@ -21,14 +25,14 @@ type ContextMenuProps = {
items: ContextMenuItems;
top: number;
left: number;
onClose: (callback?: () => void) => void;
};
export const CONTEXT_MENU_SEPARATOR = "separator";
export const ContextMenu = React.memo(
({ actionManager, items, top, left, onClose }: ContextMenuProps) => {
({ actionManager, items, top, left }: ContextMenuProps) => {
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
@@ -50,9 +54,7 @@ export const ContextMenu = React.memo(
return (
<Popover
onCloseRequest={() => {
onClose();
}}
onCloseRequest={() => setAppState({ contextMenu: null })}
top={top}
left={left}
fitInViewport={true}
@@ -100,7 +102,7 @@ export const ContextMenu = React.memo(
// we need update state before executing the action in case
// the action uses the appState it's being passed (that still
// contains a defined contextMenu) to return the next state.
onClose(() => {
setAppState({ contextMenu: null }, () => {
actionManager.executeAction(item, "contextMenu");
});
}}
+5 -7
View File
@@ -33,16 +33,14 @@
color: var(--color-gray-40);
}
@include isMobile {
top: 1.25rem;
right: 1.25rem;
}
svg {
width: 1.5rem;
height: 1.5rem;
}
}
.Dialog--fullscreen {
.Dialog__close {
top: 1.25rem;
right: 1.25rem;
}
}
}
+3 -5
View File
@@ -49,7 +49,7 @@ export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
const isFullscreen = useDevice().viewport.isMobile;
const device = useDevice();
useEffect(() => {
if (!islandNode) {
@@ -101,9 +101,7 @@ export const Dialog = (props: DialogProps) => {
return (
<Modal
className={clsx("Dialog", props.className, {
"Dialog--fullscreen": isFullscreen,
})}
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={getDialogSize(props.size)}
onCloseRequest={onClose}
@@ -121,7 +119,7 @@ export const Dialog = (props: DialogProps) => {
title={t("buttons.close")}
aria-label={t("buttons.close")}
>
{isFullscreen ? back : CloseIcon}
{device.isMobile ? back : CloseIcon}
</button>
<div className="Dialog__content">{props.children}</div>
</Island>
+1
View File
@@ -254,6 +254,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("helpDialog.movePageLeftRight")}
shortcuts={["Shift+PgUp/PgDn"]}
/>
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
<Shortcut
label={t("buttons.zenMode")}
shortcuts={[getShortcutKey("Alt+Z")]}
+1 -1
View File
@@ -22,7 +22,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (appState.openSidebar && !device.editor.canFitSidebar) {
if (appState.openSidebar && !device.canDeviceFitSidebar) {
return null;
}
+34 -69
View File
@@ -22,7 +22,7 @@ import { canvasToBlob } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../packages/utils";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
@@ -34,8 +34,6 @@ import { Tooltip } from "./Tooltip";
import "./ImageExportDialog.scss";
import { useAppProps } from "./App";
import { FilledButton } from "./FilledButton";
import { cloneJSON } from "../utils";
import { prepareElementsForExport } from "../data";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -53,47 +51,44 @@ export const ErrorCanvasPreview = () => {
};
type ImageExportModalProps = {
appStateSnapshot: Readonly<UIAppState>;
elementsSnapshot: readonly NonDeletedExcalidrawElement[];
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
};
const ImageExportModal = ({
appStateSnapshot,
elementsSnapshot,
appState,
elements,
files,
actionManager,
onExportImage,
}: ImageExportModalProps) => {
const hasSelection = isSomeElementSelected(
elementsSnapshot,
appStateSnapshot,
);
const appProps = useAppProps();
const [projectName, setProjectName] = useState(appStateSnapshot.name);
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
const [projectName, setProjectName] = useState(appState.name);
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const [exportWithBackground, setExportWithBackground] = useState(
appStateSnapshot.exportBackground,
appState.exportBackground,
);
const [exportDarkMode, setExportDarkMode] = useState(
appStateSnapshot.exportWithDarkMode,
appState.exportWithDarkMode,
);
const [embedScene, setEmbedScene] = useState(
appStateSnapshot.exportEmbedScene,
);
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
const [exportScale, setExportScale] = useState(appState.exportScale);
const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null);
const { exportedElements, exportingFrame } = prepareElementsForExport(
elementsSnapshot,
appStateSnapshot,
exportSelectionOnly,
);
const exportedElements = exportSelected
? getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
})
: elements;
useEffect(() => {
const previewNode = previewRef.current;
@@ -107,18 +102,10 @@ const ImageExportModal = ({
}
exportToCanvas({
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportWithDarkMode: exportDarkMode,
exportScale,
exportEmbedScene: embedScene,
},
appState,
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
})
.then((canvas) => {
setRenderError(null);
@@ -132,17 +119,7 @@ const ImageExportModal = ({
console.error(error);
setRenderError(error);
});
}, [
appStateSnapshot,
files,
exportedElements,
exportingFrame,
projectName,
exportWithBackground,
exportDarkMode,
exportScale,
embedScene,
]);
}, [appState, files, exportedElements]);
return (
<div className="ImageExportModal">
@@ -159,8 +136,7 @@ const ImageExportModal = ({
value={projectName}
style={{ width: "30ch" }}
disabled={
typeof appProps.name !== "undefined" ||
appStateSnapshot.viewModeEnabled
typeof appProps.name !== "undefined" || appState.viewModeEnabled
}
onChange={(event) => {
setProjectName(event.target.value);
@@ -176,16 +152,16 @@ const ImageExportModal = ({
</div>
<div className="ImageExportModal__settings">
<h3>{t("imageExportDialog.header")}</h3>
{hasSelection && (
{someElementIsSelected && (
<ExportSetting
label={t("imageExportDialog.label.onlySelected")}
name="exportOnlySelected"
>
<Switch
name="exportOnlySelected"
checked={exportSelectionOnly}
checked={exportSelected}
onChange={(checked) => {
setExportSelectionOnly(checked);
setExportSelected(checked);
}}
/>
</ExportSetting>
@@ -267,9 +243,7 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToPng")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, {
exportingFrame,
})
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
}
startIcon={downloadIcon}
>
@@ -279,9 +253,7 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToSvg")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, {
exportingFrame,
})
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
}
startIcon={downloadIcon}
>
@@ -292,9 +264,7 @@ const ImageExportModal = ({
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
exportingFrame,
})
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
}
startIcon={copyIcon}
>
@@ -355,20 +325,15 @@ export const ImageExportDialog = ({
onExportImage: AppClassProperties["onExportImage"];
onCloseRequest: () => void;
}) => {
// we need to take a snapshot so that the exported state can't be modified
// while the dialog is open
const [{ appStateSnapshot, elementsSnapshot }] = useState(() => {
return {
appStateSnapshot: cloneJSON(appState),
elementsSnapshot: cloneJSON(elements),
};
});
if (appState.openDialog !== "imageExport") {
return null;
}
return (
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
<ImageExportModal
elementsSnapshot={elementsSnapshot}
appStateSnapshot={appStateSnapshot}
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
onExportImage={onExportImage}
+3 -12
View File
@@ -23,15 +23,12 @@ export type ExportCB = (
const JSONExportModal = ({
elements,
appState,
setAppState,
files,
actionManager,
exportOpts,
canvas,
onCloseRequest,
}: {
appState: UIAppState;
setAppState: React.Component<any, UIAppState>["setState"];
files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionManager;
@@ -75,14 +72,9 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={async () => {
try {
trackEvent("export", "link", `ui (${getFrame()})`);
await onExportToBackend(elements, appState, files, canvas);
onCloseRequest();
} catch (error: any) {
setAppState({ errorMessage: error.message });
}
onClick={() => {
onExportToBackend(elements, appState, files, canvas);
trackEvent("export", "link", `ui (${getFrame()})`);
}}
/>
</Card>
@@ -122,7 +114,6 @@ export const JSONExportDialog = ({
<JSONExportModal
elements={elements}
appState={appState}
setAppState={setAppState}
files={files}
actionManager={actionManager}
onCloseRequest={handleClose}
+35 -18
View File
@@ -62,16 +62,18 @@ interface LayerUIProps {
appState: UIAppState;
files: BinaryFiles;
canvas: HTMLCanvasElement;
interactiveCanvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: AppClassProperties["togglePenMode"];
onPenModeToggle: () => void;
showExitZenModeBtn: boolean;
langCode: Language["code"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
UIOptions: AppProps["UIOptions"];
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
onExportImage: AppClassProperties["onExportImage"];
renderWelcomeScreen: boolean;
children?: React.ReactNode;
@@ -121,6 +123,7 @@ const LayerUI = ({
setAppState,
elements,
canvas,
interactiveCanvas,
onLockToggle,
onHandToolToggle,
onPenModeToggle,
@@ -128,6 +131,7 @@ const LayerUI = ({
renderTopRightUI,
renderCustomStats,
UIOptions,
onImageAction,
onExportImage,
renderWelcomeScreen,
children,
@@ -161,10 +165,7 @@ const LayerUI = ({
};
const renderImageExportDialog = () => {
if (
!UIOptions.canvasActions.saveAsImage ||
appState.openDialog !== "imageExport"
) {
if (!UIOptions.canvasActions.saveAsImage) {
return null;
}
@@ -249,7 +250,7 @@ const LayerUI = ({
>
<HintViewer
appState={appState}
isMobile={device.editor.isMobile}
isMobile={device.isMobile}
device={device}
app={app}
/>
@@ -258,7 +259,7 @@ const LayerUI = ({
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
@@ -279,9 +280,14 @@ const LayerUI = ({
<ShapesSwitcher
appState={appState}
interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
/>
</Stack.Row>
</Island>
@@ -318,7 +324,7 @@ const LayerUI = ({
)}
>
<UserList collaborators={appState.collaborators} />
{renderTopRightUI?.(device.editor.isMobile, appState)}
{renderTopRightUI?.(device.isMobile, appState)}
{!appState.viewModeEnabled &&
// hide button when sidebar docked
(!isSidebarDocked ||
@@ -339,7 +345,7 @@ const LayerUI = ({
trackEvent(
"sidebar",
`toggleDock (${docked ? "dock" : "undock"})`,
`(${device.editor.isMobile ? "mobile" : "desktop"})`,
`(${device.isMobile ? "mobile" : "desktop"})`,
);
}}
/>
@@ -367,7 +373,7 @@ const LayerUI = ({
trackEvent(
"sidebar",
`${DEFAULT_SIDEBAR.name} (open)`,
`button (${device.editor.isMobile ? "mobile" : "desktop"})`,
`button (${device.isMobile ? "mobile" : "desktop"})`,
);
}
}}
@@ -384,7 +390,7 @@ const LayerUI = ({
{appState.errorMessage}
</ErrorDialog>
)}
{eyeDropperState && !device.editor.isMobile && (
{eyeDropperState && !device.isMobile && (
<EyeDropper
colorPickerType={eyeDropperState.colorPickerType}
onCancel={() => {
@@ -454,7 +460,7 @@ const LayerUI = ({
}
/>
)}
{device.editor.isMobile && (
{device.isMobile && (
<MobileMenu
app={app}
appState={appState}
@@ -466,22 +472,23 @@ const LayerUI = ({
onLockToggle={onLockToggle}
onHandToolToggle={onHandToolToggle}
onPenModeToggle={onPenModeToggle}
interactiveCanvas={interactiveCanvas}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderSidebars={renderSidebars}
device={device}
renderWelcomeScreen={renderWelcomeScreen}
UIOptions={UIOptions}
/>
)}
{!device.editor.isMobile && (
{!device.isMobile && (
<>
<div
className="layer-ui__wrapper"
style={
appState.openSidebar &&
isSidebarDocked &&
device.editor.canFitSidebar
device.canDeviceFitSidebar
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
: {}
}
@@ -553,8 +560,18 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
return false;
}
const { canvas: _pC, appState: prevAppState, ...prev } = prevProps;
const { canvas: _nC, appState: nextAppState, ...next } = nextProps;
const {
canvas: _pC,
interactiveCanvas: _pIC,
appState: prevAppState,
...prev
} = prevProps;
const {
canvas: _nC,
interactiveCanvas: _nIC,
appState: nextAppState,
...next
} = nextProps;
return (
isShallowEqual(
+1 -1
View File
@@ -47,7 +47,7 @@ export const LibraryUnit = memo(
}, [svg]);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().editor.isMobile;
const isMobile = useDevice().isMobile;
const adder = isPending && (
<div className="library-unit__adder">{PlusIcon}</div>
);
-221
View File
@@ -1,221 +0,0 @@
@import "../css/variables.module";
$verticalBreakpoint: 860px;
.excalidraw {
.dialog-mermaid {
&-title {
margin-bottom: 5px;
margin-top: 2px;
}
&-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
}
.Modal__content .Island {
box-shadow: none;
}
@at-root .excalidraw:not(.excalidraw--mobile)#{&} {
padding: 1.25rem;
.Modal__content {
height: 100%;
max-height: 750px;
@media screen and (max-width: $verticalBreakpoint) {
height: auto;
// When vertical, we want the height to span whole viewport.
// This is also important for the children not to overflow the
// modal/viewport (for some reason).
max-height: 100%;
}
.Island {
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
.Dialog__content {
display: flex;
flex: 1 1 auto;
}
}
}
}
}
.dialog-mermaid-body {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto;
height: 100%;
column-gap: 4rem;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
}
.dialog-mermaid-panels {
display: grid;
width: 100%;
grid-template-columns: 1fr 1fr;
justify-content: space-between;
gap: 4rem;
grid-row: 1;
grid-column: 1 / 3;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
margin-bottom: 4px;
margin-left: 4px;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 4px;
}
}
&-text {
display: flex;
flex-direction: column;
textarea {
width: 20rem;
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
height: 10rem;
}
}
}
&-preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
// acts as min-height
height: 200px;
flex-grow: 1;
position: relative;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
@media screen and (max-width: $verticalBreakpoint) {
// acts as min-height
height: 400px;
width: auto;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
&-preview-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
}
&-preview {
display: flex;
flex-direction: column;
}
.mermaid-error {
color: red;
font-weight: 800;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute;
z-index: 10;
p {
font-weight: 500;
font-family: Cascadia;
text-align: left;
white-space: pre-wrap;
font-size: 0.875rem;
padding: 0 10px;
}
}
}
.dialog-mermaid-buttons {
grid-column: 2;
.dialog-mermaid-insert {
&.excalidraw-button {
font-family: "Assistant";
font-weight: 600;
height: 2.5rem;
margin-top: 1em;
margin-bottom: 0.3em;
width: 7.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
}
-243
View File
@@ -1,243 +0,0 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../types";
import { useApp } from "./App";
import { Button } from "./Button";
import { Dialog } from "./Dialog";
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../constants";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../packages/excalidraw/index";
import { NonDeletedExcalidrawElement } from "../element/types";
import { canvasToBlob } from "../data/blob";
import { ArrowRightIcon } from "./icons";
import Spinner from "./Spinner";
import "./MermaidToExcalidraw.scss";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { t } from "../i18n";
import Trans from "./Trans";
const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const saveMermaidDataToStorage = (data: string) => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importMermaidDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
if (data) {
return data;
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const ErrorComp = ({ error }: { error: string }) => {
return (
<div data-testid="mermaid-error" className="mermaid-error">
Error! <p>{error}</p>
</div>
);
};
const MermaidToExcalidraw = () => {
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = useState<{
loaded: boolean;
api: {
parseMermaidToExcalidraw: (
defination: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
} | null;
}>({ loaded: false, api: null });
const [text, setText] = useState("");
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState(null);
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
const resetPreview = () => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
useEffect(() => {
const loadMermaidToExcalidrawLib = async () => {
const api = await import(
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
);
setMermaidToExcalidrawLib({ loaded: true, api });
};
loadMermaidToExcalidrawLib();
}, []);
useEffect(() => {
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
setText(data);
}, []);
useEffect(() => {
const renderExcalidrawPreview = async () => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (
!mermaidToExcalidrawLib.loaded ||
!canvasNode ||
!parent ||
!mermaidToExcalidrawLib.api
) {
return;
}
if (!deferredText) {
resetPreview();
return;
}
try {
const { elements, files } =
await mermaidToExcalidrawLib.api.parseMermaidToExcalidraw(
deferredText,
{
fontSize: DEFAULT_FONT_SIZE,
},
);
setError(null);
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
}),
files,
};
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (e: any) {
parent.style.background = "var(--default-bg-color)";
if (deferredText) {
setError(e.message);
}
}
};
renderExcalidrawPreview();
}, [deferredText, mermaidToExcalidrawLib]);
const onClose = () => {
app.setOpenDialog(null);
saveMermaidDataToStorage(text);
};
const onSelect = () => {
const { elements: newElements, files } = data.current;
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
onClose();
};
return (
<Dialog
className="dialog-mermaid"
onCloseRequest={onClose}
size={1200}
title={
<>
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
<span className="dialog-mermaid-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
{el}
</a>
)}
/>
<br />
</span>
</>
}
>
<div className="dialog-mermaid-body">
<div className="dialog-mermaid-panels">
<div className="dialog-mermaid-panels-text">
<label>{t("mermaid.syntax")}</label>
<textarea
onChange={(event) => setText(event.target.value)}
value={text}
/>
</div>
<div className="dialog-mermaid-panels-preview">
<label>{t("mermaid.preview")}</label>
<div className="dialog-mermaid-panels-preview-wrapper">
{error && <ErrorComp error={error} />}
{mermaidToExcalidrawLib.loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="dialog-mermaid-panels-preview-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
</div>
</div>
<div className="dialog-mermaid-buttons">
<Button className="dialog-mermaid-insert" onSelect={onSelect}>
{t("mermaid.button")}
<span>{ArrowRightIcon}</span>
</Button>
</div>
</div>
</Dialog>
);
};
export default MermaidToExcalidraw;
+12 -7
View File
@@ -1,7 +1,6 @@
import React from "react";
import {
AppClassProperties,
AppProps,
AppState,
Device,
ExcalidrawProps,
@@ -36,8 +35,10 @@ type MobileMenuProps = {
elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: AppClassProperties["togglePenMode"];
onPenModeToggle: () => void;
interactiveCanvas: HTMLCanvasElement | null;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: (
isMobile: boolean,
appState: UIAppState,
@@ -46,7 +47,6 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean;
UIOptions: AppProps["UIOptions"];
app: AppClassProperties;
};
@@ -58,13 +58,13 @@ export const MobileMenu = ({
onLockToggle,
onHandToolToggle,
onPenModeToggle,
interactiveCanvas,
onImageAction,
renderTopRightUI,
renderCustomStats,
renderSidebars,
device,
renderWelcomeScreen,
UIOptions,
app,
}: MobileMenuProps) => {
const {
@@ -85,9 +85,14 @@ export const MobileMenu = ({
<Stack.Row gap={1}>
<ShapesSwitcher
appState={appState}
interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
/>
</Stack.Row>
</Island>
@@ -98,7 +103,7 @@ export const MobileMenu = ({
)}
<PenModeButton
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
+7 -4
View File
@@ -59,6 +59,12 @@
&:focus {
outline: none;
}
@include isMobile {
max-width: 100%;
border: 0;
border-radius: 0;
}
}
@keyframes Modal__background__fade-in {
@@ -99,7 +105,7 @@
}
}
.Dialog--fullscreen {
@include isMobile {
.Modal {
padding: 0;
}
@@ -110,9 +116,6 @@
left: 0;
right: 0;
bottom: 0;
max-width: 100%;
border: 0;
border-radius: 0;
}
}
}
+4 -4
View File
@@ -113,11 +113,11 @@ export const SidebarInner = forwardRef(
if ((event.target as Element).closest(".sidebar-trigger")) {
return;
}
if (!docked || !device.editor.canFitSidebar) {
if (!docked || !device.canDeviceFitSidebar) {
closeLibrary();
}
},
[closeLibrary, docked, device.editor.canFitSidebar],
[closeLibrary, docked, device.canDeviceFitSidebar],
),
);
@@ -125,7 +125,7 @@ export const SidebarInner = forwardRef(
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!docked || !device.editor.canFitSidebar)
(!docked || !device.canDeviceFitSidebar)
) {
closeLibrary();
}
@@ -134,7 +134,7 @@ export const SidebarInner = forwardRef(
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [closeLibrary, docked, device.editor.canFitSidebar]);
}, [closeLibrary, docked, device.canDeviceFitSidebar]);
return (
<Island
+1 -1
View File
@@ -18,7 +18,7 @@ export const SidebarHeader = ({
const props = useContext(SidebarPropsContext);
const renderDockButton = !!(
device.editor.canFitSidebar && props.shouldRenderDockButton
device.canDeviceFitSidebar && props.shouldRenderDockButton
);
return (
+5 -5
View File
@@ -83,12 +83,12 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
}
};
useEffect(() => {
isMountedRef.current = true;
return () => {
useEffect(
() => () => {
isMountedRef.current = false;
};
}, []);
},
[],
);
const lastPointerTypeRef = useRef<PointerType | null>(null);
-9
View File
@@ -160,15 +160,6 @@
width: var(--lg-button-size);
height: var(--lg-button-size);
@media screen and (max-width: 450px) {
width: 1.8rem;
height: 1.8rem;
}
@media screen and (max-width: 379px) {
width: 1.5rem;
height: 1.5rem;
}
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
-5
View File
@@ -16,10 +16,6 @@
align-self: center;
background-color: var(--default-border-color);
margin: 0 0.25rem;
@include isMobile {
margin: 0;
}
}
}
@@ -45,6 +41,5 @@
margin-top: 0.375rem;
right: 0;
min-width: 11.875rem;
z-index: 1;
}
}
@@ -7,6 +7,8 @@
margin-top: 0.25rem;
&--mobile {
bottom: 55px;
top: auto;
left: 0;
width: 100%;
row-gap: 0.75rem;
@@ -30,7 +30,7 @@ const MenuContent = ({
});
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.editor.isMobile,
"dropdown-menu--mobile": device.isMobile,
}).trim();
return (
@@ -43,7 +43,7 @@ const MenuContent = ({
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
{device.editor.isMobile ? (
{device.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island
@@ -14,7 +14,7 @@ const MenuItemContent = ({
<>
<div className="dropdown-menu-item__icon">{icon}</div>
<div className="dropdown-menu-item__text">{children}</div>
{shortcut && !device.editor.isMobile && (
{shortcut && !device.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>
@@ -18,7 +18,7 @@ const MenuTrigger = ({
`dropdown-menu-button ${className}`,
"zen-mode-transition",
{
"dropdown-menu-button--mobile": device.editor.isMobile,
"dropdown-menu-button--mobile": device.isMobile,
},
).trim();
return (
-16
View File
@@ -1654,22 +1654,6 @@ export const frameToolIcon = createIcon(
tablerIconProps,
);
export const mermaidLogoIcon = createIcon(
<path
fill="currentColor"
d="M407.48,111.18C335.587,108.103 269.573,152.338 245.08,220C220.587,152.338 154.573,108.103 82.68,111.18C80.285,168.229 107.577,222.632 154.74,254.82C178.908,271.419 193.35,298.951 193.27,328.27L193.27,379.13L296.9,379.13L296.9,328.27C296.816,298.953 311.255,271.42 335.42,254.82C382.596,222.644 409.892,168.233 407.48,111.18Z"
/>,
);
export const ArrowRightIcon = createIcon(
<g strokeWidth="1.25">
<path d="M4.16602 10H15.8327" />
<path d="M12.5 13.3333L15.8333 10" />
<path d="M12.5 6.66666L15.8333 9.99999" />
</g>,
modifiedTablerIconProps,
);
export const laserPointerToolIcon = createIcon(
<g
fill="none"
+2 -2
View File
@@ -29,7 +29,7 @@ const MainMenu = Object.assign(
const device = useDevice();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.editor.isMobile
const onClickOutside = device.isMobile
? undefined
: () => setAppState({ openMenu: null });
@@ -54,7 +54,7 @@ const MainMenu = Object.assign(
})}
>
{children}
{device.editor.isMobile && appState.collaborators.size > 0 && (
{device.isMobile && appState.collaborators.size > 0 && (
<fieldset className="UserList-Wrapper">
<legend>{t("labels.collaborators")}</legend>
<UserList
@@ -21,7 +21,7 @@ const WelcomeScreenMenuItemContent = ({
<>
<div className="welcome-screen-menu-item__icon">{icon}</div>
<div className="welcome-screen-menu-item__text">{children}</div>
{shortcut && !device.editor.isMobile && (
{shortcut && !device.isMobile && (
<div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
)}
</>
+6 -27
View File
@@ -105,7 +105,6 @@ export const FONT_FAMILY = {
Virgil: 1,
Helvetica: 2,
Cascadia: 3,
Assistant: 4,
};
export const THEME = {
@@ -115,18 +114,13 @@ export const THEME = {
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
roughness: 0 as ExcalidrawElement["roughness"],
roundness: null as ExcalidrawElement["roundness"],
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
radius: 8,
nameOffsetY: 3,
nameColorLightTheme: "#999999",
nameColorDarkTheme: "#7a7a7a",
nameFontSize: 14,
nameLineHeight: 1.25,
};
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
@@ -154,8 +148,6 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif",
} as const;
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
export const MIME_TYPES = {
json: "application/json",
// excalidraw data
@@ -222,13 +214,12 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
toggleTheme: null,
saveAsImage: true,
},
tools: {
image: true,
},
};
// breakpoints
// -----------------------------------------------------------------------------
// sm screen
export const MQ_SM_MAX_WIDTH = 640;
// md screen
export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
@@ -305,18 +296,6 @@ export const ROUNDNESS = {
* collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const ROUGHNESS = {
architect: 0,
artist: 1,
cartoonist: 2,
} as const;
export const STROKE_WIDTH = {
thin: 1,
bold: 2,
extraBold: 4,
} as const;
export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"];
@@ -329,10 +308,10 @@ export const DEFAULT_ELEMENT_PROPS: {
} = {
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid",
strokeWidth: 2,
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roughness: ROUGHNESS.artist,
roughness: 1,
opacity: 100,
locked: false,
};
+5 -12
View File
@@ -195,7 +195,7 @@
.buttonList label:focus-within,
input:focus-visible {
outline: transparent;
box-shadow: 0 0 0 1px var(--color-brand-hover);
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
.buttonList {
@@ -280,11 +280,6 @@
align-items: center;
justify-content: space-between;
padding: 8px;
.dropdown-menu--mobile {
bottom: 55px;
top: auto;
}
}
.App-mobile-menu {
@@ -542,13 +537,13 @@
&:not(:focus) {
&:hover {
border-color: var(--color-brand-hover);
background-color: var(--input-hover-bg-color);
}
}
&:focus {
outline: none;
border-color: var(--color-brand-hover);
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
}
@@ -597,8 +592,6 @@
background-color: var(--island-bg-color);
.ToolIcon__icon {
width: 2rem;
height: 2rem;
border-radius: 0;
}
@@ -608,8 +601,8 @@
}
.App-toolbar--mobile {
overflow: visible;
max-width: 98vw;
overflow-x: auto;
max-width: 90vw;
.ToolIcon__keybinding {
display: none;
-2
View File
@@ -99,7 +99,5 @@ export const setCursorForShape = (
interactiveCanvas.style.cursor = `url(${url}), auto`;
} else if (!["image", "custom"].includes(appState.activeTool.type)) {
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
} else if (appState.activeTool.type !== "image") {
interactiveCanvas.style.cursor = CURSOR_TYPE.AUTO;
}
};
File diff suppressed because it is too large Load Diff
+18 -53
View File
@@ -3,12 +3,12 @@ import { cleanAppStateForExport } from "../appState";
import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError, ImageSceneDataError } from "../errors";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState, DataURL, LibraryItem } from "../types";
import { ValueOf } from "../utility-types";
import { bytesToHexString, isPromiseLike } from "../utils";
import { bytesToHexString } from "../utils";
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore";
@@ -24,12 +24,15 @@ const parseFileContents = async (blob: Blob | File) => {
).decodePngMetadata(blob);
} catch (error: any) {
if (error.message === "INVALID") {
throw new ImageSceneDataError(
throw new DOMException(
t("alerts.imageDoesNotContainScene"),
"IMAGE_NOT_CONTAINS_SCENE_DATA",
"EncodingError",
);
} else {
throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
throw new DOMException(
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
}
}
} else {
@@ -55,12 +58,15 @@ const parseFileContents = async (blob: Blob | File) => {
});
} catch (error: any) {
if (error.message === "INVALID") {
throw new ImageSceneDataError(
throw new DOMException(
t("alerts.imageDoesNotContainScene"),
"IMAGE_NOT_CONTAINS_SCENE_DATA",
"EncodingError",
);
} else {
throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
throw new DOMException(
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
}
}
}
@@ -125,19 +131,8 @@ export const loadSceneOrLibraryFromBlob = async (
fileHandle?: FileSystemHandle | null,
) => {
const contents = await parseFileContents(blob);
let data;
try {
try {
data = JSON.parse(contents);
} catch (error: any) {
if (isSupportedImageFile(blob)) {
throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"),
"IMAGE_NOT_CONTAINS_SCENE_DATA",
);
}
throw error;
}
const data = JSON.parse(contents);
if (isValidExcalidrawData(data)) {
return {
type: MIME_TYPES.excalidraw,
@@ -167,9 +162,7 @@ export const loadSceneOrLibraryFromBlob = async (
}
throw new Error(t("alerts.couldNotLoadInvalidFile"));
} catch (error: any) {
if (error instanceof ImageSceneDataError) {
throw error;
}
console.error(error.message);
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
};
@@ -214,13 +207,10 @@ export const loadLibraryFromBlob = async (
};
export const canvasToBlob = async (
canvas: HTMLCanvasElement | Promise<HTMLCanvasElement>,
canvas: HTMLCanvasElement,
): Promise<Blob> => {
return new Promise(async (resolve, reject) => {
return new Promise((resolve, reject) => {
try {
if (isPromiseLike(canvas)) {
canvas = await canvas;
}
canvas.toBlob((blob) => {
if (!blob) {
return reject(
@@ -334,31 +324,6 @@ export const SVGStringToFile = (SVGString: string, filename: string = "") => {
}) as File & { type: typeof MIME_TYPES.svg };
};
export const ImageURLToFile = async (
imageUrl: string,
filename: string = "",
): Promise<File | undefined> => {
let response;
try {
response = await fetch(imageUrl);
} catch (error: any) {
throw new Error(t("errors.failedToFetchImage"));
}
if (!response.ok) {
throw new Error(t("errors.failedToFetchImage"));
}
const blob = await response.blob();
if (blob.type && isSupportedImageFile(blob)) {
const name = filename || blob.name || "";
return new File([blob], name, { type: blob.type });
}
throw new Error(t("errors.unsupportedFileType"));
};
export const getFileFromEvent = async (
event: React.DragEvent<HTMLDivElement>,
) => {
+9 -67
View File
@@ -3,19 +3,11 @@ import {
copyTextToSystemClipboard,
} from "../clipboard";
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
import { getNonDeletedElements, isFrameElement } from "../element";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { elementsOverlappingBBox } from "../packages/withinBounds";
import { isSomeElementSelected, getSelectedElements } from "../scene";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { ExportType } from "../scene/types";
import { AppState, BinaryFiles } from "../types";
import { cloneJSON } from "../utils";
import { canvasToBlob } from "./blob";
import { fileSave, FileSystemHandle } from "./filesystem";
import { serializeAsJSON } from "./json";
@@ -23,61 +15,9 @@ import { serializeAsJSON } from "./json";
export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json";
export type ExportedElements = readonly NonDeletedExcalidrawElement[] & {
_brand: "exportedElements";
};
export const prepareElementsForExport = (
elements: readonly ExcalidrawElement[],
{ selectedElementIds }: Pick<AppState, "selectedElementIds">,
exportSelectionOnly: boolean,
) => {
elements = getNonDeletedElements(elements);
const isExportingSelection =
exportSelectionOnly &&
isSomeElementSelected(elements, { selectedElementIds });
let exportingFrame: ExcalidrawFrameElement | null = null;
let exportedElements = isExportingSelection
? getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
},
)
: elements;
if (isExportingSelection) {
if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) {
exportingFrame = exportedElements[0];
exportedElements = elementsOverlappingBBox({
elements,
bounds: exportingFrame,
type: "overlap",
});
} else if (exportedElements.length > 1) {
exportedElements = getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
}
}
return {
exportingFrame,
exportedElements: cloneJSON(exportedElements) as ExportedElements,
};
};
export const exportCanvas = async (
type: Omit<ExportType, "backend">,
elements: ExportedElements,
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
{
@@ -86,14 +26,12 @@ export const exportCanvas = async (
viewBackgroundColor,
name,
fileHandle = null,
exportingFrame = null,
}: {
exportBackground: boolean;
exportPadding?: number;
viewBackgroundColor: string;
name: string;
fileHandle?: FileSystemHandle | null;
exportingFrame: ExcalidrawFrameElement | null;
},
) => {
if (elements.length === 0) {
@@ -111,7 +49,6 @@ export const exportCanvas = async (
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
files,
{ exportingFrame },
);
if (type === "svg") {
return await fileSave(
@@ -129,15 +66,17 @@ export const exportCanvas = async (
}
}
const tempCanvas = exportToCanvas(elements, appState, files, {
const tempCanvas = await exportToCanvas(elements, appState, files, {
exportBackground,
viewBackgroundColor,
exportPadding,
exportingFrame,
});
tempCanvas.style.display = "none";
document.body.appendChild(tempCanvas);
if (type === "png") {
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (appState.exportEmbedScene) {
blob = await (
await import(/* webpackChunkName: "image" */ "./image")
@@ -175,8 +114,11 @@ export const exportCanvas = async (
} else {
throw new Error(t("alerts.couldNotCopyToClipboard"));
}
} finally {
tempCanvas.remove();
}
} else {
tempCanvas.remove();
// shouldn't happen
throw new Error("Unsupported export type");
}
+1 -2
View File
@@ -23,7 +23,6 @@ import {
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
import { cloneJSON } from "../utils";
export const libraryItemsAtom = atom<{
status: "loading" | "loaded";
@@ -32,7 +31,7 @@ export const libraryItemsAtom = atom<{
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
cloneJSON(libraryItems);
JSON.parse(JSON.stringify(libraryItems));
/**
* checks if library item does not exist already in current library
+12 -12
View File
@@ -1,6 +1,7 @@
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles } from "../types";
import { exportCanvas, prepareElementsForExport } from ".";
import { exportCanvas } from ".";
import { getNonDeletedElements } from "../element";
import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async (
@@ -22,19 +23,18 @@ export const resaveAsImageWithScene = async (
exportEmbedScene: true,
};
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
await exportCanvas(
fileHandleType,
getNonDeletedElements(elements),
appState,
false,
files,
{
exportBackground,
viewBackgroundColor,
name,
fileHandle,
},
);
await exportCanvas(fileHandleType, exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
name,
fileHandle,
exportingFrame,
});
return { fileHandle };
};
+8 -8
View File
@@ -34,13 +34,13 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getDefaultLineHeight,
measureBaseline,
measureTextElement,
} from "../element/textElement";
import { normalizeLink } from "./url";
@@ -93,7 +93,8 @@ const repairBinding = (binding: PointBinding | null) => {
};
const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & {
T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
/** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -159,6 +160,9 @@ const restoreElementWithProperties = <
locked: element.locked ?? false,
};
if ("subtype" in element) {
base.subtype = element.subtype;
}
if ("customData" in element) {
base.customData = element.customData;
}
@@ -204,11 +208,7 @@ const restoreElement = (
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
const baseline = measureBaseline(
element.text,
getFontString(element),
lineHeight,
);
const baseline = measureTextElement(element, { text }).baseline;
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
+9 -146
View File
@@ -5,31 +5,7 @@ import {
} from "./transform";
import { ExcalidrawArrowElement } from "../element/types";
const opts = { regenerateIds: false };
describe("Test Transform", () => {
it("should generate id unless opts.regenerateIds is set to false explicitly", () => {
const elements = [
{
type: "rectangle",
x: 100,
y: 100,
id: "rect-1",
},
];
let data = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(data.length).toBe(1);
expect(data[0].id).toBe("id0");
data = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(data[0].id).toBe("rect-1");
});
it("should transform regular shapes", () => {
const elements = [
{
@@ -83,7 +59,6 @@ describe("Test Transform", () => {
convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
).forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
@@ -112,7 +87,6 @@ describe("Test Transform", () => {
];
convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
).forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
@@ -154,7 +128,6 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
@@ -237,7 +210,6 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(12);
@@ -295,7 +267,6 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(8);
@@ -309,90 +280,6 @@ describe("Test Transform", () => {
});
});
describe("Test Frames", () => {
it("should transform frames and update frame ids when regenerated", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{
type: "frame",
children: ["1", "2"],
name: "My frame",
},
];
const excaldrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
expect(excaldrawElements.length).toBe(4);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchObject({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should consider max of calculated and frame dimensions when provided", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{
type: "frame",
children: ["1", "2"],
name: "My frame",
width: 800,
height: 100,
},
];
const excaldrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
const frame = excaldrawElements.find((ele) => ele.type === "frame")!;
expect(frame.width).toBe(800);
expect(frame.height).toBe(126);
});
});
describe("Test arrow bindings", () => {
it("should bind arrows to shapes when start / end provided without ids", () => {
const elements = [
@@ -413,7 +300,6 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
@@ -435,7 +321,7 @@ describe("Test Transform", () => {
});
expect(text).toMatchObject({
x: 240,
x: 340,
y: 226.5,
type: "text",
text: "HELLO WORLD!!",
@@ -455,7 +341,7 @@ describe("Test Transform", () => {
});
expect(ellipse).toMatchObject({
x: 355,
x: 555,
y: 189,
type: "ellipse",
boundElements: [
@@ -497,10 +383,10 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
const [arrow, text1, text2, text3] = excaldrawElements;
expect(arrow).toMatchObject({
@@ -520,7 +406,7 @@ describe("Test Transform", () => {
});
expect(text1).toMatchObject({
x: 240,
x: 340,
y: 226.5,
type: "text",
text: "HELLO WORLD!!",
@@ -540,7 +426,7 @@ describe("Test Transform", () => {
});
expect(text3).toMatchObject({
x: 355,
x: 555,
y: 226.5,
type: "text",
boundElements: [
@@ -613,7 +499,6 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(5);
@@ -662,7 +547,6 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
@@ -716,18 +600,17 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
const [, , arrow, text] = excaldrawElements;
const [, , arrow] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
y: 239,
boundElements: [
{
id: text.id,
id: "id46",
type: "text",
},
],
@@ -767,18 +650,17 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(2);
const [arrow, rect] = excaldrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
focus: 0,
gap: 205,
gap: 5,
});
expect(rect.boundElements).toStrictEqual([
{
id: arrow.id,
id: "id47",
type: "arrow",
},
]);
@@ -810,7 +692,6 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(1);
@@ -822,22 +703,4 @@ describe("Test Transform", () => {
"Duplicate id found for rect-1",
);
});
it("should contains customData if provided", () => {
const rawData = [
{
type: "rectangle",
x: 100,
y: 100,
customData: { createdBy: "user01" },
},
];
const convertedElements = convertToExcalidrawElements(
rawData as ExcalidrawElementSkeleton[],
opts,
);
expect(convertedElements[0].customData).toStrictEqual({
createdBy: "user01",
});
});
});
+12 -161
View File
@@ -5,7 +5,6 @@ import {
VERTICAL_ALIGN,
} from "../constants";
import {
getCommonBounds,
newElement,
newLinearElement,
redrawTextBoundingBox,
@@ -13,7 +12,6 @@ import {
import { bindLinearElement } from "../element/binding";
import {
ElementConstructorOpts,
newFrameElement,
newImageElement,
newTextElement,
} from "../element/newElement";
@@ -40,9 +38,7 @@ import {
VerticalAlign,
} from "../element/types";
import { MarkOptional } from "../utility-types";
import { assertNever, cloneJSON, getFontString } from "../utils";
import { getSizeFromPoints } from "../points";
import { randomId } from "../random";
import { assertNever, getFontString } from "../utils";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -137,7 +133,9 @@ export type ValidContainer =
export type ExcalidrawElementSkeleton =
| Extract<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
ExcalidrawEmbeddableElement | ExcalidrawFreeDrawElement
| ExcalidrawEmbeddableElement
| ExcalidrawFreeDrawElement
| ExcalidrawFrameElement
>
| ({
type: Extract<ExcalidrawLinearElement["type"], "line">;
@@ -158,15 +156,10 @@ export type ExcalidrawElementSkeleton =
x: number;
y: number;
fileId: FileId;
} & Partial<ExcalidrawImageElement>)
| ({
type: "frame";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawFrameElement>);
} & Partial<ExcalidrawImageElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 100,
width: 300,
height: 0,
};
@@ -364,49 +357,6 @@ const bindLinearElementToElement = (
);
}
}
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
const endPointIndex = linearElement.points.length - 1;
const delta = 0.5;
const newPoints = cloneJSON(linearElement.points) as [number, number][];
// left to right so shift the arrow towards right
if (
linearElement.points[endPointIndex][0] >
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = delta;
newPoints[endPointIndex][0] -= delta;
}
// right to left so shift the arrow towards left
if (
linearElement.points[endPointIndex][0] <
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = -delta;
newPoints[endPointIndex][0] += delta;
}
// top to bottom so shift the arrow towards top
if (
linearElement.points[endPointIndex][1] >
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = delta;
newPoints[endPointIndex][1] -= delta;
}
// bottom to top so shift the arrow towards bottom
if (
linearElement.points[endPointIndex][1] <
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = -delta;
newPoints[endPointIndex][1] += delta;
}
Object.assign(linearElement, { points: newPoints });
return {
linearElement,
startBoundElement,
@@ -434,25 +384,18 @@ class ElementStore {
}
export const convertToExcalidrawElements = (
elementsSkeleton: ExcalidrawElementSkeleton[] | null,
opts?: { regenerateIds: boolean },
elements: ExcalidrawElementSkeleton[] | null,
) => {
if (!elementsSkeleton) {
if (!elements) {
return [];
}
const elements = cloneJSON(elementsSkeleton);
const elementStore = new ElementStore();
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
const oldToNewElementIdMap = new Map<string, string>();
// Create individual elements
for (const element of elements) {
let excalidrawElement: ExcalidrawElement;
const originalId = element.id;
if (opts?.regenerateIds !== false) {
Object.assign(element, { id: randomId() });
}
switch (element.type) {
case "rectangle":
case "ellipse":
@@ -501,11 +444,6 @@ export const convertToExcalidrawElements = (
],
...element,
});
Object.assign(
excalidrawElement,
getSizeFromPoints(excalidrawElement.points),
);
break;
}
case "text": {
@@ -539,15 +477,8 @@ export const convertToExcalidrawElements = (
break;
}
case "frame": {
excalidrawElement = newFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "freedraw":
case "frame":
case "embeddable": {
excalidrawElement = element;
break;
@@ -568,9 +499,6 @@ export const convertToExcalidrawElements = (
} else {
elementStore.add(excalidrawElement);
elementsWithIds.set(excalidrawElement.id, element);
if (originalId) {
oldToNewElementIdMap.set(originalId, excalidrawElement.id);
}
}
}
@@ -596,18 +524,6 @@ export const convertToExcalidrawElements = (
element.type === "arrow" ? element?.start : undefined;
const originalEnd =
element.type === "arrow" ? element?.end : undefined;
if (originalStart && originalStart.id) {
const newStartId = oldToNewElementIdMap.get(originalStart.id);
if (newStartId) {
Object.assign(originalStart, { id: newStartId });
}
}
if (originalEnd && originalEnd.id) {
const newEndId = oldToNewElementIdMap.get(originalEnd.id);
if (newEndId) {
Object.assign(originalEnd, { id: newEndId });
}
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
container as ExcalidrawArrowElement,
@@ -623,23 +539,13 @@ export const convertToExcalidrawElements = (
} else {
switch (element.type) {
case "arrow": {
const { start, end } = element;
if (start && start.id) {
const newStartId = oldToNewElementIdMap.get(start.id);
Object.assign(start, { id: newStartId });
}
if (end && end.id) {
const newEndId = oldToNewElementIdMap.get(end.id);
Object.assign(end, { id: newEndId });
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
excalidrawElement as ExcalidrawArrowElement,
start,
end,
element.start,
element.end,
elementStore,
);
elementStore.add(linearElement);
elementStore.add(startBoundElement);
elementStore.add(endBoundElement);
@@ -651,60 +557,5 @@ export const convertToExcalidrawElements = (
}
}
}
// Once all the excalidraw elements are created, we can add frames since we
// need to calculate coordinates and dimensions of frame which is possibe after all
// frame children are processed.
for (const [id, element] of elementsWithIds) {
if (element.type !== "frame") {
continue;
}
const frame = elementStore.getElement(id);
if (!frame) {
throw new Error(`Excalidraw element with id ${id} doesn't exist`);
}
const childrenElements: ExcalidrawElement[] = [];
element.children.forEach((id) => {
const newElementId = oldToNewElementIdMap.get(id);
if (!newElementId) {
throw new Error(`Element with ${id} wasn't mapped correctly`);
}
const elementInFrame = elementStore.getElement(newElementId);
if (!elementInFrame) {
throw new Error(`Frame element with id ${newElementId} doesn't exist`);
}
Object.assign(elementInFrame, { frameId: frame.id });
elementInFrame?.boundElements?.forEach((boundElement) => {
const ele = elementStore.getElement(boundElement.id);
if (!ele) {
throw new Error(
`Bound element with id ${boundElement.id} doesn't exist`,
);
}
Object.assign(ele, { frameId: frame.id });
childrenElements.push(ele);
});
childrenElements.push(elementInFrame);
});
let [minX, minY, maxX, maxY] = getCommonBounds(childrenElements);
const PADDING = 10;
minX = minX - PADDING;
minY = minY - PADDING;
maxX = maxX + PADDING;
maxY = maxY + PADDING;
// Take the max of calculated and provided frame dimensions, whichever is higher
const width = Math.max(frame?.width, maxX - minX);
const height = Math.max(frame?.height, maxY - minY);
Object.assign(frame, { x: minX, y: minY, width, height });
}
return elementStore.getElements();
};
+1 -1
View File
@@ -392,7 +392,7 @@ export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: number,
appState: Pick<UIAppState, "zoom">,
): Bounds => {
): [x: number, y: number, width: number, height: number] => {
const size = DEFAULT_LINK_SIZE;
const linkWidth = size / appState.zoom.value;
const linkHeight = size / appState.zoom.value;
+21 -36
View File
@@ -13,7 +13,6 @@ import { Point } from "../types";
import { generateRoughOptions } from "../scene/Shape";
import {
isArrowElement,
isBoundToContainer,
isFreeDrawElement,
isLinearElement,
isTextElement,
@@ -23,7 +22,6 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
export type RectangleBox = {
x: number;
@@ -36,12 +34,7 @@ export type RectangleBox = {
type MaybeQuadraticSolution = [number | null, number | null] | false;
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];
export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number];
export class ElementBounds {
private static boundsCache = new WeakMap<
@@ -55,35 +48,22 @@ export class ElementBounds {
static getBounds(element: ExcalidrawElement) {
const cachedBounds = ElementBounds.boundsCache.get(element);
if (
cachedBounds?.version &&
cachedBounds.version === element.version &&
// we don't invalidate cache when we update containers and not labels,
// which is causing problems down the line. Fix TBA.
!isBoundToContainer(element)
) {
if (cachedBounds?.version && cachedBounds.version === element.version) {
return cachedBounds.bounds;
}
const bounds = ElementBounds.calculateBounds(element);
// hack to ensure that downstream checks could retrieve element Scene
// so as to have correctly calculated bounds
// FIXME remove when we get rid of all the id:Scene / element:Scene mapping
const shouldCache = Scene.getScene(element);
if (shouldCache) {
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
});
}
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
});
return bounds;
}
private static calculateBounds(element: ExcalidrawElement): Bounds {
let bounds: Bounds;
let bounds: [number, number, number, number];
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
@@ -407,7 +387,7 @@ const getCubicBezierCurveBound = (
export const getMinMaxXYFromCurvePathOps = (
ops: Op[],
transformXY?: (x: number, y: number) => [number, number],
): Bounds => {
): [number, number, number, number] => {
let currentP: Point = [0, 0];
const { minX, minY, maxX, maxY } = ops.reduce(
@@ -455,9 +435,9 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY];
};
export const getBoundsFromPoints = (
const getBoundsFromPoints = (
points: ExcalidrawFreeDrawElement["points"],
): Bounds => {
): [number, number, number, number] => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
@@ -609,7 +589,7 @@ const getLinearElementRotatedBounds = (
element: ExcalidrawLinearElement,
cx: number,
cy: number,
): Bounds => {
): [number, number, number, number] => {
if (element.points.length < 2) {
const [pointX, pointY] = element.points[0];
const [x, y] = rotate(
@@ -620,7 +600,7 @@ const getLinearElementRotatedBounds = (
element.angle,
);
let coords: Bounds = [x, y, x, y];
let coords: [number, number, number, number] = [x, y, x, y];
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
@@ -645,7 +625,12 @@ const getLinearElementRotatedBounds = (
const transformXY = (x: number, y: number) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: Bounds = [res[0], res[1], res[2], res[3]];
let coords: [number, number, number, number] = [
res[0],
res[1],
res[2],
res[3],
];
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
@@ -707,7 +692,7 @@ export const getResizedElementAbsoluteCoords = (
nextWidth: number,
nextHeight: number,
normalizePoints: boolean,
): Bounds => {
): [number, number, number, number] => {
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return [
element.x,
@@ -724,7 +709,7 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints,
);
let bounds: Bounds;
let bounds: [number, number, number, number];
if (isFreeDrawElement(element)) {
// Free Draw
@@ -755,7 +740,7 @@ export const getResizedElementAbsoluteCoords = (
export const getElementPointsCoords = (
element: ExcalidrawLinearElement,
points: readonly (readonly [number, number])[],
): Bounds => {
): [number, number, number, number] => {
// This might be computationally heavey
const gen = rough.generator();
const curve =
+1 -3
View File
@@ -494,9 +494,7 @@ const hitTestFreeDrawElement = (
// for filled freedraw shapes, support
// selecting from inside
if (shape && shape.sets.length) {
return element.fillStyle === "solid"
? hitTestCurveInside(shape, x, y, "round")
: hitTestRoughShape(shape, x, y, threshold);
return hitTestRoughShape(shape, x, y, threshold);
}
return false;
+37 -51
View File
@@ -1,5 +1,5 @@
import { updateBoundElements } from "./binding";
import { Bounds, getCommonBounds } from "./bounds";
import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import { NonDeletedExcalidrawElement } from "./types";
@@ -8,11 +8,7 @@ import { getBoundTextElement } from "./textElement";
import { isSelectedViaGroup } from "../groups";
import { getGridPoint } from "../math";
import Scene from "../scene/Scene";
import {
isArrowElement,
isBoundToContainer,
isFrameElement,
} from "./typeChecks";
import { isFrameElement } from "./typeChecks";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
@@ -39,41 +35,44 @@ export const dragSelectedElements = (
if (frames.length > 0) {
const elementsInFrames = scene
.getNonDeletedElements()
.filter((e) => !isBoundToContainer(e))
.filter((e) => e.frameId !== null)
.filter((e) => frames.includes(e.frameId!));
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
}
const commonBounds = getCommonBounds(
Array.from(elementsToUpdate).map(
(el) => pointerDownState.originalElements.get(el.id) ?? el,
),
);
const adjustedOffset = calculateOffset(
commonBounds,
offset,
snapOffset,
gridSize,
);
elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, adjustedOffset);
updateElementCoords(
pointerDownState,
element,
offset,
snapOffset,
gridSize,
);
// update coords of bound text only if we're dragging the container directly
// (we don't drag the group that it's part of)
if (
// Don't update coords of arrow label since we calculate its position during render
!isArrowElement(element) &&
// container isn't part of any group
// (perf optim so we don't check `isSelectedViaGroup()` in every case)
(!element.groupIds.length ||
// container is part of a group, but we're dragging the container directly
(appState.editingGroupId && !isSelectedViaGroup(appState, element)))
!element.groupIds.length ||
// container is part of a group, but we're dragging the container directly
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
) {
const textElement = getBoundTextElement(element);
if (textElement) {
updateElementCoords(pointerDownState, textElement, adjustedOffset);
if (
textElement &&
// when container is added to a frame, so will its bound text
// so the text is already in `elementsToUpdate` and we should avoid
// updating its coords again
(!textElement.frameId || !frames.includes(textElement.frameId))
) {
updateElementCoords(
pointerDownState,
textElement,
offset,
snapOffset,
gridSize,
);
}
}
updateBoundElements(element, {
@@ -82,20 +81,23 @@ export const dragSelectedElements = (
});
};
const calculateOffset = (
commonBounds: Bounds,
const updateElementCoords = (
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
dragOffset: { x: number; y: number },
snapOffset: { x: number; y: number },
gridSize: AppState["gridSize"],
): { x: number; y: number } => {
const [x, y] = commonBounds;
let nextX = x + dragOffset.x + snapOffset.x;
let nextY = y + dragOffset.y + snapOffset.y;
) => {
const originalElement =
pointerDownState.originalElements.get(element.id) ?? element;
let nextX = originalElement.x + dragOffset.x + snapOffset.x;
let nextY = originalElement.y + dragOffset.y + snapOffset.y;
if (snapOffset.x === 0 || snapOffset.y === 0) {
const [nextGridX, nextGridY] = getGridPoint(
x + dragOffset.x,
y + dragOffset.y,
originalElement.x + dragOffset.x,
originalElement.y + dragOffset.y,
gridSize,
);
@@ -107,22 +109,6 @@ const calculateOffset = (
nextY = nextGridY;
}
}
return {
x: nextX - x,
y: nextY - y,
};
};
const updateElementCoords = (
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
dragOffset: { x: number; y: number },
) => {
const originalElement =
pointerDownState.originalElements.get(element.id) ?? element;
const nextX = originalElement.x + dragOffset.x;
const nextY = originalElement.y + dragOffset.y;
mutateElement(element, {
x: nextX,
+46 -37
View File
@@ -18,7 +18,6 @@ type EmbeddedLink =
| ({
aspectRatio: { w: number; h: number };
warning?: string;
sandbox?: { allowSameOrigin?: boolean };
} & (
| { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string }
@@ -29,29 +28,25 @@ const embeddedLinkCache = new Map<string, EmbeddedLink>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
const RE_GH_GIST_EMBED =
/https?:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)\.js["']/i;
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
// not anchored to start to allow <blockquote> twitter embeds
const RE_TWITTER = /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com/;
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
const RE_TWITTER_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i;
const RE_VALTOWN =
/^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
const RE_GIPHY =
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
const ALLOWED_DOMAINS = new Set([
"youtube.com",
"youtu.be",
@@ -64,8 +59,6 @@ const ALLOWED_DOMAINS = new Set([
"*.simplepdf.eu",
"stackblitz.com",
"val.town",
"giphy.com",
"dddice.com",
]);
const createSrcDoc = (body: string) => {
@@ -144,27 +137,46 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
}
if (RE_TWITTER.test(link)) {
// the embed srcdoc still supports twitter.com domain only
link = link.replace(/\bx.com\b/, "twitter.com");
const ret: EmbeddedLink = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
),
aspectRatio: { w: 480, h: 480 },
sandbox: { allowSameOrigin: true },
};
let ret: EmbeddedLink;
// assume embed code
if (/<blockquote/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
aspectRatio: { w: 480, h: 480 },
};
// assume regular tweet url
} else {
ret = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
),
aspectRatio: { w: 480, h: 480 },
};
}
embeddedLinkCache.set(originalLink, ret);
return ret;
}
if (RE_GH_GIST.test(link)) {
const ret: EmbeddedLink = {
type: "document",
srcdoc: () =>
createSrcDoc(`
let ret: EmbeddedLink;
// assume embed code
if (/<script>/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
aspectRatio: { w: 550, h: 720 },
};
// assume regular url
} else {
ret = {
type: "document",
srcdoc: () =>
createSrcDoc(`
<script src="${link}.js"></script>
<style type="text/css">
* { margin: 0px; }
@@ -172,8 +184,9 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
</style>
`),
aspectRatio: { w: 550, h: 720 },
};
aspectRatio: { w: 550, h: 720 },
};
}
embeddedLinkCache.set(link, ret);
return ret;
}
@@ -182,7 +195,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
return { link, aspectRatio, type };
};
export const isEmbeddableOrLabel = (
export const isEmbeddableOrFrameLabel = (
element: NonDeletedExcalidrawElement,
): Boolean => {
if (isEmbeddableElement(element)) {
@@ -291,12 +304,8 @@ export const extractSrc = (htmlString: string): string => {
}
const gistMatch = htmlString.match(RE_GH_GIST_EMBED);
if (gistMatch && gistMatch.length === 3) {
return `https://gist.github.com/${gistMatch[1]}/${gistMatch[2]}`;
}
if (RE_GIPHY.test(htmlString)) {
return `https://giphy.com/embed/${RE_GIPHY.exec(htmlString)![1]}`;
if (gistMatch && gistMatch.length === 2) {
return gistMatch[1];
}
const match = htmlString.match(RE_GENERIC_EMBED);
+1 -2
View File
@@ -21,7 +21,6 @@ import {
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import {
Bounds,
getCurvePathOps,
getElementPointsCoords,
getMinMaxXYFromCurvePathOps,
@@ -1317,7 +1316,7 @@ export class LinearElementEditor {
static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement,
elementBounds: Bounds,
elementBounds: [number, number, number, number],
boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => {
let [x1, y1, x2, y2] = elementBounds;
+24 -3
View File
@@ -6,12 +6,21 @@ import { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { getSubtypeMethods } from "./subtypes";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"id" | "version" | "versionNonce"
>;
const cleanUpdates = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
): ElementUpdate<TElement> => {
const map = getSubtypeMethods(element.subtype);
return map?.clean ? (map.clean(updates) as typeof updates) : updates;
};
// This function tracks updates of text elements for the purposes for collaboration.
// The version is used to compare updates when more than one user is working in
// the same drawing. Note: this will trigger the component to update. Make sure you
@@ -22,6 +31,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
informMutation = true,
): TElement => {
let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
@@ -70,6 +81,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
}
}
if (!didChangePoints) {
key in oldUpdates && (increment = true);
continue;
}
}
@@ -77,6 +89,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
(element as any)[key] = value;
didChange = true;
key in oldUpdates && (increment = true);
}
}
if (!didChange) {
@@ -92,9 +105,11 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element);
}
element.version++;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
if (increment) {
element.version++;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
}
if (informMutation) {
Scene.getScene(element)?.informMutation();
@@ -108,6 +123,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
updates: ElementUpdate<TElement>,
): TElement => {
let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
@@ -119,6 +136,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
continue;
}
didChange = true;
key in oldUpdates && (increment = true);
}
}
@@ -126,6 +144,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element;
}
if (!increment) {
return { ...element, ...updates };
}
return {
...element,
...updates,
+41 -30
View File
@@ -15,12 +15,7 @@ import {
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
} from "../element/types";
import {
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random";
import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups";
@@ -30,9 +25,9 @@ import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
getContainerElement,
measureText,
measureTextElement,
normalizeText,
wrapText,
wrapTextElement,
getBoundTextMaxWidth,
getDefaultLineHeight,
} from "./textElement";
@@ -45,6 +40,21 @@ import {
VERTICAL_ALIGN,
} from "../constants";
import { MarkOptional, Merge, Mutable } from "../utility-types";
import { getSubtypeMethods } from "./subtypes";
export const maybeGetSubtypeProps = (obj: {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
}) => {
const data: typeof obj = {};
if ("subtype" in obj && obj.subtype !== undefined) {
data.subtype = obj.subtype;
}
if ("customData" in obj && obj.customData !== undefined) {
data.customData = obj.customData;
}
return data as typeof obj;
};
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -58,6 +68,8 @@ export type ElementConstructorOpts = MarkOptional<
| "version"
| "versionNonce"
| "link"
| "subtype"
| "customData"
| "strokeStyle"
| "fillStyle"
| "strokeColor"
@@ -67,7 +79,6 @@ export type ElementConstructorOpts = MarkOptional<
| "roundness"
| "locked"
| "opacity"
| "customData"
>;
const _newElementBase = <T extends ExcalidrawElement>(
@@ -94,8 +105,10 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
const { subtype, customData } = rest;
// assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
...maybeGetSubtypeProps({ subtype, customData }),
id: rest.id || randomId(),
type,
x,
@@ -121,7 +134,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
updated: getUpdatedTimestamp(),
link,
locked,
customData: rest.customData,
};
return element;
};
@@ -130,8 +142,11 @@ export const newElement = (
opts: {
type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
): NonDeleted<ExcalidrawGenericElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
};
export const newEmbeddableElement = (
opts: {
@@ -146,15 +161,13 @@ export const newEmbeddableElement = (
};
export const newFrameElement = (
opts: {
name?: string;
} & ElementConstructorOpts,
opts: ElementConstructorOpts,
): NonDeleted<ExcalidrawFrameElement> => {
const frameElement = newElementWith(
{
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
type: "frame",
name: opts?.name || null,
name: null,
},
{},
);
@@ -200,10 +213,12 @@ export const newTextElement = (
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
const text = normalizeText(opts.text);
const metrics = measureText(
text,
getFontString({ fontFamily, fontSize }),
lineHeight,
const metrics = measureTextElement(
{ ...opts, fontSize, fontFamily, lineHeight },
{
text,
customData: opts.customData,
},
);
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -248,7 +263,9 @@ const getAdjustedDimensions = (
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureText(nextText, getFontString(element), element.lineHeight);
} = measureTextElement(element, {
text: nextText,
});
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
@@ -257,11 +274,7 @@ const getAdjustedDimensions = (
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId
) {
const prevMetrics = measureText(
element.text,
getFontString(element),
element.lineHeight,
);
const prevMetrics = measureTextElement(element);
const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height,
@@ -317,11 +330,9 @@ export const refreshTextDimensions = (
}
const container = getContainerElement(textElement);
if (container) {
text = wrapText(
text = wrapTextElement(textElement, getBoundTextMaxWidth(container), {
text,
getFontString(textElement),
getBoundTextMaxWidth(container),
);
});
}
const dimensions = getAdjustedDimensions(textElement, text);
return { text, ...dimensions };
+2 -6
View File
@@ -51,7 +51,7 @@ import {
handleBindTextResize,
getBoundTextMaxWidth,
getApproxMinLineHeight,
measureText,
measureTextElement,
getBoundTextMaxHeight,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
@@ -224,11 +224,7 @@ const measureFontSizeFromWidth = (
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.lineHeight,
);
const metrics = measureTextElement(element, { fontSize: nextFontSize });
return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
+1 -2
View File
@@ -13,7 +13,6 @@ import {
MaybeTransformHandleType,
} from "./transformHandles";
import { AppState, Zoom } from "../types";
import { Bounds } from "./bounds";
const isInsideTransformHandle = (
transformHandle: TransformHandle,
@@ -88,7 +87,7 @@ export const getElementWithTransformHandleType = (
};
export const getTransformHandleTypeFromCoords = (
[x1, y1, x2, y2]: Bounds,
[x1, y1, x2, y2]: readonly [number, number, number, number],
scenePointerX: number,
scenePointerY: number,
zoom: Zoom,
+222
View File
@@ -0,0 +1,222 @@
import { ExcalidrawElement, ExcalidrawTextElement, NonDeleted } from "../types";
import { getNonDeletedElements } from "../";
import { isTextElement } from "../typeChecks";
import { getContainerElement, redrawTextBoundingBox } from "../textElement";
import { ShapeCache } from "../../scene/ShapeCache";
import Scene from "../../scene/Scene";
// Use "let" instead of "const" so we can dynamically add subtypes
let subtypeNames: readonly Subtype[] = [];
let parentTypeMap: readonly {
subtype: Subtype;
parentType: ExcalidrawElement["type"];
}[] = [];
export type SubtypeRecord = Readonly<{
subtype: Subtype;
parents: readonly ExcalidrawElement["type"][];
}>;
// Subtype Names
export type Subtype = Required<ExcalidrawElement>["subtype"];
export const getSubtypeNames = (): readonly Subtype[] => {
return subtypeNames;
};
// Subtype Methods
export type SubtypeMethods = {
clean: (
updates: Omit<
Partial<ExcalidrawElement>,
"id" | "version" | "versionNonce"
>,
) => Omit<Partial<ExcalidrawElement>, "id" | "version" | "versionNonce">;
ensureLoaded: (callback?: () => void) => Promise<void>;
getEditorStyle: (element: ExcalidrawTextElement) => Record<string, any>;
measureText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "text"
| "lineHeight"
>,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => { width: number; height: number; baseline: number };
render: (
element: NonDeleted<ExcalidrawElement>,
context: CanvasRenderingContext2D,
) => void;
renderSvg: (
svgRoot: SVGElement,
root: SVGElement,
element: NonDeleted<ExcalidrawElement>,
opt?: { offsetX?: number; offsetY?: number },
) => void;
wrapText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "originalText"
| "lineHeight"
>,
containerWidth: number,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => string;
};
type MethodMap = { subtype: Subtype; methods: Partial<SubtypeMethods> };
const methodMaps = [] as Array<MethodMap>;
// Use `getSubtypeMethods` to call subtype-specialized methods, like `render`.
export const getSubtypeMethods = (
subtype: Subtype | undefined,
): Partial<SubtypeMethods> | undefined => {
const map = methodMaps.find((method) => method.subtype === subtype);
return map?.methods;
};
export const addSubtypeMethods = (
subtype: Subtype,
methods: Partial<SubtypeMethods>,
) => {
if (!subtypeNames.includes(subtype)) {
return;
}
if (!methodMaps.find((method) => method.subtype === subtype)) {
methodMaps.push({ subtype, methods });
}
};
// Callback to re-render subtyped `ExcalidrawElement`s after completing
// async loading of the subtype.
export type SubtypeLoadedCb = (hasSubtype: SubtypeCheckFn) => void;
export type SubtypeCheckFn = (element: ExcalidrawElement) => boolean;
// Functions to prepare subtypes for use
export type SubtypePrepFn = (onSubtypeLoaded?: SubtypeLoadedCb) => {
methods: Partial<SubtypeMethods>;
};
// This is the main method to set up the subtype. The optional
// `onSubtypeLoaded` callback may be used to re-render subtyped
// `ExcalidrawElement`s after the subtype has finished async loading.
export const prepareSubtype = (
record: SubtypeRecord,
subtypePrepFn: SubtypePrepFn,
onSubtypeLoaded?: SubtypeLoadedCb,
): { methods: Partial<SubtypeMethods> } => {
const map = getSubtypeMethods(record.subtype);
if (map) {
return { methods: map };
}
// Check for undefined/null subtypes and parentTypes
if (
record.subtype === undefined ||
record.subtype === "" ||
record.parents === undefined ||
record.parents.length === 0
) {
return { methods: {} };
}
// Register the types
const subtype = record.subtype;
subtypeNames = [...subtypeNames, subtype];
record.parents.forEach((parentType) => {
parentTypeMap = [...parentTypeMap, { subtype, parentType }];
});
// Prepare the subtype
const { methods } = subtypePrepFn(onSubtypeLoaded);
// Register the subtype's methods
addSubtypeMethods(record.subtype, methods);
return { methods };
};
// Ensure all subtypes are loaded before continuing, eg to
// redraw text element bounding boxes correctly.
export const ensureSubtypesLoadedForElements = async (
elements: readonly ExcalidrawElement[],
callback?: () => void,
) => {
// Only ensure the loading of subtypes which are actually needed.
// We don't want to be held up by eg downloading the MathJax SVG fonts
// if we don't actually need them yet.
const subtypesUsed = [] as Subtype[];
elements.forEach((el) => {
if (
"subtype" in el &&
el.subtype !== undefined &&
!subtypesUsed.includes(el.subtype)
) {
subtypesUsed.push(el.subtype);
}
});
await ensureSubtypesLoaded(subtypesUsed, callback);
};
export const ensureSubtypesLoaded = async (
subtypes: Subtype[],
callback?: () => void,
) => {
// Use a for loop so we can do `await map.ensureLoaded()`
for (let i = 0; i < subtypes.length; i++) {
const subtype = subtypes[i];
// Should be defined if prepareSubtype() has run
const map = getSubtypeMethods(subtype);
if (map?.ensureLoaded) {
await map.ensureLoaded();
}
}
if (callback) {
callback();
}
};
// Call this method after finishing any async loading for
// subtypes of ExcalidrawElement if the newly loaded code
// would change the rendering.
export const checkRefreshOnSubtypeLoad = (
hasSubtype: SubtypeCheckFn,
elements: readonly ExcalidrawElement[],
) => {
let refreshNeeded = false;
const scenes: Scene[] = [];
getNonDeletedElements(elements).forEach((element) => {
// If the element is of the subtype that was just
// registered, update the element's dimensions, mark the
// element for a re-render, and indicate the scene needs a refresh.
if (hasSubtype(element)) {
ShapeCache.delete(element);
if (isTextElement(element)) {
redrawTextBoundingBox(element, getContainerElement(element));
}
refreshNeeded = true;
const scene = Scene.getScene(element);
if (scene && !scenes.includes(scene)) {
// Store in case we have multiple scenes
scenes.push(scene);
}
}
});
// Only inform each scene once
scenes.forEach((scene) => scene.informMutation());
return refreshNeeded;
};
+40 -21
View File
@@ -1,3 +1,4 @@
import { getSubtypeMethods, SubtypeMethods } from "./subtypes";
import { getFontString, arrayToMap, isTestEnv } from "../utils";
import {
ExcalidrawElement,
@@ -36,6 +37,30 @@ import {
} from "./textWysiwyg";
import { ExtractSetType } from "../utility-types";
export const measureTextElement = function (element, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.measureText) {
return map.measureText(element, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.text;
return measureText(text, font, element.lineHeight);
} as SubtypeMethods["measureText"];
export const wrapTextElement = function (element, containerWidth, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.wrapText) {
return map.wrapText(element, containerWidth, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.originalText;
return wrapText(text, font, containerWidth);
} as SubtypeMethods["wrapText"];
export const normalizeText = (text: string) => {
return (
text
@@ -68,22 +93,24 @@ export const redrawTextBoundingBox = (
if (container) {
maxWidth = getBoundTextMaxWidth(container, textElement);
boundTextUpdates.text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
}
const metrics = measureText(
boundTextUpdates.text,
getFontString(textElement),
textElement.lineHeight,
);
const metrics = measureTextElement(textElement, {
text: boundTextUpdates.text,
});
boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height;
boundTextUpdates.baseline = metrics.baseline;
// Maintain coordX for non left-aligned text in case the width has changed
if (!container) {
if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
boundTextUpdates.x += textElement.width - metrics.width;
} else if (textElement.textAlign === TEXT_ALIGN.CENTER) {
boundTextUpdates.x += textElement.width / 2 - metrics.width / 2;
}
}
if (container) {
const maxContainerHeight = getBoundTextMaxHeight(
container,
@@ -91,7 +118,7 @@ export const redrawTextBoundingBox = (
);
const maxContainerWidth = getBoundTextMaxWidth(container);
if (!isArrowElement(container) && metrics.height > maxContainerHeight) {
if (metrics.height > maxContainerHeight) {
const nextHeight = computeContainerDimensionForBoundText(
metrics.height,
container.type,
@@ -196,17 +223,9 @@ export const handleBindTextResize = (
(transformHandleType !== "n" && transformHandleType !== "s")
) {
if (text) {
text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
text = wrapTextElement(textElement, maxWidth);
}
const metrics = measureText(
text,
getFontString(textElement),
textElement.lineHeight,
);
const metrics = measureTextElement(textElement, { text });
nextHeight = metrics.height;
nextWidth = metrics.width;
nextBaseLine = metrics.baseline;
+67 -61
View File
@@ -18,7 +18,6 @@ import {
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -26,7 +25,16 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const tab = " ";
const mouse = new Pointer("mouse");
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const getTextEditor = () => {
return document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
};
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => {
fireEvent.change(editor, { target: { value } });
editor.dispatchEvent(new Event("input"));
};
describe("textWysiwyg", () => {
describe("start text editing", () => {
@@ -177,7 +185,7 @@ describe("textWysiwyg", () => {
expect(h.state.editingElement?.id).toBe(boundText.id);
});
it("should edit text under cursor when clicked with text tool", async () => {
it("should edit text under cursor when clicked with text tool", () => {
const text = API.createElement({
type: "text",
text: "ola",
@@ -192,14 +200,14 @@ describe("textWysiwyg", () => {
mouse.clickAt(text.x + 50, text.y + 50);
const editor = await getTextEditor(textEditorSelector, false);
const editor = getTextEditor();
expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id);
expect(h.elements.length).toBe(1);
});
it("should edit text under cursor when double-clicked with selection tool", async () => {
it("should edit text under cursor when double-clicked with selection tool", () => {
const text = API.createElement({
type: "text",
text: "ola",
@@ -214,26 +222,12 @@ describe("textWysiwyg", () => {
mouse.doubleClickAt(text.x + 50, text.y + 50);
const editor = await getTextEditor(textEditorSelector, false);
const editor = getTextEditor();
expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id);
expect(h.elements.length).toBe(1);
});
// FIXME too flaky. No one knows why.
it.skip("should bump the version of a labeled arrow when the label is updated", async () => {
const arrow = UI.createElement("arrow", {
width: 300,
height: 0,
});
await UI.editText(arrow, "Hello");
const { version } = arrow;
await UI.editText(arrow, "Hello\nworld!");
expect(arrow.version).toEqual(version + 1);
});
});
describe("Test container-unbound text", () => {
@@ -249,15 +243,13 @@ describe("textWysiwyg", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
// @ts-ignore
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
//@ts-ignore
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
textElement = UI.createElement("text");
mouse.clickOn(textElement);
textarea = await getTextEditor(textEditorSelector, true);
textarea = getTextEditor();
});
afterAll(() => {
@@ -467,7 +459,7 @@ describe("textWysiwyg", () => {
UI.clickTool("text");
mouse.clickAt(750, 300);
textarea = await getTextEditor(textEditorSelector, true);
textarea = getTextEditor();
updateTextEditor(
textarea,
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
@@ -519,7 +511,7 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
@@ -547,7 +539,7 @@ describe("textWysiwyg", () => {
]);
expect(text.angle).toBe(rectangle.angle);
mouse.down();
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
@@ -574,7 +566,7 @@ describe("textWysiwyg", () => {
API.setSelectedElements([diamond]);
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
const value = new Array(1000).fill("1").join("\n");
@@ -609,7 +601,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
let editor = await getTextEditor(textEditorSelector, true);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@@ -624,7 +616,7 @@ describe("textWysiwyg", () => {
expect(text.containerId).toBe(rectangle.id);
mouse.down();
editor = await getTextEditor(textEditorSelector, true);
editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0));
@@ -646,7 +638,7 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
@@ -681,7 +673,7 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0));
@@ -706,7 +698,7 @@ describe("textWysiwyg", () => {
freedraw.y + freedraw.height / 2,
);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
@@ -740,7 +732,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
@@ -755,7 +747,7 @@ describe("textWysiwyg", () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
updateTextEditor(
editor,
@@ -800,7 +792,7 @@ describe("textWysiwyg", () => {
mouse.down();
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = await getTextEditor(textEditorSelector, true);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
@@ -813,7 +805,7 @@ describe("textWysiwyg", () => {
rectangle.y + rectangle.height / 2,
);
mouse.down();
editor = await getTextEditor(textEditorSelector, true);
editor = getTextEditor();
editor.select();
fireEvent.click(screen.getByTitle(/code/i));
@@ -846,7 +838,7 @@ describe("textWysiwyg", () => {
Keyboard.keyDown(KEYS.ENTER);
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = await getTextEditor(textEditorSelector, true);
let editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
@@ -867,7 +859,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(textEditorSelector, true);
editor = getTextEditor();
updateTextEditor(editor, "Hello");
await new Promise((r) => setTimeout(r, 0));
@@ -896,7 +888,7 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
@@ -933,7 +925,7 @@ describe("textWysiwyg", () => {
// Bind first text
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
editor.blur();
@@ -954,7 +946,7 @@ describe("textWysiwyg", () => {
it("should respect text alignment when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER);
let editor = await getTextEditor(textEditorSelector, true);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@@ -971,7 +963,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(textEditorSelector, true);
editor = getTextEditor();
editor.select();
@@ -994,7 +986,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(textEditorSelector, true);
editor = getTextEditor();
editor.select();
@@ -1032,7 +1024,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
mouse.down();
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
@@ -1047,7 +1039,7 @@ describe("textWysiwyg", () => {
it("should scale font size correctly when resizing using shift", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@@ -1067,7 +1059,7 @@ describe("textWysiwyg", () => {
it("should bind text correctly when container duplicated with alt-drag", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@@ -1099,7 +1091,7 @@ describe("textWysiwyg", () => {
it("undo should work", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@@ -1136,7 +1128,7 @@ describe("textWysiwyg", () => {
it("should not allow bound text with only whitespaces", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, " ");
@@ -1191,7 +1183,7 @@ describe("textWysiwyg", () => {
it("should reset the container height cache when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
let editor = await getTextEditor(textEditorSelector, true);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
@@ -1203,7 +1195,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(textEditorSelector, true);
editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@@ -1219,7 +1211,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
editor.blur();
@@ -1244,7 +1236,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
editor.blur();
expect(
@@ -1276,12 +1268,12 @@ describe("textWysiwyg", () => {
beforeEach(async () => {
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(textEditorSelector, true);
editor = getTextEditor();
updateTextEditor(editor, "Hello");
editor.blur();
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(textEditorSelector, true);
editor = getTextEditor();
editor.select();
});
@@ -1392,7 +1384,7 @@ describe("textWysiwyg", () => {
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = await getTextEditor(textEditorSelector, true);
const editor = getTextEditor();
updateTextEditor(
editor,
@@ -1438,7 +1430,7 @@ describe("textWysiwyg", () => {
type: "text",
},
],
fillStyle: "solid",
fillStyle: "hachure",
groupIds: [],
height: 35,
isDeleted: false,
@@ -1451,7 +1443,7 @@ describe("textWysiwyg", () => {
},
strokeColor: "#1e1e1e",
strokeStyle: "solid",
strokeWidth: 2,
strokeWidth: 1,
type: "rectangle",
updated: 1,
version: 1,
@@ -1480,7 +1472,7 @@ describe("textWysiwyg", () => {
// Bind first text
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
let editor = await getTextEditor(textEditorSelector, true);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!");
expect(
@@ -1505,7 +1497,7 @@ describe("textWysiwyg", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
editor = await getTextEditor(textEditorSelector, true);
editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Excalidraw");
editor.blur();
@@ -1519,4 +1511,18 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Excalidraw");
});
});
it("should bump the version of a labeled arrow when the label is updated", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const arrow = UI.createElement("arrow", {
width: 300,
height: 0,
});
await UI.editText(arrow, "Hello");
const { version } = arrow;
await UI.editText(arrow, "Hello\nworld!");
expect(arrow.version).toEqual(version + 1);
});
});
+57 -7
View File
@@ -26,6 +26,7 @@ import {
getContainerElement,
getTextElementAngle,
getTextWidth,
measureText,
normalizeText,
redrawTextBoundingBox,
wrapText,
@@ -43,8 +44,10 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App";
import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard";
import { SubtypeMethods, getSubtypeMethods } from "./subtypes";
const getTransform = (
offsetX: number,
width: number,
height: number,
angle: number,
@@ -62,7 +65,8 @@ const getTransform = (
if (height > maxHeight && zoom.value !== 1) {
translateY = (maxHeight * (zoom.value - 1)) / 2;
}
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
const offset = offsetX !== 0 ? ` translate(${offsetX}px, 0px)` : "";
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)${offset}`;
};
const originalContainerCache: {
@@ -97,6 +101,14 @@ export const getOriginalContainerHeightFromCache = (
return originalContainerCache[id]?.height ?? null;
};
const getEditorStyle = function (element) {
const map = getSubtypeMethods(element.subtype);
if (map?.getEditorStyle) {
return map.getEditorStyle(element);
}
return {};
} as SubtypeMethods["getEditorStyle"];
export const textWysiwyg = ({
id,
onChange,
@@ -156,11 +168,24 @@ export const textWysiwyg = ({
const container = getContainerElement(updatedTextElement);
let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height;
let textElementWidth = updatedTextElement.width;
// Editing metrics
const eMetrics = measureText(
container && updatedTextElement.containerId
? wrapText(
updatedTextElement.originalText,
getFontString(updatedTextElement),
getBoundTextMaxWidth(container),
)
: updatedTextElement.originalText,
getFontString(updatedTextElement),
updatedTextElement.lineHeight,
);
let maxHeight = eMetrics.height;
let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width);
// Set to element height by default since that's
// what is going to be used for unbounded text
const textElementHeight = updatedTextElement.height;
const textElementHeight = Math.max(updatedTextElement.height, maxHeight);
if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) {
@@ -246,13 +271,35 @@ export const textWysiwyg = ({
editable.selectionEnd = editable.value.length - diff;
}
let transformWidth = updatedTextElement.width;
if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth);
} else {
textElementWidth += 0.5;
transformWidth += 0.5;
}
// Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype
const offWidth = container
? Math.min(
0,
updatedTextElement.width - Math.min(maxWidth, eMetrics.width),
)
: Math.min(maxWidth, updatedTextElement.width) -
Math.min(maxWidth, eMetrics.width);
const offsetX =
textAlign === "right"
? offWidth
: textAlign === "center"
? offWidth / 2
: 0;
const { width: w, height: h } = updatedTextElement;
const transformOrigin =
updatedTextElement.width !== eMetrics.width ||
updatedTextElement.height !== eMetrics.height
? { transformOrigin: `${w / 2}px ${h / 2}px` }
: {};
let lineHeight = updatedTextElement.lineHeight;
// In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size
@@ -270,13 +317,15 @@ export const textWysiwyg = ({
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight,
width: `${textElementWidth}px`,
width: `${Math.min(textElementWidth, maxWidth)}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
...transformOrigin,
transform: getTransform(
textElementWidth,
textElementHeight,
offsetX,
transformWidth,
updatedTextElement.height,
getTextElementAngle(updatedTextElement),
appState,
maxWidth,
@@ -334,6 +383,7 @@ export const textWysiwyg = ({
whiteSpace,
overflowWrap: "break-word",
boxSizing: "content-box",
...getEditorStyle(element),
});
editable.value = element.originalText;
updateWysiwygStyle();
+2 -2
View File
@@ -4,7 +4,7 @@ import {
PointerType,
} from "./types";
import { Bounds, getElementAbsoluteCoords } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import { InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from ".";
@@ -23,7 +23,7 @@ export type TransformHandleDirection =
export type TransformHandleType = TransformHandleDirection | "rotation";
export type TransformHandle = Bounds;
export type TransformHandle = [number, number, number, number];
export type TransformHandles = Partial<{
[T in TransformHandleType]: TransformHandle;
}>;
+11 -27
View File
@@ -1,7 +1,6 @@
import { ROUNDNESS } from "../constants";
import { AppState } from "../types";
import { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils";
import {
ExcalidrawElement,
ExcalidrawTextElement,
@@ -141,32 +140,17 @@ export const isTextBindableContainer = (
);
};
export const isExcalidrawElement = (
element: any,
): element is ExcalidrawElement => {
const type: ExcalidrawElement["type"] | undefined = element?.type;
if (!type) {
return false;
}
switch (type) {
case "text":
case "diamond":
case "rectangle":
case "embeddable":
case "ellipse":
case "arrow":
case "freedraw":
case "line":
case "frame":
case "image":
case "selection": {
return true;
}
default: {
assertNever(type, null);
return false;
}
}
export const isExcalidrawElement = (element: any): boolean => {
return (
element?.type === "text" ||
element?.type === "diamond" ||
element?.type === "rectangle" ||
element?.type === "embeddable" ||
element?.type === "ellipse" ||
element?.type === "arrow" ||
element?.type === "freedraw" ||
element?.type === "line"
);
};
export const hasBoundTextElement = (
+1
View File
@@ -65,6 +65,7 @@ type _ExcalidrawElementBase = Readonly<{
updated: number;
link: string | null;
locked: boolean;
subtype?: string;
customData?: Record<string, any>;
}>;
-47
View File
@@ -1,47 +0,0 @@
type Subscriber<T extends any[]> = (...payload: T) => void;
export class Emitter<T extends any[] = []> {
public subscribers: Subscriber<T>[] = [];
public value: T | undefined;
private updateOnChangeOnly: boolean;
constructor(opts?: { initialState?: T; updateOnChangeOnly?: boolean }) {
this.updateOnChangeOnly = opts?.updateOnChangeOnly ?? false;
this.value = opts?.initialState;
}
/**
* Attaches subscriber
*
* @returns unsubscribe function
*/
on(...handlers: Subscriber<T>[] | Subscriber<T>[][]) {
const _handlers = handlers
.flat()
.filter((item) => typeof item === "function");
this.subscribers.push(..._handlers);
return () => this.off(_handlers);
}
off(...handlers: Subscriber<T>[] | Subscriber<T>[][]) {
const _handlers = handlers.flat();
this.subscribers = this.subscribers.filter(
(handler) => !_handlers.includes(handler),
);
}
trigger(...payload: T): any[] {
if (this.updateOnChangeOnly && this.value === payload) {
return [];
}
this.value = payload;
return this.subscribers.map((handler) => handler(...payload));
}
destroy() {
this.subscribers = [];
this.value = undefined;
}
}
-16
View File
@@ -16,19 +16,3 @@ export class AbortError extends DOMException {
super(message, "AbortError");
}
}
type ImageSceneDataErrorCode =
| "IMAGE_NOT_CONTAINS_SCENE_DATA"
| "IMAGE_SCENE_DATA_ERROR";
export class ImageSceneDataError extends Error {
public code;
constructor(
message = "Image Scene Data Error",
code: ImageSceneDataErrorCode = "IMAGE_SCENE_DATA_ERROR",
) {
super(message);
this.name = "EncodingError";
this.code = code;
}
}

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