mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
70 Commits
dependabot
...
frame-grou
Author | SHA1 | Date | |
---|---|---|---|
3c34b3f48a | |||
683b80ad2b | |||
d636abff79 | |||
47d8fa542c | |||
34cf71b0f4 | |||
ceb255e8ee | |||
ae5b9a4ffd | |||
3d4ff59f40 | |||
7b00089314 | |||
af6b81df40 | |||
02cc8440c4 | |||
6363492cee | |||
900b317bf3 | |||
68179356e6 | |||
3ed15e95da | |||
798e1fd858 | |||
f66c93633c | |||
a30e46b756 | |||
cee00767df | |||
71ba0a3f26 | |||
9bcd0b69dc | |||
864c0b3ea8 | |||
a9a6f8eafb | |||
afed893419 | |||
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 |
@ -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>
|
||||
|
@ -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
|
||||
|
@ -299,7 +299,7 @@ Open the `main menu` in the below example to view the footer.
|
||||
```jsx live noInline
|
||||
const MobileFooter = ({}) => {
|
||||
const device = useDevice();
|
||||
if (device.isMobile) {
|
||||
if (device.editor.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<button
|
||||
@ -335,7 +335,6 @@ The `device` has the following `attributes`
|
||||
|
||||
| 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` |
|
||||
|
@ -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**
|
||||
|
||||
|
@ -34,19 +34,44 @@ function App() {
|
||||
|
||||
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
|
||||
|
||||
The following workflow 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. 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)
|
||||
|
||||
2. Using **Next.js Dynamic** import.
|
||||
|
||||
Since Excalidraw doesn't server side rendering so you can also use `dynamic import` to render by setting `ssr` to `false`. However one drawback is the `Refs` don't work with dynamic import in Next.js. We are working on overcoming this and have a better API.
|
||||
|
||||
```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).
|
||||
|
||||
The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm)
|
||||
|
||||
## Browser
|
||||
|
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.
|
@ -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",
|
||||
|
@ -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"
|
||||
@ -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"
|
||||
@ -5189,10 +5294,10 @@ multicast-dns@^7.2.5:
|
||||
dns-packet "^5.2.2"
|
||||
thunky "^1.0.2"
|
||||
|
||||
nanoid@^3.3.6:
|
||||
version "3.3.6"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
|
||||
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
|
||||
nanoid@^3.3.4:
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
|
||||
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
|
||||
|
||||
negotiator@0.6.3:
|
||||
version "0.6.3"
|
||||
@ -5853,11 +5958,11 @@ postcss-zindex@^5.1.0:
|
||||
integrity sha512-fgFMf0OtVSBR1va1JNHYgMxYk73yhn/qb4uQDq1DLGYolz8gHCyr/sesEuGUaYs58E3ZJRcpoGuPVoB7Meiq9A==
|
||||
|
||||
postcss@^8.3.11, postcss@^8.4.13, postcss@^8.4.14, postcss@^8.4.7:
|
||||
version "8.4.31"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
|
||||
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
|
||||
version "8.4.14"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf"
|
||||
integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==
|
||||
dependencies:
|
||||
nanoid "^3.3.6"
|
||||
nanoid "^3.3.4"
|
||||
picocolors "^1.0.0"
|
||||
source-map-js "^1.0.2"
|
||||
|
||||
|
@ -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).
|
||||
*/
|
||||
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
@ -21,7 +21,8 @@
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/laser-pointer": "1.2.0",
|
||||
"@excalidraw/random-username": "1.0.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",
|
||||
@ -49,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"
|
||||
@ -125,7 +126,7 @@
|
||||
"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",
|
||||
"build:preview": "yarn build && vite preview --port 5000",
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -44,7 +44,6 @@ export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
||||
export {
|
||||
actionToggleCanvasMenu,
|
||||
actionToggleEditMenu,
|
||||
actionFullScreen,
|
||||
actionShortcuts,
|
||||
} from "./actionMenu";
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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,7 +11,6 @@ import {
|
||||
hasBackground,
|
||||
hasStrokeStyle,
|
||||
hasStrokeWidth,
|
||||
hasText,
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { AppClassProperties, UIAppState, Zoom } from "../types";
|
||||
@ -20,7 +19,7 @@ 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";
|
||||
@ -35,6 +34,7 @@ import {
|
||||
EmbedIcon,
|
||||
extraToolsIcon,
|
||||
frameToolIcon,
|
||||
mermaidLogoIcon,
|
||||
laserPointerToolIcon,
|
||||
} from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
@ -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,20 +215,15 @@ export const SelectedShapeActions = ({
|
||||
};
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
interactiveCanvas,
|
||||
activeTool,
|
||||
onImageAction,
|
||||
appState,
|
||||
app,
|
||||
}: {
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
activeTool: UIAppState["activeTool"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: UIAppState;
|
||||
app: AppClassProperties;
|
||||
}) => {
|
||||
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
||||
const device = useDevice();
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
@ -263,120 +260,76 @@ export const ShapesSwitcher = ({
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
app.setActiveTool({ type: value });
|
||||
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") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "frame", "ui");
|
||||
app.setActiveTool({ type: "frame" });
|
||||
}}
|
||||
selected={activeTool.type === "frame"}
|
||||
/>
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
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") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "embeddable", "ui");
|
||||
app.setActiveTool({ type: "embeddable" });
|
||||
}}
|
||||
selected={activeTool.type === "embeddable"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
className={clsx("App-toolbar__extra-tools-trigger", {
|
||||
"App-toolbar__extra-tools-trigger--selected":
|
||||
frameToolSelected ||
|
||||
embeddableToolSelected ||
|
||||
// in collab we're already highlighting the laser button
|
||||
// outside toolbar, so let's not highlight extra-tools button
|
||||
// on top of it
|
||||
(laserToolSelected && !app.props.isCollaborating),
|
||||
})}
|
||||
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
|
||||
title={t("toolBar.extraTools")}
|
||||
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={() => {
|
||||
app.setActiveTool({ type: "frame" });
|
||||
}}
|
||||
icon={frameToolIcon}
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
selected={frameToolSelected}
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
app.setActiveTool({ type: "embeddable" });
|
||||
}}
|
||||
icon={EmbedIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
selected={embeddableToolSelected}
|
||||
>
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
app.setActiveTool({ type: "laser" });
|
||||
}}
|
||||
icon={laserPointerToolIcon}
|
||||
data-testid="toolbar-laser"
|
||||
selected={laserToolSelected}
|
||||
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -47,7 +47,7 @@ import {
|
||||
isEraserActive,
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { parseClipboard } from "../clipboard";
|
||||
import { PastedMixedContent, parseClipboard } from "../clipboard";
|
||||
import {
|
||||
APP_NAME,
|
||||
CURSOR_TYPE,
|
||||
@ -74,7 +74,6 @@ import {
|
||||
MQ_MAX_WIDTH_LANDSCAPE,
|
||||
MQ_MAX_WIDTH_PORTRAIT,
|
||||
MQ_RIGHT_SIDEBAR_MIN_WIDTH,
|
||||
MQ_SM_MAX_WIDTH,
|
||||
POINTER_BUTTON,
|
||||
ROUNDNESS,
|
||||
SCROLL_TIMEOUT,
|
||||
@ -88,7 +87,7 @@ import {
|
||||
ZOOM_STEP,
|
||||
POINTER_EVENTS,
|
||||
} from "../constants";
|
||||
import { exportCanvas, loadFromBlob } from "../data";
|
||||
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { restore, restoreElements } from "../data/restore";
|
||||
import {
|
||||
@ -241,18 +240,13 @@ import {
|
||||
isInputLike,
|
||||
isToolIcon,
|
||||
isWritableElement,
|
||||
resetCursor,
|
||||
resolvablePromise,
|
||||
sceneCoordsToViewportCoords,
|
||||
setCursor,
|
||||
setCursorForShape,
|
||||
tupleToCoors,
|
||||
viewportCoordsToSceneCoords,
|
||||
withBatchedUpdates,
|
||||
wrapEvent,
|
||||
withBatchedUpdatesThrottled,
|
||||
updateObject,
|
||||
setEraserCursor,
|
||||
updateActiveTool,
|
||||
getShortcutKey,
|
||||
isTransparent,
|
||||
@ -279,6 +273,7 @@ import {
|
||||
generateIdFromFile,
|
||||
getDataURL,
|
||||
getFileFromEvent,
|
||||
ImageURLToFile,
|
||||
isImageFileHandle,
|
||||
isSupportedImageFile,
|
||||
loadSceneOrLibraryFromBlob,
|
||||
@ -321,7 +316,7 @@ import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||
import { actionUnlockAllElements } from "../actions/actionElementLock";
|
||||
import { Fonts } from "../scene/Fonts";
|
||||
import {
|
||||
getFrameElements,
|
||||
getFrameChildren,
|
||||
isCursorInFrame,
|
||||
bindElementsToFramesAfterDuplication,
|
||||
addElementsToFrame,
|
||||
@ -369,18 +364,30 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
import { StaticCanvas, InteractiveCanvas } from "./canvases";
|
||||
import { Renderer } from "../scene/Renderer";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import MermaidToExcalidraw from "./MermaidToExcalidraw";
|
||||
import { LaserToolOverlay } from "./LaserTool/LaserTool";
|
||||
import { LaserPathManager } from "./LaserTool/LaserPathManager";
|
||||
import {
|
||||
setEraserCursor,
|
||||
setCursor,
|
||||
resetCursor,
|
||||
setCursorForShape,
|
||||
} from "../cursor";
|
||||
import { Emitter } from "../emitter";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
|
||||
const deviceContextInitialValue = {
|
||||
isSmScreen: false,
|
||||
isMobile: false,
|
||||
viewport: {
|
||||
isMobile: false,
|
||||
isLandscape: false,
|
||||
},
|
||||
editor: {
|
||||
isMobile: false,
|
||||
canFitSidebar: false,
|
||||
},
|
||||
isTouchScreen: false,
|
||||
canDeviceFitSidebar: false,
|
||||
isLandscape: false,
|
||||
};
|
||||
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
|
||||
DeviceContext.displayName = "DeviceContext";
|
||||
@ -431,6 +438,9 @@ export const useExcalidrawSetAppState = () =>
|
||||
export const useExcalidrawActionManager = () =>
|
||||
useContext(ExcalidrawActionManagerContext);
|
||||
|
||||
const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
let didTapTwice: boolean = false;
|
||||
let tappedTwiceTimer = 0;
|
||||
let isHoldingSpace: boolean = false;
|
||||
@ -467,7 +477,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
unmounted: boolean = false;
|
||||
actionManager: ActionManager;
|
||||
device: Device = deviceContextInitialValue;
|
||||
detachIsMobileMqHandler?: () => void;
|
||||
|
||||
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
@ -502,11 +511,35 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
laserPathManager: LaserPathManager = new LaserPathManager(this);
|
||||
|
||||
onChangeEmitter = new Emitter<
|
||||
[
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
]
|
||||
>();
|
||||
|
||||
onPointerDownEmitter = new Emitter<
|
||||
[
|
||||
activeTool: AppState["activeTool"],
|
||||
pointerDownState: PointerDownState,
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
]
|
||||
>();
|
||||
|
||||
onPointerUpEmitter = new Emitter<
|
||||
[
|
||||
activeTool: AppState["activeTool"],
|
||||
pointerDownState: PointerDownState,
|
||||
event: PointerEvent,
|
||||
]
|
||||
>();
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
const {
|
||||
excalidrawRef,
|
||||
excalidrawAPI,
|
||||
viewModeEnabled = false,
|
||||
zenModeEnabled = false,
|
||||
gridModeEnabled = false,
|
||||
@ -537,14 +570,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.rc = rough.canvas(this.canvas);
|
||||
this.renderer = new Renderer(this.scene);
|
||||
|
||||
if (excalidrawRef) {
|
||||
const readyPromise =
|
||||
("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
|
||||
resolvablePromise<ExcalidrawImperativeAPI>();
|
||||
|
||||
if (excalidrawAPI) {
|
||||
const api: ExcalidrawImperativeAPI = {
|
||||
ready: true,
|
||||
readyPromise,
|
||||
updateScene: this.updateScene,
|
||||
updateLibrary: this.library.updateLibrary,
|
||||
addFiles: this.addFiles,
|
||||
@ -565,13 +592,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
resetCursor: this.resetCursor,
|
||||
updateFrameRendering: this.updateFrameRendering,
|
||||
toggleSidebar: this.toggleSidebar,
|
||||
onChange: (cb) => this.onChangeEmitter.on(cb),
|
||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||
} as const;
|
||||
if (typeof excalidrawRef === "function") {
|
||||
excalidrawRef(api);
|
||||
if (typeof excalidrawAPI === "function") {
|
||||
excalidrawAPI(api);
|
||||
} else {
|
||||
excalidrawRef.current = api;
|
||||
console.error("excalidrawAPI should be a function!");
|
||||
}
|
||||
readyPromise.resolve(api);
|
||||
}
|
||||
|
||||
this.excalidrawContainerValue = {
|
||||
@ -1011,12 +1040,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state,
|
||||
);
|
||||
|
||||
const { x: x2 } = sceneCoordsToViewportCoords(
|
||||
{ sceneX: f.x + f.width, sceneY: f.y + f.height },
|
||||
this.state,
|
||||
);
|
||||
|
||||
const FRAME_NAME_GAP = 20;
|
||||
const FRAME_NAME_EDIT_PADDING = 6;
|
||||
|
||||
const reset = () => {
|
||||
@ -1061,13 +1084,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
boxShadow: "inset 0 0 0 1px var(--color-primary)",
|
||||
fontFamily: "Assistant",
|
||||
fontSize: "14px",
|
||||
transform: `translateY(-${FRAME_NAME_EDIT_PADDING}px)`,
|
||||
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
|
||||
color: "var(--color-gray-80)",
|
||||
overflow: "hidden",
|
||||
maxWidth: `${Math.min(
|
||||
x2 - x1 - FRAME_NAME_EDIT_PADDING,
|
||||
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING,
|
||||
)}px`,
|
||||
maxWidth: `${
|
||||
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
|
||||
}px`,
|
||||
}}
|
||||
size={frameNameInEdit.length + 1 || 1}
|
||||
dir="auto"
|
||||
@ -1089,19 +1111,26 @@ class App extends React.Component<AppProps, AppState> {
|
||||
key={f.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${y1 - FRAME_NAME_GAP - this.state.offsetTop}px`,
|
||||
left: `${
|
||||
x1 -
|
||||
this.state.offsetLeft -
|
||||
(this.state.editingFrame === f.id ? FRAME_NAME_EDIT_PADDING : 0)
|
||||
// Positioning from bottom so that we don't to either
|
||||
// calculate text height or adjust using transform (which)
|
||||
// messes up input position when editing the frame name.
|
||||
// This makes the positioning deterministic and we can calculate
|
||||
// the same position when rendering to canvas / svg.
|
||||
bottom: `${
|
||||
this.state.height +
|
||||
FRAME_STYLE.nameOffsetY -
|
||||
y1 +
|
||||
this.state.offsetTop
|
||||
}px`,
|
||||
left: `${x1 - this.state.offsetLeft}px`,
|
||||
zIndex: 2,
|
||||
fontSize: "14px",
|
||||
fontSize: FRAME_STYLE.nameFontSize,
|
||||
color: isDarkTheme
|
||||
? "var(--color-gray-60)"
|
||||
: "var(--color-gray-50)",
|
||||
? FRAME_STYLE.nameColorDarkTheme
|
||||
: FRAME_STYLE.nameColorLightTheme,
|
||||
lineHeight: FRAME_STYLE.nameLineHeight,
|
||||
width: "max-content",
|
||||
maxWidth: `${x2 - x1 + FRAME_NAME_EDIT_PADDING * 2}px`,
|
||||
maxWidth: `${f.width}px`,
|
||||
overflow: f.id === this.state.editingFrame ? "visible" : "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
@ -1144,21 +1173,29 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pendingImageElementId: this.state.pendingImageElementId,
|
||||
});
|
||||
|
||||
const shouldBlockPointerEvents =
|
||||
!(
|
||||
this.state.editingElement && isLinearElement(this.state.editingElement)
|
||||
) &&
|
||||
(this.state.selectionElement ||
|
||||
this.state.draggingElement ||
|
||||
this.state.resizingElement ||
|
||||
(this.state.activeTool.type === "laser" &&
|
||||
// technically we can just test on this once we make it more safe
|
||||
this.state.cursorButton === "down") ||
|
||||
(this.state.editingElement &&
|
||||
!isTextElement(this.state.editingElement)));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("excalidraw excalidraw-container", {
|
||||
"excalidraw--view-mode": this.state.viewModeEnabled,
|
||||
"excalidraw--mobile": this.device.isMobile,
|
||||
"excalidraw--mobile": this.device.editor.isMobile,
|
||||
})}
|
||||
style={{
|
||||
["--ui-pointerEvents" as any]:
|
||||
this.state.selectionElement ||
|
||||
this.state.draggingElement ||
|
||||
this.state.resizingElement ||
|
||||
(this.state.editingElement &&
|
||||
!isTextElement(this.state.editingElement))
|
||||
? POINTER_EVENTS.disabled
|
||||
: POINTER_EVENTS.enabled,
|
||||
["--ui-pointerEvents" as any]: shouldBlockPointerEvents
|
||||
? POINTER_EVENTS.disabled
|
||||
: POINTER_EVENTS.enabled,
|
||||
}}
|
||||
ref={this.excalidrawContainerRef}
|
||||
onDrop={this.handleAppOnDrop}
|
||||
@ -1183,7 +1220,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
>
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
interactiveCanvas={this.interactiveCanvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
setAppState={this.setAppState}
|
||||
@ -1200,7 +1236,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.zenModeEnabled
|
||||
}
|
||||
UIOptions={this.props.UIOptions}
|
||||
onImageAction={this.onImageAction}
|
||||
onExportImage={this.onExportImage}
|
||||
renderWelcomeScreen={
|
||||
!this.state.isLoading &&
|
||||
@ -1213,7 +1248,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
>
|
||||
{this.props.children}
|
||||
{this.state.openDialog === "mermaid" && (
|
||||
<MermaidToExcalidraw />
|
||||
)}
|
||||
</LayerUI>
|
||||
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
<div className="excalidraw-eye-dropper-container" />
|
||||
@ -1243,6 +1282,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
top={this.state.contextMenu.top}
|
||||
left={this.state.contextMenu.left}
|
||||
actionManager={this.actionManager}
|
||||
onClose={(callback) => {
|
||||
this.setState({ contextMenu: null }, () => {
|
||||
this.focusContainer();
|
||||
callback?.();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<StaticCanvas
|
||||
@ -1322,7 +1367,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
public onExportImage = async (
|
||||
type: keyof typeof EXPORT_IMAGE_TYPES,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
elements: ExportedElements,
|
||||
opts: { exportingFrame: ExcalidrawFrameElement | null },
|
||||
) => {
|
||||
trackEvent("export", type, "ui");
|
||||
const fileHandle = await exportCanvas(
|
||||
@ -1334,6 +1380,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
exportBackground: this.state.exportBackground,
|
||||
name: this.state.name,
|
||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||
exportingFrame: opts.exportingFrame,
|
||||
},
|
||||
)
|
||||
.catch(muteFSAbortError)
|
||||
@ -1614,20 +1661,62 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
private refreshDeviceState = (container: HTMLDivElement) => {
|
||||
const { width, height } = container.getBoundingClientRect();
|
||||
private isMobileBreakpoint = (width: number, height: number) => {
|
||||
return (
|
||||
width < MQ_MAX_WIDTH_PORTRAIT ||
|
||||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
|
||||
);
|
||||
};
|
||||
|
||||
private refreshViewportBreakpoints = () => {
|
||||
const container = this.excalidrawContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { clientWidth: viewportWidth, clientHeight: viewportHeight } =
|
||||
document.body;
|
||||
|
||||
const prevViewportState = this.device.viewport;
|
||||
|
||||
const nextViewportState = updateObject(prevViewportState, {
|
||||
isLandscape: viewportWidth > viewportHeight,
|
||||
isMobile: this.isMobileBreakpoint(viewportWidth, viewportHeight),
|
||||
});
|
||||
|
||||
if (prevViewportState !== nextViewportState) {
|
||||
this.device = { ...this.device, viewport: nextViewportState };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
private refreshEditorBreakpoints = () => {
|
||||
const container = this.excalidrawContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width: editorWidth, height: editorHeight } =
|
||||
container.getBoundingClientRect();
|
||||
|
||||
const sidebarBreakpoint =
|
||||
this.props.UIOptions.dockedSidebarBreakpoint != null
|
||||
? this.props.UIOptions.dockedSidebarBreakpoint
|
||||
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
|
||||
this.device = updateObject(this.device, {
|
||||
isLandscape: width > height,
|
||||
isSmScreen: width < MQ_SM_MAX_WIDTH,
|
||||
isMobile:
|
||||
width < MQ_MAX_WIDTH_PORTRAIT ||
|
||||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE),
|
||||
canDeviceFitSidebar: width > sidebarBreakpoint,
|
||||
|
||||
const prevEditorState = this.device.editor;
|
||||
|
||||
const nextEditorState = updateObject(prevEditorState, {
|
||||
isMobile: this.isMobileBreakpoint(editorWidth, editorHeight),
|
||||
canFitSidebar: editorWidth > sidebarBreakpoint,
|
||||
});
|
||||
|
||||
if (prevEditorState !== nextEditorState) {
|
||||
this.device = { ...this.device, editor: nextEditorState };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
public async componentDidMount() {
|
||||
@ -1669,52 +1758,21 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (
|
||||
this.excalidrawContainerRef.current &&
|
||||
// bounding rects don't work in tests so updating
|
||||
// the state on init would result in making the test enviro run
|
||||
// in mobile breakpoint (0 width/height), making everything fail
|
||||
!isTestEnv()
|
||||
) {
|
||||
this.refreshDeviceState(this.excalidrawContainerRef.current);
|
||||
this.refreshViewportBreakpoints();
|
||||
this.refreshEditorBreakpoints();
|
||||
}
|
||||
|
||||
if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
|
||||
if (supportsResizeObserver && this.excalidrawContainerRef.current) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
// recompute device dimensions state
|
||||
// ---------------------------------------------------------------------
|
||||
this.refreshDeviceState(this.excalidrawContainerRef.current!);
|
||||
// refresh offsets
|
||||
// ---------------------------------------------------------------------
|
||||
this.refreshEditorBreakpoints();
|
||||
this.updateDOMRect();
|
||||
});
|
||||
this.resizeObserver?.observe(this.excalidrawContainerRef.current);
|
||||
} else if (window.matchMedia) {
|
||||
const mdScreenQuery = window.matchMedia(
|
||||
`(max-width: ${MQ_MAX_WIDTH_PORTRAIT}px), (max-height: ${MQ_MAX_HEIGHT_LANDSCAPE}px) and (max-width: ${MQ_MAX_WIDTH_LANDSCAPE}px)`,
|
||||
);
|
||||
const smScreenQuery = window.matchMedia(
|
||||
`(max-width: ${MQ_SM_MAX_WIDTH}px)`,
|
||||
);
|
||||
const canDeviceFitSidebarMediaQuery = window.matchMedia(
|
||||
`(min-width: ${
|
||||
// NOTE this won't update if a different breakpoint is supplied
|
||||
// after mount
|
||||
this.props.UIOptions.dockedSidebarBreakpoint != null
|
||||
? this.props.UIOptions.dockedSidebarBreakpoint
|
||||
: MQ_RIGHT_SIDEBAR_MIN_WIDTH
|
||||
}px)`,
|
||||
);
|
||||
const handler = () => {
|
||||
this.excalidrawContainerRef.current!.getBoundingClientRect();
|
||||
this.device = updateObject(this.device, {
|
||||
isSmScreen: smScreenQuery.matches,
|
||||
isMobile: mdScreenQuery.matches,
|
||||
canDeviceFitSidebar: canDeviceFitSidebarMediaQuery.matches,
|
||||
});
|
||||
};
|
||||
mdScreenQuery.addListener(handler);
|
||||
this.detachIsMobileMqHandler = () =>
|
||||
mdScreenQuery.removeListener(handler);
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search.slice(1));
|
||||
@ -1746,6 +1804,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.destroy();
|
||||
this.library.destroy();
|
||||
this.laserPathManager.destroy();
|
||||
this.onChangeEmitter.destroy();
|
||||
ShapeCache.destroy();
|
||||
SnapCache.destroy();
|
||||
clearTimeout(touchTimeout);
|
||||
@ -1758,6 +1817,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.forEach((element) => ShapeCache.delete(element));
|
||||
this.refreshViewportBreakpoints();
|
||||
this.updateDOMRect();
|
||||
if (!supportsResizeObserver) {
|
||||
this.refreshEditorBreakpoints();
|
||||
}
|
||||
this.setState({});
|
||||
});
|
||||
|
||||
@ -1811,7 +1875,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
false,
|
||||
);
|
||||
|
||||
this.detachIsMobileMqHandler?.();
|
||||
window.removeEventListener(EVENT.MESSAGE, this.onWindowMessage, false);
|
||||
}
|
||||
|
||||
@ -1896,11 +1959,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (
|
||||
this.excalidrawContainerRef.current &&
|
||||
prevProps.UIOptions.dockedSidebarBreakpoint !==
|
||||
this.props.UIOptions.dockedSidebarBreakpoint
|
||||
this.props.UIOptions.dockedSidebarBreakpoint
|
||||
) {
|
||||
this.refreshDeviceState(this.excalidrawContainerRef.current);
|
||||
this.refreshEditorBreakpoints();
|
||||
}
|
||||
|
||||
if (
|
||||
@ -2030,6 +2092,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state,
|
||||
this.files,
|
||||
);
|
||||
this.onChangeEmitter.trigger(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
this.state,
|
||||
this.files,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2072,7 +2139,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!isExcalidrawActive || isWritableElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
this.cutAll();
|
||||
this.actionManager.executeAction(actionCut, "keyboard", event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
@ -2084,19 +2151,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!isExcalidrawActive || isWritableElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
this.copyAll();
|
||||
this.actionManager.executeAction(actionCopy, "keyboard", event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
private cutAll = () => {
|
||||
this.actionManager.executeAction(actionCut, "keyboard");
|
||||
};
|
||||
|
||||
private copyAll = () => {
|
||||
this.actionManager.executeAction(actionCopy, "keyboard");
|
||||
};
|
||||
|
||||
private static resetTapTwice() {
|
||||
didTapTwice = false;
|
||||
}
|
||||
@ -2157,8 +2216,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
public pasteFromClipboard = withBatchedUpdates(
|
||||
async (event: ClipboardEvent | null) => {
|
||||
const isPlainPaste = !!(IS_PLAIN_PASTE && event);
|
||||
async (event: ClipboardEvent) => {
|
||||
const isPlainPaste = !!IS_PLAIN_PASTE;
|
||||
|
||||
// #686
|
||||
const target = document.activeElement;
|
||||
@ -2180,21 +2239,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// must be called in the same frame (thus before any awaits) as the paste
|
||||
// event else some browsers (FF...) will clear the clipboardData
|
||||
// (something something security)
|
||||
let file = event?.clipboardData?.files[0];
|
||||
|
||||
const data = await parseClipboard(event, isPlainPaste);
|
||||
if (!file && data.text && !isPlainPaste) {
|
||||
const string = data.text.trim();
|
||||
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
||||
// ignore SVG validation/normalization which will be done during image
|
||||
// initialization
|
||||
file = SVGStringToFile(string);
|
||||
}
|
||||
}
|
||||
|
||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: this.lastViewportPosition.x,
|
||||
@ -2203,6 +2247,29 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state,
|
||||
);
|
||||
|
||||
// must be called in the same frame (thus before any awaits) as the paste
|
||||
// event else some browsers (FF...) will clear the clipboardData
|
||||
// (something something security)
|
||||
let file = event?.clipboardData?.files[0];
|
||||
|
||||
const data = await parseClipboard(event, isPlainPaste);
|
||||
if (!file && !isPlainPaste) {
|
||||
if (data.mixedContent) {
|
||||
return this.addElementsFromMixedContentPaste(data.mixedContent, {
|
||||
isPlainPaste,
|
||||
sceneX,
|
||||
sceneY,
|
||||
});
|
||||
} else if (data.text) {
|
||||
const string = data.text.trim();
|
||||
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
||||
// ignore SVG validation/normalization which will be done during image
|
||||
// initialization
|
||||
file = SVGStringToFile(string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prefer spreadsheet data over image file (MS Office/Libre Office)
|
||||
if (isSupportedImageFile(file) && !data.spreadsheet) {
|
||||
const imageElement = this.createImageElement({ sceneX, sceneY });
|
||||
@ -2256,6 +2323,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
} else if (data.text) {
|
||||
const maybeUrl = extractSrc(data.text);
|
||||
|
||||
if (
|
||||
!isPlainPaste &&
|
||||
embeddableURLValidator(maybeUrl, this.props.validateEmbeddable) &&
|
||||
@ -2279,11 +2347,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
);
|
||||
|
||||
private addElementsFromPasteOrLibrary = (opts: {
|
||||
addElementsFromPasteOrLibrary = (opts: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
position: { clientX: number; clientY: number } | "cursor" | "center";
|
||||
retainSeed?: boolean;
|
||||
fitToContent?: boolean;
|
||||
}) => {
|
||||
const elements = restoreElements(opts.elements, null, undefined);
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
@ -2359,7 +2428,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// from library, not when pasting from clipboard. Alas.
|
||||
openSidebar:
|
||||
this.state.openSidebar &&
|
||||
this.device.canDeviceFitSidebar &&
|
||||
this.device.editor.canFitSidebar &&
|
||||
jotaiStore.get(isSidebarDockedAtom)
|
||||
? this.state.openSidebar
|
||||
: null,
|
||||
@ -2388,8 +2457,93 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
);
|
||||
this.setActiveTool({ type: "selection" });
|
||||
|
||||
if (opts.fitToContent) {
|
||||
this.scrollToContent(newElements, {
|
||||
fitToContent: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// TODO rewrite this to paste both text & images at the same time if
|
||||
// pasted data contains both
|
||||
private async addElementsFromMixedContentPaste(
|
||||
mixedContent: PastedMixedContent,
|
||||
{
|
||||
isPlainPaste,
|
||||
sceneX,
|
||||
sceneY,
|
||||
}: { isPlainPaste: boolean; sceneX: number; sceneY: number },
|
||||
) {
|
||||
if (
|
||||
!isPlainPaste &&
|
||||
mixedContent.some((node) => node.type === "imageUrl")
|
||||
) {
|
||||
const imageURLs = mixedContent
|
||||
.filter((node) => node.type === "imageUrl")
|
||||
.map((node) => node.value);
|
||||
const responses = await Promise.all(
|
||||
imageURLs.map(async (url) => {
|
||||
try {
|
||||
return { file: await ImageURLToFile(url) };
|
||||
} catch (error: any) {
|
||||
return { errorMessage: error.message as string };
|
||||
}
|
||||
}),
|
||||
);
|
||||
let y = sceneY;
|
||||
let firstImageYOffsetDone = false;
|
||||
const nextSelectedIds: Record<ExcalidrawElement["id"], true> = {};
|
||||
for (const response of responses) {
|
||||
if (response.file) {
|
||||
const imageElement = this.createImageElement({
|
||||
sceneX,
|
||||
sceneY: y,
|
||||
});
|
||||
|
||||
const initializedImageElement = await this.insertImageElement(
|
||||
imageElement,
|
||||
response.file,
|
||||
);
|
||||
if (initializedImageElement) {
|
||||
// vertically center first image in the batch
|
||||
if (!firstImageYOffsetDone) {
|
||||
firstImageYOffsetDone = true;
|
||||
y -= initializedImageElement.height / 2;
|
||||
}
|
||||
// hack to reset the `y` coord because we vertically center during
|
||||
// insertImageElement
|
||||
mutateElement(initializedImageElement, { y }, false);
|
||||
|
||||
y = imageElement.y + imageElement.height + 25;
|
||||
|
||||
nextSelectedIds[imageElement.id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
nextSelectedIds,
|
||||
this.state,
|
||||
),
|
||||
});
|
||||
|
||||
const error = responses.find((response) => !!response.errorMessage);
|
||||
if (error && error.errorMessage) {
|
||||
this.setState({ errorMessage: error.errorMessage });
|
||||
}
|
||||
} else {
|
||||
const textNodes = mixedContent.filter((node) => node.type === "text");
|
||||
if (textNodes.length) {
|
||||
this.addTextFromPaste(
|
||||
textNodes.map((node) => node.value).join("\n\n"),
|
||||
isPlainPaste,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addTextFromPaste(text: string, isPlainPaste = false) {
|
||||
const { x, y } = viewportCoordsToSceneCoords(
|
||||
{
|
||||
@ -2488,7 +2642,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isPlainPaste &&
|
||||
textElements.length > 1 &&
|
||||
PLAIN_PASTE_TOAST_SHOWN === false &&
|
||||
!this.device.isMobile
|
||||
!this.device.editor.isMobile
|
||||
) {
|
||||
this.setToast({
|
||||
message: t("toast.pasteAsSingleElement", {
|
||||
@ -2522,7 +2676,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
trackEvent(
|
||||
"toolbar",
|
||||
"toggleLock",
|
||||
`${source} (${this.device.isMobile ? "mobile" : "desktop"})`,
|
||||
`${source} (${this.device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
this.setState((prevState) => {
|
||||
@ -2562,7 +2716,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
togglePenMode = (force?: boolean) => {
|
||||
togglePenMode = (force: boolean | null) => {
|
||||
this.setState((prevState) => {
|
||||
return {
|
||||
penMode: force ?? !prevState.penMode,
|
||||
@ -3017,7 +3171,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
trackEvent(
|
||||
"toolbar",
|
||||
shape,
|
||||
`keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
|
||||
`keyboard (${
|
||||
this.device.editor.isMobile ? "mobile" : "desktop"
|
||||
})`,
|
||||
);
|
||||
}
|
||||
this.setActiveTool({ type: shape });
|
||||
@ -3129,11 +3285,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
|
||||
setActiveTool = (
|
||||
tool:
|
||||
| {
|
||||
type: ToolType;
|
||||
}
|
||||
| { type: "custom"; customType: string },
|
||||
tool: (
|
||||
| (
|
||||
| { type: Exclude<ToolType, "image"> }
|
||||
| {
|
||||
type: Extract<ToolType, "image">;
|
||||
insertOnCanvasDirectly?: boolean;
|
||||
}
|
||||
)
|
||||
| { type: "custom"; customType: string }
|
||||
) & { locked?: boolean },
|
||||
) => {
|
||||
const nextActiveTool = updateActiveTool(this.state, tool);
|
||||
if (nextActiveTool.type === "hand") {
|
||||
@ -3148,7 +3309,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({ suggestedBindings: [] });
|
||||
}
|
||||
if (nextActiveTool.type === "image") {
|
||||
this.onImageAction();
|
||||
this.onImageAction({
|
||||
insertOnCanvasDirectly:
|
||||
(tool.type === "image" && tool.insertOnCanvasDirectly) ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
this.setState((prevState) => {
|
||||
@ -3176,6 +3340,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
setOpenDialog = (dialogType: AppState["openDialog"]) => {
|
||||
this.setState({ openDialog: dialogType });
|
||||
};
|
||||
|
||||
private setCursor = (cursor: string) => {
|
||||
setCursor(this.interactiveCanvas, cursor);
|
||||
};
|
||||
@ -3739,7 +3907,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
element,
|
||||
this.state,
|
||||
[scenePointer.x, scenePointer.y],
|
||||
this.device.isMobile,
|
||||
this.device.editor.isMobile,
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -3771,7 +3939,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
this.state,
|
||||
[lastPointerDownCoords.x, lastPointerDownCoords.y],
|
||||
this.device.isMobile,
|
||||
this.device.editor.isMobile,
|
||||
);
|
||||
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
||||
this.lastPointerUpEvent!,
|
||||
@ -3781,7 +3949,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
this.state,
|
||||
[lastPointerUpCoords.x, lastPointerUpCoords.y],
|
||||
this.device.isMobile,
|
||||
this.device.editor.isMobile,
|
||||
);
|
||||
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
|
||||
let url = this.hitLinkElement.link;
|
||||
@ -4126,6 +4294,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
|
||||
this.hitLinkElement = this.getElementLinkAtPosition(
|
||||
scenePointer,
|
||||
hitElement,
|
||||
@ -4392,6 +4561,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCanvasPointerDown = (
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
@ -4479,12 +4649,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastPointerDownEvent = event;
|
||||
|
||||
// we must exit before we set `cursorButton` state and `savePointer`
|
||||
// else it will send pointer state & laser pointer events in collab when
|
||||
// panning
|
||||
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastPointerDownEvent = event;
|
||||
|
||||
this.setState({
|
||||
lastPointerDownWith: event.pointerType,
|
||||
cursorButton: "down",
|
||||
@ -4578,7 +4751,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState,
|
||||
);
|
||||
} else if (this.state.activeTool.type === "custom") {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
||||
setCursorForShape(this.interactiveCanvas, this.state);
|
||||
} else if (this.state.activeTool.type === "frame") {
|
||||
this.createFrameElementOnPointerDown(pointerDownState);
|
||||
} else if (this.state.activeTool.type === "laser") {
|
||||
@ -4597,6 +4770,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
this.props?.onPointerDown?.(this.state.activeTool, pointerDownState);
|
||||
this.onPointerDownEmitter.trigger(
|
||||
this.state.activeTool,
|
||||
pointerDownState,
|
||||
event,
|
||||
);
|
||||
|
||||
const onPointerMove =
|
||||
this.onPointerMoveFromPointerDownHandler(pointerDownState);
|
||||
@ -4633,7 +4811,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
const clicklength =
|
||||
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
|
||||
if (this.device.isMobile && clicklength < 300) {
|
||||
if (this.device.editor.isMobile && clicklength < 300) {
|
||||
const hitElement = this.getElementAtPosition(
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
@ -5151,7 +5329,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// if hitElement is frame, deselect all of its elements if they are selected
|
||||
if (hitElement.type === "frame") {
|
||||
getFrameElements(
|
||||
getFrameChildren(
|
||||
previouslySelectedElements,
|
||||
hitElement.id,
|
||||
).forEach((element) => {
|
||||
@ -6449,6 +6627,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({ pendingImageElementId: null });
|
||||
}
|
||||
|
||||
this.onPointerUpEmitter.trigger(
|
||||
this.state.activeTool,
|
||||
pointerDownState,
|
||||
childEvent,
|
||||
);
|
||||
|
||||
if (draggingElement?.type === "freedraw") {
|
||||
const pointerCoords = viewportCoordsToSceneCoords(
|
||||
childEvent,
|
||||
@ -6684,10 +6868,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
topLayerFrame &&
|
||||
!this.state.selectedElementIds[topLayerFrame.id]
|
||||
) {
|
||||
const processedGroupIds = new Map<string, boolean>();
|
||||
const elementsToAdd = selectedElements.filter(
|
||||
(element) =>
|
||||
element.frameId !== topLayerFrame.id &&
|
||||
isElementInFrame(element, nextElements, this.state),
|
||||
isElementInFrame(element, nextElements, this.state, {
|
||||
processedGroupIds,
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.state.editingGroupId) {
|
||||
@ -7290,7 +7477,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.addNewElement(imageElement);
|
||||
|
||||
try {
|
||||
await this.initializeImage({
|
||||
return await this.initializeImage({
|
||||
imageFile,
|
||||
imageElement,
|
||||
showCursorImagePreview,
|
||||
@ -7303,6 +7490,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({
|
||||
errorMessage: error.message || t("errors.imageInsertError"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@ -7345,9 +7533,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private onImageAction = async (
|
||||
{ insertOnCanvasDirectly } = { insertOnCanvasDirectly: false },
|
||||
) => {
|
||||
private onImageAction = async ({
|
||||
insertOnCanvasDirectly,
|
||||
}: {
|
||||
insertOnCanvasDirectly: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const clientX = this.state.width / 2 + this.state.offsetLeft;
|
||||
const clientY = this.state.height / 2 + this.state.offsetTop;
|
||||
@ -8006,7 +8196,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
>();
|
||||
|
||||
selectedFrames.forEach((frame) => {
|
||||
const elementsInFrame = getFrameElements(
|
||||
const elementsInFrame = getFrameChildren(
|
||||
this.scene.getNonDeletedElements(),
|
||||
frame.id,
|
||||
);
|
||||
@ -8076,7 +8266,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const elementsToHighlight = new Set<ExcalidrawElement>();
|
||||
selectedFrames.forEach((frame) => {
|
||||
const elementsInFrame = getFrameElements(
|
||||
const elementsInFrame = getFrameChildren(
|
||||
this.scene.getNonDeletedElements(),
|
||||
frame.id,
|
||||
);
|
||||
|
@ -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 {
|
||||
|
@ -254,7 +254,6 @@ 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")]}
|
||||
|
@ -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}
|
||||
|
@ -91,7 +91,7 @@ export class LaserPathManager {
|
||||
private collaboratorsState: Map<string, CollabolatorState> = new Map();
|
||||
|
||||
private rafId: number | undefined;
|
||||
private lastUpdate = 0;
|
||||
private isDrawing = false;
|
||||
private container: SVGSVGElement | undefined;
|
||||
|
||||
constructor(private app: App) {
|
||||
@ -100,7 +100,7 @@ export class LaserPathManager {
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
this.lastUpdate = 0;
|
||||
this.isDrawing = false;
|
||||
this.ownState = instantiateCollabolatorState();
|
||||
this.collaboratorsState = new Map();
|
||||
}
|
||||
@ -127,7 +127,7 @@ export class LaserPathManager {
|
||||
}
|
||||
|
||||
private updatePath(state: CollabolatorState) {
|
||||
this.lastUpdate = performance.now();
|
||||
this.isDrawing = true;
|
||||
|
||||
if (!this.isRunning) {
|
||||
this.start();
|
||||
@ -160,7 +160,7 @@ export class LaserPathManager {
|
||||
|
||||
this.updateCollabolatorsState();
|
||||
|
||||
if (performance.now() - this.lastUpdate < DECAY_TIME * 2) {
|
||||
if (this.isDrawing) {
|
||||
this.update();
|
||||
} else {
|
||||
this.isRunning = false;
|
||||
@ -250,6 +250,8 @@ export class LaserPathManager {
|
||||
return;
|
||||
}
|
||||
|
||||
let somePathsExist = false;
|
||||
|
||||
for (const [key, state] of this.collaboratorsState.entries()) {
|
||||
if (!this.app.state.collaborators.has(key)) {
|
||||
state.svg.remove();
|
||||
@ -269,6 +271,10 @@ export class LaserPathManager {
|
||||
paths += ` ${this.draw(state.currentPath)}`;
|
||||
}
|
||||
|
||||
if (paths.trim()) {
|
||||
somePathsExist = true;
|
||||
}
|
||||
|
||||
state.svg.setAttribute("d", paths);
|
||||
state.svg.setAttribute("fill", getClientColor(key));
|
||||
}
|
||||
@ -287,7 +293,17 @@ export class LaserPathManager {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -62,18 +62,16 @@ interface LayerUIProps {
|
||||
appState: UIAppState;
|
||||
files: BinaryFiles;
|
||||
canvas: HTMLCanvasElement;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => 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;
|
||||
@ -123,7 +121,6 @@ const LayerUI = ({
|
||||
setAppState,
|
||||
elements,
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
@ -131,7 +128,6 @@ const LayerUI = ({
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
UIOptions,
|
||||
onImageAction,
|
||||
onExportImage,
|
||||
renderWelcomeScreen,
|
||||
children,
|
||||
@ -165,7 +161,10 @@ const LayerUI = ({
|
||||
};
|
||||
|
||||
const renderImageExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.saveAsImage) {
|
||||
if (
|
||||
!UIOptions.canvasActions.saveAsImage ||
|
||||
appState.openDialog !== "imageExport"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -250,7 +249,7 @@ const LayerUI = ({
|
||||
>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
isMobile={device.isMobile}
|
||||
isMobile={device.editor.isMobile}
|
||||
device={device}
|
||||
app={app}
|
||||
/>
|
||||
@ -259,7 +258,7 @@ const LayerUI = ({
|
||||
<PenModeButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.penMode}
|
||||
onChange={onPenModeToggle}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
title={t("toolBar.penMode")}
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
@ -280,14 +279,8 @@ const LayerUI = ({
|
||||
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
app={app}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
@ -324,7 +317,7 @@ const LayerUI = ({
|
||||
)}
|
||||
>
|
||||
<UserList collaborators={appState.collaborators} />
|
||||
{renderTopRightUI?.(device.isMobile, appState)}
|
||||
{renderTopRightUI?.(device.editor.isMobile, appState)}
|
||||
{!appState.viewModeEnabled &&
|
||||
// hide button when sidebar docked
|
||||
(!isSidebarDocked ||
|
||||
@ -345,7 +338,7 @@ const LayerUI = ({
|
||||
trackEvent(
|
||||
"sidebar",
|
||||
`toggleDock (${docked ? "dock" : "undock"})`,
|
||||
`(${device.isMobile ? "mobile" : "desktop"})`,
|
||||
`(${device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@ -373,7 +366,7 @@ const LayerUI = ({
|
||||
trackEvent(
|
||||
"sidebar",
|
||||
`${DEFAULT_SIDEBAR.name} (open)`,
|
||||
`button (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
`button (${device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
@ -390,7 +383,7 @@ const LayerUI = ({
|
||||
{appState.errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
{eyeDropperState && !device.isMobile && (
|
||||
{eyeDropperState && !device.editor.isMobile && (
|
||||
<EyeDropper
|
||||
colorPickerType={eyeDropperState.colorPickerType}
|
||||
onCancel={() => {
|
||||
@ -460,7 +453,7 @@ const LayerUI = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{device.isMobile && (
|
||||
{device.editor.isMobile && (
|
||||
<MobileMenu
|
||||
app={app}
|
||||
appState={appState}
|
||||
@ -472,8 +465,6 @@ const LayerUI = ({
|
||||
onLockToggle={onLockToggle}
|
||||
onHandToolToggle={onHandToolToggle}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
renderSidebars={renderSidebars}
|
||||
@ -481,14 +472,14 @@ const LayerUI = ({
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
/>
|
||||
)}
|
||||
{!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)` }
|
||||
: {}
|
||||
}
|
||||
@ -560,18 +551,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;
|
@ -35,10 +35,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,
|
||||
@ -58,8 +56,7 @@ export const MobileMenu = ({
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
interactiveCanvas,
|
||||
onImageAction,
|
||||
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
renderSidebars,
|
||||
@ -85,14 +82,8 @@ export const MobileMenu = ({
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
app={app}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
@ -103,7 +94,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,6 +160,15 @@
|
||||
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);
|
||||
|
@ -16,12 +16,17 @@
|
||||
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);
|
||||
@ -40,5 +45,6 @@
|
||||
margin-top: 0.375rem;
|
||||
right: 0;
|
||||
min-width: 11.875rem;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
@ -80,6 +78,11 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-hover-bg);
|
||||
border-color: var(--color-brand-active);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
@ -98,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
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
@ -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 (
|
||||
|
@ -1654,6 +1654,22 @@ export const frameToolIcon = createIcon(
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const mermaidLogoIcon = createIcon(
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M407.48,111.18C335.587,108.103 269.573,152.338 245.08,220C220.587,152.338 154.573,108.103 82.68,111.18C80.285,168.229 107.577,222.632 154.74,254.82C178.908,271.419 193.35,298.951 193.27,328.27L193.27,379.13L296.9,379.13L296.9,328.27C296.816,298.953 311.255,271.42 335.42,254.82C382.596,222.644 409.892,168.233 407.48,111.18Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
export const ArrowRightIcon = createIcon(
|
||||
<g strokeWidth="1.25">
|
||||
<path d="M4.16602 10H15.8327" />
|
||||
<path d="M12.5 13.3333L15.8333 10" />
|
||||
<path d="M12.5 6.66666L15.8333 9.99999" />
|
||||
</g>,
|
||||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const laserPointerToolIcon = createIcon(
|
||||
<g
|
||||
fill="none"
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
@ -174,7 +174,7 @@
|
||||
justify-content: space-between;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
border: 1px solid transparent;
|
||||
|
||||
padding: 0.75rem;
|
||||
|
||||
@ -204,7 +204,7 @@
|
||||
|
||||
.welcome-screen-menu-item:hover {
|
||||
text-decoration: none;
|
||||
background: var(--color-gray-10);
|
||||
background: var(--button-hover-bg);
|
||||
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
@ -216,7 +216,8 @@
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item:active {
|
||||
background: var(--color-gray-20);
|
||||
background: var(--button-hover-bg);
|
||||
border-color: var(--color-brand-active);
|
||||
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
@ -247,8 +248,7 @@
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item:hover {
|
||||
background: var(--color-gray-85);
|
||||
|
||||
background-color: var(--color-surface-low);
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
}
|
||||
@ -259,7 +259,6 @@
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item:active {
|
||||
background-color: var(--color-gray-90);
|
||||
.welcome-screen-menu-item__text {
|
||||
color: var(--color-gray-10);
|
||||
}
|
||||
|
@ -105,6 +105,7 @@ export const FONT_FAMILY = {
|
||||
Virgil: 1,
|
||||
Helvetica: 2,
|
||||
Cascadia: 3,
|
||||
Assistant: 4,
|
||||
};
|
||||
|
||||
export const THEME = {
|
||||
@ -114,13 +115,18 @@ export const THEME = {
|
||||
|
||||
export const FRAME_STYLE = {
|
||||
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
|
||||
strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
|
||||
strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
|
||||
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
|
||||
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
|
||||
roughness: 0 as ExcalidrawElement["roughness"],
|
||||
roundness: null as ExcalidrawElement["roundness"],
|
||||
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
|
||||
radius: 8,
|
||||
nameOffsetY: 3,
|
||||
nameColorLightTheme: "#999999",
|
||||
nameColorDarkTheme: "#7a7a7a",
|
||||
nameFontSize: 14,
|
||||
nameLineHeight: 1.25,
|
||||
};
|
||||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
@ -148,6 +154,8 @@ export const IMAGE_MIME_TYPES = {
|
||||
jfif: "image/jfif",
|
||||
} as const;
|
||||
|
||||
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
|
||||
|
||||
export const MIME_TYPES = {
|
||||
json: "application/json",
|
||||
// excalidraw data
|
||||
@ -218,8 +226,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
|
||||
// breakpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
// sm screen
|
||||
export const MQ_SM_MAX_WIDTH = 640;
|
||||
// md screen
|
||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
@ -296,6 +302,18 @@ export const ROUNDNESS = {
|
||||
* collaboration */
|
||||
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
|
||||
|
||||
export const ROUGHNESS = {
|
||||
architect: 0,
|
||||
artist: 1,
|
||||
cartoonist: 2,
|
||||
} as const;
|
||||
|
||||
export const STROKE_WIDTH = {
|
||||
thin: 1,
|
||||
bold: 2,
|
||||
extraBold: 4,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_ELEMENT_PROPS: {
|
||||
strokeColor: ExcalidrawElement["strokeColor"];
|
||||
backgroundColor: ExcalidrawElement["backgroundColor"];
|
||||
@ -308,10 +326,10 @@ export const DEFAULT_ELEMENT_PROPS: {
|
||||
} = {
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
backgroundColor: COLOR_PALETTE.transparent,
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
roughness: ROUGHNESS.artist,
|
||||
opacity: 100,
|
||||
locked: false,
|
||||
};
|
||||
|
@ -195,7 +195,7 @@
|
||||
.buttonList label:focus-within,
|
||||
input:focus-visible {
|
||||
outline: transparent;
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
box-shadow: 0 0 0 1px var(--color-brand-hover);
|
||||
}
|
||||
|
||||
.buttonList {
|
||||
@ -280,6 +280,11 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
|
||||
.dropdown-menu--mobile {
|
||||
bottom: 55px;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.App-mobile-menu {
|
||||
@ -444,13 +449,14 @@
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 1px solid var(--color-primary-darkest);
|
||||
border: 1px solid var(--button-active-border);
|
||||
}
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
@include outlineButtonStyles;
|
||||
background-color: var(--island-bg-color);
|
||||
@include filledButtonOnCanvas;
|
||||
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
@ -536,13 +542,13 @@
|
||||
|
||||
&:not(:focus) {
|
||||
&:hover {
|
||||
background-color: var(--input-hover-bg-color);
|
||||
border-color: var(--color-brand-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
border-color: var(--color-brand-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@ -591,6 +597,8 @@
|
||||
background-color: var(--island-bg-color);
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@ -600,8 +608,8 @@
|
||||
}
|
||||
|
||||
.App-toolbar--mobile {
|
||||
overflow-x: auto;
|
||||
max-width: 90vw;
|
||||
overflow: visible;
|
||||
max-width: 98vw;
|
||||
|
||||
.ToolIcon__keybinding {
|
||||
display: none;
|
||||
@ -621,6 +629,20 @@
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.main-menu-trigger {
|
||||
@include filledButtonOnCanvas;
|
||||
}
|
||||
|
||||
.App-menu__left {
|
||||
--button-border: transparent;
|
||||
--button-bg: var(--color-surface-mid);
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
--button-hover-bg: #363541;
|
||||
--button-bg: var(--color-surface-high);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ErrorSplash.excalidraw {
|
||||
|
@ -12,27 +12,30 @@
|
||||
--dialog-border-color: var(--color-gray-20);
|
||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||
--focus-highlight-color: #{$oc-blue-2};
|
||||
--icon-fill-color: var(--color-gray-80);
|
||||
--icon-fill-color: var(--color-on-surface);
|
||||
--icon-green-fill-color: #{$oc-green-9};
|
||||
--default-bg-color: #{$oc-white};
|
||||
--input-bg-color: #{$oc-white};
|
||||
--input-border-color: #{$oc-gray-4};
|
||||
--input-hover-bg-color: #{$oc-gray-1};
|
||||
--input-label-color: #{$oc-gray-7};
|
||||
--island-bg-color: rgba(255, 255, 255, 0.96);
|
||||
--island-bg-color: #ffffff;
|
||||
--keybinding-color: var(--color-gray-40);
|
||||
--link-color: #{$oc-blue-7};
|
||||
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
|
||||
--popup-bg-color: #{$oc-white};
|
||||
--popup-bg-color: var(--island-bg-color);
|
||||
--popup-secondary-bg-color: #{$oc-gray-1};
|
||||
--popup-text-color: #{$oc-black};
|
||||
--popup-text-inverted-color: #{$oc-white};
|
||||
--select-highlight-color: #{$oc-blue-5};
|
||||
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
|
||||
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
|
||||
0px 0px 0.931014px rgba(0, 0, 0, 0.1702);
|
||||
--button-hover-bg: var(--color-gray-10);
|
||||
--default-border-color: var(--color-gray-30);
|
||||
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
|
||||
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
--button-hover-bg: var(--color-surface-high);
|
||||
--button-active-bg: var(--color-surface-high);
|
||||
--button-active-border: var(--color-brand-active);
|
||||
--default-border-color: var(--color-surface-high);
|
||||
|
||||
--default-button-size: 2rem;
|
||||
--default-icon-size: 1rem;
|
||||
@ -63,14 +66,14 @@
|
||||
0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035),
|
||||
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
|
||||
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
|
||||
--sidebar-border-color: var(--color-gray-20);
|
||||
--sidebar-bg-color: #fff;
|
||||
--sidebar-border-color: var(--color-surface-high);
|
||||
--sidebar-bg-color: var(--island-bg-color);
|
||||
--library-dropdown-shadow: 0px 15px 6px rgba(0, 0, 0, 0.01),
|
||||
0px 8px 5px rgba(0, 0, 0, 0.05), 0px 4px 4px rgba(0, 0, 0, 0.09),
|
||||
0px 1px 2px rgba(0, 0, 0, 0.1), 0px 0px 0px rgba(0, 0, 0, 0.1);
|
||||
|
||||
--space-factor: 0.25rem;
|
||||
--text-primary-color: var(--color-gray-80);
|
||||
--text-primary-color: var(--color-on-surface);
|
||||
|
||||
--color-selection: #6965db;
|
||||
|
||||
@ -132,6 +135,19 @@
|
||||
--border-radius-md: 0.375rem;
|
||||
--border-radius-lg: 0.5rem;
|
||||
|
||||
--color-surface-high: hsl(244, 100%, 97%);
|
||||
--color-surface-mid: hsl(240 25% 96%);
|
||||
--color-surface-low: hsl(240 25% 94%);
|
||||
--color-surface-lowest: #ffffff;
|
||||
--color-on-surface: #1b1b1f;
|
||||
--color-brand-hover: #5753d0;
|
||||
--color-on-primary-container: #030064;
|
||||
--color-surface-primary-container: #e0dfff;
|
||||
--color-brand-active: #4440bf;
|
||||
--color-border-outline: #767680;
|
||||
--color-border-outline-variant: #c5c5d0;
|
||||
--color-surface-primary-container: #e0dfff;
|
||||
|
||||
&.theme--dark {
|
||||
&.theme--dark-background-none {
|
||||
background: none;
|
||||
@ -150,29 +166,24 @@
|
||||
--dialog-border-color: var(--color-gray-80);
|
||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||
--focus-highlight-color: #{$oc-blue-6};
|
||||
--icon-fill-color: var(--color-gray-40);
|
||||
--icon-green-fill-color: #{$oc-green-4};
|
||||
--default-bg-color: #121212;
|
||||
--input-bg-color: #121212;
|
||||
--input-border-color: #2e2e2e;
|
||||
--input-hover-bg-color: #181818;
|
||||
--input-label-color: #{$oc-gray-2};
|
||||
--island-bg-color: #262627;
|
||||
--island-bg-color: #232329;
|
||||
--keybinding-color: var(--color-gray-60);
|
||||
--link-color: #{$oc-blue-4};
|
||||
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
|
||||
--popup-bg-color: #2c2c2c;
|
||||
--popup-secondary-bg-color: #222;
|
||||
--popup-text-color: #{$oc-gray-4};
|
||||
--popup-text-inverted-color: #2c2c2c;
|
||||
--select-highlight-color: #{$oc-blue-4};
|
||||
--text-primary-color: var(--color-gray-40);
|
||||
--button-hover-bg: var(--color-gray-80);
|
||||
--default-border-color: var(--color-gray-80);
|
||||
--shadow-island: 0px 13px 33px rgba(0, 0, 0, 0.07),
|
||||
0px 4.13px 9.94853px rgba(0, 0, 0, 0.0456112),
|
||||
0px 1.13px 4.13211px rgba(0, 0, 0, 0.035),
|
||||
0px 0.769896px 1.4945px rgba(0, 0, 0, 0.0243888);
|
||||
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
|
||||
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
|
||||
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
|
||||
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
|
||||
@ -180,8 +191,6 @@
|
||||
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
|
||||
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
|
||||
--avatar-border-color: var(--color-gray-85);
|
||||
--sidebar-border-color: var(--color-gray-85);
|
||||
--sidebar-bg-color: #191919;
|
||||
|
||||
--scrollbar-thumb: #{$oc-gray-8};
|
||||
--scrollbar-thumb-hover: #{$oc-gray-7};
|
||||
@ -224,5 +233,18 @@
|
||||
--color-promo: #d297ff;
|
||||
|
||||
--color-logo-text: #e2dfff;
|
||||
|
||||
--color-surface-high: hsl(245, 10%, 21%);
|
||||
--color-surface-low: hsl(240, 8%, 15%);
|
||||
--color-surface-mid: hsl(240 6% 10%);
|
||||
--color-surface-lowest: hsl(0, 0%, 7%);
|
||||
--color-on-surface: #e3e3e8;
|
||||
--color-brand-hover: #bbb8ff;
|
||||
--color-on-primary-container: #e0dfff;
|
||||
--color-surface-primary-container: #403e6a;
|
||||
--color-brand-active: #d0ccff;
|
||||
--color-border-outline: #8e8d9c;
|
||||
--color-border-outline-variant: #46464f;
|
||||
--color-surface-primary-container: #403e6a;
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@
|
||||
.ToolIcon_type_radio,
|
||||
.ToolIcon_type_checkbox {
|
||||
&:checked + .ToolIcon__icon {
|
||||
--icon-fill-color: var(--color-primary-darker);
|
||||
--icon-fill-color: var(--color-on-primary-container);
|
||||
|
||||
svg {
|
||||
fill: var(--icon-fill-color);
|
||||
@ -23,11 +23,11 @@
|
||||
.ToolIcon_type_radio,
|
||||
.ToolIcon_type_checkbox {
|
||||
&:checked + .ToolIcon__icon {
|
||||
background: var(--color-primary-light);
|
||||
--keybinding-color: var(--color-gray-60);
|
||||
background: var(--color-surface-primary-container);
|
||||
--keybinding-color: var(--color-on-primary-container);
|
||||
|
||||
svg {
|
||||
color: var(--color-primary-darker);
|
||||
color: var(--color-on-primary-container);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -44,7 +44,11 @@
|
||||
|
||||
&:active {
|
||||
background: var(--button-hover-bg);
|
||||
border: 1px solid var(--color-primary-darkest);
|
||||
border: 1px solid var(--button-active-border);
|
||||
|
||||
svg {
|
||||
color: var(--color-on-primary-container);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -63,7 +67,7 @@
|
||||
border-radius: var(--border-radius-lg);
|
||||
cursor: pointer;
|
||||
background-color: var(--button-bg, var(--island-bg-color));
|
||||
color: var(--button-color, var(--text-primary-color));
|
||||
color: var(--button-color, var(--color-on-surface));
|
||||
|
||||
svg {
|
||||
width: var(--button-width, var(--lg-icon-size));
|
||||
@ -88,22 +92,38 @@
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--button-selected-bg, var(--color-primary-light));
|
||||
border-color: var(--button-selected-border, var(--color-primary-light));
|
||||
background-color: var(
|
||||
--button-selected-bg,
|
||||
var(--color-surface-primary-container)
|
||||
);
|
||||
border-color: var(
|
||||
--button-selected-border,
|
||||
var(--color-surface-primary-container)
|
||||
);
|
||||
|
||||
&:hover {
|
||||
background-color: var(
|
||||
--button-selected-hover-bg,
|
||||
var(--color-primary-light)
|
||||
var(--color-surface-primary-container)
|
||||
);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--button-color, var(--color-primary-darker));
|
||||
color: var(--button-color, var(--color-on-primary-container));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin filledButtonOnCanvas {
|
||||
border: none;
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
background-color: var(--color-surface-low);
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 0 0 1px var(--color-brand-active);
|
||||
}
|
||||
}
|
||||
|
||||
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
||||
$right-sidebar-width: "302px";
|
||||
|
||||
|
105
src/cursor.ts
Normal file
105
src/cursor.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { CURSOR_TYPE, MIME_TYPES, THEME } from "./constants";
|
||||
import OpenColor from "open-color";
|
||||
import { AppState, DataURL } from "./types";
|
||||
import { isHandToolActive, isEraserActive } from "./appState";
|
||||
|
||||
const laserPointerCursorSVG_tag = `<svg viewBox="0 0 24 24" stroke-width="1" width="28" height="28" xmlns="http://www.w3.org/2000/svg">`;
|
||||
const laserPointerCursorBackgroundSVG = `<path d="M6.164 11.755a5.314 5.314 0 0 1-4.932-5.298 5.314 5.314 0 0 1 5.311-5.311 5.314 5.314 0 0 1 5.307 5.113l8.773 8.773a3.322 3.322 0 0 1 0 4.696l-.895.895a3.322 3.322 0 0 1-4.696 0l-8.868-8.868Z" style="fill:#fff"/>`;
|
||||
const laserPointerCursorIconSVG = `<path stroke="#1b1b1f" fill="#fff" d="m7.868 11.113 7.773 7.774a2.359 2.359 0 0 0 1.667.691 2.368 2.368 0 0 0 2.357-2.358c0-.625-.248-1.225-.69-1.667L11.201 7.78 9.558 9.469l-1.69 1.643v.001Zm10.273 3.606-3.333 3.333m-3.25-6.583 2 2m-7-7 3 3M3.664 3.625l1 1M2.529 6.922l1.407-.144m5.735-2.932-1.118.866M4.285 9.823l.758-1.194m1.863-6.207-.13 1.408"/>`;
|
||||
|
||||
const laserPointerCursorDataURL_lightMode = `data:${
|
||||
MIME_TYPES.svg
|
||||
},${encodeURIComponent(
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
const laserPointerCursorDataURL_darkMode = `data:${
|
||||
MIME_TYPES.svg
|
||||
},${encodeURIComponent(
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
|
||||
export const resetCursor = (interactiveCanvas: HTMLCanvasElement | null) => {
|
||||
if (interactiveCanvas) {
|
||||
interactiveCanvas.style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
export const setCursor = (
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
cursor: string,
|
||||
) => {
|
||||
if (interactiveCanvas) {
|
||||
interactiveCanvas.style.cursor = cursor;
|
||||
}
|
||||
};
|
||||
|
||||
let eraserCanvasCache: any;
|
||||
let previewDataURL: string;
|
||||
export const setEraserCursor = (
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
theme: AppState["theme"],
|
||||
) => {
|
||||
const cursorImageSizePx = 20;
|
||||
|
||||
const drawCanvas = () => {
|
||||
const isDarkTheme = theme === THEME.DARK;
|
||||
eraserCanvasCache = document.createElement("canvas");
|
||||
eraserCanvasCache.theme = theme;
|
||||
eraserCanvasCache.height = cursorImageSizePx;
|
||||
eraserCanvasCache.width = cursorImageSizePx;
|
||||
const context = eraserCanvasCache.getContext("2d")!;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
eraserCanvasCache.width / 2,
|
||||
eraserCanvasCache.height / 2,
|
||||
5,
|
||||
0,
|
||||
2 * Math.PI,
|
||||
);
|
||||
context.fillStyle = isDarkTheme ? OpenColor.black : OpenColor.white;
|
||||
context.fill();
|
||||
context.strokeStyle = isDarkTheme ? OpenColor.white : OpenColor.black;
|
||||
context.stroke();
|
||||
previewDataURL = eraserCanvasCache.toDataURL(MIME_TYPES.svg) as DataURL;
|
||||
};
|
||||
if (!eraserCanvasCache || eraserCanvasCache.theme !== theme) {
|
||||
drawCanvas();
|
||||
}
|
||||
|
||||
setCursor(
|
||||
interactiveCanvas,
|
||||
`url(${previewDataURL}) ${cursorImageSizePx / 2} ${
|
||||
cursorImageSizePx / 2
|
||||
}, auto`,
|
||||
);
|
||||
};
|
||||
|
||||
export const setCursorForShape = (
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
appState: Pick<AppState, "activeTool" | "theme">,
|
||||
) => {
|
||||
if (!interactiveCanvas) {
|
||||
return;
|
||||
}
|
||||
if (appState.activeTool.type === "selection") {
|
||||
resetCursor(interactiveCanvas);
|
||||
} else if (isHandToolActive(appState)) {
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.GRAB;
|
||||
} else if (isEraserActive(appState)) {
|
||||
setEraserCursor(interactiveCanvas, appState.theme);
|
||||
// do nothing if image tool is selected which suggests there's
|
||||
// a image-preview set as the cursor
|
||||
// Ignore custom type as well and let host decide
|
||||
} else if (appState.activeTool.type === "laser") {
|
||||
const url =
|
||||
appState.theme === THEME.LIGHT
|
||||
? laserPointerCursorDataURL_lightMode
|
||||
: laserPointerCursorDataURL_darkMode;
|
||||
interactiveCanvas.style.cursor = `url(${url}), auto`;
|
||||
} else if (!["image", "custom"].includes(appState.activeTool.type)) {
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
||||
} else {
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.AUTO;
|
||||
}
|
||||
};
|
File diff suppressed because it is too large
Load Diff
@ -8,7 +8,7 @@ import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { AppState, DataURL, LibraryItem } from "../types";
|
||||
import { ValueOf } from "../utility-types";
|
||||
import { bytesToHexString } from "../utils";
|
||||
import { bytesToHexString, isPromiseLike } from "../utils";
|
||||
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
|
||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||
import { restore, restoreLibraryItems } from "./restore";
|
||||
@ -207,10 +207,13 @@ export const loadLibraryFromBlob = async (
|
||||
};
|
||||
|
||||
export const canvasToBlob = async (
|
||||
canvas: HTMLCanvasElement,
|
||||
canvas: HTMLCanvasElement | Promise<HTMLCanvasElement>,
|
||||
): Promise<Blob> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
if (isPromiseLike(canvas)) {
|
||||
canvas = await canvas;
|
||||
}
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
return reject(
|
||||
@ -324,6 +327,31 @@ export const SVGStringToFile = (SVGString: string, filename: string = "") => {
|
||||
}) as File & { type: typeof MIME_TYPES.svg };
|
||||
};
|
||||
|
||||
export const ImageURLToFile = async (
|
||||
imageUrl: string,
|
||||
filename: string = "",
|
||||
): Promise<File | undefined> => {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(imageUrl);
|
||||
} catch (error: any) {
|
||||
throw new Error(t("errors.failedToFetchImage"));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t("errors.failedToFetchImage"));
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
if (blob.type && isSupportedImageFile(blob)) {
|
||||
const name = filename || blob.name || "";
|
||||
return new File([blob], name, { type: blob.type });
|
||||
}
|
||||
|
||||
throw new Error(t("errors.unsupportedFileType"));
|
||||
};
|
||||
|
||||
export const getFileFromEvent = async (
|
||||
event: React.DragEvent<HTMLDivElement>,
|
||||
) => {
|
||||
|
@ -3,11 +3,19 @@ import {
|
||||
copyTextToSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { getNonDeletedElements, isFrameElement } from "../element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { elementsOverlappingBBox } from "../packages/withinBounds";
|
||||
import { isSomeElementSelected, getSelectedElements } from "../scene";
|
||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { cloneJSON } from "../utils";
|
||||
import { canvasToBlob } from "./blob";
|
||||
import { fileSave, FileSystemHandle } from "./filesystem";
|
||||
import { serializeAsJSON } from "./json";
|
||||
@ -15,9 +23,61 @@ import { serializeAsJSON } from "./json";
|
||||
export { loadFromBlob } from "./blob";
|
||||
export { loadFromJSON, saveAsJSON } from "./json";
|
||||
|
||||
export type ExportedElements = readonly NonDeletedExcalidrawElement[] & {
|
||||
_brand: "exportedElements";
|
||||
};
|
||||
|
||||
export const prepareElementsForExport = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
{ selectedElementIds }: Pick<AppState, "selectedElementIds">,
|
||||
exportSelectionOnly: boolean,
|
||||
) => {
|
||||
elements = getNonDeletedElements(elements);
|
||||
|
||||
const isExportingSelection =
|
||||
exportSelectionOnly &&
|
||||
isSomeElementSelected(elements, { selectedElementIds });
|
||||
|
||||
let exportingFrame: ExcalidrawFrameElement | null = null;
|
||||
let exportedElements = isExportingSelection
|
||||
? getSelectedElements(
|
||||
elements,
|
||||
{ selectedElementIds },
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
)
|
||||
: elements;
|
||||
|
||||
if (isExportingSelection) {
|
||||
if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) {
|
||||
exportingFrame = exportedElements[0];
|
||||
exportedElements = elementsOverlappingBBox({
|
||||
elements,
|
||||
bounds: exportingFrame,
|
||||
type: "overlap",
|
||||
});
|
||||
} else if (exportedElements.length > 1) {
|
||||
exportedElements = getSelectedElements(
|
||||
elements,
|
||||
{ selectedElementIds },
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exportingFrame,
|
||||
exportedElements: cloneJSON(exportedElements) as ExportedElements,
|
||||
};
|
||||
};
|
||||
|
||||
export const exportCanvas = async (
|
||||
type: Omit<ExportType, "backend">,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
elements: ExportedElements,
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
{
|
||||
@ -26,12 +86,14 @@ export const exportCanvas = async (
|
||||
viewBackgroundColor,
|
||||
name,
|
||||
fileHandle = null,
|
||||
exportingFrame = null,
|
||||
}: {
|
||||
exportBackground: boolean;
|
||||
exportPadding?: number;
|
||||
viewBackgroundColor: string;
|
||||
name: string;
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
exportingFrame: ExcalidrawFrameElement | null;
|
||||
},
|
||||
) => {
|
||||
if (elements.length === 0) {
|
||||
@ -49,6 +111,7 @@ export const exportCanvas = async (
|
||||
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
||||
},
|
||||
files,
|
||||
{ exportingFrame },
|
||||
);
|
||||
if (type === "svg") {
|
||||
return await fileSave(
|
||||
@ -66,17 +129,15 @@ export const exportCanvas = async (
|
||||
}
|
||||
}
|
||||
|
||||
const tempCanvas = await exportToCanvas(elements, appState, files, {
|
||||
const tempCanvas = exportToCanvas(elements, appState, files, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
exportingFrame,
|
||||
});
|
||||
tempCanvas.style.display = "none";
|
||||
document.body.appendChild(tempCanvas);
|
||||
|
||||
if (type === "png") {
|
||||
let blob = await canvasToBlob(tempCanvas);
|
||||
tempCanvas.remove();
|
||||
if (appState.exportEmbedScene) {
|
||||
blob = await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
@ -114,11 +175,8 @@ export const exportCanvas = async (
|
||||
} else {
|
||||
throw new Error(t("alerts.couldNotCopyToClipboard"));
|
||||
}
|
||||
} finally {
|
||||
tempCanvas.remove();
|
||||
}
|
||||
} else {
|
||||
tempCanvas.remove();
|
||||
// shouldn't happen
|
||||
throw new Error("Unsupported export type");
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ import {
|
||||
LIBRARY_SIDEBAR_TAB,
|
||||
} from "../constants";
|
||||
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
|
||||
import { cloneJSON } from "../utils";
|
||||
|
||||
export const libraryItemsAtom = atom<{
|
||||
status: "loading" | "loaded";
|
||||
@ -31,7 +32,7 @@ export const libraryItemsAtom = atom<{
|
||||
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
|
||||
|
||||
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
||||
JSON.parse(JSON.stringify(libraryItems));
|
||||
cloneJSON(libraryItems);
|
||||
|
||||
/**
|
||||
* checks if library item does not exist already in current library
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { exportCanvas } from ".";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { exportCanvas, prepareElementsForExport } from ".";
|
||||
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
||||
|
||||
export const resaveAsImageWithScene = async (
|
||||
@ -23,18 +22,19 @@ export const resaveAsImageWithScene = async (
|
||||
exportEmbedScene: true,
|
||||
};
|
||||
|
||||
await exportCanvas(
|
||||
fileHandleType,
|
||||
getNonDeletedElements(elements),
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
{
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
name,
|
||||
fileHandle,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
await exportCanvas(fileHandleType, exportedElements, appState, files, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
name,
|
||||
fileHandle,
|
||||
exportingFrame,
|
||||
});
|
||||
|
||||
return { fileHandle };
|
||||
};
|
||||
|
@ -189,7 +189,7 @@ const restoreElement = (
|
||||
fontSize = parseFloat(fontPx);
|
||||
fontFamily = getFontFamilyByName(_fontFamily);
|
||||
}
|
||||
const text = element.text ?? "";
|
||||
const text = (typeof element.text === "string" && element.text) || "";
|
||||
|
||||
// line-height might not be specified either when creating elements
|
||||
// programmatically, or when importing old diagrams.
|
||||
@ -222,9 +222,17 @@ const restoreElement = (
|
||||
baseline,
|
||||
});
|
||||
|
||||
// if empty text, mark as deleted. We keep in array
|
||||
// for data integrity purposes (collab etc.)
|
||||
if (!text && !element.isDeleted) {
|
||||
element = { ...element, originalText: text, isDeleted: true };
|
||||
element = bumpVersion(element);
|
||||
}
|
||||
|
||||
if (refreshDimensions) {
|
||||
element = { ...element, ...refreshTextDimensions(element) };
|
||||
}
|
||||
|
||||
return element;
|
||||
case "freedraw": {
|
||||
return restoreElementWithProperties(element, {
|
||||
@ -299,6 +307,7 @@ const restoreElement = (
|
||||
// We also don't want to throw, but instead return void so we filter
|
||||
// out these unsupported elements from the restored array.
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -5,7 +5,31 @@ import {
|
||||
} from "./transform";
|
||||
import { ExcalidrawArrowElement } from "../element/types";
|
||||
|
||||
const opts = { regenerateIds: false };
|
||||
|
||||
describe("Test Transform", () => {
|
||||
it("should generate id unless opts.regenerateIds is set to false explicitly", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
id: "rect-1",
|
||||
},
|
||||
];
|
||||
let data = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
expect(data.length).toBe(1);
|
||||
expect(data[0].id).toBe("id0");
|
||||
|
||||
data = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
expect(data[0].id).toBe("rect-1");
|
||||
});
|
||||
|
||||
it("should transform regular shapes", () => {
|
||||
const elements = [
|
||||
{
|
||||
@ -59,6 +83,7 @@ describe("Test Transform", () => {
|
||||
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
@ -87,6 +112,7 @@ describe("Test Transform", () => {
|
||||
];
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
@ -128,6 +154,7 @@ describe("Test Transform", () => {
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
@ -210,6 +237,7 @@ describe("Test Transform", () => {
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(12);
|
||||
@ -267,6 +295,7 @@ describe("Test Transform", () => {
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(8);
|
||||
@ -280,6 +309,90 @@ describe("Test Transform", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Frames", () => {
|
||||
it("should transform frames and update frame ids when regenerated", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
strokeWidth: 2,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 120,
|
||||
y: 20,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "HELLO EXCALIDRAW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 30,
|
||||
},
|
||||
id: "2",
|
||||
},
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
name: "My frame",
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elementsSkeleton,
|
||||
opts,
|
||||
);
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchObject({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should consider max of calculated and frame dimensions when provided", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
strokeWidth: 2,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 120,
|
||||
y: 20,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "HELLO EXCALIDRAW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 30,
|
||||
},
|
||||
id: "2",
|
||||
},
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
name: "My frame",
|
||||
width: 800,
|
||||
height: 100,
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elementsSkeleton,
|
||||
opts,
|
||||
);
|
||||
const frame = excaldrawElements.find((ele) => ele.type === "frame")!;
|
||||
expect(frame.width).toBe(800);
|
||||
expect(frame.height).toBe(126);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test arrow bindings", () => {
|
||||
it("should bind arrows to shapes when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
@ -300,6 +413,7 @@ describe("Test Transform", () => {
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
@ -321,7 +435,7 @@ describe("Test Transform", () => {
|
||||
});
|
||||
|
||||
expect(text).toMatchObject({
|
||||
x: 340,
|
||||
x: 240,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
@ -341,7 +455,7 @@ describe("Test Transform", () => {
|
||||
});
|
||||
|
||||
expect(ellipse).toMatchObject({
|
||||
x: 555,
|
||||
x: 355,
|
||||
y: 189,
|
||||
type: "ellipse",
|
||||
boundElements: [
|
||||
@ -383,10 +497,10 @@ describe("Test Transform", () => {
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
const [arrow, text1, text2, text3] = excaldrawElements;
|
||||
|
||||
expect(arrow).toMatchObject({
|
||||
@ -406,7 +520,7 @@ describe("Test Transform", () => {
|
||||
});
|
||||
|
||||
expect(text1).toMatchObject({
|
||||
x: 340,
|
||||
x: 240,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
@ -426,7 +540,7 @@ describe("Test Transform", () => {
|
||||
});
|
||||
|
||||
expect(text3).toMatchObject({
|
||||
x: 555,
|
||||
x: 355,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
boundElements: [
|
||||
@ -499,6 +613,7 @@ describe("Test Transform", () => {
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(5);
|
||||
@ -547,6 +662,7 @@ describe("Test Transform", () => {
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
@ -600,17 +716,18 @@ describe("Test Transform", () => {
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
const [, , arrow] = excaldrawElements;
|
||||
const [, , arrow, text] = excaldrawElements;
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [
|
||||
{
|
||||
id: "id46",
|
||||
id: text.id,
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
@ -650,17 +767,18 @@ describe("Test Transform", () => {
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
expect(excaldrawElements.length).toBe(2);
|
||||
const [arrow, rect] = excaldrawElements;
|
||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||
elementId: "rect-1",
|
||||
focus: 0,
|
||||
gap: 5,
|
||||
gap: 205,
|
||||
});
|
||||
expect(rect.boundElements).toStrictEqual([
|
||||
{
|
||||
id: "id47",
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
]);
|
||||
@ -692,6 +810,7 @@ describe("Test Transform", () => {
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(1);
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
getCommonBounds,
|
||||
newElement,
|
||||
newLinearElement,
|
||||
redrawTextBoundingBox,
|
||||
@ -12,6 +13,7 @@ import {
|
||||
import { bindLinearElement } from "../element/binding";
|
||||
import {
|
||||
ElementConstructorOpts,
|
||||
newFrameElement,
|
||||
newImageElement,
|
||||
newTextElement,
|
||||
} from "../element/newElement";
|
||||
@ -38,7 +40,9 @@ import {
|
||||
VerticalAlign,
|
||||
} from "../element/types";
|
||||
import { MarkOptional } from "../utility-types";
|
||||
import { assertNever, getFontString } from "../utils";
|
||||
import { assertNever, cloneJSON, getFontString } from "../utils";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { randomId } from "../random";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
@ -133,9 +137,7 @@ export type ValidContainer =
|
||||
export type ExcalidrawElementSkeleton =
|
||||
| Extract<
|
||||
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawFrameElement
|
||||
ExcalidrawEmbeddableElement | ExcalidrawFreeDrawElement
|
||||
>
|
||||
| ({
|
||||
type: Extract<ExcalidrawLinearElement["type"], "line">;
|
||||
@ -156,10 +158,15 @@ export type ExcalidrawElementSkeleton =
|
||||
x: number;
|
||||
y: number;
|
||||
fileId: FileId;
|
||||
} & Partial<ExcalidrawImageElement>);
|
||||
} & Partial<ExcalidrawImageElement>)
|
||||
| ({
|
||||
type: "frame";
|
||||
children: readonly ExcalidrawElement["id"][];
|
||||
name?: string;
|
||||
} & Partial<ExcalidrawFrameElement>);
|
||||
|
||||
const DEFAULT_LINEAR_ELEMENT_PROPS = {
|
||||
width: 300,
|
||||
width: 100,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
@ -357,6 +364,49 @@ const bindLinearElementToElement = (
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
|
||||
const endPointIndex = linearElement.points.length - 1;
|
||||
const delta = 0.5;
|
||||
|
||||
const newPoints = cloneJSON(linearElement.points) as [number, number][];
|
||||
// left to right so shift the arrow towards right
|
||||
if (
|
||||
linearElement.points[endPointIndex][0] >
|
||||
linearElement.points[endPointIndex - 1][0]
|
||||
) {
|
||||
newPoints[0][0] = delta;
|
||||
newPoints[endPointIndex][0] -= delta;
|
||||
}
|
||||
|
||||
// right to left so shift the arrow towards left
|
||||
if (
|
||||
linearElement.points[endPointIndex][0] <
|
||||
linearElement.points[endPointIndex - 1][0]
|
||||
) {
|
||||
newPoints[0][0] = -delta;
|
||||
newPoints[endPointIndex][0] += delta;
|
||||
}
|
||||
// top to bottom so shift the arrow towards top
|
||||
if (
|
||||
linearElement.points[endPointIndex][1] >
|
||||
linearElement.points[endPointIndex - 1][1]
|
||||
) {
|
||||
newPoints[0][1] = delta;
|
||||
newPoints[endPointIndex][1] -= delta;
|
||||
}
|
||||
|
||||
// bottom to top so shift the arrow towards bottom
|
||||
if (
|
||||
linearElement.points[endPointIndex][1] <
|
||||
linearElement.points[endPointIndex - 1][1]
|
||||
) {
|
||||
newPoints[0][1] = -delta;
|
||||
newPoints[endPointIndex][1] += delta;
|
||||
}
|
||||
|
||||
Object.assign(linearElement, { points: newPoints });
|
||||
|
||||
return {
|
||||
linearElement,
|
||||
startBoundElement,
|
||||
@ -384,18 +434,25 @@ class ElementStore {
|
||||
}
|
||||
|
||||
export const convertToExcalidrawElements = (
|
||||
elements: ExcalidrawElementSkeleton[] | null,
|
||||
elementsSkeleton: ExcalidrawElementSkeleton[] | null,
|
||||
opts?: { regenerateIds: boolean },
|
||||
) => {
|
||||
if (!elements) {
|
||||
if (!elementsSkeleton) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const elements = cloneJSON(elementsSkeleton);
|
||||
const elementStore = new ElementStore();
|
||||
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
|
||||
const oldToNewElementIdMap = new Map<string, string>();
|
||||
|
||||
// Create individual elements
|
||||
for (const element of elements) {
|
||||
let excalidrawElement: ExcalidrawElement;
|
||||
const originalId = element.id;
|
||||
if (opts?.regenerateIds !== false) {
|
||||
Object.assign(element, { id: randomId() });
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
@ -444,6 +501,11 @@ export const convertToExcalidrawElements = (
|
||||
],
|
||||
...element,
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
excalidrawElement,
|
||||
getSizeFromPoints(excalidrawElement.points),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "text": {
|
||||
@ -477,8 +539,15 @@ export const convertToExcalidrawElements = (
|
||||
|
||||
break;
|
||||
}
|
||||
case "frame": {
|
||||
excalidrawElement = newFrameElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "freedraw":
|
||||
case "frame":
|
||||
case "embeddable": {
|
||||
excalidrawElement = element;
|
||||
break;
|
||||
@ -499,6 +568,9 @@ export const convertToExcalidrawElements = (
|
||||
} else {
|
||||
elementStore.add(excalidrawElement);
|
||||
elementsWithIds.set(excalidrawElement.id, element);
|
||||
if (originalId) {
|
||||
oldToNewElementIdMap.set(originalId, excalidrawElement.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -524,6 +596,18 @@ export const convertToExcalidrawElements = (
|
||||
element.type === "arrow" ? element?.start : undefined;
|
||||
const originalEnd =
|
||||
element.type === "arrow" ? element?.end : undefined;
|
||||
if (originalStart && originalStart.id) {
|
||||
const newStartId = oldToNewElementIdMap.get(originalStart.id);
|
||||
if (newStartId) {
|
||||
Object.assign(originalStart, { id: newStartId });
|
||||
}
|
||||
}
|
||||
if (originalEnd && originalEnd.id) {
|
||||
const newEndId = oldToNewElementIdMap.get(originalEnd.id);
|
||||
if (newEndId) {
|
||||
Object.assign(originalEnd, { id: newEndId });
|
||||
}
|
||||
}
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
container as ExcalidrawArrowElement,
|
||||
@ -539,13 +623,23 @@ export const convertToExcalidrawElements = (
|
||||
} else {
|
||||
switch (element.type) {
|
||||
case "arrow": {
|
||||
const { start, end } = element;
|
||||
if (start && start.id) {
|
||||
const newStartId = oldToNewElementIdMap.get(start.id);
|
||||
Object.assign(start, { id: newStartId });
|
||||
}
|
||||
if (end && end.id) {
|
||||
const newEndId = oldToNewElementIdMap.get(end.id);
|
||||
Object.assign(end, { id: newEndId });
|
||||
}
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
excalidrawElement as ExcalidrawArrowElement,
|
||||
element.start,
|
||||
element.end,
|
||||
start,
|
||||
end,
|
||||
elementStore,
|
||||
);
|
||||
|
||||
elementStore.add(linearElement);
|
||||
elementStore.add(startBoundElement);
|
||||
elementStore.add(endBoundElement);
|
||||
@ -557,5 +651,60 @@ export const convertToExcalidrawElements = (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Once all the excalidraw elements are created, we can add frames since we
|
||||
// need to calculate coordinates and dimensions of frame which is possibe after all
|
||||
// frame children are processed.
|
||||
for (const [id, element] of elementsWithIds) {
|
||||
if (element.type !== "frame") {
|
||||
continue;
|
||||
}
|
||||
const frame = elementStore.getElement(id);
|
||||
|
||||
if (!frame) {
|
||||
throw new Error(`Excalidraw element with id ${id} doesn't exist`);
|
||||
}
|
||||
const childrenElements: ExcalidrawElement[] = [];
|
||||
|
||||
element.children.forEach((id) => {
|
||||
const newElementId = oldToNewElementIdMap.get(id);
|
||||
if (!newElementId) {
|
||||
throw new Error(`Element with ${id} wasn't mapped correctly`);
|
||||
}
|
||||
|
||||
const elementInFrame = elementStore.getElement(newElementId);
|
||||
if (!elementInFrame) {
|
||||
throw new Error(`Frame element with id ${newElementId} doesn't exist`);
|
||||
}
|
||||
Object.assign(elementInFrame, { frameId: frame.id });
|
||||
|
||||
elementInFrame?.boundElements?.forEach((boundElement) => {
|
||||
const ele = elementStore.getElement(boundElement.id);
|
||||
if (!ele) {
|
||||
throw new Error(
|
||||
`Bound element with id ${boundElement.id} doesn't exist`,
|
||||
);
|
||||
}
|
||||
Object.assign(ele, { frameId: frame.id });
|
||||
childrenElements.push(ele);
|
||||
});
|
||||
|
||||
childrenElements.push(elementInFrame);
|
||||
});
|
||||
|
||||
let [minX, minY, maxX, maxY] = getCommonBounds(childrenElements);
|
||||
|
||||
const PADDING = 10;
|
||||
minX = minX - PADDING;
|
||||
minY = minY - PADDING;
|
||||
maxX = maxX + PADDING;
|
||||
maxY = maxY + PADDING;
|
||||
|
||||
// Take the max of calculated and provided frame dimensions, whichever is higher
|
||||
const width = Math.max(frame?.width, maxX - minX);
|
||||
const height = Math.max(frame?.height, maxY - minY);
|
||||
Object.assign(frame, { x: minX, y: minY, width, height });
|
||||
}
|
||||
|
||||
return elementStore.getElements();
|
||||
};
|
||||
|
@ -392,7 +392,7 @@ export const getLinkHandleFromCoords = (
|
||||
[x1, y1, x2, y2]: Bounds,
|
||||
angle: number,
|
||||
appState: Pick<UIAppState, "zoom">,
|
||||
): [x: number, y: number, width: number, height: number] => {
|
||||
): Bounds => {
|
||||
const size = DEFAULT_LINK_SIZE;
|
||||
const linkWidth = size / appState.zoom.value;
|
||||
const linkHeight = size / appState.zoom.value;
|
||||
|
@ -34,7 +34,12 @@ export type RectangleBox = {
|
||||
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number];
|
||||
export type Bounds = readonly [
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number,
|
||||
];
|
||||
|
||||
export class ElementBounds {
|
||||
private static boundsCache = new WeakMap<
|
||||
@ -63,7 +68,7 @@ export class ElementBounds {
|
||||
}
|
||||
|
||||
private static calculateBounds(element: ExcalidrawElement): Bounds {
|
||||
let bounds: [number, number, number, number];
|
||||
let bounds: Bounds;
|
||||
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||
|
||||
@ -387,7 +392,7 @@ const getCubicBezierCurveBound = (
|
||||
export const getMinMaxXYFromCurvePathOps = (
|
||||
ops: Op[],
|
||||
transformXY?: (x: number, y: number) => [number, number],
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
let currentP: Point = [0, 0];
|
||||
|
||||
const { minX, minY, maxX, maxY } = ops.reduce(
|
||||
@ -435,9 +440,9 @@ export const getMinMaxXYFromCurvePathOps = (
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
const getBoundsFromPoints = (
|
||||
export const getBoundsFromPoints = (
|
||||
points: ExcalidrawFreeDrawElement["points"],
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
@ -589,7 +594,7 @@ const getLinearElementRotatedBounds = (
|
||||
element: ExcalidrawLinearElement,
|
||||
cx: number,
|
||||
cy: number,
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
if (element.points.length < 2) {
|
||||
const [pointX, pointY] = element.points[0];
|
||||
const [x, y] = rotate(
|
||||
@ -600,7 +605,7 @@ const getLinearElementRotatedBounds = (
|
||||
element.angle,
|
||||
);
|
||||
|
||||
let coords: [number, number, number, number] = [x, y, x, y];
|
||||
let coords: Bounds = [x, y, x, y];
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
|
||||
@ -625,12 +630,7 @@ const getLinearElementRotatedBounds = (
|
||||
const transformXY = (x: number, y: number) =>
|
||||
rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
||||
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||
let coords: [number, number, number, number] = [
|
||||
res[0],
|
||||
res[1],
|
||||
res[2],
|
||||
res[3],
|
||||
];
|
||||
let coords: Bounds = [res[0], res[1], res[2], res[3]];
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
|
||||
@ -692,7 +692,7 @@ export const getResizedElementAbsoluteCoords = (
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
normalizePoints: boolean,
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
|
||||
return [
|
||||
element.x,
|
||||
@ -709,7 +709,7 @@ export const getResizedElementAbsoluteCoords = (
|
||||
normalizePoints,
|
||||
);
|
||||
|
||||
let bounds: [number, number, number, number];
|
||||
let bounds: Bounds;
|
||||
|
||||
if (isFreeDrawElement(element)) {
|
||||
// Free Draw
|
||||
@ -740,7 +740,7 @@ export const getResizedElementAbsoluteCoords = (
|
||||
export const getElementPointsCoords = (
|
||||
element: ExcalidrawLinearElement,
|
||||
points: readonly (readonly [number, number])[],
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
// This might be computationally heavey
|
||||
const gen = rough.generator();
|
||||
const curve =
|
||||
|
@ -494,7 +494,9 @@ const hitTestFreeDrawElement = (
|
||||
// for filled freedraw shapes, support
|
||||
// selecting from inside
|
||||
if (shape && shape.sets.length) {
|
||||
return hitTestRoughShape(shape, x, y, threshold);
|
||||
return element.fillStyle === "solid"
|
||||
? hitTestCurveInside(shape, x, y, "round")
|
||||
: hitTestRoughShape(shape, x, y, threshold);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { Bounds, getCommonBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
@ -8,7 +8,11 @@ import { getBoundTextElement } from "./textElement";
|
||||
import { isSelectedViaGroup } from "../groups";
|
||||
import { getGridPoint } from "../math";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isFrameElement } from "./typeChecks";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
@ -35,44 +39,41 @@ export const dragSelectedElements = (
|
||||
if (frames.length > 0) {
|
||||
const elementsInFrames = scene
|
||||
.getNonDeletedElements()
|
||||
.filter((e) => !isBoundToContainer(e))
|
||||
.filter((e) => e.frameId !== null)
|
||||
.filter((e) => frames.includes(e.frameId!));
|
||||
|
||||
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
|
||||
}
|
||||
|
||||
const commonBounds = getCommonBounds(
|
||||
Array.from(elementsToUpdate).map(
|
||||
(el) => pointerDownState.originalElements.get(el.id) ?? el,
|
||||
),
|
||||
);
|
||||
const adjustedOffset = calculateOffset(
|
||||
commonBounds,
|
||||
offset,
|
||||
snapOffset,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(
|
||||
pointerDownState,
|
||||
element,
|
||||
offset,
|
||||
snapOffset,
|
||||
gridSize,
|
||||
);
|
||||
updateElementCoords(pointerDownState, element, adjustedOffset);
|
||||
// update coords of bound text only if we're dragging the container directly
|
||||
// (we don't drag the group that it's part of)
|
||||
if (
|
||||
// Don't update coords of arrow label since we calculate its position during render
|
||||
!isArrowElement(element) &&
|
||||
// container isn't part of any group
|
||||
// (perf optim so we don't check `isSelectedViaGroup()` in every case)
|
||||
!element.groupIds.length ||
|
||||
// container is part of a group, but we're dragging the container directly
|
||||
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
|
||||
(!element.groupIds.length ||
|
||||
// container is part of a group, but we're dragging the container directly
|
||||
(appState.editingGroupId && !isSelectedViaGroup(appState, element)))
|
||||
) {
|
||||
const textElement = getBoundTextElement(element);
|
||||
if (
|
||||
textElement &&
|
||||
// when container is added to a frame, so will its bound text
|
||||
// so the text is already in `elementsToUpdate` and we should avoid
|
||||
// updating its coords again
|
||||
(!textElement.frameId || !frames.includes(textElement.frameId))
|
||||
) {
|
||||
updateElementCoords(
|
||||
pointerDownState,
|
||||
textElement,
|
||||
offset,
|
||||
snapOffset,
|
||||
gridSize,
|
||||
);
|
||||
if (textElement) {
|
||||
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
||||
}
|
||||
}
|
||||
updateBoundElements(element, {
|
||||
@ -81,23 +82,20 @@ export const dragSelectedElements = (
|
||||
});
|
||||
};
|
||||
|
||||
const updateElementCoords = (
|
||||
pointerDownState: PointerDownState,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
const calculateOffset = (
|
||||
commonBounds: Bounds,
|
||||
dragOffset: { x: number; y: number },
|
||||
snapOffset: { x: number; y: number },
|
||||
gridSize: AppState["gridSize"],
|
||||
) => {
|
||||
const originalElement =
|
||||
pointerDownState.originalElements.get(element.id) ?? element;
|
||||
|
||||
let nextX = originalElement.x + dragOffset.x + snapOffset.x;
|
||||
let nextY = originalElement.y + dragOffset.y + snapOffset.y;
|
||||
): { x: number; y: number } => {
|
||||
const [x, y] = commonBounds;
|
||||
let nextX = x + dragOffset.x + snapOffset.x;
|
||||
let nextY = y + dragOffset.y + snapOffset.y;
|
||||
|
||||
if (snapOffset.x === 0 || snapOffset.y === 0) {
|
||||
const [nextGridX, nextGridY] = getGridPoint(
|
||||
originalElement.x + dragOffset.x,
|
||||
originalElement.y + dragOffset.y,
|
||||
x + dragOffset.x,
|
||||
y + dragOffset.y,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
@ -109,6 +107,22 @@ const updateElementCoords = (
|
||||
nextY = nextGridY;
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: nextX - x,
|
||||
y: nextY - y,
|
||||
};
|
||||
};
|
||||
|
||||
const updateElementCoords = (
|
||||
pointerDownState: PointerDownState,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
dragOffset: { x: number; y: number },
|
||||
) => {
|
||||
const originalElement =
|
||||
pointerDownState.originalElements.get(element.id) ?? element;
|
||||
|
||||
const nextX = originalElement.x + dragOffset.x;
|
||||
const nextY = originalElement.y + dragOffset.y;
|
||||
|
||||
mutateElement(element, {
|
||||
x: nextX,
|
||||
|
@ -2,7 +2,8 @@ import { register } from "../actions/register";
|
||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { ExcalidrawProps } from "../types";
|
||||
import { getFontString, setCursorForShape, updateActiveTool } from "../utils";
|
||||
import { getFontString, updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { newTextElement } from "./newElement";
|
||||
import { getContainerElement, wrapText } from "./textElement";
|
||||
import { isEmbeddableElement } from "./typeChecks";
|
||||
@ -27,6 +28,7 @@ const embeddedLinkCache = new Map<string, EmbeddedLink>();
|
||||
|
||||
const RE_YOUTUBE =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
||||
|
||||
const RE_VIMEO =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
|
||||
@ -46,6 +48,9 @@ const RE_VALTOWN =
|
||||
const RE_GENERIC_EMBED =
|
||||
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
|
||||
|
||||
const RE_GIPHY =
|
||||
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
|
||||
|
||||
const ALLOWED_DOMAINS = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
@ -58,6 +63,8 @@ const ALLOWED_DOMAINS = new Set([
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"val.town",
|
||||
"giphy.com",
|
||||
"dddice.com",
|
||||
]);
|
||||
|
||||
const createSrcDoc = (body: string) => {
|
||||
@ -194,7 +201,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
||||
return { link, aspectRatio, type };
|
||||
};
|
||||
|
||||
export const isEmbeddableOrFrameLabel = (
|
||||
export const isEmbeddableOrLabel = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
): Boolean => {
|
||||
if (isEmbeddableElement(element)) {
|
||||
@ -307,6 +314,10 @@ export const extractSrc = (htmlString: string): string => {
|
||||
return gistMatch[1];
|
||||
}
|
||||
|
||||
if (RE_GIPHY.test(htmlString)) {
|
||||
return `https://giphy.com/embed/${RE_GIPHY.exec(htmlString)![1]}`;
|
||||
}
|
||||
|
||||
const match = htmlString.match(RE_GENERIC_EMBED);
|
||||
if (match && match.length === 2) {
|
||||
return match[1];
|
||||
|
@ -21,6 +21,7 @@ import {
|
||||
} from "../math";
|
||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||
import {
|
||||
Bounds,
|
||||
getCurvePathOps,
|
||||
getElementPointsCoords,
|
||||
getMinMaxXYFromCurvePathOps,
|
||||
@ -1316,7 +1317,7 @@ export class LinearElementEditor {
|
||||
|
||||
static getMinMaxXYWithBoundText = (
|
||||
element: ExcalidrawLinearElement,
|
||||
elementBounds: [number, number, number, number],
|
||||
elementBounds: Bounds,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
): [number, number, number, number, number, number] => {
|
||||
let [x1, y1, x2, y2] = elementBounds;
|
||||
|
@ -140,8 +140,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
*
|
||||
* NOTE: does not trigger re-render.
|
||||
*/
|
||||
export const bumpVersion = (
|
||||
element: Mutable<ExcalidrawElement>,
|
||||
export const bumpVersion = <T extends Mutable<ExcalidrawElement>>(
|
||||
element: T,
|
||||
version?: ExcalidrawElement["version"],
|
||||
) => {
|
||||
element.version = (version ?? element.version) + 1;
|
||||
|
@ -144,13 +144,15 @@ export const newEmbeddableElement = (
|
||||
};
|
||||
|
||||
export const newFrameElement = (
|
||||
opts: ElementConstructorOpts,
|
||||
opts: {
|
||||
name?: string;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFrameElement> => {
|
||||
const frameElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
|
||||
type: "frame",
|
||||
name: null,
|
||||
name: opts?.name || null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
MaybeTransformHandleType,
|
||||
} from "./transformHandles";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { Bounds } from "./bounds";
|
||||
|
||||
const isInsideTransformHandle = (
|
||||
transformHandle: TransformHandle,
|
||||
@ -87,7 +88,7 @@ export const getElementWithTransformHandleType = (
|
||||
};
|
||||
|
||||
export const getTransformHandleTypeFromCoords = (
|
||||
[x1, y1, x2, y2]: readonly [number, number, number, number],
|
||||
[x1, y1, x2, y2]: Bounds,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
zoom: Zoom,
|
||||
|
@ -91,7 +91,7 @@ export const redrawTextBoundingBox = (
|
||||
);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
if (!isArrowElement(container) && metrics.height > maxContainerHeight) {
|
||||
const nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
|
@ -17,8 +17,8 @@ import {
|
||||
} from "./types";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { resize } from "../tests/utils";
|
||||
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
|
||||
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
@ -26,16 +26,7 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
const tab = " ";
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const getTextEditor = () => {
|
||||
return document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => {
|
||||
fireEvent.change(editor, { target: { value } });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
};
|
||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||
|
||||
describe("textWysiwyg", () => {
|
||||
describe("start text editing", () => {
|
||||
@ -186,7 +177,7 @@ describe("textWysiwyg", () => {
|
||||
expect(h.state.editingElement?.id).toBe(boundText.id);
|
||||
});
|
||||
|
||||
it("should edit text under cursor when clicked with text tool", () => {
|
||||
it("should edit text under cursor when clicked with text tool", async () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
@ -201,14 +192,14 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, false);
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should edit text under cursor when double-clicked with selection tool", () => {
|
||||
it("should edit text under cursor when double-clicked with selection tool", async () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
@ -223,12 +214,26 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, false);
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
// FIXME too flaky. No one knows why.
|
||||
it.skip("should bump the version of a labeled arrow when the label is updated", async () => {
|
||||
const arrow = UI.createElement("arrow", {
|
||||
width: 300,
|
||||
height: 0,
|
||||
});
|
||||
await UI.editText(arrow, "Hello");
|
||||
const { version } = arrow;
|
||||
|
||||
await UI.editText(arrow, "Hello\nworld!");
|
||||
|
||||
expect(arrow.version).toEqual(version + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test container-unbound text", () => {
|
||||
@ -244,13 +249,15 @@ describe("textWysiwyg", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
//@ts-ignore
|
||||
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
|
||||
// @ts-ignore
|
||||
h.app.refreshViewportBreakpoints();
|
||||
// @ts-ignore
|
||||
h.app.refreshEditorBreakpoints();
|
||||
|
||||
textElement = UI.createElement("text");
|
||||
|
||||
mouse.clickOn(textElement);
|
||||
textarea = getTextEditor();
|
||||
textarea = await getTextEditor(textEditorSelector, true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@ -460,7 +467,7 @@ describe("textWysiwyg", () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(750, 300);
|
||||
|
||||
textarea = getTextEditor();
|
||||
textarea = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(
|
||||
textarea,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
@ -512,7 +519,7 @@ describe("textWysiwyg", () => {
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
@ -540,7 +547,7 @@ describe("textWysiwyg", () => {
|
||||
]);
|
||||
expect(text.angle).toBe(rectangle.angle);
|
||||
mouse.down();
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
@ -567,7 +574,7 @@ describe("textWysiwyg", () => {
|
||||
API.setSelectedElements([diamond]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const value = new Array(1000).fill("1").join("\n");
|
||||
@ -602,7 +609,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
let editor = getTextEditor();
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
@ -617,7 +624,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
mouse.down();
|
||||
editor = getTextEditor();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@ -639,7 +646,7 @@ describe("textWysiwyg", () => {
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
@ -674,7 +681,7 @@ describe("textWysiwyg", () => {
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@ -699,7 +706,7 @@ describe("textWysiwyg", () => {
|
||||
freedraw.y + freedraw.height / 2,
|
||||
);
|
||||
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
|
||||
@ -733,7 +740,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
@ -748,7 +755,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(
|
||||
editor,
|
||||
@ -793,7 +800,7 @@ describe("textWysiwyg", () => {
|
||||
mouse.down();
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = getTextEditor();
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
@ -806,7 +813,7 @@ describe("textWysiwyg", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
mouse.down();
|
||||
editor = getTextEditor();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
editor.select();
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
@ -839,7 +846,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = getTextEditor();
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
@ -860,7 +867,7 @@ describe("textWysiwyg", () => {
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = getTextEditor();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@ -889,7 +896,7 @@ describe("textWysiwyg", () => {
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
@ -926,7 +933,7 @@ describe("textWysiwyg", () => {
|
||||
// Bind first text
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
@ -947,13 +954,13 @@ describe("textWysiwyg", () => {
|
||||
it("should respect text alignment when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
let editor = getTextEditor();
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
// should center align horizontally and vertically by default
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
[
|
||||
85,
|
||||
@ -964,7 +971,7 @@ describe("textWysiwyg", () => {
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = getTextEditor();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
editor.select();
|
||||
|
||||
@ -977,7 +984,7 @@ describe("textWysiwyg", () => {
|
||||
editor.blur();
|
||||
|
||||
// should left align horizontally and bottom vertically after resize
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
[
|
||||
15,
|
||||
@ -987,7 +994,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = getTextEditor();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
editor.select();
|
||||
|
||||
@ -999,7 +1006,7 @@ describe("textWysiwyg", () => {
|
||||
editor.blur();
|
||||
|
||||
// should right align horizontally and top vertically after resize
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
[
|
||||
374.99999999999994,
|
||||
@ -1025,7 +1032,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
mouse.down();
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
@ -1040,7 +1047,7 @@ describe("textWysiwyg", () => {
|
||||
it("should scale font size correctly when resizing using shift", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
@ -1049,7 +1056,7 @@ describe("textWysiwyg", () => {
|
||||
expect(rectangle.height).toBe(75);
|
||||
expect(textElement.fontSize).toBe(20);
|
||||
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
|
||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
|
||||
shift: true,
|
||||
});
|
||||
expect(rectangle.width).toBe(200);
|
||||
@ -1060,7 +1067,7 @@ describe("textWysiwyg", () => {
|
||||
it("should bind text correctly when container duplicated with alt-drag", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
@ -1092,7 +1099,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
it("undo should work", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
@ -1129,7 +1136,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
it("should not allow bound text with only whitespaces", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
updateTextEditor(editor, " ");
|
||||
@ -1184,19 +1191,19 @@ describe("textWysiwyg", () => {
|
||||
it("should reset the container height cache when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
let editor = getTextEditor();
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect(rectangle.height).toBeCloseTo(155, 8);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = getTextEditor();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@ -1212,7 +1219,7 @@ describe("textWysiwyg", () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
|
||||
@ -1237,7 +1244,7 @@ describe("textWysiwyg", () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(
|
||||
@ -1269,12 +1276,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = getTextEditor();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = getTextEditor();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
editor.select();
|
||||
});
|
||||
|
||||
@ -1385,7 +1392,7 @@ describe("textWysiwyg", () => {
|
||||
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(
|
||||
editor,
|
||||
@ -1431,7 +1438,7 @@ describe("textWysiwyg", () => {
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
fillStyle: "hachure",
|
||||
fillStyle: "solid",
|
||||
groupIds: [],
|
||||
height: 35,
|
||||
isDeleted: false,
|
||||
@ -1444,7 +1451,7 @@ describe("textWysiwyg", () => {
|
||||
},
|
||||
strokeColor: "#1e1e1e",
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
strokeWidth: 2,
|
||||
type: "rectangle",
|
||||
updated: 1,
|
||||
version: 1,
|
||||
@ -1473,7 +1480,7 @@ describe("textWysiwyg", () => {
|
||||
// Bind first text
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
let editor = getTextEditor();
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello!");
|
||||
expect(
|
||||
@ -1498,7 +1505,7 @@ describe("textWysiwyg", () => {
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
editor = getTextEditor();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Excalidraw");
|
||||
editor.blur();
|
||||
@ -1512,30 +1519,4 @@ describe("textWysiwyg", () => {
|
||||
expect(text.text).toBe("Excalidraw");
|
||||
});
|
||||
});
|
||||
|
||||
it("should bump the version of labelled arrow when label updated", async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
const arrow = UI.createElement("arrow", {
|
||||
width: 300,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
mouse.select(arrow);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
const { version } = arrow;
|
||||
|
||||
mouse.select(arrow);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello\nworld!");
|
||||
editor.blur();
|
||||
|
||||
expect(arrow.version).toEqual(version + 1);
|
||||
});
|
||||
});
|
||||
|
@ -584,7 +584,7 @@ export const textWysiwyg = ({
|
||||
window.removeEventListener("pointerdown", onPointerDown);
|
||||
window.removeEventListener("pointerup", bindBlurEvent);
|
||||
window.removeEventListener("blur", handleSubmit);
|
||||
|
||||
window.removeEventListener("beforeunload", handleSubmit);
|
||||
unbindUpdate();
|
||||
|
||||
editable.remove();
|
||||
@ -701,6 +701,7 @@ export const textWysiwyg = ({
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
window.addEventListener("beforeunload", handleSubmit);
|
||||
excalidrawContainer
|
||||
?.querySelector(".excalidraw-textEditorContainer")!
|
||||
.appendChild(editable);
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
PointerType,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { Bounds, getElementAbsoluteCoords } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
import { InteractiveCanvasAppState, Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
@ -23,7 +23,7 @@ export type TransformHandleDirection =
|
||||
|
||||
export type TransformHandleType = TransformHandleDirection | "rotation";
|
||||
|
||||
export type TransformHandle = [number, number, number, number];
|
||||
export type TransformHandle = Bounds;
|
||||
export type TransformHandles = Partial<{
|
||||
[T in TransformHandleType]: TransformHandle;
|
||||
}>;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user