mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
91 Commits
dwelle/v0.
...
v0.17.4
Author | SHA1 | Date | |
---|---|---|---|
640caba739 | |||
2879c9d852 | |||
81046ccd6b | |||
207a0bcc6e | |||
f53edb7437 | |||
8d0a8ce65b | |||
a528769b68 | |||
ddb7585057 | |||
111a48ffb1 | |||
54153629c0 | |||
9c425224c7 | |||
9d1d45a8ea | |||
029c3c48ba | |||
adfd95be33 | |||
ceb255e8ee | |||
ae5b9a4ffd | |||
3d4ff59f40 | |||
7b00089314 | |||
af6b81df40 | |||
02cc8440c4 | |||
6363492cee | |||
900b317bf3 | |||
68179356e6 | |||
3ed15e95da | |||
798e1fd858 | |||
f66c93633c | |||
cee00767df | |||
864c0b3ea8 | |||
a9a6f8eafb | |||
3c96943db3 | |||
9006caff39 | |||
ce7a847668 | |||
b1037b342d | |||
18a7b97515 | |||
e8def8da8d | |||
a7db41c5ba | |||
d8166d9e1d | |||
81c0259041 | |||
f5c91c3a0f | |||
9b8de8a12e | |||
ea677d4581 | |||
ec2de7205f | |||
d5e3f436dc | |||
dcf4592e79 | |||
d1f8eec174 | |||
0f81c30276 | |||
f098789d16 | |||
f794b0bb90 | |||
104f64f1dc | |||
71ad3c5356 | |||
afea0df141 | |||
d2a508104e | |||
3697618266 | |||
e7cc2337ea | |||
9eb89f9960 | |||
ab1bcc7615 | |||
b1cac35269 | |||
83f86e2b86 | |||
7e38cab76e | |||
2cabb1f1f4 | |||
63650f82d1 | |||
dde3dac931 | |||
5b94cffc74 | |||
aaf73c8ff3 | |||
44d9d5fcac | |||
89a3bbddb7 | |||
b86184a849 | |||
b552166924 | |||
26ff3993bb | |||
7ad02c359a | |||
2523fe82e3 | |||
4ea079eb85 | |||
f20ba90ffa | |||
03da9112cf | |||
a249f332a2 | |||
2e61926a6b | |||
e921bfb1ae | |||
e6f74350ac | |||
fa33aa08ab | |||
8b838049df | |||
1f4f5e11ae | |||
12420592ef | |||
bfd318e765 | |||
6a821f3b76 | |||
84fd13e872 | |||
7d2b6f3374 | |||
ceb637f5ea | |||
4c35eba72d | |||
4765f5536e | |||
556175558a | |||
4db73a7f95 |
@ -1,3 +0,0 @@
|
||||
## 2020-10-13
|
||||
|
||||
- Added ability to embed scene source into exported PNG/SVG files so you can import the scene from them (open via `Load` button or drag & drop). #2219
|
@ -25,6 +25,9 @@
|
||||
<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>
|
||||
@ -70,7 +73,7 @@ The Excalidraw editor (npm package) supports:
|
||||
|
||||
## Excalidraw.com
|
||||
|
||||
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/src/excalidraw-app) is part of this repository as well, and the app features:
|
||||
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/excalidraw-app) is part of this repository as well, and the app features:
|
||||
|
||||
- 📡 PWA support (works offline).
|
||||
- 🤼 Real-time collaboration.
|
||||
|
@ -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.isMobile) {
|
||||
if (device.editor.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<button
|
||||
|
@ -38,6 +38,7 @@ To render an item, its recommended to use `MainMenu.Item`.
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| --- | --- | :-: | :-: | --- |
|
||||
| `onSelect` | `function` | Yes | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. |
|
||||
| `selected` | `boolean` | No | `false` | Whether item is active |
|
||||
| `children` | `React.ReactNode` | Yes | - | The content of the menu item |
|
||||
| `icon` | `JSX.Element` | No | - | The icon used in the menu item |
|
||||
| `shortcut` | `string` | No | - | The shortcut to be shown for the menu item |
|
||||
@ -70,6 +71,7 @@ function App() {
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| --- | --- | :-: | :-: | --- |
|
||||
| `onSelect` | `function` | No | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. |
|
||||
| `selected` | `boolean` | No | `false` | Whether item is active |
|
||||
| `href` | `string` | Yes | - | The `href` attribute to be added to the `anchor` element. |
|
||||
| `children` | `React.ReactNode` | Yes | - | The content of the menu item |
|
||||
| `icon` | `JSX.Element` | No | - | The icon used in the menu item |
|
||||
|
@ -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/ref#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/excalidraw-api#toggleSidebar) api to control the sidebar, but we export a trigger button to make UI use cases easier.
|
||||
|
||||
## Example
|
||||
|
||||
|
@ -10,13 +10,17 @@ The [`ExcalidrawElementSkeleton`](https://github.com/excalidraw/excalidraw/blob/
|
||||
|
||||
**_Signature_**
|
||||
|
||||
<pre>
|
||||
convertToExcalidrawElements(elements:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L133">
|
||||
ExcalidrawElementSkeleton
|
||||
</a>
|
||||
)
|
||||
</pre>
|
||||
```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`. |
|
||||
|
||||
**_How to use_**
|
||||
|
||||
@ -24,13 +28,13 @@ The [`ExcalidrawElementSkeleton`](https://github.com/excalidraw/excalidraw/blob/
|
||||
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/ref#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/excalidraw-api#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.
|
||||
|
||||
@ -427,3 +431,45 @@ convertToExcalidrawElements([
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Frames
|
||||
|
||||
To create a frame, you need to pass `type`, `children` (list of Excalidraw element ids). The rest of the attributes are optional.
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "frame";
|
||||
children: readonly ExcalidrawElement["id"][];
|
||||
name?: string;
|
||||
} & Partial<ExcalidrawFrameElement>);
|
||||
```
|
||||
|
||||
```ts
|
||||
convertToExcalidrawElements([
|
||||
{
|
||||
"type": "rectangle",
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"strokeWidth": 2,
|
||||
"id": "1"
|
||||
},
|
||||
{
|
||||
"type": "diamond",
|
||||
"x": 120,
|
||||
"y": 20,
|
||||
"backgroundColor": "#fff3bf",
|
||||
"strokeWidth": 2,
|
||||
"label": {
|
||||
"text": "HELLO EXCALIDRAW",
|
||||
"strokeColor": "#099268",
|
||||
"fontSize": 30
|
||||
},
|
||||
"id": "2"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"children": ["1", "2"],
|
||||
"name": "My frame"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
@ -1,27 +1,26 @@
|
||||
# ref
|
||||
# excalidrawAPI
|
||||
|
||||
<pre>
|
||||
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">
|
||||
createRef
|
||||
</a>{" "}
|
||||
|{" "}
|
||||
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
|
||||
|{" "}
|
||||
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
|
||||
callbackRef
|
||||
</a>{" "}
|
||||
| <br />
|
||||
{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
|
||||
resolvablePromise
|
||||
</a> } }
|
||||
(api:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L616">
|
||||
ExcalidrawAPI
|
||||
</a>
|
||||
) => void;
|
||||
</pre>
|
||||
|
||||
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs:
|
||||
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:
|
||||
|
||||
| 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 |
|
||||
@ -39,54 +38,15 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the
|
||||
| [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 |
|
||||
|
||||
## readyPromise
|
||||
:::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`.
|
||||
|
||||
<pre>
|
||||
const excalidrawRef = { current:{ readyPromise:
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
|
||||
resolvablePromise
|
||||
</a>
|
||||
} }
|
||||
</pre>
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@ -387,14 +347,25 @@ 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.
|
||||
|
||||
<pre>
|
||||
(tool: <br /> { type:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
|
||||
SHAPES
|
||||
</a>
|
||||
[number]["value"]| "eraser" } |
|
||||
<br /> { type: "custom"; customType: string }) => void
|
||||
</pre>
|
||||
```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 |
|
||||
|
||||
## setCursor
|
||||
|
||||
@ -421,3 +392,51 @@ This API is especially useful when you render a custom [`<Sidebar/>`](/docs/@exc
|
||||
```
|
||||
|
||||
This API can be used to reset to default mouse cursor.
|
||||
|
||||
## onChange
|
||||
|
||||
```tsx
|
||||
(
|
||||
callback: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => void
|
||||
) => () => void
|
||||
```
|
||||
|
||||
Subscribes to change events, similar to [`props.onChange`](/docs/@excalidraw/excalidraw/api/props#onchange).
|
||||
|
||||
Returns an unsubscribe function.
|
||||
|
||||
## onPointerDown
|
||||
|
||||
```tsx
|
||||
(
|
||||
callback: (
|
||||
activeTool: AppState["activeTool"],
|
||||
pointerDownState: PointerDownState,
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
) => void,
|
||||
) => () => void
|
||||
```
|
||||
|
||||
Subscribes to canvas `pointerdown` events.
|
||||
|
||||
Returns an unsubscribe function.
|
||||
|
||||
## onPointerUp
|
||||
|
||||
```tsx
|
||||
(
|
||||
callback: (
|
||||
activeTool: AppState["activeTool"],
|
||||
pointerDownState: PointerDownState,
|
||||
event: PointerEvent,
|
||||
) => void,
|
||||
) => () => void
|
||||
```
|
||||
|
||||
Subscribes to canvas `pointerup` events.
|
||||
|
||||
Returns an unsubscribe function.
|
@ -1,11 +1,11 @@
|
||||
# Props
|
||||
|
||||
All `props` are *optional*.
|
||||
All `props` are _optional_.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` | `null` | <code>Promise<object | null></code> | `null` | The initial data with which app loads. |
|
||||
| [`ref`](/docs/@excalidraw/excalidraw/api/props/ref) | `object` | _ | `Ref` to be passed to Excalidraw |
|
||||
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` | `null` | <code>Promise<object | null></code> | `null` | The initial data with which app loads. |
|
||||
| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | _ | Callback triggered with the excalidraw api once rendered |
|
||||
| [`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/ref#updatescene) / [`updateLibrary`](/docs/@excalidraw/excalidraw/api/props/ref#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/excalidraw-api#updatescene) / [`updateLibrary`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api#updatelibrary) afterwards.
|
||||
|
||||
```js showLineNumbers
|
||||
{
|
||||
@ -93,8 +93,16 @@ 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
|
||||
@ -110,7 +118,11 @@ 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 | null) => boolean
|
||||
(data:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L18">
|
||||
ClipboardData
|
||||
</a>
|
||||
, event: ClipboardEvent | 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.
|
||||
@ -136,8 +148,11 @@ 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<{ nativeEvent: MouseEvent }>) => void
|
||||
(element:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
||||
ExcalidrawElement
|
||||
</a>
|
||||
, event: CustomEvent<{ nativeEvent: MouseEvent }>) => void
|
||||
</pre>
|
||||
|
||||
Example:
|
||||
@ -180,30 +195,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
|
||||
@ -236,4 +251,4 @@ validateEmbeddable?: boolean | string[] | RegExp | RegExp[] | ((link: string) =>
|
||||
|
||||
This is an optional property. By default we support a handful of well-known sites. You may allow additional sites or disallow the default ones by supplying a custom validator. If you pass `true`, all URLs will be allowed. You can also supply a list of hostnames, RegExp (or list of RegExp objects), or a function. If the function returns `undefined`, the built-in validator will be used.
|
||||
|
||||
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.
|
||||
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.
|
||||
|
@ -1,6 +1,6 @@
|
||||
# UIOptions
|
||||
|
||||
This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasactions), [`dockedSidebarBreakpoint`](#dockedsidebarbreakpoint) and [`welcomeScreen`](#welcmescreen).
|
||||
This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasactions), [`dockedSidebarBreakpoint`](#dockedsidebarbreakpoint) [`welcomeScreen`](#welcmescreen) and [`tools`](#tools).
|
||||
|
||||
<pre>
|
||||
{
|
||||
@ -70,3 +70,12 @@ function App() {
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## tools
|
||||
|
||||
This `prop ` controls the visibility of the tools in the editor.
|
||||
Currently you can control the visibility of `image` tool via this prop.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| image | boolean | true | Decides whether `image` tool should be visible.
|
@ -129,7 +129,7 @@ if (contents.type === MIME_TYPES.excalidraw) {
|
||||
|
||||
<pre>
|
||||
loadSceneOrLibraryFromBlob(<br/>
|
||||
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,
|
||||
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,<br/>
|
||||
localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a> | null,<br/>
|
||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null,<br/>
|
||||
fileHandle?: FileSystemHandle | null<br/>
|
||||
@ -164,9 +164,9 @@ import { isLinearElement } from "@excalidraw/excalidraw";
|
||||
|
||||
**Signature**
|
||||
|
||||
```tsx
|
||||
<pre>
|
||||
isLinearElement(elementType?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L80">ExcalidrawElement</a>): boolean
|
||||
```
|
||||
</pre>
|
||||
|
||||
### getNonDeletedElements
|
||||
|
||||
@ -195,8 +195,10 @@ import { mergeLibraryItems } from "@excalidraw/excalidraw";
|
||||
**_Signature_**
|
||||
|
||||
<pre>
|
||||
mergeLibraryItems(localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>,<br/>
|
||||
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>
|
||||
mergeLibraryItems(<br/>
|
||||
localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>,<br/>
|
||||
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>
|
||||
</pre>
|
||||
|
||||
### parseLibraryTokensFromUrl
|
||||
@ -299,7 +301,7 @@ Open the `main menu` in the below example to view the footer.
|
||||
```jsx live noInline
|
||||
const MobileFooter = ({}) => {
|
||||
const device = useDevice();
|
||||
if (device.isMobile) {
|
||||
if (device.editor.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<button
|
||||
@ -331,14 +333,15 @@ const App = () => (
|
||||
render(<App />);
|
||||
```
|
||||
|
||||
The `device` has the following `attributes`
|
||||
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
|
||||
|
||||
| Name | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `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` |
|
||||
| `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 |
|
||||
|
||||
### i18n
|
||||
|
||||
@ -383,3 +386,94 @@ 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/>
|
||||
elements: readonly NonDeletedExcalidrawElement[];<br/>
|
||||
bounds: <a href="https://github.com/excalidraw/excalidraw/blob/9c425224c789d083bf16e0597ce4a429b9ee008e/src/element/bounds.ts#L37-L42">Bounds</a> | ExcalidrawElement;<br/>
|
||||
errorMargin?: number;<br/>
|
||||
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/>
|
||||
element: NonDeletedExcalidrawElement,<br/>
|
||||
bounds: <a href="https://github.com/excalidraw/excalidraw/blob/9c425224c789d083bf16e0597ce4a429b9ee008e/src/element/bounds.ts#L37-L42">Bounds</a>,<br/>
|
||||
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/>
|
||||
element: NonDeletedExcalidrawElement,<br/>
|
||||
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";
|
||||
```
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Customizing Styles
|
||||
|
||||
Excalidraw is using CSS variables to style certain components. To override them, you should set your own on the `.excalidraw` and `.excalidraw.theme--dark` (for dark mode variables) selectors.
|
||||
Excalidraw uses CSS variables to style certain components. To override them, you should set your own on the `.excalidraw` and `.excalidraw.theme--dark` (for dark mode variables) selectors.
|
||||
|
||||
Make sure the selector has higher specificity, e.g. by prefixing it with your app's selector:
|
||||
|
||||
|
@ -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 version
|
||||
yarn prerelease:excalidraw
|
||||
```
|
||||
|
||||
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
|
||||
yarn release:excalidraw
|
||||
```
|
||||
|
||||
This will publish the package.
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
### Does this package support collaboration ?
|
||||
|
||||
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same.
|
||||
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same.
|
||||
|
||||
### Turning off Aggressive Anti-Fingerprinting in Brave browser
|
||||
|
||||
@ -18,7 +18,7 @@ We strongly recommend turning it off. You can follow the steps below on how to d
|
||||
|
||||
2. Once opened, look for **Aggressively Block Fingerprinting**
|
||||
|
||||

|
||||

|
||||
|
||||
3. Switch to **Block Fingerprinting**
|
||||
|
||||
@ -31,6 +31,17 @@ We strongly recommend turning it off. You can follow the steps below on how to d
|
||||
If disabling this setting doesn't fix the display of text elements, please consider opening an [issue](https://github.com/excalidraw/excalidraw/issues/new) on our GitHub, or message us on [Discord](https://discord.gg/UexuTaE).
|
||||
|
||||
|
||||
### ReferenceError: process is not defined
|
||||
|
||||
When using `vite` or any build tools, you will have to make sure the `process` is accessible as we are accessing `process.env.IS_PREACT` to decide whether to use `preact` build.
|
||||
|
||||
Since Vite removes env variables by default, you can update the vite config to ensure its available :point_down:
|
||||
|
||||
```
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||
},
|
||||
```
|
||||
|
||||
## Need help?
|
||||
|
||||
|
@ -30,25 +30,74 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering Excalidraw only on client
|
||||
### Next.js
|
||||
|
||||
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
|
||||
|
||||
The following worfklow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
|
||||
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.
|
||||
|
||||
```jsx showLineNumbers
|
||||
import { useState, useEffect } from "react";
|
||||
export default function App() {
|
||||
const [Excalidraw, setExcalidraw] = useState(null);
|
||||
useEffect(() => {
|
||||
import("@excalidraw/excalidraw").then((comp) => setExcalidraw(comp.Excalidraw));
|
||||
import("@excalidraw/excalidraw").then((comp) =>
|
||||
setExcalidraw(comp.Excalidraw),
|
||||
);
|
||||
}, []);
|
||||
return <>{Excalidraw && <Excalidraw />}</>;
|
||||
}
|
||||
```
|
||||
|
||||
Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-5xb3d)
|
||||
|
||||
The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm)
|
||||
|
||||
### Preact
|
||||
|
||||
Since we support `umd` build ships with `react/jsx-runtime` and `react-dom/client` inlined with the package. This conflicts with `Preact` and hence the build doesn't work directly with `Preact`.
|
||||
|
||||
However we have shipped a separate build for `Preact` so if you are using `Preact` you need to set `process.env.IS_PREACT` to `true` to use the `Preact` build.
|
||||
|
||||
Once the above `env` variable is set, you will be able to use the package in `Preact` as well.
|
||||
|
||||
:::info
|
||||
|
||||
When using `vite` or any build tools, you will have to make sure the `process` is accessible as we are accessing `process.env.IS_PREACT` to decide whether to use `preact` build.
|
||||
|
||||
Since Vite removes env variables by default, you can update the vite config to ensure its available :point_down:
|
||||
|
||||
```
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||
},
|
||||
```
|
||||
:::
|
||||
|
||||
## Browser
|
||||
|
||||
To use it in a browser directly:
|
||||
|
22
dev-docs/docs/codebase/frames.mdx
Normal file
22
dev-docs/docs/codebase/frames.mdx
Normal file
@ -0,0 +1,22 @@
|
||||
# Frames
|
||||
|
||||
## Ordering
|
||||
|
||||
Frames should be ordered where frame children come first, followed by the frame element itself:
|
||||
|
||||
```
|
||||
[
|
||||
other_element,
|
||||
frame1_child1,
|
||||
frame1_child2,
|
||||
frame1,
|
||||
other_element,
|
||||
frame2_child1,
|
||||
frame2_child2,
|
||||
frame2,
|
||||
other_element,
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
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.
|
@ -15,7 +15,7 @@ In case you want to pick up something from the roadmap, comment on that issue an
|
||||
1. Run `yarn` to install dependencies
|
||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
||||
|
||||
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
|
||||
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork, run:
|
||||
>
|
||||
> ```bash
|
||||
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
||||
|
@ -1,6 +1,11 @@
|
||||
// @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",
|
||||
@ -139,7 +144,15 @@ const config = {
|
||||
},
|
||||
}),
|
||||
themes: ["@docusaurus/theme-live-codeblock"],
|
||||
plugins: ["docusaurus-plugin-sass"],
|
||||
plugins: [
|
||||
"docusaurus-plugin-sass",
|
||||
[
|
||||
"docusaurus2-dotenv",
|
||||
{
|
||||
systemvars: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
@ -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.15.2-eb020d0",
|
||||
"@excalidraw/excalidraw": "0.17.0",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-plugin-sass": "0.2.3",
|
||||
@ -30,6 +30,7 @@
|
||||
"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": {
|
||||
|
@ -23,7 +23,11 @@ const sidebars = {
|
||||
},
|
||||
items: ["introduction/development", "introduction/contributing"],
|
||||
},
|
||||
{ type: "category", label: "Codebase", items: ["codebase/json-schema"] },
|
||||
{
|
||||
type: "category",
|
||||
label: "Codebase",
|
||||
items: ["codebase/json-schema", "codebase/frames"],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "@excalidraw/excalidraw",
|
||||
@ -49,7 +53,7 @@ const sidebars = {
|
||||
},
|
||||
items: [
|
||||
"@excalidraw/excalidraw/api/props/initialdata",
|
||||
"@excalidraw/excalidraw/api/props/ref",
|
||||
"@excalidraw/excalidraw/api/props/excalidraw-api",
|
||||
"@excalidraw/excalidraw/api/props/render-props",
|
||||
"@excalidraw/excalidraw/api/props/ui-options",
|
||||
],
|
||||
|
@ -15,7 +15,7 @@ const FeatureList = [
|
||||
Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Want to build your own app powered by Excalidraw by don't know where to
|
||||
Want to build your own app powered by Excalidraw but don't know where to
|
||||
start?
|
||||
</>
|
||||
),
|
||||
|
@ -145,6 +145,14 @@
|
||||
dependencies:
|
||||
"@babel/highlight" "^7.18.6"
|
||||
|
||||
"@babel/code-frame@^7.22.13":
|
||||
version "7.22.13"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
|
||||
integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
|
||||
dependencies:
|
||||
"@babel/highlight" "^7.22.13"
|
||||
chalk "^2.4.2"
|
||||
|
||||
"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8":
|
||||
version "7.18.8"
|
||||
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d"
|
||||
@ -202,6 +210,16 @@
|
||||
"@jridgewell/gen-mapping" "^0.3.2"
|
||||
jsesc "^2.5.1"
|
||||
|
||||
"@babel/generator@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.0.tgz#df5c386e2218be505b34837acbcb874d7a983420"
|
||||
integrity sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==
|
||||
dependencies:
|
||||
"@babel/types" "^7.23.0"
|
||||
"@jridgewell/gen-mapping" "^0.3.2"
|
||||
"@jridgewell/trace-mapping" "^0.3.17"
|
||||
jsesc "^2.5.1"
|
||||
|
||||
"@babel/helper-annotate-as-pure@^7.18.6":
|
||||
version "7.18.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb"
|
||||
@ -265,6 +283,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be"
|
||||
integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==
|
||||
|
||||
"@babel/helper-environment-visitor@^7.22.20":
|
||||
version "7.22.20"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167"
|
||||
integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==
|
||||
|
||||
"@babel/helper-explode-assignable-expression@^7.18.6":
|
||||
version "7.18.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz#41f8228ef0a6f1a036b8dfdfec7ce94f9a6bc096"
|
||||
@ -280,6 +303,14 @@
|
||||
"@babel/template" "^7.18.6"
|
||||
"@babel/types" "^7.18.9"
|
||||
|
||||
"@babel/helper-function-name@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759"
|
||||
integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==
|
||||
dependencies:
|
||||
"@babel/template" "^7.22.15"
|
||||
"@babel/types" "^7.23.0"
|
||||
|
||||
"@babel/helper-hoist-variables@^7.18.6":
|
||||
version "7.18.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678"
|
||||
@ -287,6 +318,13 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.18.6"
|
||||
|
||||
"@babel/helper-hoist-variables@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb"
|
||||
integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==
|
||||
dependencies:
|
||||
"@babel/types" "^7.22.5"
|
||||
|
||||
"@babel/helper-member-expression-to-functions@^7.18.9":
|
||||
version "7.18.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz#1531661e8375af843ad37ac692c132841e2fd815"
|
||||
@ -374,11 +412,28 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.18.6"
|
||||
|
||||
"@babel/helper-split-export-declaration@^7.22.6":
|
||||
version "7.22.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz#322c61b7310c0997fe4c323955667f18fcefb91c"
|
||||
integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==
|
||||
dependencies:
|
||||
"@babel/types" "^7.22.5"
|
||||
|
||||
"@babel/helper-string-parser@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f"
|
||||
integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.18.6":
|
||||
version "7.18.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
|
||||
integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.22.20":
|
||||
version "7.22.20"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
|
||||
integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
|
||||
|
||||
"@babel/helper-validator-option@^7.18.6":
|
||||
version "7.18.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8"
|
||||
@ -412,11 +467,25 @@
|
||||
chalk "^2.0.0"
|
||||
js-tokens "^4.0.0"
|
||||
|
||||
"@babel/highlight@^7.22.13":
|
||||
version "7.22.20"
|
||||
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
|
||||
integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
|
||||
dependencies:
|
||||
"@babel/helper-validator-identifier" "^7.22.20"
|
||||
chalk "^2.4.2"
|
||||
js-tokens "^4.0.0"
|
||||
|
||||
"@babel/parser@^7.12.7", "@babel/parser@^7.18.6", "@babel/parser@^7.18.8", "@babel/parser@^7.18.9":
|
||||
version "7.18.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.9.tgz#f2dde0c682ccc264a9a8595efd030a5cc8fd2539"
|
||||
integrity sha512-9uJveS9eY9DJ0t64YbIBZICtJy8a5QrDEVdiLCG97fVLpDTpGX7t8mMSb6OWw6Lrnjqj4O8zwjELX3dhoMgiBg==
|
||||
|
||||
"@babel/parser@^7.22.15", "@babel/parser@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719"
|
||||
integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==
|
||||
|
||||
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
|
||||
version "7.18.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2"
|
||||
@ -1147,19 +1216,28 @@
|
||||
"@babel/parser" "^7.18.6"
|
||||
"@babel/types" "^7.18.6"
|
||||
|
||||
"@babel/traverse@^7.12.9", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9":
|
||||
version "7.18.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.9.tgz#deeff3e8f1bad9786874cb2feda7a2d77a904f98"
|
||||
integrity sha512-LcPAnujXGwBgv3/WHv01pHtb2tihcyW1XuL9wd7jqh1Z8AQkTd+QVjMrMijrln0T7ED3UXLIy36P9Ao7W75rYg==
|
||||
"@babel/template@^7.22.15":
|
||||
version "7.22.15"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
|
||||
integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.18.6"
|
||||
"@babel/generator" "^7.18.9"
|
||||
"@babel/helper-environment-visitor" "^7.18.9"
|
||||
"@babel/helper-function-name" "^7.18.9"
|
||||
"@babel/helper-hoist-variables" "^7.18.6"
|
||||
"@babel/helper-split-export-declaration" "^7.18.6"
|
||||
"@babel/parser" "^7.18.9"
|
||||
"@babel/types" "^7.18.9"
|
||||
"@babel/code-frame" "^7.22.13"
|
||||
"@babel/parser" "^7.22.15"
|
||||
"@babel/types" "^7.22.15"
|
||||
|
||||
"@babel/traverse@^7.12.9", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9":
|
||||
version "7.23.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8"
|
||||
integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.22.13"
|
||||
"@babel/generator" "^7.23.0"
|
||||
"@babel/helper-environment-visitor" "^7.22.20"
|
||||
"@babel/helper-function-name" "^7.23.0"
|
||||
"@babel/helper-hoist-variables" "^7.22.5"
|
||||
"@babel/helper-split-export-declaration" "^7.22.6"
|
||||
"@babel/parser" "^7.23.0"
|
||||
"@babel/types" "^7.23.0"
|
||||
debug "^4.1.0"
|
||||
globals "^11.1.0"
|
||||
|
||||
@ -1171,6 +1249,15 @@
|
||||
"@babel/helper-validator-identifier" "^7.18.6"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@babel/types@^7.22.15", "@babel/types@^7.22.5", "@babel/types@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
|
||||
integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==
|
||||
dependencies:
|
||||
"@babel/helper-string-parser" "^7.22.5"
|
||||
"@babel/helper-validator-identifier" "^7.22.20"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@colors/colors@1.5.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
|
||||
@ -1631,10 +1718,10 @@
|
||||
url-loader "^4.1.1"
|
||||
webpack "^5.73.0"
|
||||
|
||||
"@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==
|
||||
"@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==
|
||||
|
||||
"@hapi/hoek@^9.0.0":
|
||||
version "9.3.0"
|
||||
@ -1670,6 +1757,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
|
||||
integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
|
||||
|
||||
"@jridgewell/resolve-uri@^3.1.0":
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721"
|
||||
integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==
|
||||
|
||||
"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
|
||||
@ -1688,6 +1780,19 @@
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
|
||||
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
|
||||
|
||||
"@jridgewell/sourcemap-codec@^1.4.14":
|
||||
version "1.4.15"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
|
||||
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
|
||||
|
||||
"@jridgewell/trace-mapping@^0.3.17":
|
||||
version "0.3.20"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz#72e45707cf240fa6b081d0366f8265b0cd10197f"
|
||||
integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==
|
||||
dependencies:
|
||||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||
|
||||
"@jridgewell/trace-mapping@^0.3.7", "@jridgewell/trace-mapping@^0.3.9":
|
||||
version "0.3.14"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed"
|
||||
@ -3462,6 +3567,13 @@ 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"
|
||||
@ -3547,6 +3659,25 @@ dot-prop@^5.2.0:
|
||||
dependencies:
|
||||
is-obj "^2.0.0"
|
||||
|
||||
dotenv-defaults@^1.0.2:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/dotenv-defaults/-/dotenv-defaults-1.1.1.tgz#032c024f4b5906d9990eb06d722dc74cc60ec1bd"
|
||||
integrity sha512-6fPRo9o/3MxKvmRZBD3oNFdxODdhJtIy1zcJeUSCs6HCy4tarUpd+G67UTU9tF6OWXeSPqsm4fPAB+2eY9Rt9Q==
|
||||
dependencies:
|
||||
dotenv "^6.2.0"
|
||||
|
||||
dotenv-webpack@1.7.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/dotenv-webpack/-/dotenv-webpack-1.7.0.tgz#4384d8c57ee6f405c296278c14a9f9167856d3a1"
|
||||
integrity sha512-wwNtOBW/6gLQSkb8p43y0Wts970A3xtNiG/mpwj9MLUhtPCQG6i+/DSXXoNN7fbPCU/vQ7JjwGmgOeGZSSZnsw==
|
||||
dependencies:
|
||||
dotenv-defaults "^1.0.2"
|
||||
|
||||
dotenv@^6.2.0:
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064"
|
||||
integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==
|
||||
|
||||
duplexer3@^0.1.4:
|
||||
version "0.1.5"
|
||||
resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.5.tgz#0b5e4d7bad5de8901ea4440624c8e1d20099217e"
|
||||
|
@ -80,7 +80,8 @@ export const ExportToExcalidrawPlus: React.FC<{
|
||||
appState: Partial<AppState>;
|
||||
files: BinaryFiles;
|
||||
onError: (error: Error) => void;
|
||||
}> = ({ elements, appState, files, onError }) => {
|
||||
onSuccess: () => void;
|
||||
}> = ({ elements, appState, files, onError, onSuccess }) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Card color="primary">
|
||||
@ -107,6 +108,7 @@ export const ExportToExcalidrawPlus: React.FC<{
|
||||
try {
|
||||
trackEvent("export", "eplus", `ui (${getFrame()})`);
|
||||
await exportToExcalidrawPlus(elements, appState, files);
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
if (error.name !== "AbortError") {
|
||||
|
@ -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 sotrage
|
||||
* reason or another. We also save different data to different storage
|
||||
* (localStorage, indexedDB).
|
||||
*/
|
||||
|
||||
|
@ -107,7 +107,7 @@ export type SocketUpdateDataSource = {
|
||||
type: "MOUSE_LOCATION";
|
||||
payload: {
|
||||
socketId: string;
|
||||
pointer: { x: number; y: number };
|
||||
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||||
button: "down" | "up";
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
username: string;
|
||||
|
@ -131,5 +131,5 @@ export class Debug {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
window.debug = Debug;
|
||||
|
@ -608,7 +608,7 @@ const ExcalidrawWrapper = () => {
|
||||
canvas: HTMLCanvasElement,
|
||||
) => {
|
||||
if (exportedElements.length === 0) {
|
||||
return window.alert(t("alerts.cannotExportEmptyCanvas"));
|
||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||
}
|
||||
if (canvas) {
|
||||
try {
|
||||
@ -624,7 +624,7 @@ const ExcalidrawWrapper = () => {
|
||||
);
|
||||
|
||||
if (errorMessage) {
|
||||
setErrorMessage(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
@ -634,7 +634,7 @@ const ExcalidrawWrapper = () => {
|
||||
if (error.name !== "AbortError") {
|
||||
const { width, height } = canvas;
|
||||
console.error(error, { width, height });
|
||||
setErrorMessage(error.message);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -691,7 +691,7 @@ const ExcalidrawWrapper = () => {
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
ref={excalidrawRefCallback}
|
||||
excalidrawAPI={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
isCollaborating={isCollaborating}
|
||||
@ -714,6 +714,11 @@ const ExcalidrawWrapper = () => {
|
||||
},
|
||||
});
|
||||
}}
|
||||
onSuccess={() => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: { openDialog: null },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
@ -17,8 +17,10 @@ describe("Test MobileMenu", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
//@ts-ignore
|
||||
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
|
||||
// @ts-ignore
|
||||
h.app.refreshViewportBreakpoints();
|
||||
// @ts-ignore
|
||||
h.app.refreshEditorBreakpoints();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@ -28,11 +30,15 @@ describe("Test MobileMenu", () => {
|
||||
it("should set device correctly", () => {
|
||||
expect(h.app.device).toMatchInlineSnapshot(`
|
||||
{
|
||||
"canDeviceFitSidebar": false,
|
||||
"isLandscape": true,
|
||||
"isMobile": true,
|
||||
"isSmScreen": false,
|
||||
"editor": {
|
||||
"canFitSidebar": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
"isTouchScreen": false,
|
||||
"viewport": {
|
||||
"isLandscape": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
@ -8,6 +8,7 @@ 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 = {
|
||||
@ -93,8 +94,6 @@ 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)[],
|
||||
@ -115,15 +114,15 @@ const test = <U extends `${string}:${"L" | "R"}`>(
|
||||
"remote reconciliation",
|
||||
);
|
||||
|
||||
const __local = cleanElements(cloneDeep(_remote));
|
||||
const __remote = addParents(cleanElements(cloneDeep(remoteReconciled)));
|
||||
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
|
||||
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
|
||||
if (bidirectional) {
|
||||
try {
|
||||
expect(
|
||||
cleanElements(
|
||||
reconcileElements(
|
||||
cloneDeep(__local),
|
||||
cloneDeep(__remote),
|
||||
cloneJSON(__local),
|
||||
cloneJSON(__remote),
|
||||
{} as AppState,
|
||||
),
|
||||
),
|
||||
|
14
package.json
14
package.json
@ -20,7 +20,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@excalidraw/laser-pointer": "1.2.0",
|
||||
"@excalidraw/mermaid-to-excalidraw": "0.1.2",
|
||||
"@excalidraw/random-username": "1.1.0",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
"@sentry/browser": "6.2.5",
|
||||
@ -48,11 +50,11 @@
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
"png-chunks-extract": "1.0.0",
|
||||
"points-on-curve": "0.2.0",
|
||||
"points-on-curve": "1.0.1",
|
||||
"pwacompat": "2.0.17",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"roughjs": "4.5.2",
|
||||
"roughjs": "4.6.4",
|
||||
"sass": "1.51.0",
|
||||
"socket.io-client": "2.3.1",
|
||||
"tunnel-rat": "0.1.2"
|
||||
@ -124,10 +126,10 @@
|
||||
"test": "yarn test:app",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:coverage:watch": "vitest --coverage --watch",
|
||||
"test:ui": "yarn test --ui",
|
||||
"test:ui": "yarn test --ui --coverage.enabled=true",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease": "node scripts/prerelease.js",
|
||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||
"build:preview": "yarn build && vite preview --port 5000",
|
||||
"release": "node scripts/release.js"
|
||||
"release:excalidraw": "node scripts/release.js"
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@
|
||||
{
|
||||
"src": "apple-touch-icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256"
|
||||
"sizes": "180x180"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
|
@ -10,7 +10,7 @@ import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
@ -21,6 +21,7 @@ import {
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { Bounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
@ -437,5 +438,6 @@ export const actionToggleHandTool = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.H,
|
||||
keyTest: (event) =>
|
||||
!event.altKey && !event[KEYS.CTRL_OR_CMD] && event.key === KEYS.H,
|
||||
});
|
||||
|
@ -3,33 +3,43 @@ import { register } from "./register";
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
createPasteEvent,
|
||||
probablySupportsClipboardBlob,
|
||||
probablySupportsClipboardWriteText,
|
||||
readSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { exportCanvas } from "../data/index";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
const elementsToCopy = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
copyToClipboard(elementsToCopy, app.files);
|
||||
try {
|
||||
await copyToClipboard(elementsToCopy, app.files, event);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@ -38,15 +48,55 @@ export const actionCopy = register({
|
||||
export const actionPaste = register({
|
||||
name: "paste",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements: any, appStates: any, data, app) => {
|
||||
app.pasteFromClipboard(null);
|
||||
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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@ -55,13 +105,10 @@ export const actionPaste = register({
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, data, app) => {
|
||||
actionCopy.perform(elements, appState, data, app);
|
||||
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
actionCopy.perform(elements, appState, event, 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,
|
||||
});
|
||||
@ -75,20 +122,23 @@ export const actionCopyAsSvg = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elements,
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard-svg",
|
||||
selectedElements.length
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
exportedElements,
|
||||
appState,
|
||||
app.files,
|
||||
appState,
|
||||
{
|
||||
...appState,
|
||||
exportingFrame,
|
||||
},
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@ -124,16 +174,17 @@ export const actionCopyAsPng = register({
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elements,
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard",
|
||||
selectedElements.length
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.files,
|
||||
appState,
|
||||
);
|
||||
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
||||
...appState,
|
||||
exportingFrame,
|
||||
});
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
@ -46,6 +46,7 @@ const deleteSelectedElements = (
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -25,7 +25,7 @@ import { normalizeElementOrder } from "../element/sortElements";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
import {
|
||||
bindElementsToFramesAfterDuplication,
|
||||
getFrameElements,
|
||||
getFrameChildren,
|
||||
} from "../frame";
|
||||
import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
@ -155,7 +155,7 @@ const duplicateElements = (
|
||||
groupId,
|
||||
).flatMap((element) =>
|
||||
isFrameElement(element)
|
||||
? [...getFrameElements(elements, element.id), element]
|
||||
? [...getFrameChildren(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
|
||||
@ -181,7 +181,7 @@ const duplicateElements = (
|
||||
continue;
|
||||
}
|
||||
if (isElementAFrame) {
|
||||
const elementsInFrame = getFrameElements(sortedElements, element.id);
|
||||
const elementsInFrame = getFrameChildren(sortedElements, element.id);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
|
@ -191,7 +191,15 @@ export const actionSaveFileToDisk = register({
|
||||
},
|
||||
app.files,
|
||||
);
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog: null,
|
||||
fileHandle,
|
||||
toast: { message: t("toast.fileSaved") },
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
@ -209,7 +217,7 @@ export const actionSaveFileToDisk = register({
|
||||
icon={saveAs}
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
showAriaLabel={useDevice().editor.isMobile}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { isInvisiblySmallElement } from "../element";
|
||||
import { updateActiveTool, resetCursor } from "../utils";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
@ -15,6 +15,7 @@ import {
|
||||
} from "../element/binding";
|
||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
import { resetCursor } from "../cursor";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
@ -90,7 +91,9 @@ export const actionFinalize = register({
|
||||
}
|
||||
}
|
||||
if (isInvisiblySmallElement(multiPointElement)) {
|
||||
newElements = newElements.slice(0, -1);
|
||||
newElements = newElements.filter(
|
||||
(el) => el.id !== multiPointElement.id,
|
||||
);
|
||||
}
|
||||
|
||||
// If the multi point line closes the loop,
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { removeAllElementsFromFrame } from "../frame";
|
||||
import { getFrameElements } from "../frame";
|
||||
import { getFrameChildren } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { setCursorForShape, updateActiveTool } from "../utils";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { register } from "./register";
|
||||
|
||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
||||
@ -20,7 +21,7 @@ export const actionSelectAllElementsInFrame = register({
|
||||
const selectedFrame = app.scene.getSelectedElements(appState)[0];
|
||||
|
||||
if (selectedFrame && selectedFrame.type === "frame") {
|
||||
const elementsInFrame = getFrameElements(
|
||||
const elementsInFrame = getFrameChildren(
|
||||
getNonDeletedElements(elements),
|
||||
selectedFrame.id,
|
||||
).filter((element) => !(element.type === "text" && element.containerId));
|
||||
|
@ -17,15 +17,12 @@ import {
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { randomId } from "../random";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
getFrameElements,
|
||||
groupByFrames,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
@ -190,13 +187,6 @@ 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)) {
|
||||
@ -221,7 +211,19 @@ export const actionUngroup = register({
|
||||
null,
|
||||
);
|
||||
|
||||
frames.forEach((frame) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
const selectedElementFrameIds = new Set(
|
||||
selectedElements
|
||||
.filter((element) => element.frameId)
|
||||
.map((element) => element.frameId!),
|
||||
);
|
||||
|
||||
const targetFrames = getFrameElements(elements).filter((frame) =>
|
||||
selectedElementFrameIds.has(frame.id),
|
||||
);
|
||||
|
||||
targetFrames.forEach((frame) => {
|
||||
if (frame) {
|
||||
nextElements = replaceAllElementsInFrame(
|
||||
nextElements,
|
||||
|
@ -3,7 +3,6 @@ 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({
|
||||
@ -52,23 +51,6 @@ export const actionToggleEditMenu = register({
|
||||
),
|
||||
});
|
||||
|
||||
export const actionFullScreen = register({
|
||||
name: "toggleFullScreen",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
|
||||
perform: () => {
|
||||
if (!isFullScreen()) {
|
||||
allowFullScreen();
|
||||
}
|
||||
if (isFullScreen()) {
|
||||
exitFullScreen();
|
||||
}
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
viewMode: true,
|
||||
|
167
src/actions/actionProperties.test.tsx
Normal file
167
src/actions/actionProperties.test.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
@ -1,4 +1,4 @@
|
||||
import { AppState } from "../../src/types";
|
||||
import { AppState, Primitive } from "../../src/types";
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
@ -51,6 +51,7 @@ import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
STROKE_WIDTH,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
@ -82,7 +83,6 @@ import { getLanguage, t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { randomInteger } from "../random";
|
||||
import {
|
||||
canChangeRoundness,
|
||||
canHaveArrowheads,
|
||||
getCommonAttributeOfSelectedElements,
|
||||
getSelectedElements,
|
||||
@ -118,25 +118,44 @@ export const changeProperty = (
|
||||
});
|
||||
};
|
||||
|
||||
export const getFormValue = function <T>(
|
||||
export const getFormValue = function <T extends Primitive>(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
getAttribute: (element: ExcalidrawElement) => T,
|
||||
defaultValue: T,
|
||||
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
||||
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
||||
): T {
|
||||
const editingElement = appState.editingElement;
|
||||
const nonDeletedElements = getNonDeletedElements(elements);
|
||||
return (
|
||||
(editingElement && getAttribute(editingElement)) ??
|
||||
(isSomeElementSelected(nonDeletedElements, appState)
|
||||
? getCommonAttributeOfSelectedElements(
|
||||
nonDeletedElements,
|
||||
|
||||
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)),
|
||||
appState,
|
||||
getAttribute,
|
||||
)
|
||||
: defaultValue) ??
|
||||
defaultValue
|
||||
);
|
||||
) ??
|
||||
(typeof defaultValue === "function"
|
||||
? defaultValue(true)
|
||||
: defaultValue);
|
||||
} else {
|
||||
ret =
|
||||
typeof defaultValue === "function" ? defaultValue(false) : defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
||||
const offsetElementAfterFontResize = (
|
||||
@ -247,6 +266,7 @@ export const actionChangeStrokeColor = register({
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.strokeColor,
|
||||
true,
|
||||
appState.currentItemStrokeColor,
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||
@ -289,6 +309,7 @@ export const actionChangeBackgroundColor = register({
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.backgroundColor,
|
||||
true,
|
||||
appState.currentItemBackgroundColor,
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||
@ -307,7 +328,7 @@ export const actionChangeFillStyle = register({
|
||||
trackEvent(
|
||||
"element",
|
||||
"changeFillStyle",
|
||||
`${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
||||
`${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@ -338,23 +359,28 @@ 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,
|
||||
appState.currentItemFillStyle,
|
||||
(element) => element.hasOwnProperty("fillStyle"),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemFillStyle,
|
||||
)}
|
||||
onClick={(value, event) => {
|
||||
const nextValue =
|
||||
@ -393,26 +419,31 @@ export const actionChangeStrokeWidth = register({
|
||||
group="stroke-width"
|
||||
options={[
|
||||
{
|
||||
value: 1,
|
||||
value: STROKE_WIDTH.thin,
|
||||
text: t("labels.thin"),
|
||||
icon: StrokeWidthBaseIcon,
|
||||
testId: "strokeWidth-thin",
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
value: STROKE_WIDTH.bold,
|
||||
text: t("labels.bold"),
|
||||
icon: StrokeWidthBoldIcon,
|
||||
testId: "strokeWidth-bold",
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
value: STROKE_WIDTH.extraBold,
|
||||
text: t("labels.extraBold"),
|
||||
icon: StrokeWidthExtraBoldIcon,
|
||||
testId: "strokeWidth-extraBold",
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.strokeWidth,
|
||||
appState.currentItemStrokeWidth,
|
||||
(element) => element.hasOwnProperty("strokeWidth"),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemStrokeWidth,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
@ -461,7 +492,9 @@ export const actionChangeSloppiness = register({
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.roughness,
|
||||
appState.currentItemRoughness,
|
||||
(element) => element.hasOwnProperty("roughness"),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemRoughness,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
@ -509,7 +542,9 @@ export const actionChangeStrokeStyle = register({
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.strokeStyle,
|
||||
appState.currentItemStrokeStyle,
|
||||
(element) => element.hasOwnProperty("strokeStyle"),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemStrokeStyle,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
@ -549,6 +584,7 @@ export const actionChangeOpacity = register({
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.opacity,
|
||||
true,
|
||||
appState.currentItemOpacity,
|
||||
) ?? undefined
|
||||
}
|
||||
@ -607,7 +643,12 @@ export const actionChangeFontSize = register({
|
||||
}
|
||||
return null;
|
||||
},
|
||||
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||
(element) =>
|
||||
isTextElement(element) || getBoundTextElement(element) !== null,
|
||||
(hasSelection) =>
|
||||
hasSelection
|
||||
? null
|
||||
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
@ -692,21 +733,25 @@ 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",
|
||||
},
|
||||
];
|
||||
|
||||
@ -729,7 +774,12 @@ export const actionChangeFontFamily = register({
|
||||
}
|
||||
return null;
|
||||
},
|
||||
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||
(element) =>
|
||||
isTextElement(element) || getBoundTextElement(element) !== null,
|
||||
(hasSelection) =>
|
||||
hasSelection
|
||||
? null
|
||||
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
@ -806,7 +856,10 @@ export const actionChangeTextAlign = register({
|
||||
}
|
||||
return null;
|
||||
},
|
||||
appState.currentItemTextAlign,
|
||||
(element) =>
|
||||
isTextElement(element) || getBoundTextElement(element) !== null,
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemTextAlign,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
@ -882,7 +935,9 @@ export const actionChangeVerticalAlign = register({
|
||||
}
|
||||
return null;
|
||||
},
|
||||
VERTICAL_ALIGN.MIDDLE,
|
||||
(element) =>
|
||||
isTextElement(element) || getBoundTextElement(element) !== null,
|
||||
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
@ -947,9 +1002,9 @@ export const actionChangeRoundness = register({
|
||||
appState,
|
||||
(element) =>
|
||||
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
|
||||
(canChangeRoundness(appState.activeTool.type) &&
|
||||
appState.currentItemRoundness) ||
|
||||
null,
|
||||
(element) => element.hasOwnProperty("roundness"),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemRoundness,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
@ -1043,6 +1098,7 @@ export const actionChangeArrowhead = register({
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.startArrowhead
|
||||
: appState.currentItemStartArrowhead,
|
||||
true,
|
||||
appState.currentItemStartArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "start", type: value })}
|
||||
@ -1089,6 +1145,7 @@ export const actionChangeArrowhead = register({
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.endArrowhead
|
||||
: appState.currentItemEndArrowhead,
|
||||
true,
|
||||
appState.currentItemEndArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "end", type: value })}
|
||||
|
@ -21,8 +21,10 @@ import {
|
||||
canApplyRoundnessTypeToElement,
|
||||
getDefaultRoundnessTypeForElement,
|
||||
isFrameElement,
|
||||
isArrowElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
@ -99,16 +101,19 @@ export const actionPasteStyles = register({
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
const fontSize =
|
||||
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).fontSize ||
|
||||
DEFAULT_FONT_SIZE;
|
||||
const fontFamily =
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily ||
|
||||
DEFAULT_FONT_FAMILY;
|
||||
newElement = newElementWith(newElement, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign:
|
||||
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).textAlign ||
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
elementStylesToCopyFrom.lineHeight ||
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||
getDefaultLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
@ -123,7 +128,10 @@ export const actionPasteStyles = register({
|
||||
redrawTextBoundingBox(newElement, container);
|
||||
}
|
||||
|
||||
if (newElement.type === "arrow") {
|
||||
if (
|
||||
newElement.type === "arrow" &&
|
||||
isArrowElement(elementStylesToCopyFrom)
|
||||
) {
|
||||
newElement = newElementWith(newElement, {
|
||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||
|
@ -15,6 +15,7 @@ export const actionToggleGridMode = register({
|
||||
appState: {
|
||||
...appState,
|
||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||
objectsSnapModeEnabled: false,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
|
28
src/actions/actionToggleObjectsSnapMode.tsx
Normal file
28
src/actions/actionToggleObjectsSnapMode.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleObjectsSnapMode = register({
|
||||
name: "objectsSnapMode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
objectsSnapModeEnabled: !this.checked!(appState),
|
||||
gridSize: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.objectsSnapModeEnabled,
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "buttons.objectsSnapMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
||||
});
|
@ -44,7 +44,6 @@ export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
||||
export {
|
||||
actionToggleCanvasMenu,
|
||||
actionToggleEditMenu,
|
||||
actionFullScreen,
|
||||
actionShortcuts,
|
||||
} from "./actionMenu";
|
||||
|
||||
@ -80,6 +79,7 @@ export {
|
||||
|
||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
|
@ -29,7 +29,7 @@ const trackAction = (
|
||||
trackEvent(
|
||||
action.trackEvent.category,
|
||||
action.trackEvent.action || action.name,
|
||||
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
||||
`${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -119,10 +119,10 @@ export class ActionManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
executeAction(
|
||||
action: Action,
|
||||
executeAction<T extends Action>(
|
||||
action: T,
|
||||
source: ActionSource = "api",
|
||||
value: any = null,
|
||||
value: Parameters<T["perform"]>[2] = null,
|
||||
) {
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
|
@ -28,6 +28,7 @@ export type ShortcutName =
|
||||
| "ungroup"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "objectsSnapMode"
|
||||
| "stats"
|
||||
| "addToLibrary"
|
||||
| "viewMode"
|
||||
@ -74,6 +75,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
zenMode: [getShortcutKey("Alt+Z")],
|
||||
objectsSnapMode: [getShortcutKey("Alt+S")],
|
||||
stats: [getShortcutKey("Alt+/")],
|
||||
addToLibrary: [],
|
||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||
|
@ -51,6 +51,7 @@ export type ActionName =
|
||||
| "pasteStyles"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "objectsSnapMode"
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
|
@ -99,6 +99,12 @@ export const getDefaultAppState = (): Omit<
|
||||
pendingImageElementId: null,
|
||||
showHyperlinkPopup: false,
|
||||
selectedLinearElement: null,
|
||||
snapLines: [],
|
||||
originSnapOffset: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
objectsSnapModeEnabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
@ -206,6 +212,9 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
pendingImageElementId: { browser: false, export: false, server: false },
|
||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||
selectedLinearElement: { browser: true, export: false, server: false },
|
||||
snapLines: { browser: false, export: false, server: false },
|
||||
originSnapOffset: { browser: false, export: false, server: false },
|
||||
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
@ -1,27 +1,196 @@
|
||||
import { parseClipboard } from "./clipboard";
|
||||
import {
|
||||
createPasteEvent,
|
||||
parseClipboard,
|
||||
serializeAsClipboardJSON,
|
||||
} from "./clipboard";
|
||||
import { API } from "./tests/helpers/api";
|
||||
|
||||
describe("Test parseClipboard", () => {
|
||||
it("should parse valid json correctly", async () => {
|
||||
let text = "123";
|
||||
|
||||
let clipboardData = await parseClipboard({
|
||||
//@ts-ignore
|
||||
clipboardData: {
|
||||
getData: () => text,
|
||||
},
|
||||
});
|
||||
describe("parseClipboard()", () => {
|
||||
it("should parse JSON as plaintext if not excalidraw-api/clipboard data", async () => {
|
||||
let text;
|
||||
let clipboardData;
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
text = "123";
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({ types: { "text/plain": text } }),
|
||||
);
|
||||
expect(clipboardData.text).toBe(text);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
text = "[123]";
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({ types: { "text/plain": text } }),
|
||||
);
|
||||
expect(clipboardData.text).toBe(text);
|
||||
|
||||
clipboardData = await parseClipboard({
|
||||
//@ts-ignore
|
||||
clipboardData: {
|
||||
getData: () => 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="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">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="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
354
src/clipboard.ts
354
src/clipboard.ts
@ -3,14 +3,18 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { BinaryFiles } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import {
|
||||
ALLOWED_PASTE_MIME_TYPES,
|
||||
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 { isPromiseLike, isTestEnv } from "./utils";
|
||||
import { isMemberOf, isPromiseLike } from "./utils";
|
||||
import { t } from "./i18n";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
@ -18,17 +22,23 @@ type ElementsClipboard = {
|
||||
files: BinaryFiles | undefined;
|
||||
};
|
||||
|
||||
export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
|
||||
|
||||
export interface ClipboardData {
|
||||
spreadsheet?: Spreadsheet;
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
files?: BinaryFiles;
|
||||
text?: string;
|
||||
mixedContent?: PastedMixedContent;
|
||||
errorMessage?: string;
|
||||
programmaticAPI?: boolean;
|
||||
}
|
||||
|
||||
let CLIPBOARD = "";
|
||||
let PREFER_APP_CLIPBOARD = false;
|
||||
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
||||
|
||||
type ParsedClipboardEvent =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "mixedContent"; value: PastedMixedContent };
|
||||
|
||||
export const probablySupportsClipboardReadText =
|
||||
"clipboard" in navigator && "readText" in navigator.clipboard;
|
||||
@ -58,10 +68,61 @@ const clipboardContainsElements = (
|
||||
return false;
|
||||
};
|
||||
|
||||
export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
files: BinaryFiles | null,
|
||||
) => {
|
||||
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;
|
||||
}) => {
|
||||
const framesToCopy = new Set(
|
||||
elements.filter((element) => element.type === "frame"),
|
||||
);
|
||||
@ -83,7 +144,7 @@ export const copyToClipboard = async (
|
||||
);
|
||||
}
|
||||
|
||||
// select binded text elements when copying
|
||||
// select bound text elements when copying
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements: elements.map((element) => {
|
||||
@ -102,34 +163,20 @@ export const copyToClipboard = async (
|
||||
}),
|
||||
files: files ? _files : undefined,
|
||||
};
|
||||
const json = 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);
|
||||
}
|
||||
return JSON.stringify(contents);
|
||||
};
|
||||
|
||||
const getAppClipboard = (): Partial<ElementsClipboard> => {
|
||||
if (!CLIPBOARD) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(CLIPBOARD);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
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 parsePotentialSpreadsheet = (
|
||||
@ -142,22 +189,137 @@ const parsePotentialSpreadsheet = (
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves content from system clipboard (either from ClipboardEvent or
|
||||
* via async clipboard API if supported)
|
||||
*/
|
||||
export const getSystemClipboard = async (
|
||||
event: ClipboardEvent | null,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const text = event
|
||||
? event.clipboardData?.getData("text/plain")
|
||||
: probablySupportsClipboardReadText &&
|
||||
(await navigator.clipboard.readText());
|
||||
/** internal, specific to parsing paste events. Do not reuse. */
|
||||
function parseHTMLTree(el: ChildNode) {
|
||||
let result: PastedMixedContent = [];
|
||||
for (const node of el.childNodes) {
|
||||
if (node.nodeType === 3) {
|
||||
const text = node.textContent?.trim();
|
||||
if (text) {
|
||||
result.push({ type: "text", value: text });
|
||||
}
|
||||
} else if (node instanceof HTMLImageElement) {
|
||||
const url = node.getAttribute("src");
|
||||
if (url && url.startsWith("http")) {
|
||||
result.push({ type: "imageUrl", value: url });
|
||||
}
|
||||
} else {
|
||||
result = result.concat(parseHTMLTree(node));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return (text || "").trim();
|
||||
const maybeParseHTMLPaste = (
|
||||
event: ClipboardEvent,
|
||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||
const html = event.clipboardData?.getData("text/html");
|
||||
|
||||
if (!html) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
const content = parseHTMLTree(doc.body);
|
||||
|
||||
if (content.length) {
|
||||
return { type: "mixedContent", value: content };
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`error in parseHTMLFromPaste: ${error.message}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const readSystemClipboard = async () => {
|
||||
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return { "text/plain": await navigator.clipboard?.readText() };
|
||||
}
|
||||
} catch (error: any) {
|
||||
// @ts-ignore
|
||||
if (navigator.clipboard?.read) {
|
||||
console.warn(
|
||||
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let clipboardItems: ClipboardItems;
|
||||
|
||||
try {
|
||||
clipboardItems = await navigator.clipboard?.read();
|
||||
} catch (error: any) {
|
||||
if (error.name === "DataError") {
|
||||
console.warn(
|
||||
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
|
||||
);
|
||||
return types;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const item of clipboardItems) {
|
||||
for (const type of item.types) {
|
||||
if (!isMemberOf(ALLOWED_PASTE_MIME_TYPES, type)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
types[type] = await (await item.getType(type)).text();
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(types).length === 0) {
|
||||
console.warn("No clipboard data found from clipboard.read().");
|
||||
return types;
|
||||
}
|
||||
|
||||
return types;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses "paste" ClipboardEvent.
|
||||
*/
|
||||
const parseClipboardEvent = async (
|
||||
event: ClipboardEvent,
|
||||
isPlainPaste = false,
|
||||
): Promise<ParsedClipboardEvent> => {
|
||||
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;
|
||||
}
|
||||
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
|
||||
return { type: "text", value: (text || "").trim() };
|
||||
} catch {
|
||||
return "";
|
||||
return { type: "text", value: "" };
|
||||
}
|
||||
};
|
||||
|
||||
@ -165,34 +327,32 @@ export const getSystemClipboard = async (
|
||||
* Attempts to parse clipboard. Prefers system clipboard.
|
||||
*/
|
||||
export const parseClipboard = async (
|
||||
event: ClipboardEvent | null,
|
||||
event: ClipboardEvent,
|
||||
isPlainPaste = false,
|
||||
): Promise<ClipboardData> => {
|
||||
const systemClipboard = await getSystemClipboard(event);
|
||||
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
|
||||
|
||||
// if system clipboard empty, couldn't be resolved, or contains previously
|
||||
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
|
||||
// elements
|
||||
if (
|
||||
!systemClipboard ||
|
||||
(!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
|
||||
) {
|
||||
return getAppClipboard();
|
||||
if (parsedEventData.type === "mixedContent") {
|
||||
return {
|
||||
mixedContent: parsedEventData.value,
|
||||
};
|
||||
}
|
||||
|
||||
// if system clipboard contains spreadsheet, use it even though it's
|
||||
// technically possible it's staler than in-app clipboard
|
||||
const spreadsheetResult =
|
||||
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
|
||||
|
||||
if (spreadsheetResult) {
|
||||
return spreadsheetResult;
|
||||
}
|
||||
|
||||
const appClipboardData = getAppClipboard();
|
||||
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(systemClipboard);
|
||||
// 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);
|
||||
}
|
||||
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(parsedEventData.value);
|
||||
const programmaticAPI =
|
||||
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
@ -205,18 +365,9 @@ export const parseClipboard = async (
|
||||
programmaticAPI,
|
||||
};
|
||||
}
|
||||
} 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 };
|
||||
} catch {}
|
||||
|
||||
return { text: parsedEventData.value };
|
||||
};
|
||||
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
@ -249,28 +400,49 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||
let copied = false;
|
||||
export const copyTextToSystemClipboard = async (
|
||||
text: string | null,
|
||||
clipboardEvent?: ClipboardEvent | null,
|
||||
) => {
|
||||
// (1) first try using Async Clipboard API
|
||||
if (probablySupportsClipboardWriteText) {
|
||||
try {
|
||||
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
||||
// not focused
|
||||
await navigator.clipboard.writeText(text || "");
|
||||
copied = true;
|
||||
return;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
// (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"));
|
||||
}
|
||||
};
|
||||
|
||||
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
||||
const copyTextViaExecCommand = (text: string) => {
|
||||
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 isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
|
@ -2,13 +2,13 @@
|
||||
.undo-redo-buttons {
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
}
|
||||
|
||||
.zoom-button,
|
||||
.undo-redo-buttons button {
|
||||
border: 1px solid var(--default-border-color) !important;
|
||||
border-radius: 0 !important;
|
||||
background-color: transparent !important;
|
||||
background-color: var(--color-surface-low) !important;
|
||||
font-size: 0.875rem !important;
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import {
|
||||
@ -11,21 +11,15 @@ import {
|
||||
hasBackground,
|
||||
hasStrokeStyle,
|
||||
hasStrokeWidth,
|
||||
hasText,
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { UIAppState, Zoom } from "../types";
|
||||
import {
|
||||
capitalizeString,
|
||||
isTransparent,
|
||||
updateActiveTool,
|
||||
setCursorForShape,
|
||||
} from "../utils";
|
||||
import { AppClassProperties, AppProps, 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 } from "../element/typeChecks";
|
||||
import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@ -36,7 +30,13 @@ import {
|
||||
|
||||
import "./Actions.scss";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
|
||||
import {
|
||||
EmbedIcon,
|
||||
extraToolsIcon,
|
||||
frameToolIcon,
|
||||
mermaidLogoIcon,
|
||||
laserPointerToolIcon,
|
||||
} from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
@ -66,7 +66,8 @@ export const SelectedShapeActions = ({
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const showFillIcons =
|
||||
hasBackground(appState.activeTool.type) ||
|
||||
(hasBackground(appState.activeTool.type) &&
|
||||
!isTransparent(appState.currentItemBackgroundColor)) ||
|
||||
targetElements.some(
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
@ -123,14 +124,15 @@ export const SelectedShapeActions = ({
|
||||
<>{renderAction("changeRoundness")}</>
|
||||
)}
|
||||
|
||||
{(hasText(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasText(element.type))) && (
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
{renderAction("changeFontSize")}
|
||||
|
||||
{renderAction("changeFontFamily")}
|
||||
|
||||
{suppportsHorizontalAlign(targetElements) &&
|
||||
{(appState.activeTool.type === "text" ||
|
||||
suppportsHorizontalAlign(targetElements)) &&
|
||||
renderAction("changeTextAlign")}
|
||||
</>
|
||||
)}
|
||||
@ -200,8 +202,8 @@ export const SelectedShapeActions = ({
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
{!device.isMobile && renderAction("duplicateSelection")}
|
||||
{!device.isMobile && renderAction("deleteSelectedElements")}
|
||||
{!device.editor.isMobile && renderAction("duplicateSelection")}
|
||||
{!device.editor.isMobile && renderAction("deleteSelectedElements")}
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
@ -213,23 +215,33 @@ export const SelectedShapeActions = ({
|
||||
};
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
interactiveCanvas,
|
||||
activeTool,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
appState,
|
||||
app,
|
||||
UIOptions,
|
||||
}: {
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
activeTool: UIAppState["activeTool"];
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: UIAppState;
|
||||
app: AppClassProperties;
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
}) => {
|
||||
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
||||
const device = useDevice();
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||
|
||||
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]);
|
||||
@ -251,155 +263,83 @@ export const ShapesSwitcher = ({
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: value,
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
activeEmbeddable: null,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(interactiveCanvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="App-toolbar__divider" />
|
||||
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
|
||||
{device.isMobile ? (
|
||||
<>
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
|
||||
<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" })}
|
||||
icon={frameToolIcon}
|
||||
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") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "frame", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
selected={frameToolSelected}
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "embeddable" })}
|
||||
icon={EmbedIcon}
|
||||
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") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "embeddable", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
className="App-toolbar__extra-tools-trigger"
|
||||
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
|
||||
title={t("toolBar.extraTools")}
|
||||
data-testid="toolbar-embeddable"
|
||||
selected={embeddableToolSelected}
|
||||
>
|
||||
{extraToolsIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||
onSelect={() => setIsExtraToolsMenuOpen(false)}
|
||||
className="App-toolbar__extra-tools-dropdown"
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "laser" })}
|
||||
icon={laserPointerToolIcon}
|
||||
data-testid="toolbar-laser"
|
||||
selected={laserToolSelected}
|
||||
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
}}
|
||||
icon={frameToolIcon}
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
}}
|
||||
icon={EmbedIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
>
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setOpenDialog("mermaid")}
|
||||
icon={mermaidLogoIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
>
|
||||
{t("toolBar.mermaidToExcalidraw")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -98,7 +98,7 @@ export const ColorInput = ({
|
||||
}}
|
||||
/>
|
||||
{/* TODO reenable on mobile with a better UX */}
|
||||
{!device.isMobile && (
|
||||
{!device.editor.isMobile && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
|
@ -80,7 +80,7 @@ const ColorPickerPopupContent = ({
|
||||
);
|
||||
|
||||
const { container } = useExcalidrawContainer();
|
||||
const { isMobile, isLandscape } = useDevice();
|
||||
const device = useDevice();
|
||||
|
||||
const colorInputJSX = (
|
||||
<div>
|
||||
@ -136,8 +136,16 @@ const ColorPickerPopupContent = ({
|
||||
updateData({ openPopup: null });
|
||||
setActiveColorPickerSection(null);
|
||||
}}
|
||||
side={isMobile && !isLandscape ? "bottom" : "right"}
|
||||
align={isMobile && !isLandscape ? "center" : "start"}
|
||||
side={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "bottom"
|
||||
: "right"
|
||||
}
|
||||
align={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "center"
|
||||
: "start"
|
||||
}
|
||||
alignOffset={-16}
|
||||
sideOffset={20}
|
||||
style={{
|
||||
|
@ -55,6 +55,7 @@ export const TopPicks = ({
|
||||
type="button"
|
||||
title={color}
|
||||
onClick={() => onChange(color)}
|
||||
data-testid={`color-top-pick-${color}`}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
</button>
|
||||
|
@ -9,11 +9,7 @@ import {
|
||||
} from "../actions/shortcuts";
|
||||
import { Action } from "../actions/types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import {
|
||||
useExcalidrawAppState,
|
||||
useExcalidrawElements,
|
||||
useExcalidrawSetAppState,
|
||||
} from "./App";
|
||||
import { useExcalidrawAppState, useExcalidrawElements } from "./App";
|
||||
import React from "react";
|
||||
|
||||
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
|
||||
@ -25,14 +21,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 }: ContextMenuProps) => {
|
||||
({ actionManager, items, top, left, onClose }: ContextMenuProps) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
|
||||
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
|
||||
@ -54,7 +50,9 @@ export const ContextMenu = React.memo(
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onCloseRequest={() => setAppState({ contextMenu: null })}
|
||||
onCloseRequest={() => {
|
||||
onClose();
|
||||
}}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
@ -102,7 +100,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.
|
||||
setAppState({ contextMenu: null }, () => {
|
||||
onClose(() => {
|
||||
actionManager.executeAction(item, "contextMenu");
|
||||
});
|
||||
}}
|
||||
|
@ -33,14 +33,16 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
const [lastActiveElement] = useState(document.activeElement);
|
||||
const { id } = useExcalidrawContainer();
|
||||
const device = useDevice();
|
||||
const isFullscreen = useDevice().viewport.isMobile;
|
||||
|
||||
useEffect(() => {
|
||||
if (!islandNode) {
|
||||
@ -101,7 +101,9 @@ export const Dialog = (props: DialogProps) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={clsx("Dialog", props.className)}
|
||||
className={clsx("Dialog", props.className, {
|
||||
"Dialog--fullscreen": isFullscreen,
|
||||
})}
|
||||
labelledBy="dialog-title"
|
||||
maxWidth={getDialogSize(props.size)}
|
||||
onCloseRequest={onClose}
|
||||
@ -119,7 +121,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{device.isMobile ? back : CloseIcon}
|
||||
{isFullscreen ? back : CloseIcon}
|
||||
</button>
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
</Island>
|
||||
|
@ -12,32 +12,32 @@
|
||||
|
||||
&--color-primary {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--input-bg-color);
|
||||
--text-color: var(--color-surface-lowest);
|
||||
--back-color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-primary-darker);
|
||||
--back-color: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-primary-darkest);
|
||||
--back-color: var(--color-brand-active);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-primary);
|
||||
--border-color: var(--color-primary);
|
||||
--back-color: var(--input-bg-color);
|
||||
--border-color: var(--color-border-outline);
|
||||
--back-color: transparent;
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-primary-darker);
|
||||
--border-color: var(--color-primary-darker);
|
||||
--text-color: var(--color-brand-hover);
|
||||
--border-color: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-primary-darkest);
|
||||
--border-color: var(--color-primary-darkest);
|
||||
--text-color: var(--color-brand-active);
|
||||
--border-color: var(--color-brand-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,20 +19,35 @@
|
||||
}
|
||||
|
||||
&__btn {
|
||||
--background: var(--color-surface-mid);
|
||||
|
||||
display: flex;
|
||||
column-gap: 0.5rem;
|
||||
align-items: center;
|
||||
border: 1px solid var(--default-border-color);
|
||||
background-color: var(--background);
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid var(--background);
|
||||
border-radius: var(--border-radius-lg);
|
||||
color: var(--text-primary-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
--background: var(--color-surface-high);
|
||||
&:hover {
|
||||
--background: #363541;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--background: var(--color-surface-high);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__link-icon {
|
||||
|
@ -165,6 +165,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
|
||||
<Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
|
||||
<Shortcut
|
||||
label={t("labels.eyeDropper")}
|
||||
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
||||
@ -253,11 +254,14 @@ 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")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.objectsSnapMode")}
|
||||
shortcuts={[getShortcutKey("Alt+S")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showGrid")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||
|
@ -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.canDeviceFitSidebar) {
|
||||
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -22,7 +22,7 @@ import { canvasToBlob } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../packages/utils";
|
||||
|
||||
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
||||
@ -34,6 +34,8 @@ 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")!;
|
||||
@ -51,44 +53,47 @@ export const ErrorCanvasPreview = () => {
|
||||
};
|
||||
|
||||
type ImageExportModalProps = {
|
||||
appState: UIAppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appStateSnapshot: Readonly<UIAppState>;
|
||||
elementsSnapshot: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
};
|
||||
|
||||
const ImageExportModal = ({
|
||||
appState,
|
||||
elements,
|
||||
appStateSnapshot,
|
||||
elementsSnapshot,
|
||||
files,
|
||||
actionManager,
|
||||
onExportImage,
|
||||
}: ImageExportModalProps) => {
|
||||
const hasSelection = isSomeElementSelected(
|
||||
elementsSnapshot,
|
||||
appStateSnapshot,
|
||||
);
|
||||
|
||||
const appProps = useAppProps();
|
||||
const [projectName, setProjectName] = useState(appState.name);
|
||||
|
||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||
|
||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||
const [projectName, setProjectName] = useState(appStateSnapshot.name);
|
||||
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
|
||||
const [exportWithBackground, setExportWithBackground] = useState(
|
||||
appState.exportBackground,
|
||||
appStateSnapshot.exportBackground,
|
||||
);
|
||||
const [exportDarkMode, setExportDarkMode] = useState(
|
||||
appState.exportWithDarkMode,
|
||||
appStateSnapshot.exportWithDarkMode,
|
||||
);
|
||||
const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
|
||||
const [exportScale, setExportScale] = useState(appState.exportScale);
|
||||
const [embedScene, setEmbedScene] = useState(
|
||||
appStateSnapshot.exportEmbedScene,
|
||||
);
|
||||
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
|
||||
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
})
|
||||
: elements;
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elementsSnapshot,
|
||||
appStateSnapshot,
|
||||
exportSelectionOnly,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const previewNode = previewRef.current;
|
||||
@ -102,10 +107,18 @@ const ImageExportModal = ({
|
||||
}
|
||||
exportToCanvas({
|
||||
elements: exportedElements,
|
||||
appState,
|
||||
appState: {
|
||||
...appStateSnapshot,
|
||||
name: projectName,
|
||||
exportBackground: exportWithBackground,
|
||||
exportWithDarkMode: exportDarkMode,
|
||||
exportScale,
|
||||
exportEmbedScene: embedScene,
|
||||
},
|
||||
files,
|
||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
|
||||
exportingFrame,
|
||||
})
|
||||
.then((canvas) => {
|
||||
setRenderError(null);
|
||||
@ -119,7 +132,17 @@ const ImageExportModal = ({
|
||||
console.error(error);
|
||||
setRenderError(error);
|
||||
});
|
||||
}, [appState, files, exportedElements]);
|
||||
}, [
|
||||
appStateSnapshot,
|
||||
files,
|
||||
exportedElements,
|
||||
exportingFrame,
|
||||
projectName,
|
||||
exportWithBackground,
|
||||
exportDarkMode,
|
||||
exportScale,
|
||||
embedScene,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="ImageExportModal">
|
||||
@ -136,7 +159,8 @@ const ImageExportModal = ({
|
||||
value={projectName}
|
||||
style={{ width: "30ch" }}
|
||||
disabled={
|
||||
typeof appProps.name !== "undefined" || appState.viewModeEnabled
|
||||
typeof appProps.name !== "undefined" ||
|
||||
appStateSnapshot.viewModeEnabled
|
||||
}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
@ -152,16 +176,16 @@ const ImageExportModal = ({
|
||||
</div>
|
||||
<div className="ImageExportModal__settings">
|
||||
<h3>{t("imageExportDialog.header")}</h3>
|
||||
{someElementIsSelected && (
|
||||
{hasSelection && (
|
||||
<ExportSetting
|
||||
label={t("imageExportDialog.label.onlySelected")}
|
||||
name="exportOnlySelected"
|
||||
>
|
||||
<Switch
|
||||
name="exportOnlySelected"
|
||||
checked={exportSelected}
|
||||
checked={exportSelectionOnly}
|
||||
onChange={(checked) => {
|
||||
setExportSelected(checked);
|
||||
setExportSelectionOnly(checked);
|
||||
}}
|
||||
/>
|
||||
</ExportSetting>
|
||||
@ -243,7 +267,9 @@ const ImageExportModal = ({
|
||||
className="ImageExportModal__settings__buttons__button"
|
||||
label={t("imageExportDialog.title.exportToPng")}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
|
||||
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, {
|
||||
exportingFrame,
|
||||
})
|
||||
}
|
||||
startIcon={downloadIcon}
|
||||
>
|
||||
@ -253,7 +279,9 @@ const ImageExportModal = ({
|
||||
className="ImageExportModal__settings__buttons__button"
|
||||
label={t("imageExportDialog.title.exportToSvg")}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
|
||||
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, {
|
||||
exportingFrame,
|
||||
})
|
||||
}
|
||||
startIcon={downloadIcon}
|
||||
>
|
||||
@ -264,7 +292,9 @@ const ImageExportModal = ({
|
||||
className="ImageExportModal__settings__buttons__button"
|
||||
label={t("imageExportDialog.title.copyPngToClipboard")}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
|
||||
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
|
||||
exportingFrame,
|
||||
})
|
||||
}
|
||||
startIcon={copyIcon}
|
||||
>
|
||||
@ -325,15 +355,20 @@ export const ImageExportDialog = ({
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
onCloseRequest: () => void;
|
||||
}) => {
|
||||
if (appState.openDialog !== "imageExport") {
|
||||
return null;
|
||||
}
|
||||
// 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),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
|
||||
<ImageExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
elementsSnapshot={elementsSnapshot}
|
||||
appStateSnapshot={appStateSnapshot}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportImage={onExportImage}
|
||||
|
@ -23,12 +23,15 @@ export type ExportCB = (
|
||||
const JSONExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
setAppState,
|
||||
files,
|
||||
actionManager,
|
||||
exportOpts,
|
||||
canvas,
|
||||
onCloseRequest,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
files: BinaryFiles;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionManager;
|
||||
@ -72,9 +75,14 @@ const JSONExportModal = ({
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
onExportToBackend(elements, appState, files, canvas);
|
||||
trackEvent("export", "link", `ui (${getFrame()})`);
|
||||
onClick={async () => {
|
||||
try {
|
||||
trackEvent("export", "link", `ui (${getFrame()})`);
|
||||
await onExportToBackend(elements, appState, files, canvas);
|
||||
onCloseRequest();
|
||||
} catch (error: any) {
|
||||
setAppState({ errorMessage: error.message });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
@ -114,6 +122,7 @@ export const JSONExportDialog = ({
|
||||
<JSONExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onCloseRequest={handleClose}
|
||||
|
309
src/components/LaserTool/LaserPathManager.ts
Normal file
309
src/components/LaserTool/LaserPathManager.ts
Normal file
@ -0,0 +1,309 @@
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
|
||||
import { sceneCoordsToViewportCoords } from "../../utils";
|
||||
import App from "../App";
|
||||
import { getClientColor } from "../../clients";
|
||||
|
||||
// decay time in milliseconds
|
||||
const DECAY_TIME = 1000;
|
||||
// length of line in points before it starts decaying
|
||||
const DECAY_LENGTH = 50;
|
||||
|
||||
const average = (a: number, b: number) => (a + b) / 2;
|
||||
function getSvgPathFromStroke(points: number[][], closed = true) {
|
||||
const len = points.length;
|
||||
|
||||
if (len < 4) {
|
||||
return ``;
|
||||
}
|
||||
|
||||
let a = points[0];
|
||||
let b = points[1];
|
||||
const c = points[2];
|
||||
|
||||
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(
|
||||
2,
|
||||
)},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average(
|
||||
b[1],
|
||||
c[1],
|
||||
).toFixed(2)} T`;
|
||||
|
||||
for (let i = 2, max = len - 1; i < max; i++) {
|
||||
a = points[i];
|
||||
b = points[i + 1];
|
||||
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(
|
||||
2,
|
||||
)} `;
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
result += "Z";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
LPM: LaserPathManager;
|
||||
}
|
||||
}
|
||||
|
||||
function easeOutCubic(t: number) {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function instantiateCollabolatorState(): CollabolatorState {
|
||||
return {
|
||||
currentPath: undefined,
|
||||
finishedPaths: [],
|
||||
lastPoint: [-10000, -10000],
|
||||
svg: document.createElementNS("http://www.w3.org/2000/svg", "path"),
|
||||
};
|
||||
}
|
||||
|
||||
function instantiatePath() {
|
||||
LaserPointer.constants.cornerDetectionMaxAngle = 70;
|
||||
|
||||
return new LaserPointer({
|
||||
simplify: 0,
|
||||
streamline: 0.4,
|
||||
sizeMapping: (c) => {
|
||||
const pt = DECAY_TIME;
|
||||
const pl = DECAY_LENGTH;
|
||||
const t = Math.max(0, 1 - (performance.now() - c.pressure) / pt);
|
||||
const l = (pl - Math.min(pl, c.totalLength - c.currentIndex)) / pl;
|
||||
|
||||
return Math.min(easeOutCubic(l), easeOutCubic(t));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type CollabolatorState = {
|
||||
currentPath: LaserPointer | undefined;
|
||||
finishedPaths: LaserPointer[];
|
||||
lastPoint: [number, number];
|
||||
svg: SVGPathElement;
|
||||
};
|
||||
|
||||
export class LaserPathManager {
|
||||
private ownState: CollabolatorState;
|
||||
private collaboratorsState: Map<string, CollabolatorState> = new Map();
|
||||
|
||||
private rafId: number | undefined;
|
||||
private isDrawing = false;
|
||||
private container: SVGSVGElement | undefined;
|
||||
|
||||
constructor(private app: App) {
|
||||
this.ownState = instantiateCollabolatorState();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
this.isDrawing = false;
|
||||
this.ownState = instantiateCollabolatorState();
|
||||
this.collaboratorsState = new Map();
|
||||
}
|
||||
|
||||
startPath(x: number, y: number) {
|
||||
this.ownState.currentPath = instantiatePath();
|
||||
this.ownState.currentPath.addPoint([x, y, performance.now()]);
|
||||
this.updatePath(this.ownState);
|
||||
}
|
||||
|
||||
addPointToPath(x: number, y: number) {
|
||||
if (this.ownState.currentPath) {
|
||||
this.ownState.currentPath?.addPoint([x, y, performance.now()]);
|
||||
this.updatePath(this.ownState);
|
||||
}
|
||||
}
|
||||
|
||||
endPath() {
|
||||
if (this.ownState.currentPath) {
|
||||
this.ownState.currentPath.close();
|
||||
this.ownState.finishedPaths.push(this.ownState.currentPath);
|
||||
this.updatePath(this.ownState);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePath(state: CollabolatorState) {
|
||||
this.isDrawing = true;
|
||||
|
||||
if (!this.isRunning) {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
private isRunning = false;
|
||||
|
||||
start(svg?: SVGSVGElement) {
|
||||
if (svg) {
|
||||
this.container = svg;
|
||||
this.container.appendChild(this.ownState.svg);
|
||||
}
|
||||
|
||||
this.stop();
|
||||
this.isRunning = true;
|
||||
this.loop();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
}
|
||||
this.rafId = undefined;
|
||||
}
|
||||
|
||||
loop() {
|
||||
this.rafId = requestAnimationFrame(this.loop.bind(this));
|
||||
|
||||
this.updateCollabolatorsState();
|
||||
|
||||
if (this.isDrawing) {
|
||||
this.update();
|
||||
} else {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
draw(path: LaserPointer) {
|
||||
const stroke = path
|
||||
.getStrokeOutline(path.options.size / this.app.state.zoom.value)
|
||||
.map(([x, y]) => {
|
||||
const result = sceneCoordsToViewportCoords(
|
||||
{ sceneX: x, sceneY: y },
|
||||
this.app.state,
|
||||
);
|
||||
|
||||
return [result.x, result.y];
|
||||
});
|
||||
|
||||
return getSvgPathFromStroke(stroke, true);
|
||||
}
|
||||
|
||||
updateCollabolatorsState() {
|
||||
if (!this.container || !this.app.state.collaborators.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, collabolator] of this.app.state.collaborators.entries()) {
|
||||
if (!this.collaboratorsState.has(key)) {
|
||||
const state = instantiateCollabolatorState();
|
||||
this.container.appendChild(state.svg);
|
||||
this.collaboratorsState.set(key, state);
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
|
||||
const state = this.collaboratorsState.get(key)!;
|
||||
|
||||
if (collabolator.pointer && collabolator.pointer.tool === "laser") {
|
||||
if (collabolator.button === "down" && state.currentPath === undefined) {
|
||||
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||
state.currentPath = instantiatePath();
|
||||
state.currentPath.addPoint([
|
||||
collabolator.pointer.x,
|
||||
collabolator.pointer.y,
|
||||
performance.now(),
|
||||
]);
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
|
||||
if (collabolator.button === "down" && state.currentPath !== undefined) {
|
||||
if (
|
||||
collabolator.pointer.x !== state.lastPoint[0] ||
|
||||
collabolator.pointer.y !== state.lastPoint[1]
|
||||
) {
|
||||
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||
state.currentPath.addPoint([
|
||||
collabolator.pointer.x,
|
||||
collabolator.pointer.y,
|
||||
performance.now(),
|
||||
]);
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
}
|
||||
|
||||
if (collabolator.button === "up" && state.currentPath !== undefined) {
|
||||
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||
state.currentPath.addPoint([
|
||||
collabolator.pointer.x,
|
||||
collabolator.pointer.y,
|
||||
performance.now(),
|
||||
]);
|
||||
state.currentPath.close();
|
||||
|
||||
state.finishedPaths.push(state.currentPath);
|
||||
state.currentPath = undefined;
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let somePathsExist = false;
|
||||
|
||||
for (const [key, state] of this.collaboratorsState.entries()) {
|
||||
if (!this.app.state.collaborators.has(key)) {
|
||||
state.svg.remove();
|
||||
this.collaboratorsState.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
state.finishedPaths = state.finishedPaths.filter((path) => {
|
||||
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
|
||||
|
||||
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
|
||||
});
|
||||
|
||||
let paths = state.finishedPaths.map((path) => this.draw(path)).join(" ");
|
||||
|
||||
if (state.currentPath) {
|
||||
paths += ` ${this.draw(state.currentPath)}`;
|
||||
}
|
||||
|
||||
if (paths.trim()) {
|
||||
somePathsExist = true;
|
||||
}
|
||||
|
||||
state.svg.setAttribute("d", paths);
|
||||
state.svg.setAttribute("fill", getClientColor(key));
|
||||
}
|
||||
|
||||
this.ownState.finishedPaths = this.ownState.finishedPaths.filter((path) => {
|
||||
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
|
||||
|
||||
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
|
||||
});
|
||||
|
||||
let paths = this.ownState.finishedPaths
|
||||
.map((path) => this.draw(path))
|
||||
.join(" ");
|
||||
|
||||
if (this.ownState.currentPath) {
|
||||
paths += ` ${this.draw(this.ownState.currentPath)}`;
|
||||
}
|
||||
|
||||
paths = paths.trim();
|
||||
|
||||
if (paths) {
|
||||
somePathsExist = true;
|
||||
}
|
||||
|
||||
this.ownState.svg.setAttribute("d", paths);
|
||||
this.ownState.svg.setAttribute("fill", "red");
|
||||
|
||||
if (!somePathsExist) {
|
||||
this.isDrawing = false;
|
||||
}
|
||||
}
|
||||
}
|
41
src/components/LaserTool/LaserPointerButton.tsx
Normal file
41
src/components/LaserTool/LaserPointerButton.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import "../ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "../ToolButton";
|
||||
import { laserPointerToolIcon } from "../icons";
|
||||
|
||||
type LaserPointerIconProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "small";
|
||||
|
||||
export const LaserPointerButton = (props: LaserPointerIconProps) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__LaserPointer",
|
||||
`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}
|
||||
data-testid="toolbar-LaserPointer"
|
||||
/>
|
||||
<div className="ToolIcon__icon">{laserPointerToolIcon}</div>
|
||||
</label>
|
||||
);
|
||||
};
|
27
src/components/LaserTool/LaserTool.tsx
Normal file
27
src/components/LaserTool/LaserTool.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { LaserPathManager } from "./LaserPathManager";
|
||||
import "./LaserToolOverlay.scss";
|
||||
|
||||
type LaserToolOverlayProps = {
|
||||
manager: LaserPathManager;
|
||||
};
|
||||
|
||||
export const LaserToolOverlay = ({ manager }: LaserToolOverlayProps) => {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (svgRef.current) {
|
||||
manager.start(svgRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
manager.stop();
|
||||
};
|
||||
}, [manager]);
|
||||
|
||||
return (
|
||||
<div className="LaserToolOverlay">
|
||||
<svg ref={svgRef} className="LaserToolOverlayCanvas" />
|
||||
</div>
|
||||
);
|
||||
};
|
20
src/components/LaserTool/LaserToolOverlay.scss
Normal file
20
src/components/LaserTool/LaserToolOverlay.scss
Normal file
@ -0,0 +1,20 @@
|
||||
.excalidraw {
|
||||
.LaserToolOverlay {
|
||||
pointer-events: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
z-index: 2;
|
||||
|
||||
.LaserToolOverlayCanvas {
|
||||
image-rendering: auto;
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
@ -55,28 +55,28 @@ import "./Toolbar.scss";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import Scene from "../scene/Scene";
|
||||
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
appState: UIAppState;
|
||||
files: BinaryFiles;
|
||||
canvas: HTMLCanvasElement;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
onPenModeToggle: AppClassProperties["togglePenMode"];
|
||||
showExitZenModeBtn: boolean;
|
||||
langCode: Language["code"];
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
renderWelcomeScreen: boolean;
|
||||
children?: React.ReactNode;
|
||||
app: AppClassProperties;
|
||||
isCollaborating: boolean;
|
||||
}
|
||||
|
||||
const DefaultMainMenu: React.FC<{
|
||||
@ -121,7 +121,6 @@ const LayerUI = ({
|
||||
setAppState,
|
||||
elements,
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
@ -129,11 +128,11 @@ const LayerUI = ({
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
UIOptions,
|
||||
onImageAction,
|
||||
onExportImage,
|
||||
renderWelcomeScreen,
|
||||
children,
|
||||
app,
|
||||
isCollaborating,
|
||||
}: LayerUIProps) => {
|
||||
const device = useDevice();
|
||||
const tunnels = useInitializeTunnels();
|
||||
@ -162,7 +161,10 @@ const LayerUI = ({
|
||||
};
|
||||
|
||||
const renderImageExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.saveAsImage) {
|
||||
if (
|
||||
!UIOptions.canvasActions.saveAsImage ||
|
||||
appState.openDialog !== "imageExport"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -247,7 +249,7 @@ const LayerUI = ({
|
||||
>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
isMobile={device.isMobile}
|
||||
isMobile={device.editor.isMobile}
|
||||
device={device}
|
||||
app={app}
|
||||
/>
|
||||
@ -256,7 +258,7 @@ const LayerUI = ({
|
||||
<PenModeButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.penMode}
|
||||
onChange={onPenModeToggle}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
title={t("toolBar.penMode")}
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
@ -277,17 +279,30 @@ const LayerUI = ({
|
||||
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
}}
|
||||
UIOptions={UIOptions}
|
||||
app={app}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
{isCollaborating && (
|
||||
<Island
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
alignSelf: "center",
|
||||
height: "fit-content",
|
||||
}}
|
||||
>
|
||||
<LaserPointerButton
|
||||
title={t("toolBar.laser")}
|
||||
checked={appState.activeTool.type === "laser"}
|
||||
onChange={() =>
|
||||
app.setActiveTool({ type: "laser" })
|
||||
}
|
||||
isMobile
|
||||
/>
|
||||
</Island>
|
||||
)}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
@ -303,7 +318,7 @@ const LayerUI = ({
|
||||
)}
|
||||
>
|
||||
<UserList collaborators={appState.collaborators} />
|
||||
{renderTopRightUI?.(device.isMobile, appState)}
|
||||
{renderTopRightUI?.(device.editor.isMobile, appState)}
|
||||
{!appState.viewModeEnabled &&
|
||||
// hide button when sidebar docked
|
||||
(!isSidebarDocked ||
|
||||
@ -324,7 +339,7 @@ const LayerUI = ({
|
||||
trackEvent(
|
||||
"sidebar",
|
||||
`toggleDock (${docked ? "dock" : "undock"})`,
|
||||
`(${device.isMobile ? "mobile" : "desktop"})`,
|
||||
`(${device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@ -352,7 +367,7 @@ const LayerUI = ({
|
||||
trackEvent(
|
||||
"sidebar",
|
||||
`${DEFAULT_SIDEBAR.name} (open)`,
|
||||
`button (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
`button (${device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
@ -369,7 +384,7 @@ const LayerUI = ({
|
||||
{appState.errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
{eyeDropperState && !device.isMobile && (
|
||||
{eyeDropperState && !device.editor.isMobile && (
|
||||
<EyeDropper
|
||||
colorPickerType={eyeDropperState.colorPickerType}
|
||||
onCancel={() => {
|
||||
@ -439,7 +454,7 @@ const LayerUI = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{device.isMobile && (
|
||||
{device.editor.isMobile && (
|
||||
<MobileMenu
|
||||
app={app}
|
||||
appState={appState}
|
||||
@ -451,23 +466,22 @@ const LayerUI = ({
|
||||
onLockToggle={onLockToggle}
|
||||
onHandToolToggle={onHandToolToggle}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
renderSidebars={renderSidebars}
|
||||
device={device}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
UIOptions={UIOptions}
|
||||
/>
|
||||
)}
|
||||
{!device.isMobile && (
|
||||
{!device.editor.isMobile && (
|
||||
<>
|
||||
<div
|
||||
className="layer-ui__wrapper"
|
||||
style={
|
||||
appState.openSidebar &&
|
||||
isSidebarDocked &&
|
||||
device.canDeviceFitSidebar
|
||||
device.editor.canFitSidebar
|
||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||
: {}
|
||||
}
|
||||
@ -539,18 +553,8 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
canvas: _pC,
|
||||
interactiveCanvas: _pIC,
|
||||
appState: prevAppState,
|
||||
...prev
|
||||
} = prevProps;
|
||||
const {
|
||||
canvas: _nC,
|
||||
interactiveCanvas: _nIC,
|
||||
appState: nextAppState,
|
||||
...next
|
||||
} = nextProps;
|
||||
const { canvas: _pC, appState: prevAppState, ...prev } = prevProps;
|
||||
const { canvas: _nC, appState: nextAppState, ...next } = nextProps;
|
||||
|
||||
return (
|
||||
isShallowEqual(
|
||||
|
@ -99,10 +99,10 @@
|
||||
font-size: 0.75rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-darker);
|
||||
background-color: var(--color-brand-hover);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--color-primary-darkest);
|
||||
background-color: var(--color-brand-active);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -47,7 +47,7 @@ export const LibraryUnit = memo(
|
||||
}, [svg]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useDevice().isMobile;
|
||||
const isMobile = useDevice().editor.isMobile;
|
||||
const adder = isPending && (
|
||||
<div className="library-unit__adder">{PlusIcon}</div>
|
||||
);
|
||||
|
221
src/components/MermaidToExcalidraw.scss
Normal file
221
src/components/MermaidToExcalidraw.scss
Normal file
@ -0,0 +1,221 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
$verticalBreakpoint: 860px;
|
||||
|
||||
.excalidraw {
|
||||
.dialog-mermaid {
|
||||
&-title {
|
||||
margin-bottom: 5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
&-desc {
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.Modal__content .Island {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@at-root .excalidraw:not(.excalidraw--mobile)#{&} {
|
||||
padding: 1.25rem;
|
||||
|
||||
.Modal__content {
|
||||
height: 100%;
|
||||
max-height: 750px;
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
height: auto;
|
||||
// When vertical, we want the height to span whole viewport.
|
||||
// This is also important for the children not to overflow the
|
||||
// modal/viewport (for some reason).
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.Island {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
|
||||
.Dialog__content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-mermaid-body {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
height: 100%;
|
||||
column-gap: 4rem;
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-mermaid-panels {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
justify-content: space-between;
|
||||
gap: 4rem;
|
||||
|
||||
grid-row: 1;
|
||||
grid-column: 1 / 3;
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
margin-left: 4px;
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
textarea {
|
||||
width: 20rem;
|
||||
height: 100%;
|
||||
resize: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
white-space: pre-wrap;
|
||||
padding: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
width: auto;
|
||||
height: 10rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-preview-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
// acts as min-height
|
||||
height: 200px;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
// acts as min-height
|
||||
height: 400px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-preview-canvas-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mermaid-error {
|
||||
color: red;
|
||||
font-weight: 800;
|
||||
font-size: 30px;
|
||||
word-break: break-word;
|
||||
overflow: auto;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
|
||||
p {
|
||||
font-weight: 500;
|
||||
font-family: Cascadia;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.875rem;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-mermaid-buttons {
|
||||
grid-column: 2;
|
||||
|
||||
.dialog-mermaid-insert {
|
||||
&.excalidraw-button {
|
||||
font-family: "Assistant";
|
||||
font-weight: 600;
|
||||
height: 2.5rem;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.3em;
|
||||
width: 7.5rem;
|
||||
font-size: 12px;
|
||||
color: $oc-white;
|
||||
background-color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--color-primary-darkest);
|
||||
}
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding-left: 0.5rem;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
243
src/components/MermaidToExcalidraw.tsx
Normal file
243
src/components/MermaidToExcalidraw.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
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;
|
@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppProps,
|
||||
AppState,
|
||||
Device,
|
||||
ExcalidrawProps,
|
||||
@ -35,10 +36,8 @@ type MobileMenuProps = {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
onPenModeToggle: AppClassProperties["togglePenMode"];
|
||||
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: UIAppState,
|
||||
@ -47,6 +46,7 @@ type MobileMenuProps = {
|
||||
renderSidebars: () => JSX.Element | null;
|
||||
device: Device;
|
||||
renderWelcomeScreen: boolean;
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
app: AppClassProperties;
|
||||
};
|
||||
|
||||
@ -58,13 +58,13 @@ export const MobileMenu = ({
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
interactiveCanvas,
|
||||
onImageAction,
|
||||
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
renderSidebars,
|
||||
device,
|
||||
renderWelcomeScreen,
|
||||
UIOptions,
|
||||
app,
|
||||
}: MobileMenuProps) => {
|
||||
const {
|
||||
@ -85,14 +85,9 @@ export const MobileMenu = ({
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
}}
|
||||
UIOptions={UIOptions}
|
||||
app={app}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
@ -103,7 +98,7 @@ export const MobileMenu = ({
|
||||
)}
|
||||
<PenModeButton
|
||||
checked={appState.penMode}
|
||||
onChange={onPenModeToggle}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
title={t("toolBar.penMode")}
|
||||
isMobile
|
||||
penDetected={appState.penDetected}
|
||||
|
@ -59,12 +59,6 @@
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes Modal__background__fade-in {
|
||||
@ -105,7 +99,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
.Dialog--fullscreen {
|
||||
.Modal {
|
||||
padding: 0;
|
||||
}
|
||||
@ -116,6 +110,9 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,27 +1,18 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
--RadioGroup-background: #ffffff;
|
||||
--RadioGroup-border: var(--color-gray-30);
|
||||
--RadioGroup-background: var(--island-bg-color);
|
||||
--RadioGroup-border: var(--color-surface-high);
|
||||
|
||||
--RadioGroup-choice-color-off: var(--color-primary);
|
||||
--RadioGroup-choice-color-off-hover: var(--color-primary-darkest);
|
||||
--RadioGroup-choice-background-off: white;
|
||||
--RadioGroup-choice-background-off-active: var(--color-gray-20);
|
||||
--RadioGroup-choice-color-off-hover: var(--color-brand-hover);
|
||||
--RadioGroup-choice-background-off: var(--island-bg-color);
|
||||
--RadioGroup-choice-background-off-active: var(--color-surface-high);
|
||||
|
||||
--RadioGroup-choice-color-on: white;
|
||||
--RadioGroup-choice-color-on: var(--color-surface-lowest);
|
||||
--RadioGroup-choice-background-on: var(--color-primary);
|
||||
--RadioGroup-choice-background-on-hover: var(--color-primary-darker);
|
||||
--RadioGroup-choice-background-on-active: var(--color-primary-darkest);
|
||||
|
||||
&.theme--dark {
|
||||
--RadioGroup-background: var(--color-gray-85);
|
||||
--RadioGroup-border: var(--color-gray-70);
|
||||
|
||||
--RadioGroup-choice-background-off: var(--color-gray-85);
|
||||
--RadioGroup-choice-background-off-active: var(--color-gray-70);
|
||||
--RadioGroup-choice-color-on: var(--color-gray-85);
|
||||
}
|
||||
--RadioGroup-choice-background-on-hover: var(--color-brand-hover);
|
||||
--RadioGroup-choice-background-on-active: var(--color-brand-active);
|
||||
|
||||
.RadioGroup {
|
||||
box-sizing: border-box;
|
||||
|
@ -113,11 +113,11 @@ export const SidebarInner = forwardRef(
|
||||
if ((event.target as Element).closest(".sidebar-trigger")) {
|
||||
return;
|
||||
}
|
||||
if (!docked || !device.canDeviceFitSidebar) {
|
||||
if (!docked || !device.editor.canFitSidebar) {
|
||||
closeLibrary();
|
||||
}
|
||||
},
|
||||
[closeLibrary, docked, device.canDeviceFitSidebar],
|
||||
[closeLibrary, docked, device.editor.canFitSidebar],
|
||||
),
|
||||
);
|
||||
|
||||
@ -125,7 +125,7 @@ export const SidebarInner = forwardRef(
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
(!docked || !device.canDeviceFitSidebar)
|
||||
(!docked || !device.editor.canFitSidebar)
|
||||
) {
|
||||
closeLibrary();
|
||||
}
|
||||
@ -134,7 +134,7 @@ export const SidebarInner = forwardRef(
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [closeLibrary, docked, device.canDeviceFitSidebar]);
|
||||
}, [closeLibrary, docked, device.editor.canFitSidebar]);
|
||||
|
||||
return (
|
||||
<Island
|
||||
|
@ -18,7 +18,7 @@ export const SidebarHeader = ({
|
||||
const props = useContext(SidebarPropsContext);
|
||||
|
||||
const renderDockButton = !!(
|
||||
device.canDeviceFitSidebar && props.shouldRenderDockButton
|
||||
device.editor.canFitSidebar && props.shouldRenderDockButton
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -3,8 +3,7 @@
|
||||
.excalidraw {
|
||||
.sidebar-trigger {
|
||||
@include outlineButtonStyles;
|
||||
|
||||
background-color: var(--island-bg-color);
|
||||
@include filledButtonOnCanvas;
|
||||
|
||||
width: auto;
|
||||
height: var(--lg-button-size);
|
||||
|
@ -1,15 +1,13 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
--Switch-disabled-color: #d6d6d6;
|
||||
--Switch-track-background: white;
|
||||
--Switch-thumb-background: #3d3d3d;
|
||||
|
||||
&.theme--dark {
|
||||
--Switch-disabled-color: #5c5c5c;
|
||||
--Switch-track-background: #242424;
|
||||
--Switch-thumb-background: #b8b8b8;
|
||||
}
|
||||
--Switch-disabled-color: var(--color-border-outline);
|
||||
--Switch-disabled-toggled-background: var(--color-border-outline-variant);
|
||||
--Switch-disabled-border: var(--color-border-outline-variant);
|
||||
--Switch-track-background: var(--island-bg-color);
|
||||
--Switch-thumb-background: var(--color-on-surface);
|
||||
--Switch-hover-background: var(--color-brand-hover);
|
||||
--Switch-active-background: var(--color-brand-active);
|
||||
|
||||
.Switch {
|
||||
position: relative;
|
||||
@ -28,7 +26,11 @@
|
||||
|
||||
&:hover {
|
||||
background: var(--Switch-track-background);
|
||||
border: 1px solid #999999;
|
||||
border: 1px solid var(--Switch-hover-background);
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 1px solid var(--Switch-active-background);
|
||||
}
|
||||
|
||||
&.toggled {
|
||||
@ -43,11 +45,11 @@
|
||||
|
||||
&.disabled {
|
||||
background: var(--Switch-track-background);
|
||||
border: 1px solid var(--Switch-disabled-color);
|
||||
border: 1px solid var(--Switch-disabled-border);
|
||||
|
||||
&.toggled {
|
||||
background: var(--Switch-disabled-color);
|
||||
border: 1px solid var(--Switch-disabled-color);
|
||||
background: var(--Switch-disabled-toggled-background);
|
||||
border: 1px solid var(--Switch-disabled-toggled-background);
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,7 +94,7 @@
|
||||
}
|
||||
|
||||
&.disabled.toggled:before {
|
||||
background: var(--color-gray-50);
|
||||
background: var(--Switch-disabled-color);
|
||||
}
|
||||
|
||||
& input {
|
||||
|
@ -1,25 +1,16 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
--ExcTextField--color: var(--color-gray-80);
|
||||
--ExcTextField--label-color: var(--color-gray-80);
|
||||
--ExcTextField--background: white;
|
||||
--ExcTextField--readonly--background: var(--color-gray-10);
|
||||
--ExcTextField--readonly--color: var(--color-gray-80);
|
||||
--ExcTextField--border: var(--color-gray-40);
|
||||
--ExcTextField--border-hover: var(--color-gray-50);
|
||||
--ExcTextField--placeholder: var(--color-gray-40);
|
||||
|
||||
&.theme--dark {
|
||||
--ExcTextField--color: var(--color-gray-10);
|
||||
--ExcTextField--label-color: var(--color-gray-20);
|
||||
--ExcTextField--background: var(--color-gray-85);
|
||||
--ExcTextField--readonly--background: var(--color-gray-80);
|
||||
--ExcTextField--readonly--color: var(--color-gray-40);
|
||||
--ExcTextField--border: var(--color-gray-70);
|
||||
--ExcTextField--border-hover: var(--color-gray-60);
|
||||
--ExcTextField--placeholder: var(--color-gray-80);
|
||||
}
|
||||
--ExcTextField--color: var(--color-on-surface);
|
||||
--ExcTextField--label-color: var(--color-on-surface);
|
||||
--ExcTextField--background: transparent;
|
||||
--ExcTextField--readonly--background: var(--color-surface-high);
|
||||
--ExcTextField--readonly--color: var(--color-on-surface);
|
||||
--ExcTextField--border: var(--color-border-outline);
|
||||
--ExcTextField--readonly--border: var(--color-border-outline-variant);
|
||||
--ExcTextField--border-hover: var(--color-brand-hover);
|
||||
--ExcTextField--border-active: var(--color-brand-active);
|
||||
--ExcTextField--placeholder: var(--color-border-outline-variant);
|
||||
|
||||
.ExcTextField {
|
||||
&--fullWidth {
|
||||
@ -61,7 +52,7 @@
|
||||
|
||||
&:active,
|
||||
&:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--ExcTextField--border-active);
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,7 +98,7 @@
|
||||
|
||||
&--readonly {
|
||||
background: var(--ExcTextField--readonly--background);
|
||||
border-color: transparent;
|
||||
border-color: var(--ExcTextField--readonly--border);
|
||||
|
||||
& input {
|
||||
color: var(--ExcTextField--readonly--color);
|
||||
|
@ -83,12 +83,12 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
||||
|
||||
|
@ -97,10 +97,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// &:hover {
|
||||
// background-color: var(--button-gray-2);
|
||||
// }
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
@ -110,7 +106,6 @@
|
||||
}
|
||||
|
||||
&--hide {
|
||||
// visibility: hidden;
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@ -165,10 +160,24 @@
|
||||
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 {
|
||||
width: var(--default-button-size);
|
||||
height: var(--default-button-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,23 +16,35 @@
|
||||
align-self: center;
|
||||
background-color: var(--default-border-color);
|
||||
margin: 0 0.25rem;
|
||||
|
||||
@include isMobile {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar__extra-tools-trigger {
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-hover-bg);
|
||||
box-shadow: 0 0 0 1px
|
||||
var(--button-active-border, var(--color-primary-darkest)) inset;
|
||||
}
|
||||
|
||||
&--selected,
|
||||
&--selected:hover {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar__extra-tools-dropdown {
|
||||
margin-top: 0.375rem;
|
||||
right: 0;
|
||||
min-width: 11.875rem;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
@ -193,6 +193,8 @@ const getRelevantAppStateProps = (
|
||||
showHyperlinkPopup: appState.showHyperlinkPopup,
|
||||
collaborators: appState.collaborators, // Necessary for collab. sessions
|
||||
activeEmbeddable: appState.activeEmbeddable,
|
||||
snapLines: appState.snapLines,
|
||||
zenModeEnabled: appState.zenModeEnabled,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
@ -114,11 +114,13 @@ const areEqual = (
|
||||
return false;
|
||||
}
|
||||
|
||||
return isShallowEqual(
|
||||
// asserting AppState because we're being passed the whole AppState
|
||||
// but resolve to only the StaticCanvas-relevant props
|
||||
getRelevantAppStateProps(prevProps.appState as AppState),
|
||||
getRelevantAppStateProps(nextProps.appState as AppState),
|
||||
return (
|
||||
isShallowEqual(
|
||||
// asserting AppState because we're being passed the whole AppState
|
||||
// but resolve to only the StaticCanvas-relevant props
|
||||
getRelevantAppStateProps(prevProps.appState as AppState),
|
||||
getRelevantAppStateProps(nextProps.appState as AppState),
|
||||
) && isShallowEqual(prevProps.renderConfig, nextProps.renderConfig)
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -7,8 +7,6 @@
|
||||
margin-top: 0.25rem;
|
||||
|
||||
&--mobile {
|
||||
bottom: 55px;
|
||||
top: auto;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
row-gap: 0.75rem;
|
||||
@ -16,7 +14,7 @@
|
||||
.dropdown-menu-container {
|
||||
padding: 8px 8px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--island-bg-color);
|
||||
// background-color: var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
@ -29,7 +27,7 @@
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
background-color: #fff !important;
|
||||
background-color: var(--island-bg-color);
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow-y: auto;
|
||||
--gap: 2;
|
||||
@ -40,7 +38,7 @@
|
||||
padding: 0 0.625rem;
|
||||
column-gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-100);
|
||||
color: var(--color-on-surface);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-weight: normal;
|
||||
@ -49,7 +47,7 @@
|
||||
|
||||
.dropdown-menu-item {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
border: 1px solid transparent;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
@ -59,6 +57,11 @@
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--color-primary-light);
|
||||
--icon-fill-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&__text {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
@ -75,6 +78,11 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-hover-bg);
|
||||
border-color: var(--color-brand-active);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
@ -93,22 +101,33 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
&.theme--dark {
|
||||
.dropdown-menu-item {
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
background-color: var(--color-gray-90) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-button {
|
||||
@include outlineButtonStyles;
|
||||
background-color: var(--island-bg-color);
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
--background: var(--color-surface-mid);
|
||||
|
||||
background-color: var(--background);
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
--background: var(--color-surface-high);
|
||||
&:hover {
|
||||
--background: #363541;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--background: var(--color-surface-high);
|
||||
background-color: var(--background);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
|
@ -30,7 +30,7 @@ const MenuContent = ({
|
||||
});
|
||||
|
||||
const classNames = clsx(`dropdown-menu ${className}`, {
|
||||
"dropdown-menu--mobile": device.isMobile,
|
||||
"dropdown-menu--mobile": device.editor.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.isMobile ? (
|
||||
{device.editor.isMobile ? (
|
||||
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
|
||||
) : (
|
||||
<Island
|
||||
|
@ -11,12 +11,14 @@ const DropdownMenuItem = ({
|
||||
children,
|
||||
shortcut,
|
||||
className,
|
||||
selected,
|
||||
...rest
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
onSelect: (event: Event) => void;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
@ -26,7 +28,7 @@ const DropdownMenuItem = ({
|
||||
{...rest}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
className={getDropdownMenuItemClassName(className)}
|
||||
className={getDropdownMenuItemClassName(className, selected)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent icon={icon} shortcut={shortcut}>
|
||||
|
@ -14,7 +14,7 @@ const MenuItemContent = ({
|
||||
<>
|
||||
<div className="dropdown-menu-item__icon">{icon}</div>
|
||||
<div className="dropdown-menu-item__text">{children}</div>
|
||||
{shortcut && !device.isMobile && (
|
||||
{shortcut && !device.editor.isMobile && (
|
||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
</>
|
||||
|
@ -3,15 +3,19 @@ import React from "react";
|
||||
const DropdownMenuItemCustom = ({
|
||||
children,
|
||||
className = "",
|
||||
selected,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
|
||||
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className} ${
|
||||
selected ? `dropdown-menu-item--selected` : ``
|
||||
}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
@ -12,6 +12,7 @@ const DropdownMenuItemLink = ({
|
||||
children,
|
||||
onSelect,
|
||||
className = "",
|
||||
selected,
|
||||
...rest
|
||||
}: {
|
||||
href: string;
|
||||
@ -19,6 +20,7 @@ const DropdownMenuItemLink = ({
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
onSelect?: (event: Event) => void;
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
@ -29,7 +31,7 @@ const DropdownMenuItemLink = ({
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={getDropdownMenuItemClassName(className)}
|
||||
className={getDropdownMenuItemClassName(className, selected)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
onClick={handleClick}
|
||||
>
|
||||
|
@ -18,7 +18,7 @@ const MenuTrigger = ({
|
||||
`dropdown-menu-button ${className}`,
|
||||
"zen-mode-transition",
|
||||
{
|
||||
"dropdown-menu-button--mobile": device.isMobile,
|
||||
"dropdown-menu-button--mobile": device.editor.isMobile,
|
||||
},
|
||||
).trim();
|
||||
return (
|
||||
|
@ -6,8 +6,13 @@ export const DropdownMenuContentPropsContext = React.createContext<{
|
||||
onSelect?: (event: Event) => void;
|
||||
}>({});
|
||||
|
||||
export const getDropdownMenuItemClassName = (className = "") => {
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
|
||||
export const getDropdownMenuItemClassName = (
|
||||
className = "",
|
||||
selected = false,
|
||||
) => {
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className} ${
|
||||
selected ? "dropdown-menu-item--selected" : ""
|
||||
}`.trim();
|
||||
};
|
||||
|
||||
export const useHandleDropdownMenuItemClick = (
|
||||
|
@ -13,7 +13,7 @@ import clsx from "clsx";
|
||||
import { Theme } from "../element/types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
|
||||
export const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
|
||||
|
||||
const handlerColor = (theme: Theme) =>
|
||||
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
|
||||
@ -1653,3 +1653,38 @@ export const frameToolIcon = createIcon(
|
||||
</g>,
|
||||
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"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
transform="rotate(90 10 10)"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="m9.644 13.69 7.774-7.773a2.357 2.357 0 0 0-3.334-3.334l-7.773 7.774L8 12l1.643 1.69Z"
|
||||
/>
|
||||
<path d="m13.25 3.417 3.333 3.333M10 10l2-2M5 15l3-3M2.156 17.894l1-1M5.453 19.029l-.144-1.407M2.377 11.887l.866 1.118M8.354 17.273l-1.194-.758M.953 14.652l1.408.13" />
|
||||
</g>,
|
||||
|
||||
20,
|
||||
);
|
||||
|
@ -14,6 +14,8 @@
|
||||
|
||||
--button-active-bg: var(--color-primary-darker);
|
||||
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
// double .active to force specificity
|
||||
|
@ -29,7 +29,7 @@ const MainMenu = Object.assign(
|
||||
const device = useDevice();
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const onClickOutside = device.isMobile
|
||||
const onClickOutside = device.editor.isMobile
|
||||
? undefined
|
||||
: () => setAppState({ openMenu: null });
|
||||
|
||||
@ -43,6 +43,7 @@ const MainMenu = Object.assign(
|
||||
});
|
||||
}}
|
||||
data-testid="main-menu-trigger"
|
||||
className="main-menu-trigger"
|
||||
>
|
||||
{HamburgerMenuIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
@ -53,7 +54,7 @@ const MainMenu = Object.assign(
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
{device.isMobile && appState.collaborators.size > 0 && (
|
||||
{device.editor.isMobile && appState.collaborators.size > 0 && (
|
||||
<fieldset className="UserList-Wrapper">
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList
|
||||
|
@ -21,7 +21,7 @@ const WelcomeScreenMenuItemContent = ({
|
||||
<>
|
||||
<div className="welcome-screen-menu-item__icon">{icon}</div>
|
||||
<div className="welcome-screen-menu-item__text">{children}</div>
|
||||
{shortcut && !device.isMobile && (
|
||||
{shortcut && !device.editor.isMobile && (
|
||||
<div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
</>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user