Compare commits

..

16 Commits

Author SHA1 Message Date
Preet
c552ff4554 added an explanatory comment 2023-10-23 21:48:35 -07:00
Preet
26f9b54199 compute midpoints properly when dealing with split line indices 2023-10-23 21:41:00 -07:00
Preet
7f5b7bab69 split linear segments as curves 2023-10-23 21:29:53 -07:00
Preet
bf7c91536f render and toggle split points for linear elements as well 2023-10-23 21:15:25 -07:00
Preet
4372e992e0 highlight squares appropriately 2023-10-23 18:13:43 -07:00
Preet
1e4bfceb13 render split points as squares 2023-10-23 17:58:28 -07:00
Preet
539071fcfe ensure split indices are sorted 2023-10-23 17:29:12 -07:00
Preet
3700cf2d10 fix some linting/prettier issues 2023-10-23 10:50:52 -07:00
Preet
89218ba596 update indices when inserting/removing points 2023-10-22 17:39:51 -07:00
Preet
bc5436592e split curve only for rounded curves 2023-10-22 17:07:08 -07:00
Preet
750055ddfa draw split curves 2023-10-21 21:45:27 -07:00
Preet
93e4cb8d25 restore properly 2023-10-21 17:24:01 -07:00
Preet
a2dd3c6ea2 visual indicator that curve is being split 2023-10-21 16:57:56 -07:00
Preet
0360e64219 . 2023-10-21 16:41:32 -07:00
Preet
c2867c9a93 defined split array 2023-10-21 16:15:41 -07:00
Preet
14bca119f7 update rough to include hetrogeneous curves 2023-10-21 16:04:06 -07:00
177 changed files with 2671 additions and 8430 deletions

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>

View File

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

View File

@@ -80,7 +80,7 @@ A given tab trigger button that switches to a given sidebar tab. It must be rend
| `className` | `string` | No | |
| `style` | `React.CSSProperties` | No | |
You can use the [`ref.toggleSidebar({ name: "custom" })`](/docs/@excalidraw/excalidraw/api/props/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

View File

@@ -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"
}]
}
```

View File

@@ -1,11 +1,11 @@
# Props
All `props` are _optional_.
All `props` are *optional*.
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; null></code> | `null` | The initial data with which app loads. |
| [`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.

View File

@@ -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.

View File

@@ -1,6 +1,6 @@
# UIOptions
This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasactions), [`dockedSidebarBreakpoint`](#dockedsidebarbreakpoint) [`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.

View File

@@ -129,7 +129,7 @@ if (contents.type === MIME_TYPES.excalidraw) {
<pre>
loadSceneOrLibraryFromBlob(<br/>&nbsp;
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,<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";
```

View File

@@ -43,7 +43,7 @@ Once the version is released `@excalibot` will post a comment with the release v
To release the next stable version follow the below steps:
```bash
yarn prerelease: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.

View File

@@ -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": process.env.IS_PREACT,
},
```
## Need help?

View File

@@ -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": process.env.IS_PREACT,
},
```
:::
## Browser
To use it in a browser directly:

View File

@@ -19,4 +19,14 @@ 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.
# Arrows
An arrow can be a child of a frame only if it has no binding (either start or end) to any other element, regardless of whether the bound element is inside the frame or not.
This ensures that when an arrow is bound to an element outside the frame, it's rendered and behaves correctly.
Therefore, when an arrow (that's a child of a frame) gets bound to an element, it's automatically removed from the frame.
Bound-arrow is duplicated alongside a frame only if the arrow start is bound to an element within that frame.

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;

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": {

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",
],

View File

@@ -1718,10 +1718,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"
@@ -3567,13 +3567,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 +3652,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"

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).
*/

View File

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

View File

@@ -691,7 +691,7 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
ref={excalidrawRefCallback}
onChange={onChange}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}

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,
},
}
`);
});

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,
),
),

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",
@@ -54,7 +53,7 @@
"pwacompat": "2.0.17",
"react": "18.2.0",
"react-dom": "18.2.0",
"roughjs": "4.6.4",
"roughjs": "4.6.5",
"sass": "1.51.0",
"socket.io-client": "2.3.1",
"tunnel-rat": "0.1.2"
@@ -96,7 +95,7 @@
"vitest-canvas-mock": "0.3.2"
},
"engines": {
"node": "18.0.0 - 20.x.x"
"node": ">=18.0.0"
},
"homepage": ".",
"name": "excalidraw",
@@ -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"
}
}

View File

@@ -9,7 +9,6 @@ import {
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
@@ -29,7 +28,7 @@ const alignActionsPredicate = (
return (
selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el))
!selectedElements.some((el) => el.type === "frame")
);
};

View File

@@ -265,21 +265,7 @@ export const zoomToFit = ({
30.0,
) as NormalizedZoomValue;
let appStateWidth = appState.width;
if (appState.openSidebar) {
const sidebarDOMElem = document.querySelector(
".sidebar",
) as HTMLElement | null;
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
appStateWidth = !isRTL
? appState.width - sidebarWidth
: appState.width + sidebarWidth;
}
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else {
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
@@ -452,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,
});

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,

View File

@@ -10,7 +10,7 @@ import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { isBoundToContainer } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
@@ -20,7 +20,7 @@ const deleteSelectedElements = (
) => {
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => isFrameLikeElement(el)),
elements.filter((el) => el.type === "frame"),
appState,
).map((el) => el.id),
);

View File

@@ -5,7 +5,6 @@ import {
import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
@@ -20,7 +19,7 @@ const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
return (
selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el))
!selectedElements.some((el) => el.type === "frame")
);
};

View File

@@ -20,12 +20,12 @@ import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
} from "../element/textElement";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons";
import {
bindElementsToFramesAfterDuplication,
getFrameChildren,
getFrameElements,
} from "../frame";
import {
excludeElementsInFramesFromSelection,
@@ -140,11 +140,11 @@ const duplicateElements = (
}
const boundTextElement = getBoundTextElement(element);
const isElementAFrameLike = isFrameLikeElement(element);
const isElementAFrame = isFrameElement(element);
if (idsOfElementsToDuplicate.get(element.id)) {
// if a group or a container/bound-text or frame, duplicate atomically
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
if (element.groupIds.length || boundTextElement || isElementAFrame) {
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
// TODO:
@@ -154,8 +154,13 @@ const duplicateElements = (
sortedElements,
groupId,
).flatMap((element) =>
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
isFrameElement(element)
? [
...getFrameElements(elements, element.id, {
includeBoundArrows: true,
}),
element,
]
: [element],
);
@@ -180,8 +185,10 @@ const duplicateElements = (
);
continue;
}
if (isElementAFrameLike) {
const elementsInFrame = getFrameChildren(sortedElements, element.id);
if (isElementAFrame) {
const elementsInFrame = getFrameElements(sortedElements, element.id, {
includeBoundArrows: true,
});
elementsWithClones.push(
...markAsProcessed([

View File

@@ -1,5 +1,4 @@
import { newElementWith } from "../element/mutateElement";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
@@ -52,7 +51,7 @@ export const actionToggleElementLock = register({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
if (selected.length === 1 && selected[0].type !== "frame") {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";

View File

@@ -217,7 +217,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"

View File

@@ -1,33 +1,29 @@
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";
import { setCursorForShape } from "../cursor";
import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length === 1 && isFrameLikeElement(selectedElements[0])
);
return selectedElements.length === 1 && selectedElements[0].type === "frame";
};
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElement =
app.scene.getSelectedElements(appState).at(0) || null;
const selectedFrame = app.scene.getSelectedElements(appState)[0];
if (isFrameLikeElement(selectedElement)) {
const elementsInFrame = getFrameChildren(
if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameElements(
getNonDeletedElements(elements),
selectedElement.id,
selectedFrame.id,
).filter((element) => !(element.type === "text" && element.containerId));
return {
@@ -58,20 +54,15 @@ export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState, _, app) => {
const selectedElement =
app.scene.getSelectedElements(appState).at(0) || null;
const selectedFrame = app.scene.getSelectedElements(appState)[0];
if (isFrameLikeElement(selectedElement)) {
if (selectedFrame && selectedFrame.type === "frame") {
return {
elements: removeAllElementsFromFrame(
elements,
selectedElement,
appState,
),
elements: removeAllElementsFromFrame(elements, selectedFrame, appState),
appState: {
...appState,
selectedElementIds: {
[selectedElement.id]: true,
[selectedFrame.id]: true,
},
},
commitToHistory: true,

View File

@@ -17,13 +17,16 @@ 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,
getFrameLikeElements,
groupByFrameLikes,
groupByFrames,
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
@@ -102,7 +105,7 @@ export const actionGroup = register({
// when it happens, we want to remove elements that are in the frame
// and are going to be grouped from the frame (mouthful, I know)
if (groupingElementsFromDifferentFrames) {
const frameElementsMap = groupByFrameLikes(selectedElements);
const frameElementsMap = groupByFrames(selectedElements);
frameElementsMap.forEach((elementsInFrame, frameId) => {
nextElements = removeElementsFromFrame(
@@ -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 = getFrameLikeElements(elements).filter((frame) =>
selectedElementFrameIds.has(frame.id),
);
targetFrames.forEach((frame) => {
frames.forEach((frame) => {
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,

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,23 +52,35 @@ 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,
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.openDialog?.name === "help") {
if (appState.openDialog === "help") {
focusContainer();
}
return {
appState: {
...appState,
openDialog:
appState.openDialog?.name === "help"
? null
: {
name: "help",
},
openDialog: appState.openDialog === "help" ? null : "help",
},
commitToHistory: false,
};

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();
});
});
});

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 })}

View File

@@ -20,11 +20,9 @@ import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
isFrameLikeElement,
isArrowElement,
isFrameElement,
} 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,17 +123,14 @@ 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,
});
}
if (isFrameLikeElement(element)) {
if (isFrameElement(element)) {
newElement = newElementWith(newElement, {
roundness: null,
backgroundColor: "transparent",

View File

@@ -44,6 +44,7 @@ export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
export {
actionToggleCanvasMenu,
actionToggleEditMenu,
actionFullScreen,
actionShortcuts,
} from "./actionMenu";

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();

View File

@@ -1,7 +1,3 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
export const trackEvent = (
category: string,
action: string,
@@ -9,13 +5,13 @@ export const trackEvent = (
value?: number,
) => {
try {
// prettier-ignore
if (
typeof window === "undefined"
|| import.meta.env.VITE_WORKER_ID
// comment out to debug locally
|| import.meta.env.PROD
) {
// place here categories that you want to track as events
// KEEP IN MIND THE PRICING
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
// Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value });
if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) {
return;
}
@@ -23,10 +19,6 @@ export const trackEvent = (
return;
}
if (!import.meta.env.PROD) {
console.info("trackEvent", { category, action, label, value });
}
if (window.sa_event) {
window.sa_event(action, {
category,

View File

@@ -1,196 +1,22 @@
import {
createPasteEvent,
parseClipboard,
serializeAsClipboardJSON,
} from "./clipboard";
import { API } from "./tests/helpers/api";
import { parseClipboard } from "./clipboard";
import { createPasteEvent } from "./tests/test-utils";
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";
text = "123";
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
let clipboardData = await parseClipboard(
createPasteEvent({ "text/plain": text }),
);
expect(clipboardData.text).toBe(text);
// -------------------------------------------------------------------------
expect(clipboardData.text).toBe(text);
text = "[123]";
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
createPasteEvent({ "text/plain": 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],
});
});
});

View File

@@ -3,21 +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 {
isFrameLikeElement,
isInitializedImageElement,
} from "./element/typeChecks";
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;
@@ -37,11 +30,8 @@ export interface ClipboardData {
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;
@@ -71,63 +61,12 @@ 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) => isFrameLikeElement(element)),
elements.filter((element) => element.type === "frame"),
);
let foundFile = false;
@@ -147,7 +86,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) => {
@@ -166,20 +105,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 = (
@@ -213,9 +166,7 @@ function parseHTMLTree(el: ChildNode) {
return result;
}
const maybeParseHTMLPaste = (
event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const maybeParseHTMLPaste = (event: ClipboardEvent) => {
const html = event.clipboardData?.getData("text/html");
if (!html) {
@@ -228,7 +179,7 @@ const maybeParseHTMLPaste = (
const content = parseHTMLTree(doc.body);
if (content.length) {
return { type: "mixedContent", value: content };
return content;
}
} catch (error: any) {
console.error(`error in parseHTMLFromPaste: ${error.message}`);
@@ -237,88 +188,27 @@ const maybeParseHTMLPaste = (
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,
const getSystemClipboard = async (
event: ClipboardEvent | null,
isPlainPaste = false,
): Promise<ParsedClipboardEvent> => {
): Promise<
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent }
> => {
try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) {
return {
type: "text",
value:
event.clipboardData?.getData("text/plain") ||
mixedContent.value
.map((item) => item.value)
.join("\n")
.trim(),
};
}
return mixedContent;
return { type: "mixedContent", value: mixedContent };
}
const text = event.clipboardData?.getData("text/plain");
const text = event
? event.clipboardData?.getData("text/plain")
: probablySupportsClipboardReadText &&
(await navigator.clipboard.readText());
return { type: "text", value: (text || "").trim() };
} catch {
@@ -330,32 +220,40 @@ 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, isPlainPaste);
if (parsedEventData.type === "mixedContent") {
if (systemClipboard.type === "mixedContent") {
return {
mixedContent: parsedEventData.value,
mixedContent: systemClipboard.value,
};
}
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 (spreadsheetResult) {
return spreadsheetResult;
}
} catch (error: any) {
console.error(error);
// 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.value.includes(SVG_EXPORT_TAG))
) {
return getAppClipboard();
}
// 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.value);
if (spreadsheetResult) {
return spreadsheetResult;
}
const appClipboardData = getAppClipboard();
try {
const systemClipboardData = JSON.parse(parsedEventData.value);
const systemClipboardData = JSON.parse(systemClipboard.value);
const programmaticAPI =
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
if (clipboardContainsElements(systemClipboardData)) {
@@ -368,9 +266,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.value };
};
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
@@ -403,49 +310,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");

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, ExcalidrawElementType } from "../element/types";
import { ExcalidrawElement } 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,10 +35,7 @@ import {
EmbedIcon,
extraToolsIcon,
frameToolIcon,
mermaidLogoIcon,
laserPointerToolIcon,
OpenAIIcon,
MagicIcon,
} from "./icons";
import { KEYS } from "../keys";
@@ -68,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),
@@ -81,8 +78,7 @@ export const SelectedShapeActions = ({
const showLinkIcon =
targetElements.length === 1 || isSingleElementBoundContainer;
let commonSelectedType: ExcalidrawElementType | null =
targetElements[0]?.type || null;
let commonSelectedType: string | null = targetElements[0]?.type || null;
for (const element of targetElements) {
if (element.type !== commonSelectedType) {
@@ -97,8 +93,7 @@ export const SelectedShapeActions = ({
{((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image" &&
commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") ||
commonSelectedType !== "frame") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
</div>
@@ -128,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")}
</>
)}
@@ -206,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")}
@@ -222,14 +216,13 @@ export const ShapesSwitcher = ({
activeTool,
appState,
app,
UIOptions,
}: {
activeTool: UIAppState["activeTool"];
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";
@@ -238,14 +231,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]);
@@ -287,91 +272,111 @@ export const ShapesSwitcher = ({
);
})}
<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>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
<DropdownMenu.Item
onSelect={() => app.setOpenDialog({ name: "mermaid" })}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "d2c-settings", "settings");
app.setOpenDialog({
name: "magicSettings",
source: "settings",
});
}}
icon={OpenAIIcon}
data-testid="toolbar-magicSettings"
>
{t("toolBar.magicSettings")}
</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>
)}
</>
);
};

File diff suppressed because it is too large Load Diff

View File

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

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={{

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>

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");
});
}}

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;
}
}
}

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>

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")]}

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;
}

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}

View File

@@ -1,15 +0,0 @@
export const InlineIcon = ({ icon }: { icon: JSX.Element }) => {
return (
<span
style={{
width: "1em",
margin: "0 0.5ex 0 0.5ex",
display: "inline-block",
lineHeight: 0,
verticalAlign: "middle",
}}
>
{icon}
</span>
);
};

View File

@@ -117,7 +117,7 @@ export const JSONExportDialog = ({
return (
<>
{appState.openDialog?.name === "jsonExport" && (
{appState.openDialog === "jsonExport" && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal
elements={elements}

View File

@@ -1,12 +1,7 @@
import clsx from "clsx";
import React from "react";
import { ActionManager } from "../actions/manager";
import {
CLASSES,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_WIDTH,
TOOL_TYPE,
} from "../constants";
import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants";
import { showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
@@ -61,7 +56,6 @@ import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
interface LayerUIProps {
actionManager: ActionManager;
@@ -72,7 +66,7 @@ interface LayerUIProps {
elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: AppClassProperties["togglePenMode"];
onPenModeToggle: () => void;
showExitZenModeBtn: boolean;
langCode: Language["code"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
@@ -83,14 +77,6 @@ interface LayerUIProps {
children?: React.ReactNode;
app: AppClassProperties;
isCollaborating: boolean;
openAIKey: string | null;
isOpenAIKeyPersisted: boolean;
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
onMagicSettingsConfirm: (
apiKey: string,
shouldPersist: boolean,
source: "tool" | "generation" | "settings",
) => void;
}
const DefaultMainMenu: React.FC<{
@@ -147,10 +133,6 @@ const LayerUI = ({
children,
app,
isCollaborating,
openAIKey,
isOpenAIKeyPersisted,
onOpenAIAPIKeyChange,
onMagicSettingsConfirm,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@@ -179,10 +161,7 @@ const LayerUI = ({
};
const renderImageExportDialog = () => {
if (
!UIOptions.canvasActions.saveAsImage ||
appState.openDialog?.name !== "imageExport"
) {
if (!UIOptions.canvasActions.saveAsImage) {
return null;
}
@@ -267,7 +246,7 @@ const LayerUI = ({
>
<HintViewer
appState={appState}
isMobile={device.editor.isMobile}
isMobile={device.isMobile}
device={device}
app={app}
/>
@@ -276,7 +255,7 @@ const LayerUI = ({
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
@@ -298,7 +277,6 @@ const LayerUI = ({
<ShapesSwitcher
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
/>
</Stack.Row>
@@ -313,11 +291,9 @@ const LayerUI = ({
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={
appState.activeTool.type === TOOL_TYPE.laser
}
checked={appState.activeTool.type === "laser"}
onChange={() =>
app.setActiveTool({ type: TOOL_TYPE.laser })
app.setActiveTool({ type: "laser" })
}
isMobile
/>
@@ -338,7 +314,7 @@ const LayerUI = ({
)}
>
<UserList collaborators={appState.collaborators} />
{renderTopRightUI?.(device.editor.isMobile, appState)}
{renderTopRightUI?.(device.isMobile, appState)}
{!appState.viewModeEnabled &&
// hide button when sidebar docked
(!isSidebarDocked ||
@@ -359,7 +335,7 @@ const LayerUI = ({
trackEvent(
"sidebar",
`toggleDock (${docked ? "dock" : "undock"})`,
`(${device.editor.isMobile ? "mobile" : "desktop"})`,
`(${device.isMobile ? "mobile" : "desktop"})`,
);
}}
/>
@@ -387,7 +363,7 @@ const LayerUI = ({
trackEvent(
"sidebar",
`${DEFAULT_SIDEBAR.name} (open)`,
`button (${device.editor.isMobile ? "mobile" : "desktop"})`,
`button (${device.isMobile ? "mobile" : "desktop"})`,
);
}
}}
@@ -404,7 +380,7 @@ const LayerUI = ({
{appState.errorMessage}
</ErrorDialog>
)}
{eyeDropperState && !device.editor.isMobile && (
{eyeDropperState && !device.isMobile && (
<EyeDropper
colorPickerType={eyeDropperState.colorPickerType}
onCancel={() => {
@@ -452,32 +428,13 @@ const LayerUI = ({
}}
/>
)}
{appState.openDialog?.name === "help" && (
{appState.openDialog === "help" && (
<HelpDialog
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
{appState.openDialog?.name === "magicSettings" && (
<MagicSettings
openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => {
const source =
appState.openDialog?.name === "magicSettings"
? appState.openDialog?.source
: "settings";
setAppState({ openDialog: null }, () => {
onMagicSettingsConfirm(apiKey, shouldPersist, source);
});
}}
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
<ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
@@ -493,7 +450,7 @@ const LayerUI = ({
}
/>
)}
{device.editor.isMobile && (
{device.isMobile && (
<MobileMenu
app={app}
appState={appState}
@@ -510,17 +467,16 @@ const LayerUI = ({
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)` }
: {}
}

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>
);

View File

@@ -1,38 +0,0 @@
import "./ToolIcon.scss";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
const DEFAULT_SIZE: ToolButtonSize = "small";
export const ElementCanvasButton = (props: {
title?: string;
icon: JSX.Element;
name?: string;
checked: boolean;
onChange?(): void;
isMobile?: boolean;
}) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon__MagicButton",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
},
)}
title={`${props.title}`}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name={props.name}
onChange={props.onChange}
checked={props.checked}
aria-label={props.title}
/>
<div className="ToolIcon__icon">{props.icon}</div>
</label>
);
};

View File

@@ -1,9 +0,0 @@
.excalidraw {
.MagicSettings-confirm {
padding: 0.5rem 1rem;
}
.MagicSettings__confirm {
margin-top: 2rem;
}
}

View File

@@ -1,130 +0,0 @@
import { useState } from "react";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { MagicIcon, OpenAIIcon } from "./icons";
import { FilledButton } from "./FilledButton";
import { CheckboxItem } from "./CheckboxItem";
import { KEYS } from "../keys";
import { useUIAppState } from "../context/ui-appState";
import { InlineIcon } from "./InlineIcon";
import { Paragraph } from "./Paragraph";
import "./MagicSettings.scss";
export const MagicSettings = (props: {
openAIKey: string | null;
isPersisted: boolean;
onChange: (key: string, shouldPersist: boolean) => void;
onConfirm: (key: string, shouldPersist: boolean) => void;
onClose: () => void;
}) => {
const { theme } = useUIAppState();
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
const [shouldPersist, setShouldPersist] = useState<boolean>(
props.isPersisted,
);
const onConfirm = () => {
props.onConfirm(keyInputValue.trim(), shouldPersist);
};
return (
<Dialog
onCloseRequest={() => {
props.onClose();
props.onConfirm(keyInputValue.trim(), shouldPersist);
}}
title={
<div style={{ display: "flex" }}>
Diagram to Code (AI){" "}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.1rem 0.5rem",
marginLeft: "1rem",
fontSize: 14,
borderRadius: "12px",
background: theme === "light" ? "#FFCCCC" : "#703333",
}}
>
Experimental
</div>
</div>
}
className="MagicSettings"
autofocus={false}
>
<Paragraph
style={{
display: "inline-flex",
alignItems: "center",
marginBottom: 0,
}}
>
For the diagram-to-code feature we use <InlineIcon icon={OpenAIIcon} />
OpenAI.
</Paragraph>
<Paragraph>
While the OpenAI API is in beta, its use is strictly limited as such
we require you use your own API key. You can create an{" "}
<a
href="https://platform.openai.com/login?launch"
rel="noopener noreferrer"
target="_blank"
>
OpenAI account
</a>
, add a small credit (5 USD minimum), and{" "}
<a
href="https://platform.openai.com/api-keys"
rel="noopener noreferrer"
target="_blank"
>
generate your own API key
</a>
.
</Paragraph>
<Paragraph>
Your OpenAI key does not leave the browser, and you can also set your
own limit in your OpenAI account dashboard if needed.
</Paragraph>
<TextField
isRedacted
value={keyInputValue}
placeholder="Paste your API key here"
label="OpenAI API key"
onChange={(value) => {
setKeyInputValue(value);
props.onChange(value.trim(), shouldPersist);
}}
selectOnRender
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
/>
<Paragraph>
By default, your API token is not persisted anywhere so you'll need to
insert it again after reload. But, you can persist locally in your
browser below.
</Paragraph>
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
Persist API key in browser storage
</CheckboxItem>
<Paragraph>
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
tool to wrap your elements in a frame that will then allow you to turn
it into code. This dialog can be accessed using the <b>AI Settings</b>{" "}
<InlineIcon icon={OpenAIIcon} />.
</Paragraph>
<FilledButton
className="MagicSettings__confirm"
size="large"
label="Confirm"
onClick={onConfirm}
/>
</Dialog>
);
};

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;
}
}
}
}

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;

View File

@@ -1,7 +1,6 @@
import React from "react";
import {
AppClassProperties,
AppProps,
AppState,
Device,
ExcalidrawProps,
@@ -36,7 +35,7 @@ type MobileMenuProps = {
elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
onHandToolToggle: () => void;
onPenModeToggle: AppClassProperties["togglePenMode"];
onPenModeToggle: () => void;
renderTopRightUI?: (
isMobile: boolean,
@@ -46,7 +45,6 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean;
UIOptions: AppProps["UIOptions"];
app: AppClassProperties;
};
@@ -64,7 +62,6 @@ export const MobileMenu = ({
renderSidebars,
device,
renderWelcomeScreen,
UIOptions,
app,
}: MobileMenuProps) => {
const {
@@ -86,7 +83,6 @@ export const MobileMenu = ({
<ShapesSwitcher
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
/>
</Stack.Row>
@@ -98,7 +94,7 @@ export const MobileMenu = ({
)}
<PenModeButton
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}

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;
}
}
}

View File

@@ -47,7 +47,7 @@ export const ExportToImage = () => {
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
onClick={() => {
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
setAppState({ openDialog: { name: "imageExport" } });
setAppState({ openDialog: "imageExport" });
}}
>
{t("overwriteConfirm.action.exportToImage.description")}

View File

@@ -1,10 +0,0 @@
export const Paragraph = (props: {
children: React.ReactNode;
style?: React.CSSProperties;
}) => {
return (
<p className="excalidraw__paragraph" style={props.style}>
{props.children}
</p>
);
};

View File

@@ -94,7 +94,7 @@ export const PasteChartDialog = ({
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertElements(elements);
trackEvent("paste", "chart", chartType);
trackEvent("magic", "chart", chartType);
setAppState({
currentChartType: chartType,
pasteDialog: {

View File

@@ -8,7 +8,6 @@ import Trans from "./Trans";
import { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas, exportToSvg } from "../packages/utils";
import {
EDITOR_LS_KEYS,
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
MIME_TYPES,
@@ -20,7 +19,6 @@ import { chunk } from "../utils";
import DialogActionButton from "./DialogActionButton";
import { CloseIcon } from "./icons";
import { ToolButton } from "./ToolButton";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import "./PublishLibrary.scss";
@@ -33,6 +31,34 @@ interface PublishLibraryDataParams {
website: string;
}
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
try {
localStorage.setItem(
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
JSON.stringify(data),
);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importPublishLibDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
if (data) {
return JSON.parse(data);
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const generatePreviewImage = async (libraryItems: LibraryItems) => {
const MAX_ITEMS_PER_ROW = 6;
const BOX_SIZE = 128;
@@ -229,9 +255,7 @@ const PublishLibrary = ({
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const data = EditorLocalStorage.get<PublishLibraryDataParams>(
EDITOR_LS_KEYS.PUBLISH_LIBRARY,
);
const data = importPublishLibDataFromStorage();
if (data) {
setLibraryData(data);
}
@@ -304,7 +328,7 @@ const PublishLibrary = ({
if (response.ok) {
return response.json().then(({ url }) => {
// flush data from local storage
EditorLocalStorage.delete(EDITOR_LS_KEYS.PUBLISH_LIBRARY);
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
onSuccess({
url,
authorName: libraryData.authorName,
@@ -360,7 +384,7 @@ const PublishLibrary = ({
const onDialogClose = useCallback(() => {
updateItemsInStorage(clonedLibItems);
EditorLocalStorage.set(EDITOR_LS_KEYS.PUBLISH_LIBRARY, libraryData);
savePublishLibDataToStorage(libraryData);
onClose();
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);

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

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 (

View File

@@ -4,15 +4,12 @@ import {
useImperativeHandle,
KeyboardEvent,
useLayoutEffect,
useState,
} from "react";
import clsx from "clsx";
import "./TextField.scss";
import { Button } from "./Button";
import { eyeIcon, eyeClosedIcon } from "./icons";
type TextFieldProps = {
export type TextFieldProps = {
value?: string;
onChange?: (value: string) => void;
@@ -25,7 +22,6 @@ type TextFieldProps = {
label?: string;
placeholder?: string;
isRedacted?: boolean;
};
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@@ -39,7 +35,6 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
readonly,
selectOnRender,
onKeyDown,
isRedacted = false,
},
ref,
) => {
@@ -53,9 +48,6 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
}
}, [selectOnRender]);
const [isTemporarilyUnredacted, setIsTemporarilyUnredacted] =
useState<boolean>(false);
return (
<div
className={clsx("ExcTextField", {
@@ -72,26 +64,14 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
})}
>
<input
className={clsx({
"is-redacted": value && isRedacted && !isTemporarilyUnredacted,
})}
readOnly={readonly}
type="text"
value={value}
placeholder={placeholder}
ref={innerRef}
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown}
/>
{isRedacted && (
<Button
onSelect={() =>
setIsTemporarilyUnredacted(!isTemporarilyUnredacted)
}
style={{ border: 0, userSelect: "none" }}
>
{isTemporarilyUnredacted ? eyeClosedIcon : eyeIcon}
</Button>
)}
</div>
</div>
);

View File

@@ -160,23 +160,13 @@
width: var(--lg-button-size);
height: var(--lg-button-size);
@media screen and (max-width: 450px) {
width: 1.8rem;
height: 1.8rem;
}
@media screen and (max-width: 379px) {
width: 1.5rem;
height: 1.5rem;
}
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
.ToolIcon__LaserPointer .ToolIcon__icon,
.ToolIcon__MagicButton .ToolIcon__icon {
.ToolIcon__LaserPointer .ToolIcon__icon {
width: var(--default-button-size);
height: var(--default-button-size);
}

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;
}
}

View File

@@ -189,6 +189,8 @@ const getRelevantAppStateProps = (
suggestedBindings: appState.suggestedBindings,
isRotating: appState.isRotating,
elementsToHighlight: appState.elementsToHighlight,
openSidebar: appState.openSidebar,
showHyperlinkPopup: appState.showHyperlinkPopup,
collaborators: appState.collaborators, // Necessary for collab. sessions
activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines,

View File

@@ -7,6 +7,8 @@
margin-top: 0.25rem;
&--mobile {
bottom: 55px;
top: auto;
left: 0;
width: 100%;
row-gap: 0.75rem;

View File

@@ -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

View File

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

View File

@@ -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 (

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"
@@ -1688,57 +1672,3 @@ export const laserPointerToolIcon = createIcon(
20,
);
export const MagicIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" />
<path d="M6 21l15 -15l-3 -3l-15 15l3 3" />
<path d="M15 6l3 3" />
<path d="M9 3a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
<path d="M19 13a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
</g>,
tablerIconProps,
);
export const OpenAIIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M11.217 19.384a3.501 3.501 0 0 0 6.783 -1.217v-5.167l-6 -3.35" />
<path d="M5.214 15.014a3.501 3.501 0 0 0 4.446 5.266l4.34 -2.534v-6.946" />
<path d="M6 7.63c-1.391 -.236 -2.787 .395 -3.534 1.689a3.474 3.474 0 0 0 1.271 4.745l4.263 2.514l6 -3.348" />
<path d="M12.783 4.616a3.501 3.501 0 0 0 -6.783 1.217v5.067l6 3.45" />
<path d="M18.786 8.986a3.501 3.501 0 0 0 -4.446 -5.266l-4.34 2.534v6.946" />
<path d="M18 16.302c1.391 .236 2.787 -.395 3.534 -1.689a3.474 3.474 0 0 0 -1.271 -4.745l-4.308 -2.514l-5.955 3.42" />
</g>,
tablerIconProps,
);
export const fullscreenIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
</g>,
tablerIconProps,
);
export const eyeIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
</g>,
tablerIconProps,
);
export const eyeClosedIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
<path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
<path d="M3 3l18 18" />
</g>,
tablerIconProps,
);

View File

@@ -107,7 +107,7 @@ export const SaveAsImage = () => {
<DropdownMenuItem
icon={ExportImageIcon}
data-testid="image-export-button"
onSelect={() => setAppState({ openDialog: { name: "imageExport" } })}
onSelect={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
aria-label={t("buttons.exportImage")}
>
@@ -230,7 +230,7 @@ export const Export = () => {
<DropdownMenuItem
icon={ExportIcon}
onSelect={() => {
setAppState({ openDialog: { name: "jsonExport" } });
setAppState({ openDialog: "jsonExport" });
}}
data-testid="json-export-button"
aria-label={t("buttons.export")}

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

View File

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

View File

@@ -80,7 +80,6 @@ export enum EVENT {
EXCALIDRAW_LINK = "excalidraw-link",
MENU_ITEM_SELECT = "menu.itemSelect",
MESSAGE = "message",
FULLSCREENCHANGE = "fullscreenchange",
}
export const YOUTUBE_STATES = {
@@ -106,7 +105,6 @@ export const FONT_FAMILY = {
Virgil: 1,
Helvetica: 2,
Cascadia: 3,
Assistant: 4,
};
export const THEME = {
@@ -116,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";
@@ -155,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
@@ -223,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;
@@ -312,12 +302,6 @@ export const ROUGHNESS = {
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"];
@@ -345,33 +329,4 @@ export const DEFAULT_SIDEBAR = {
defaultTab: LIBRARY_SIDEBAR_TAB,
} as const;
export const LIBRARY_DISABLED_TYPES = new Set([
"iframe",
"embeddable",
"image",
] as const);
// use these constants to easily identify reference sites
export const TOOL_TYPE = {
selection: "selection",
rectangle: "rectangle",
diamond: "diamond",
ellipse: "ellipse",
arrow: "arrow",
line: "line",
freedraw: "freedraw",
text: "text",
image: "image",
eraser: "eraser",
hand: "hand",
frame: "frame",
magicframe: "magicframe",
embeddable: "embeddable",
laser: "laser",
} as const;
export const EDITOR_LS_KEYS = {
OAI_API_KEY: "excalidraw-oai-api-key",
// legacy naming (non)scheme
PUBLISH_LIBRARY: "publish-library-data",
} as const;
export const LIBRARY_DISABLED_TYPES = new Set(["embeddable", "image"] as const);

View File

@@ -5,11 +5,9 @@
--zIndex-canvas: 1;
--zIndex-interactiveCanvas: 2;
--zIndex-wysiwyg: 3;
--zIndex-canvasButtons: 3;
--zIndex-layerUI: 4;
--zIndex-eyeDropperBackdrop: 5;
--zIndex-eyeDropperPreview: 6;
--zIndex-hyperlinkContainer: 7;
--zIndex-modal: 1000;
--zIndex-popup: 1001;
@@ -197,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 {
@@ -282,11 +280,6 @@
align-items: center;
justify-content: space-between;
padding: 8px;
.dropdown-menu--mobile {
bottom: 55px;
top: auto;
}
}
.App-mobile-menu {
@@ -533,12 +526,6 @@
}
}
input.is-redacted {
// we don't use type=password because browsers (chrome?) prompt
// you to save it which is annoying
-webkit-text-security: disc;
}
input[type="text"],
textarea:not(.excalidraw-wysiwyg) {
color: var(--text-primary-color);
@@ -550,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);
}
}
@@ -605,8 +592,6 @@
background-color: var(--island-bg-color);
.ToolIcon__icon {
width: 2rem;
height: 2rem;
border-radius: 0;
}
@@ -616,8 +601,8 @@
}
.App-toolbar--mobile {
overflow: visible;
max-width: 98vw;
overflow-x: auto;
max-width: 90vw;
.ToolIcon__keybinding {
display: none;
@@ -734,8 +719,4 @@
letter-spacing: 0.6px;
font-family: "Assistant";
}
.excalidraw__paragraph {
margin: 1rem 0;
}
}

View File

@@ -99,7 +99,7 @@ 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") {
} else {
interactiveCanvas.style.cursor = CURSOR_TYPE.AUTO;
}
};

View File

@@ -1,51 +0,0 @@
import { EDITOR_LS_KEYS } from "../constants";
import { JSONValue } from "../types";
export class EditorLocalStorage {
static has(key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS]) {
try {
return !!window.localStorage.getItem(key);
} catch (error: any) {
console.warn(`localStorage.getItem error: ${error.message}`);
return false;
}
}
static get<T extends JSONValue>(
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
) {
try {
const value = window.localStorage.getItem(key);
if (value) {
return JSON.parse(value) as T;
}
return null;
} catch (error: any) {
console.warn(`localStorage.getItem error: ${error.message}`);
return null;
}
}
static set = (
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
value: JSONValue,
) => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error: any) {
console.warn(`localStorage.setItem error: ${error.message}`);
return false;
}
};
static delete = (
name: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
) => {
try {
window.localStorage.removeItem(name);
} catch (error: any) {
console.warn(`localStorage.removeItem error: ${error.message}`);
}
};
}

View File

@@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "#d8f5a2",
"boundElements": [
{
"id": "id45",
"id": "id40",
"type": "arrow",
},
{
"id": "id46",
"id": "id41",
"type": "arrow",
},
],
@@ -45,7 +45,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id46",
"id": "id41",
"type": "arrow",
},
],
@@ -97,12 +97,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100,
"points": [
[
0.5,
0.5,
0,
0,
],
[
394.5,
34.5,
395,
35,
],
],
"roughness": 1,
@@ -110,7 +110,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id47",
"elementId": "id42",
"focus": -0.08139534883720931,
"gap": 1,
},
@@ -150,11 +150,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
399.5,
400,
0,
],
],
@@ -186,7 +186,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id45",
"id": "id40",
"type": "arrow",
},
],
@@ -222,7 +222,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"baseline": 0,
"boundElements": [
{
"id": "id48",
"id": "id43",
"type": "arrow",
},
],
@@ -266,7 +266,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"baseline": 0,
"boundElements": [
{
"id": "id48",
"id": "id43",
"type": "arrow",
},
],
@@ -309,7 +309,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id49",
"id": "id44",
"type": "text",
},
],
@@ -317,7 +317,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": {
"elementId": "text-2",
"focus": 0,
"gap": 205,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@@ -331,11 +331,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -355,7 +355,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 255,
"y": 239,
}
@@ -367,7 +367,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id48",
"containerId": "id43",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -395,7 +395,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 240,
"x": 340,
"y": 226.5,
}
`;
@@ -406,13 +406,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id38",
"id": "id33",
"type": "text",
},
],
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id40",
"elementId": "id35",
"focus": 0,
"gap": 1,
},
@@ -428,11 +428,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -441,7 +441,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id39",
"elementId": "id34",
"focus": 0,
"gap": 1,
},
@@ -452,7 +452,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 255,
"y": 239,
}
@@ -464,7 +464,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id37",
"containerId": "id32",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -492,7 +492,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 240,
"x": 340,
"y": 226.5,
}
`;
@@ -503,7 +503,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id37",
"id": "id32",
"type": "arrow",
},
],
@@ -538,7 +538,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id37",
"id": "id32",
"type": "arrow",
},
],
@@ -562,7 +562,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"x": 355,
"x": 555,
"y": 189,
}
`;
@@ -573,13 +573,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id42",
"id": "id37",
"type": "text",
},
],
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id44",
"elementId": "id39",
"focus": 0,
"gap": 1,
},
@@ -595,11 +595,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -608,7 +608,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id43",
"elementId": "id38",
"focus": 0,
"gap": 1,
},
@@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 255,
"y": 239,
}
@@ -631,7 +631,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id41",
"containerId": "id36",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -659,7 +659,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 240,
"x": 340,
"y": 226.5,
}
`;
@@ -671,7 +671,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"baseline": 0,
"boundElements": [
{
"id": "id41",
"id": "id36",
"type": "arrow",
},
],
@@ -715,7 +715,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"baseline": 0,
"boundElements": [
{
"id": "id41",
"id": "id36",
"type": "arrow",
},
],
@@ -747,7 +747,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 100,
"x": 355,
"x": 555,
"y": 226.5,
}
`;
@@ -801,11 +801,11 @@ exports[`Test Transform > should transform linear elements 1`] = `
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -821,7 +821,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 100,
"y": 20,
}
@@ -846,11 +846,11 @@ exports[`Test Transform > should transform linear elements 2`] = `
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -866,7 +866,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 450,
"y": 20,
}
@@ -895,7 +895,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
0,
],
[
100,
300,
0,
],
],
@@ -911,7 +911,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 100,
"y": 60,
}
@@ -940,7 +940,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
0,
],
[
100,
300,
0,
],
],
@@ -956,7 +956,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 450,
"y": 60,
}
@@ -1221,6 +1221,56 @@ exports[`Test Transform > should transform text element 2`] = `
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id28",
"type": "text",
},
],
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"id": Any<String>,
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0,
0,
],
[
300,
0,
],
],
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 300,
"x": 100,
"y": 100,
}
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 2`] = `
{
"angle": 0,
"backgroundColor": "transparent",
@@ -1244,11 +1294,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -1264,13 +1314,13 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 100,
"y": 100,
"y": 200,
}
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 2`] = `
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 3`] = `
{
"angle": 0,
"backgroundColor": "transparent",
@@ -1285,7 +1335,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"height": 130,
"id": Any<String>,
"isDeleted": false,
"lastCommittedPoint": null,
@@ -1294,11 +1344,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -1307,20 +1357,20 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
"strokeColor": "#1098ad",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 1,
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 100,
"y": 200,
"y": 300,
}
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 3`] = `
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 4`] = `
{
"angle": 0,
"backgroundColor": "transparent",
@@ -1335,7 +1385,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"height": 130,
"id": Any<String>,
"isDeleted": false,
"lastCommittedPoint": null,
@@ -1344,11 +1394,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0.5,
0,
0,
],
[
99.5,
300,
0,
],
],
@@ -1362,59 +1412,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 1,
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"x": 100,
"y": 300,
}
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 4`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id32",
"type": "text",
},
],
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"id": Any<String>,
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0.5,
0,
],
[
99.5,
0,
],
],
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1098ad",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 100,
"y": 400,
}
@@ -1426,7 +1426,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id25",
"containerId": "id24",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1454,7 +1454,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 85,
"x": 185,
"y": 87.5,
}
`;
@@ -1465,7 +1465,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id26",
"containerId": "id25",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1493,7 +1493,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 200,
"x": 50,
"x": 150,
"y": 187.5,
}
`;
@@ -1504,7 +1504,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id27",
"containerId": "id26",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1533,7 +1533,7 @@ LABELLED ARROW",
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 150,
"x": 75,
"x": 175,
"y": 275,
}
`;
@@ -1544,7 +1544,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id28",
"containerId": "id27",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1573,7 +1573,7 @@ LABELLED ARROW",
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 150,
"x": 75,
"x": 175,
"y": 375,
}
`;
@@ -1584,7 +1584,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id19",
"id": "id18",
"type": "text",
},
],
@@ -1619,7 +1619,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id20",
"id": "id19",
"type": "text",
},
],
@@ -1654,7 +1654,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id21",
"id": "id20",
"type": "text",
},
],
@@ -1689,7 +1689,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "#fff3bf",
"boundElements": [
{
"id": "id22",
"id": "id21",
"type": "text",
},
],
@@ -1724,7 +1724,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id23",
"id": "id22",
"type": "text",
},
],
@@ -1759,7 +1759,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "#ffec99",
"boundElements": [
{
"id": "id24",
"id": "id23",
"type": "text",
},
],
@@ -1794,7 +1794,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id13",
"containerId": "id12",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1833,7 +1833,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id14",
"containerId": "id13",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1873,7 +1873,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id15",
"containerId": "id14",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1915,7 +1915,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id16",
"containerId": "id15",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1955,7 +1955,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id17",
"containerId": "id16",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1996,7 +1996,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id18",
"containerId": "id17",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,

View File

@@ -1,300 +0,0 @@
export namespace OpenAIInput {
type ChatCompletionContentPart =
| ChatCompletionContentPartText
| ChatCompletionContentPartImage;
interface ChatCompletionContentPartImage {
image_url: ChatCompletionContentPartImage.ImageURL;
/**
* The type of the content part.
*/
type: "image_url";
}
namespace ChatCompletionContentPartImage {
export interface ImageURL {
/**
* Either a URL of the image or the base64 encoded image data.
*/
url: string;
/**
* Specifies the detail level of the image.
*/
detail?: "auto" | "low" | "high";
}
}
interface ChatCompletionContentPartText {
/**
* The text content.
*/
text: string;
/**
* The type of the content part.
*/
type: "text";
}
interface ChatCompletionUserMessageParam {
/**
* The contents of the user message.
*/
content: string | Array<ChatCompletionContentPart> | null;
/**
* The role of the messages author, in this case `user`.
*/
role: "user";
}
interface ChatCompletionSystemMessageParam {
/**
* The contents of the system message.
*/
content: string | null;
/**
* The role of the messages author, in this case `system`.
*/
role: "system";
}
export interface ChatCompletionCreateParamsBase {
/**
* A list of messages comprising the conversation so far.
* [Example Python code](https://cookbook.openai.com/examples/how_to_format_inputs_to_chatgpt_models).
*/
messages: Array<
ChatCompletionUserMessageParam | ChatCompletionSystemMessageParam
>;
/**
* ID of the model to use. See the
* [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility)
* table for details on which models work with the Chat API.
*/
model:
| (string & {})
| "gpt-4-1106-preview"
| "gpt-4-vision-preview"
| "gpt-4"
| "gpt-4-0314"
| "gpt-4-0613"
| "gpt-4-32k"
| "gpt-4-32k-0314"
| "gpt-4-32k-0613"
| "gpt-3.5-turbo"
| "gpt-3.5-turbo-16k"
| "gpt-3.5-turbo-0301"
| "gpt-3.5-turbo-0613"
| "gpt-3.5-turbo-16k-0613";
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on their
* existing frequency in the text so far, decreasing the model's likelihood to
* repeat the same line verbatim.
*
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
*/
frequency_penalty?: number | null;
/**
* Modify the likelihood of specified tokens appearing in the completion.
*
* Accepts a JSON object that maps tokens (specified by their token ID in the
* tokenizer) to an associated bias value from -100 to 100. Mathematically, the
* bias is added to the logits generated by the model prior to sampling. The exact
* effect will vary per model, but values between -1 and 1 should decrease or
* increase likelihood of selection; values like -100 or 100 should result in a ban
* or exclusive selection of the relevant token.
*/
logit_bias?: Record<string, number> | null;
/**
* The maximum number of [tokens](/tokenizer) to generate in the chat completion.
*
* The total length of input tokens and generated tokens is limited by the model's
* context length.
* [Example Python code](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken)
* for counting tokens.
*/
max_tokens?: number | null;
/**
* How many chat completion choices to generate for each input message.
*/
n?: number | null;
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on
* whether they appear in the text so far, increasing the model's likelihood to
* talk about new topics.
*
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
*/
presence_penalty?: number | null;
/**
* This feature is in Beta. If specified, our system will make a best effort to
* sample deterministically, such that repeated requests with the same `seed` and
* parameters should return the same result. Determinism is not guaranteed, and you
* should refer to the `system_fingerprint` response parameter to monitor changes
* in the backend.
*/
seed?: number | null;
/**
* Up to 4 sequences where the API will stop generating further tokens.
*/
stop?: string | null | Array<string>;
/**
* If set, partial message deltas will be sent, like in ChatGPT. Tokens will be
* sent as data-only
* [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format)
* as they become available, with the stream terminated by a `data: [DONE]`
* message.
* [Example Python code](https://cookbook.openai.com/examples/how_to_stream_completions).
*/
stream?: boolean | null;
/**
* What sampling temperature to use, between 0 and 2. Higher values like 0.8 will
* make the output more random, while lower values like 0.2 will make it more
* focused and deterministic.
*
* We generally recommend altering this or `top_p` but not both.
*/
temperature?: number | null;
/**
* An alternative to sampling with temperature, called nucleus sampling, where the
* model considers the results of the tokens with top_p probability mass. So 0.1
* means only the tokens comprising the top 10% probability mass are considered.
*
* We generally recommend altering this or `temperature` but not both.
*/
top_p?: number | null;
/**
* A unique identifier representing your end-user, which can help OpenAI to monitor
* and detect abuse.
* [Learn more](https://platform.openai.com/docs/guides/safety-best-practices/end-user-ids).
*/
user?: string;
}
}
export namespace OpenAIOutput {
export interface ChatCompletion {
/**
* A unique identifier for the chat completion.
*/
id: string;
/**
* A list of chat completion choices. Can be more than one if `n` is greater
* than 1.
*/
choices: Array<Choice>;
/**
* The Unix timestamp (in seconds) of when the chat completion was created.
*/
created: number;
/**
* The model used for the chat completion.
*/
model: string;
/**
* The object type, which is always `chat.completion`.
*/
object: "chat.completion";
/**
* This fingerprint represents the backend configuration that the model runs with.
*
* Can be used in conjunction with the `seed` request parameter to understand when
* backend changes have been made that might impact determinism.
*/
system_fingerprint?: string;
/**
* Usage statistics for the completion request.
*/
usage?: CompletionUsage;
}
export interface Choice {
/**
* The reason the model stopped generating tokens. This will be `stop` if the model
* hit a natural stop point or a provided stop sequence, `length` if the maximum
* number of tokens specified in the request was reached, `content_filter` if
* content was omitted due to a flag from our content filters, `tool_calls` if the
* model called a tool, or `function_call` (deprecated) if the model called a
* function.
*/
finish_reason:
| "stop"
| "length"
| "tool_calls"
| "content_filter"
| "function_call";
/**
* The index of the choice in the list of choices.
*/
index: number;
/**
* A chat completion message generated by the model.
*/
message: ChatCompletionMessage;
}
interface ChatCompletionMessage {
/**
* The contents of the message.
*/
content: string | null;
/**
* The role of the author of this message.
*/
role: "assistant";
}
/**
* Usage statistics for the completion request.
*/
interface CompletionUsage {
/**
* Number of tokens in the generated completion.
*/
completion_tokens: number;
/**
* Number of tokens in the prompt.
*/
prompt_tokens: number;
/**
* Total number of tokens used in the request (prompt + completion).
*/
total_tokens: number;
}
export interface APIError {
readonly status: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500 | undefined;
readonly headers: Headers | undefined;
readonly error: { message: string } | undefined;
readonly code: string | null | undefined;
readonly param: string | null | undefined;
readonly type: string | undefined;
}
}

View File

@@ -3,7 +3,7 @@ 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";
@@ -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"));
}
};

View File

@@ -3,20 +3,11 @@ import {
copyTextToSystemClipboard,
} from "../clipboard";
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
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";
@@ -24,64 +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: ExcalidrawFrameLikeElement | null = null;
let exportedElements = isExportingSelection
? getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
},
)
: elements;
if (isExportingSelection) {
if (
exportedElements.length === 1 &&
isFrameLikeElement(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,
{
@@ -90,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: ExcalidrawFrameLikeElement | null;
},
) => {
if (elements.length === 0) {
@@ -115,7 +49,6 @@ export const exportCanvas = async (
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
files,
{ exportingFrame },
);
if (type === "svg") {
return await fileSave(
@@ -137,7 +70,6 @@ export const exportCanvas = async (
exportBackground,
viewBackgroundColor,
exportPadding,
exportingFrame,
});
if (type === "png") {

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

View File

@@ -1,104 +0,0 @@
import { Theme } from "../element/types";
import { DataURL } from "../types";
import { OpenAIInput, OpenAIOutput } from "./ai/types";
export type MagicCacheData =
| {
status: "pending";
}
| { status: "done"; html: string }
| {
status: "error";
message?: string;
code: "ERR_GENERATION_INTERRUPTED" | string;
};
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
Your role is to transform low-fidelity wireframes into working front-end HTML code.
YOU MUST FOLLOW FOLLOWING RULES:
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
- Inline JavaScript when needed
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
- Source images from Unsplash or create applicable placeholders
- Interpret annotations as intended vs literal UI
- Fill gaps using your expertise in UX and business logic
- generate primarily for desktop UI, but make it responsive.
- Use grid and flexbox wherever applicable.
- Convert the wireframe in its entirety, don't omit elements if possible.
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
Your goal is a production-ready prototype that brings the wireframes to life.
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
export async function diagramToHTML({
image,
apiKey,
text,
theme = "light",
}: {
image: DataURL;
apiKey: string;
text: string;
theme?: Theme;
}) {
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
model: "gpt-4-vision-preview",
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
max_tokens: 4096,
temperature: 0.1,
messages: [
{
role: "system",
content: SYSTEM_PROMPT,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: image,
detail: "high",
},
},
{
type: "text",
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
},
{
type: "text",
text,
},
],
},
],
};
let result:
| ({ ok: true } & OpenAIOutput.ChatCompletion)
| ({ ok: false } & OpenAIOutput.APIError);
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (resp.ok) {
const json: OpenAIOutput.ChatCompletion = await resp.json();
result = { ...json, ok: true };
} else {
const json: OpenAIOutput.APIError = await resp.json();
result = { ...json, ok: false };
}
return result;
}

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 };
};

View File

@@ -1,6 +1,5 @@
import {
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FontFamilyValues,
@@ -44,6 +43,7 @@ import {
measureBaseline,
} from "../element/textElement";
import { normalizeLink } from "./url";
import { isValidFrameChild } from "../frame";
type RestoredAppState = Omit<
AppState,
@@ -69,7 +69,6 @@ export const AllowedExcalidrawActiveTools: Record<
embeddable: true,
hand: true,
laser: false,
magicframe: false,
};
export type RestoredDataState = {
@@ -113,7 +112,7 @@ const restoreElementWithProperties = <
// @ts-ignore TS complains here but type checks the call sites fine.
keyof K
> &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
): T => {
const base: Pick<T, keyof ExcalidrawElement> & {
[PRECEDING_ELEMENT_KEY]?: string;
@@ -161,9 +160,8 @@ const restoreElementWithProperties = <
locked: element.locked ?? false,
};
if ("customData" in element || "customData" in extra) {
base.customData =
"customData" in extra ? extra.customData : element.customData;
if ("customData" in element) {
base.customData = element.customData;
}
if (PRECEDING_ELEMENT_KEY in element) {
@@ -276,7 +274,7 @@ const restoreElement = (
return restoreElementWithProperties(element, {
type:
(element.type as ExcalidrawElementType | "draw") === "draw"
(element.type as ExcalidrawElement["type"] | "draw") === "draw"
? "line"
: element.type,
startBinding: repairBinding(element.startBinding),
@@ -287,20 +285,23 @@ const restoreElement = (
points,
x,
y,
segmentSplitIndices: element.segmentSplitIndices
? [...element.segmentSplitIndices]
: [],
});
}
// generic elements
case "ellipse":
return restoreElementWithProperties(element, {});
case "rectangle":
return restoreElementWithProperties(element, {});
case "diamond":
case "iframe":
return restoreElementWithProperties(element, {});
case "embeddable":
return restoreElementWithProperties(element, {
validated: null,
});
case "magicframe":
case "frame":
return restoreElementWithProperties(element, {
name: element.name ?? null,
@@ -399,7 +400,7 @@ const repairBoundElement = (
};
/**
* Remove an element's frameId if its containing frame is non-existent
* resets `frameId` if no longer applicable.
*
* NOTE mutates elements.
*/
@@ -407,12 +408,16 @@ const repairFrameMembership = (
element: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (element.frameId) {
const containingFrame = elementsMap.get(element.frameId);
if (!element.frameId) {
return;
}
if (!containingFrame) {
element.frameId = null;
}
if (
!isValidFrameChild(element) ||
// target frame not exists
!elementsMap.get(element.frameId)
) {
element.frameId = null;
}
};
@@ -456,6 +461,8 @@ export const restoreElements = (
// repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) {
// repair frame membership *after* bindings we do in restoreElement()
// since we rely on bindings to be correct
if (element.frameId) {
repairFrameMembership(element, restoredElementsMap);
}

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);

View File

@@ -5,7 +5,6 @@ import {
VERTICAL_ALIGN,
} from "../constants";
import {
getCommonBounds,
newElement,
newLinearElement,
redrawTextBoundingBox,
@@ -13,9 +12,7 @@ import {
import { bindLinearElement } from "../element/binding";
import {
ElementConstructorOpts,
newFrameElement,
newImageElement,
newMagicFrameElement,
newTextElement,
} from "../element/newElement";
import {
@@ -27,13 +24,12 @@ import {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawEmbeddableElement,
ExcalidrawFrameElement,
ExcalidrawFreeDrawElement,
ExcalidrawGenericElement,
ExcalidrawIframeLikeElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawMagicFrameElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FileId,
@@ -42,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";
@@ -63,12 +57,7 @@ export type ValidLinearElement = {
| {
type: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
"image" | "text" | "frame" | "embeddable"
>;
id?: ExcalidrawGenericElement["id"];
}
@@ -76,12 +65,7 @@ export type ValidLinearElement = {
id: ExcalidrawGenericElement["id"];
type?: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
"image" | "text" | "frame" | "embeddable"
>;
}
)
@@ -105,12 +89,7 @@ export type ValidLinearElement = {
| {
type: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
"image" | "text" | "frame" | "embeddable"
>;
id?: ExcalidrawGenericElement["id"];
}
@@ -118,12 +97,7 @@ export type ValidLinearElement = {
id: ExcalidrawGenericElement["id"];
type?: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
"image" | "text" | "frame" | "embeddable"
>;
}
)
@@ -159,7 +133,9 @@ export type ValidContainer =
export type ExcalidrawElementSkeleton =
| Extract<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement
| ExcalidrawEmbeddableElement
| ExcalidrawFreeDrawElement
| ExcalidrawFrameElement
>
| ({
type: Extract<ExcalidrawLinearElement["type"], "line">;
@@ -180,20 +156,10 @@ export type ExcalidrawElementSkeleton =
x: number;
y: number;
fileId: FileId;
} & Partial<ExcalidrawImageElement>)
| ({
type: "frame";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawFrameElement>)
| ({
type: "magicframe";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawMagicFrameElement>);
} & Partial<ExcalidrawImageElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 100,
width: 300,
height: 0,
};
@@ -391,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,
@@ -461,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":
@@ -528,11 +444,6 @@ export const convertToExcalidrawElements = (
],
...element,
});
Object.assign(
excalidrawElement,
getSizeFromPoints(excalidrawElement.points),
);
break;
}
case "text": {
@@ -566,24 +477,8 @@ export const convertToExcalidrawElements = (
break;
}
case "frame": {
excalidrawElement = newFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "magicframe": {
excalidrawElement = newMagicFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "freedraw":
case "iframe":
case "frame":
case "embeddable": {
excalidrawElement = element;
break;
@@ -604,9 +499,6 @@ export const convertToExcalidrawElements = (
} else {
elementStore.add(excalidrawElement);
elementsWithIds.set(excalidrawElement.id, element);
if (originalId) {
oldToNewElementIdMap.set(originalId, excalidrawElement.id);
}
}
}
@@ -632,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,
@@ -659,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);
@@ -687,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" && element.type !== "magicframe") {
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();
};

View File

@@ -1,10 +1,10 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const normalizeLink = (link: string) => {
link = link.trim();
if (!link) {
return link;
}
link = link.trim();
return sanitizeUrl(link);
};

View File

@@ -1,14 +0,0 @@
.excalidraw {
.excalidraw-canvas-buttons {
position: absolute;
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
z-index: var(--zIndex-canvasButtons);
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
}

View File

@@ -1,60 +0,0 @@
import { AppState } from "../types";
import { sceneCoordsToViewportCoords } from "../utils";
import { NonDeletedExcalidrawElement } from "./types";
import { getElementAbsoluteCoords } from ".";
import { useExcalidrawAppState } from "../components/App";
import "./ElementCanvasButtons.scss";
const CONTAINER_PADDING = 5;
const getContainerCoords = (
element: NonDeletedExcalidrawElement,
appState: AppState,
) => {
const [x1, y1] = getElementAbsoluteCoords(element);
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{ sceneX: x1 + element.width, sceneY: y1 },
appState,
);
const x = viewportX - appState.offsetLeft + 10;
const y = viewportY - appState.offsetTop;
return { x, y };
};
export const ElementCanvasButtons = ({
children,
element,
}: {
children: React.ReactNode;
element: NonDeletedExcalidrawElement;
}) => {
const appState = useExcalidrawAppState();
if (
appState.contextMenu ||
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating ||
appState.openMenu ||
appState.viewModeEnabled
) {
return null;
}
const { x, y } = getContainerCoords(element, appState);
return (
<div
className="excalidraw-canvas-buttons"
style={{
top: `${y}px`,
left: `${x}px`,
// width: CONTAINER_WIDTH,
padding: CONTAINER_PADDING,
}}
>
{children}
</div>
);
};

View File

@@ -6,7 +6,7 @@
justify-content: space-between;
position: absolute;
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
z-index: var(--zIndex-hyperlinkContainer);
z-index: 100;
background: var(--island-bg-color);
border-radius: var(--border-radius-md);
box-sizing: border-box;

View File

@@ -121,7 +121,7 @@ export const Hyperlink = ({
setToast({ message: embedLink.warning, closable: true });
}
const ar = embedLink
? embedLink.intrinsicSize.w / embedLink.intrinsicSize.h
? embedLink.aspectRatio.w / embedLink.aspectRatio.h
: 1;
const hasLinkChanged =
embeddableLinkCache.get(element.id) !== element.link;
@@ -210,7 +210,6 @@ export const Hyperlink = ({
};
const { x, y } = getCoordsForPopover(element, appState);
if (
appState.contextMenu ||
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating ||
@@ -393,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;

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