Compare commits

..

1 Commits

199 changed files with 4645 additions and 8719 deletions
-5
View File
@@ -23,11 +23,6 @@ REACT_APP_DEV_DISABLE_LIVE_RELOAD=
FAST_REFRESH=false FAST_REFRESH=false
# MATOMO
REACT_APP_MATOMO_URL=
REACT_APP_CDN_MATOMO_TRACKER_URL=
REACT_APP_MATOMO_SITE_ID=
#Debug flags #Debug flags
# To enable bounding box for text containers # To enable bounding box for text containers
-7
View File
@@ -12,13 +12,6 @@ REACT_APP_WS_SERVER_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# production-only vars # production-only vars
# GOOGLE ANALYTICS
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13 REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
# MATOMO
REACT_APP_MATOMO_URL=https://excalidraw.matomo.cloud/
REACT_APP_CDN_MATOMO_TRACKER_URL=//cdn.matomo.cloud/excalidraw.matomo.cloud/matomo.js
REACT_APP_MATOMO_SITE_ID=1
REACT_APP_PLUS_APP=https://app.excalidraw.com REACT_APP_PLUS_APP=https://app.excalidraw.com
+1 -1
View File
@@ -17,7 +17,7 @@
An open source virtual hand-drawn style whiteboard. </br> An open source virtual hand-drawn style whiteboard. </br>
Collaborative and end-to-end encrypted. </br> Collaborative and end-to-end encrypted. </br>
<br /> <br />
</h2> </h3>
</div> </div>
<br /> <br />
@@ -1,19 +1,6 @@
# ref # ref
<pre> <pre>
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs"> <a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">createRef</a> &#124; <a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a> &#124; <a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">callbackRef</a> &#124; <br/>&#123; current: &#123; readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">resolvablePromise</a> } }
createRef
</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
callbackRef
</a>{" "}
&#124; <br />
&#123; current: &#123; readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
resolvablePromise
</a> } }
</pre> </pre>
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs: You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs:
@@ -152,9 +139,7 @@ function App() {
return ( return (
<div style={{ height: "500px" }}> <div style={{ height: "500px" }}>
<p style={{ fontSize: "16px" }}> Click to update the scene</p> <p style={{ fontSize: "16px" }}> Click to update the scene</p>
<button className="custom-button" onClick={updateScene}> <button className="custom-button" onClick={updateScene}>Update Scene</button>
Update Scene
</button>
<Excalidraw ref={(api) => setExcalidrawAPI(api)} /> <Excalidraw ref={(api) => setExcalidrawAPI(api)} />
</div> </div>
); );
@@ -202,8 +187,7 @@ function App() {
return ( return (
<div style={{ height: "500px" }}> <div style={{ height: "500px" }}>
<p style={{ fontSize: "16px" }}> Click to update the library items</p> <p style={{ fontSize: "16px" }}> Click to update the library items</p>
<button <button className="custom-button"
className="custom-button"
onClick={() => { onClick={() => {
const libraryItems = [ const libraryItems = [
{ {
@@ -221,8 +205,10 @@ function App() {
]; ];
excalidrawAPI.updateLibrary({ excalidrawAPI.updateLibrary({
libraryItems, libraryItems,
openLibraryMenu: true, openLibraryMenu: true
}); });
}} }}
> >
Update Library Update Library
@@ -264,7 +250,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
<pre> <pre>
() =>{" "} () =>{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
ExcalidrawElement[] ExcalidrawElement[]
</a> </a>
</pre> </pre>
@@ -275,7 +261,7 @@ Returns all the elements including the deleted in the scene.
<pre> <pre>
() => NonDeleted&#60; () => NonDeleted&#60;
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
ExcalidrawElement ExcalidrawElement
</a> </a>
[]&#62; []&#62;
@@ -307,31 +293,18 @@ This is the history API. history.clear() will clear the history.
## scrollToContent ## scrollToContent
<pre> <pre>
(<br /> (target?:{" "}
{" "} <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement ExcalidrawElement
</a>{" "} </a>{" "}
&#124;{" "} &#124;{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
ExcalidrawElement ExcalidrawElement
</a> </a>
[], []) => void
<br />
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
&#125;
<br />) => void
</pre> </pre>
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene. Scroll the nearest element out of the elements supplied to the center. Defaults to the elements on the scene.
| Attribute | type | default | Description |
| --- | --- | --- | --- |
| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
## refresh ## refresh
@@ -350,7 +323,7 @@ For any other cases if the position of excalidraw is updated (example due to scr
This API can be used to show the toast with custom message. This API can be used to show the toast with custom message.
```tsx ```tsx
({ message: string, closable?:boolean,duration?:number ({ message: string, closable?:boolean,duration?:number
} | null) => void } | null) => void
``` ```
@@ -385,18 +358,15 @@ This API can be used to get the files present in the scene. It may contain files
This API has the below signature. It sets the `tool` passed in param as the active tool. This API has the below signature. It sets the `tool` passed in param as the active tool.
<pre> <pre>
(tool: <br /> &#123; type:{" "} (tool: <br/> &#123; type: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">SHAPES</a>[number]["value"]&#124; "eraser" &#125; &#124;<br/> &#123; type: "custom"; customType: string &#125;) => void
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
SHAPES
</a>
[number]["value"]&#124; "eraser" &#125; &#124;
<br /> &#123; type: "custom"; customType: string &#125;) => void
</pre> </pre>
## setCursor ## setCursor
This API can be used to customise the mouse cursor on the canvas and has the below signature. It sets the mouse cursor to the cursor passed in param. This API can be used to customise the mouse cursor on the canvas and has the below signature.
It sets the mouse cursor to the cursor passed in param.
```tsx ```tsx
(cursor: string) => void (cursor: string) => void
@@ -31,29 +31,10 @@ You can pass `null` / `undefined` if not applicable.
restoreElements( restoreElements(
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>&nbsp; elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>&nbsp; localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>&nbsp;
opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/> refreshDimensions?: boolean<br/>
) )
</pre> </pre>
| Prop | Type | Description |
| ---- | ---- | ---- |
| `elements` | <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a> | The `elements` to be restored |
| [`localElements`](#localelements) | <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> &#124; null &#124; undefined | When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`. |
| [`opts`](#opts) | `Object` | The extra optional parameter to configure restored elements
#### localElements
When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`.
Use this when you `import` elements which may already be present in the scene to ensure that you do not disregard the newly imported elements if you're using element version to detect the update
#### opts
The extra optional parameter to configure restored elements. It has the following attributes
| Prop | Type | Description|
| --- | --- | ------|
| `refreshDimensions` | `boolean` | Indicates whether we should also `recalculate` text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
| `repairBindings` |`boolean` | Indicates whether the `bindings` for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
**_How to use_** **_How to use_**
```js ```js
@@ -62,6 +43,9 @@ import { restoreElements } from "@excalidraw/excalidraw";
This function will make sure all properties of element is correctly set and if any attribute is missing, it will be set to its default value. This function will make sure all properties of element is correctly set and if any attribute is missing, it will be set to its default value.
When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`.
Use this when you import elements which may already be present in the scene to ensure that you do not disregard the newly imported elements if you're using element version to detect the updates.
Parameter `refreshDimensions` indicates whether we should also `recalculate` text element dimensions. Defaults to `false`. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. Parameter `refreshDimensions` indicates whether we should also `recalculate` text element dimensions. Defaults to `false`. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration.
### restore ### restore
@@ -72,9 +56,7 @@ Parameter `refreshDimensions` indicates whether we should also `recalculate` tex
restore( restore(
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState</a>,<br/>&nbsp; data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState</a>,<br/>&nbsp;
localAppState: Partial&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>> | null | undefined,<br/>&nbsp; localAppState: Partial&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>> | null | undefined,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L4">DataState</a><br/> localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L4">DataState</a>
opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/>
) )
</pre> </pre>
@@ -339,47 +339,3 @@ The `device` has the following `attributes`
| `isMobile` | `boolean` | Set to `true` when the device is `mobile` | | `isMobile` | `boolean` | Set to `true` when the device is `mobile` |
| `isTouchScreen` | `boolean` | Set to `true` for `touch` devices | | `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
| `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` | | `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |
### i18n
To help with localization, we export the following.
| name | type |
| --- | --- |
| `defaultLang` | `string` |
| `languages` | [`Language[]`](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L15) |
| `useI18n` | [`() => { langCode, t }`](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L15) |
```js
import { defaultLang, languages, useI18n } from "@excalidraw/excalidraw";
```
#### defaultLang
Default language code, `en`.
#### languages
List of supported language codes. You can pass any of these to `Excalidraw`'s [`langCode` prop](/docs/@excalidraw/excalidraw/api/props/#langcode).
#### useI18n
A hook that returns the current language code and translation helper function. You can use this to translate strings in the components you render as children of `<Excalidraw>`.
```jsx live
function App() {
const { t } = useI18n();
return (
<div style={{ height: "500px" }}>
<Excalidraw>
<button
style={{ position: "absolute", zIndex: 10, height: "2rem" }}
onClick={() => window.alert(t("labels.madeWithExcalidraw"))}
>
{t("buttons.confirm")}
</button>
</Excalidraw>
</div>
);
}
```
+1 -1
View File
@@ -18,7 +18,7 @@
"@docusaurus/core": "2.2.0", "@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0", "@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0", "@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.15.2", "@excalidraw/excalidraw": "0.14.2",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3", "docusaurus-plugin-sass": "0.2.3",
@@ -24,7 +24,6 @@ const ExcalidrawScope = {
Sidebar: ExcalidrawComp.Sidebar, Sidebar: ExcalidrawComp.Sidebar,
exportToCanvas: ExcalidrawComp.exportToCanvas, exportToCanvas: ExcalidrawComp.exportToCanvas,
initialData, initialData,
useI18n: ExcalidrawComp.useI18n,
}; };
export default ExcalidrawScope; export default ExcalidrawScope;
+7 -7
View File
@@ -1631,10 +1631,10 @@
url-loader "^4.1.1" url-loader "^4.1.1"
webpack "^5.73.0" webpack "^5.73.0"
"@excalidraw/excalidraw@0.15.2": "@excalidraw/excalidraw@0.14.2":
version "0.15.2" version "0.14.2"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2.tgz#7dba4f6e10c52015a007efb75a9fc1afe598574c" resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.14.2.tgz#150cb4b7a1bf0d11cd64295936c930e7e0db8375"
integrity sha512-rTI02kgWSTXiUdIkBxt9u/581F3eXcqQgJdIxmz54TFtG3ughoxO5fr4t7Fr2LZIturBPqfocQHGKZ0t2KLKgw== integrity sha512-8LdjpTBWEK5waDWB7Bt/G9YBI4j0OxkstUhvaDGz7dwQGfzF6FW5CXBoYHNEoX0qmb+Fg/NPOlZ7FrKsrSVCqg==
"@hapi/hoek@^9.0.0": "@hapi/hoek@^9.0.0":
version "9.3.0" version "9.3.0"
@@ -7159,9 +7159,9 @@ typescript@^4.7.4:
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
ua-parser-js@^0.7.30: ua-parser-js@^0.7.30:
version "0.7.33" version "0.7.31"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.31.tgz#649a656b191dffab4f21d5e053e27ca17cbff5c6"
integrity sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw== integrity sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==
unescape@^1.0.1: unescape@^1.0.1:
version "1.0.1" version "1.0.1"
+3 -3
View File
@@ -19,7 +19,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-tabs": "1.0.2", "@dwelle/tunnel-rat": "0.1.1",
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2", "@testing-library/jest-dom": "5.16.2",
@@ -50,8 +50,8 @@
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"roughjs": "4.5.2", "roughjs": "4.5.2",
"sass": "1.51.0", "sass": "1.51.0",
"socket.io-client": "4.6.1", "socket.io-client": "2.3.1",
"tunnel-rat": "0.1.2", "tunnel-rat": "0.1.0",
"workbox-background-sync": "^6.5.4", "workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4", "workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4", "workbox-cacheable-response": "^6.5.4",
+2 -28
View File
@@ -79,7 +79,6 @@
</style> </style>
<!-------------------------------------------------------------------------> <!------------------------------------------------------------------------->
<% if (process.env.NODE_ENV === "production") { %>
<script> <script>
// Redirect Excalidraw+ users which have auto-redirect enabled. // Redirect Excalidraw+ users which have auto-redirect enabled.
// //
@@ -98,7 +97,6 @@
window.location.href = "https://app.excalidraw.com"; window.location.href = "https://app.excalidraw.com";
} }
</script> </script>
<% } %>
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
@@ -148,18 +146,8 @@
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw"; window.name = "_excalidraw";
</script> </script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %> <% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' &&
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<!-- Fathom - privacy-friendly analytics -->
<script
src="https://cdn.usefathom.com/script.js"
data-site="VMSBUEYA"
defer
></script>
<!-- / Fathom -->
<!-- LEGACY GOOGLE ANALYTICS -->
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script <script
async async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%" src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
@@ -173,8 +161,6 @@
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%"); gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
</script> </script>
<% } %> <% } %>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
<!-- FIXME: remove this when we update CRA (fix SW caching) --> <!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style> <style>
@@ -227,17 +213,5 @@
<h1 class="visually-hidden">Excalidraw</h1> <h1 class="visually-hidden">Excalidraw</h1>
</header> </header>
<div id="root"></div> <div id="root"></div>
<!-- 100% privacy friendly analytics -->
<script
async
defer
src="https://scripts.simpleanalyticscdn.com/latest.js"
></script>
<noscript
><img
src="https://queue.simpleanalyticscdn.com/noscript.gif"
alt=""
referrerpolicy="no-referrer-when-downgrade"
/></noscript>
</body> </body>
</html> </html>
+20
View File
@@ -1,9 +1,22 @@
const fs = require("fs");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`; const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`; const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage); const pkg = require(excalidrawPackage);
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
const updateReadme = () => {
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
// remove note for stable readme
const data = originalReadMe.slice(excalidrawIndex);
// update readme
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
};
const publish = () => { const publish = () => {
try { try {
execSync(`yarn --frozen-lockfile`); execSync(`yarn --frozen-lockfile`);
@@ -17,8 +30,15 @@ const publish = () => {
}; };
const release = () => { const release = () => {
updateReadme();
console.info("Note for stable readme removed");
publish(); publish();
console.info(`Published ${pkg.version}!`); console.info(`Published ${pkg.version}!`);
// revert readme after release
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
console.info("Readme reverted");
}; };
release(); release();
+83 -113
View File
@@ -1,13 +1,7 @@
import { import { BOUND_TEXT_PADDING, ROUNDNESS, VERTICAL_ALIGN } from "../constants";
BOUND_TEXT_PADDING,
ROUNDNESS,
VERTICAL_ALIGN,
TEXT_ALIGN,
} from "../constants";
import { getNonDeletedElements, isTextElement, newElement } from "../element"; import { getNonDeletedElements, isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { import {
computeBoundTextPosition,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
measureText, measureText,
@@ -16,7 +10,6 @@ import {
import { import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache,
} from "../element/textWysiwyg"; } from "../element/textWysiwyg";
import { import {
hasBoundTextElement, hasBoundTextElement,
@@ -30,7 +23,6 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
} from "../element/types"; } from "../element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
@@ -40,7 +32,6 @@ export const actionUnbindText = register({
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => { predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return selectedElements.some((element) => hasBoundTextElement(element)); return selectedElements.some((element) => hasBoundTextElement(element));
}, },
perform: (elements, appState) => { perform: (elements, appState) => {
@@ -51,24 +42,20 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const { width, height, baseline } = measureText( const { width, height } = measureText(
boundTextElement.originalText, boundTextElement.originalText,
getFontString(boundTextElement), getFontString(boundTextElement),
boundTextElement.lineHeight,
); );
const originalContainerHeight = getOriginalContainerHeightFromCache( const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id, element.id,
); );
resetOriginalContainerCache(element.id); resetOriginalContainerCache(element.id);
const { x, y } = computeBoundTextPosition(element, boundTextElement);
mutateElement(boundTextElement as ExcalidrawTextElement, { mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null, containerId: null,
width, width,
height, height,
baseline,
text: boundTextElement.originalText, text: boundTextElement.originalText,
x,
y,
}); });
mutateElement(element, { mutateElement(element, {
boundElements: element.boundElements?.filter( boundElements: element.boundElements?.filter(
@@ -138,7 +125,6 @@ export const actionBindText = register({
mutateElement(textElement, { mutateElement(textElement, {
containerId: container.id, containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER,
}); });
mutateElement(container, { mutateElement(container, {
boundElements: (container.boundElements || []).concat({ boundElements: (container.boundElements || []).concat({
@@ -146,11 +132,7 @@ export const actionBindText = register({
id: textElement.id, id: textElement.id,
}), }),
}); });
const originalContainerHeight = container.height;
redrawTextBoundingBox(textElement, container); redrawTextBoundingBox(textElement, container);
// overwritting the cache with original container height so
// it can be restored when unbind
updateOriginalContainerCache(container.id, originalContainerHeight);
return { return {
elements: pushTextAboveContainer(elements, container, textElement), elements: pushTextAboveContainer(elements, container, textElement),
@@ -196,117 +178,105 @@ const pushContainerBelowText = (
return updatedElements; return updatedElements;
}; };
export const actionWrapTextInContainer = register({ export const actionCreateContainerFromText = register({
name: "wrapTextInContainer", name: "createContainerFromText",
contextItemLabel: "labels.createContainerFromText", contextItemLabel: "labels.createContainerFromText",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => { predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const areTextElements = selectedElements.every((el) => isTextElement(el)); return selectedElements.length === 1 && isTextElement(selectedElements[0]);
return selectedElements.length > 0 && areTextElements;
}, },
perform: (elements, appState) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
); );
let updatedElements: readonly ExcalidrawElement[] = elements.slice(); const updatedElements = elements.slice();
const containerIds: AppState["selectedElementIds"] = {}; if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
const textElement = selectedElements[0];
const container = newElement({
type: "rectangle",
backgroundColor: appState.currentItemBackgroundColor,
boundElements: [
...(textElement.boundElements || []),
{ id: textElement.id, type: "text" },
],
angle: textElement.angle,
fillStyle: appState.currentItemFillStyle,
strokeColor: appState.currentItemStrokeColor,
roughness: appState.currentItemRoughness,
strokeWidth: appState.currentItemStrokeWidth,
strokeStyle: appState.currentItemStrokeStyle,
roundness:
appState.currentItemRoundness === "round"
? {
type: isUsingAdaptiveRadius("rectangle")
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
opacity: 100,
locked: false,
x: textElement.x - BOUND_TEXT_PADDING,
y: textElement.y - BOUND_TEXT_PADDING,
width: computeContainerDimensionForBoundText(
textElement.width,
"rectangle",
),
height: computeContainerDimensionForBoundText(
textElement.height,
"rectangle",
),
groupIds: textElement.groupIds,
});
for (const textElement of selectedElements) { // update bindings
if (isTextElement(textElement)) { if (textElement.boundElements?.length) {
const container = newElement({ const linearElementIds = textElement.boundElements
type: "rectangle", .filter((ele) => ele.type === "arrow")
backgroundColor: appState.currentItemBackgroundColor, .map((el) => el.id);
boundElements: [ const linearElements = updatedElements.filter((ele) =>
...(textElement.boundElements || []), linearElementIds.includes(ele.id),
{ id: textElement.id, type: "text" }, ) as ExcalidrawLinearElement[];
], linearElements.forEach((ele) => {
angle: textElement.angle, let startBinding = null;
fillStyle: appState.currentItemFillStyle, let endBinding = null;
strokeColor: appState.currentItemStrokeColor, if (ele.startBinding) {
roughness: appState.currentItemRoughness, startBinding = { ...ele.startBinding, elementId: container.id };
strokeWidth: appState.currentItemStrokeWidth, }
strokeStyle: appState.currentItemStrokeStyle, if (ele.endBinding) {
roundness: endBinding = { ...ele.endBinding, elementId: container.id };
appState.currentItemRoundness === "round" }
? { mutateElement(ele, { startBinding, endBinding });
type: isUsingAdaptiveRadius("rectangle")
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
opacity: 100,
locked: false,
x: textElement.x - BOUND_TEXT_PADDING,
y: textElement.y - BOUND_TEXT_PADDING,
width: computeContainerDimensionForBoundText(
textElement.width,
"rectangle",
),
height: computeContainerDimensionForBoundText(
textElement.height,
"rectangle",
),
groupIds: textElement.groupIds,
}); });
}
// update bindings mutateElement(textElement, {
if (textElement.boundElements?.length) { containerId: container.id,
const linearElementIds = textElement.boundElements verticalAlign: VERTICAL_ALIGN.MIDDLE,
.filter((ele) => ele.type === "arrow") boundElements: null,
.map((el) => el.id); });
const linearElements = updatedElements.filter((ele) => redrawTextBoundingBox(textElement, container);
linearElementIds.includes(ele.id),
) as ExcalidrawLinearElement[];
linearElements.forEach((ele) => {
let startBinding = ele.startBinding;
let endBinding = ele.endBinding;
if (startBinding?.elementId === textElement.id) { return {
startBinding = { elements: pushContainerBelowText(
...startBinding, [...elements, container],
elementId: container.id,
};
}
if (endBinding?.elementId === textElement.id) {
endBinding = { ...endBinding, elementId: container.id };
}
if (startBinding || endBinding) {
mutateElement(ele, { startBinding, endBinding }, false);
}
});
}
mutateElement(
textElement,
{
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
textAlign: TEXT_ALIGN.CENTER,
},
false,
);
redrawTextBoundingBox(textElement, container);
updatedElements = pushContainerBelowText(
[...updatedElements, container],
container, container,
textElement, textElement,
); ),
containerIds[container.id] = true; appState: {
} ...appState,
selectedElementIds: {
[container.id]: true,
[textElement.id]: false,
},
},
commitToHistory: true,
};
} }
return { return {
elements: updatedElements, elements: updatedElements,
appState: { appState,
...appState,
selectedElementIds: containerIds,
},
commitToHistory: true, commitToHistory: true,
}; };
}, },
+1 -1
View File
@@ -226,7 +226,7 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue; return clampedZoomValueToFitElements as NormalizedZoomValue;
}; };
export const zoomToFitElements = ( const zoomToFitElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
zoomToSelection: boolean, zoomToSelection: boolean,
+1 -1
View File
@@ -18,7 +18,7 @@ export const actionCopy = register({
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState, true); const selectedElements = getSelectedElements(elements, appState, true);
copyToClipboard(selectedElements, app.files); copyToClipboard(selectedElements, appState, app.files);
return { return {
commitToHistory: false, commitToHistory: false,
+36 -62
View File
@@ -1,5 +1,4 @@
import { AppState } from "../../src/types"; import { AppState } from "../../src/types";
import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
@@ -38,7 +37,6 @@ import {
TextAlignLeftIcon, TextAlignLeftIcon,
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignRightIcon, TextAlignRightIcon,
FillZigZagIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
@@ -56,7 +54,6 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
import { import {
getBoundTextElement, getBoundTextElement,
getContainerElement, getContainerElement,
getDefaultLineHeight,
} from "../element/textElement"; } from "../element/textElement";
import { import {
isBoundToContainer, isBoundToContainer,
@@ -84,7 +81,7 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -296,12 +293,7 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({ export const actionChangeFillStyle = register({
name: "changeFillStyle", name: "changeFillStyle",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value, app) => { perform: (elements, appState, value) => {
trackEvent(
"element",
"changeFillStyle",
`${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
);
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { newElementWith(el, {
@@ -312,57 +304,40 @@ export const actionChangeFillStyle = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => (
const selectedElements = getSelectedElements(elements, appState); <fieldset>
const allElementsZigZag = <legend>{t("labels.fill")}</legend>
selectedElements.length > 0 && <ButtonIconSelect
selectedElements.every((el) => el.fillStyle === "zigzag"); options={[
{
return ( value: "hachure",
<fieldset> text: t("labels.hachure"),
<legend>{t("labels.fill")}</legend> icon: FillHachureIcon,
<ButtonIconSelect },
type="button" {
options={[ value: "cross-hatch",
{ text: t("labels.crossHatch"),
value: "hachure", icon: FillCrossHatchIcon,
text: `${ },
allElementsZigZag ? t("labels.zigzag") : t("labels.hachure") {
} (${getShortcutKey("Alt-Click")})`, value: "solid",
icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon, text: t("labels.solid"),
active: allElementsZigZag ? true : undefined, icon: FillSolidIcon,
}, },
{ ]}
value: "cross-hatch", group="fill"
text: t("labels.crossHatch"), value={getFormValue(
icon: FillCrossHatchIcon, elements,
}, appState,
{ (element) => element.fillStyle,
value: "solid", appState.currentItemFillStyle,
text: t("labels.solid"), )}
icon: FillSolidIcon, onChange={(value) => {
}, updateData(value);
]} }}
value={getFormValue( />
elements, </fieldset>
appState, ),
(element) => element.fillStyle,
appState.currentItemFillStyle,
)}
onClick={(value, event) => {
const nextValue =
event.altKey &&
value === "hachure" &&
selectedElements.every((el) => el.fillStyle === "hachure")
? "zigzag"
: value;
updateData(nextValue);
}}
/>
</fieldset>
);
},
}); });
export const actionChangeStrokeWidth = register({ export const actionChangeStrokeWidth = register({
@@ -662,7 +637,6 @@ export const actionChangeFontFamily = register({
oldElement, oldElement,
{ {
fontFamily: value, fontFamily: value,
lineHeight: getDefaultLineHeight(value),
}, },
); );
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(newElement, getContainerElement(oldElement));
+4 -13
View File
@@ -12,10 +12,7 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { import { getBoundTextElement } from "../element/textElement";
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
@@ -95,18 +92,12 @@ export const actionPasteStyles = register({
}); });
if (isTextElement(newElement)) { if (isTextElement(newElement)) {
const fontSize =
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
const fontFamily =
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
newElement = newElementWith(newElement, { newElement = newElementWith(newElement, {
fontSize, fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
fontFamily, fontFamily:
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN, elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
lineHeight:
elementStylesToCopyFrom.lineHeight ||
getDefaultLineHeight(fontFamily),
}); });
let container = null; let container = null;
if (newElement.containerId) { if (newElement.containerId) {
+1 -1
View File
@@ -115,7 +115,7 @@ export type ActionName =
| "toggleLinearEditor" | "toggleLinearEditor"
| "toggleEraserTool" | "toggleEraserTool"
| "toggleHandTool" | "toggleHandTool"
| "wrapTextInContainer"; | "createContainerFromText";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
+22 -41
View File
@@ -1,41 +1,22 @@
export const trackEvent = ( export const trackEvent =
category: string, typeof process !== "undefined" &&
action: string, process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
label?: string, typeof window !== "undefined" &&
value?: number, window.gtag
) => { ? (category: string, action: string, label?: string, value?: number) => {
try { try {
// Uncomment the next line to track locally window.gtag("event", action, {
// console.log("Track Event", { category, action, label, value }); event_category: category,
event_label: label,
if (typeof window === "undefined" || process.env.JEST_WORKER_ID) { value,
return; });
} } catch (error) {
console.error("error logging to ga", error);
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) { }
window.gtag("event", action, { }
event_category: category, : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
event_label: label, ? (category: string, action: string, label?: string, value?: number) => {}
value, : (category: string, action: string, label?: string, value?: number) => {
}); // Uncomment the next line to track locally
} // console.log("Track Event", { category, action, label, value });
};
if (window.sa_event) {
window.sa_event(action, {
category,
label,
value,
});
}
if (window.fathom) {
window.fathom.trackEvent(action, {
category,
label,
value,
});
}
} catch (error) {
console.error("error during analytics", error);
}
};
+10 -15
View File
@@ -1,6 +1,5 @@
import oc from "open-color"; import oc from "open-color";
import { import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
@@ -24,18 +23,18 @@ export const getDefaultAppState = (): Omit<
theme: THEME.LIGHT, theme: THEME.LIGHT,
collaborators: new Map(), collaborators: new Map(),
currentChartType: "bar", currentChartType: "bar",
currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor, currentItemBackgroundColor: "transparent",
currentItemEndArrowhead: "arrow", currentItemEndArrowhead: "arrow",
currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle, currentItemFillStyle: "hachure",
currentItemFontFamily: DEFAULT_FONT_FAMILY, currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE, currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity, currentItemOpacity: 100,
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness, currentItemRoughness: 1,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: oc.black,
currentItemRoundness: "round", currentItemRoundness: "round",
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeStyle: "solid",
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemStrokeWidth: 1,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
cursorButton: "up", cursorButton: "up",
draggingElement: null, draggingElement: null,
@@ -45,7 +44,7 @@ export const getDefaultAppState = (): Omit<
activeTool: { activeTool: {
type: "selection", type: "selection",
customType: null, customType: null,
locked: DEFAULT_ELEMENT_PROPS.locked, locked: false,
lastActiveTool: null, lastActiveTool: null,
}, },
penMode: false, penMode: false,
@@ -58,7 +57,7 @@ export const getDefaultAppState = (): Omit<
fileHandle: null, fileHandle: null,
gridSize: null, gridSize: null,
isBindingEnabled: true, isBindingEnabled: true,
defaultSidebarDockedPreference: false, isSidebarDocked: false,
isLoading: false, isLoading: false,
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
@@ -150,11 +149,7 @@ const APP_STATE_STORAGE_CONF = (<
gridSize: { browser: true, export: true, server: true }, gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false }, isBindingEnabled: { browser: false, export: false, server: false },
defaultSidebarDockedPreference: { isSidebarDocked: { browser: true, export: false, server: false },
browser: true,
export: false,
server: false,
},
isLoading: { browser: false, export: false, server: false }, isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false }, isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false }, isRotating: { browser: false, export: false, server: false },
+17 -1
View File
@@ -1,5 +1,10 @@
import colors from "./colors"; import colors from "./colors";
import { DEFAULT_FONT_SIZE, ENV } from "./constants"; import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
ENV,
VERTICAL_ALIGN,
} from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element"; import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types"; import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random"; import { randomId } from "./random";
@@ -161,7 +166,17 @@ const bgColors = colors.elementBackground.slice(
// Put all the common properties here so when the whole chart is selected // Put all the common properties here so when the whole chart is selected
// the properties dialog shows the correct selected values // the properties dialog shows the correct selected values
const commonProps = { const commonProps = {
fillStyle: "hachure",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: colors.elementStroke[0], strokeColor: colors.elementStroke[0],
roundness: null,
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const; } as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => { const getChartDimentions = (spreadsheet: Spreadsheet) => {
@@ -308,6 +323,7 @@ const chartBaseElements = (
x: x + chartWidth / 2, x: x + chartWidth / 2,
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
roundness: null, roundness: null,
strokeStyle: "solid",
textAlign: "center", textAlign: "center",
}) })
: null; : null;
+11 -27
View File
@@ -2,12 +2,12 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { BinaryFiles } from "./types"; import { AppState, BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export"; import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import { isInitializedImageElement } from "./element/typeChecks";
import { isPromiseLike, isTestEnv } from "./utils"; import { isPromiseLike } from "./utils";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -55,40 +55,24 @@ const clipboardContainsElements = (
export const copyToClipboard = async ( export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles | null, files: BinaryFiles | null,
) => { ) => {
let foundFile = false;
const _files = elements.reduce((acc, element) => {
if (isInitializedImageElement(element)) {
foundFile = true;
if (files && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
}
return acc;
}, {} as BinaryFiles);
if (foundFile && !files) {
console.warn(
"copyToClipboard: attempting to file element(s) without providing associated `files` object.",
);
}
// select binded text elements when copying // select binded text elements when copying
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements, elements,
files: files ? _files : undefined, files: files
? elements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles)
: undefined,
}; };
const json = JSON.stringify(contents); const json = JSON.stringify(contents);
if (isTestEnv()) {
return json;
}
CLIPBOARD = json; CLIPBOARD = json;
try { try {
PREFER_APP_CLIPBOARD = false; PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json); await copyTextToSystemClipboard(json);
+6 -7
View File
@@ -14,7 +14,7 @@ import {
hasText, hasText,
} from "../scene"; } from "../scene";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
import { UIAppState, Zoom } from "../types"; import { AppState, Zoom } from "../types";
import { import {
capitalizeString, capitalizeString,
isTransparent, isTransparent,
@@ -28,20 +28,19 @@ import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks"; import { hasBoundTextElement } from "../element/typeChecks";
import clsx from "clsx"; import clsx from "clsx";
import { actionToggleZenMode } from "../actions"; import { actionToggleZenMode } from "../actions";
import "./Actions.scss";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { import {
shouldAllowVerticalAlign, shouldAllowVerticalAlign,
suppportsHorizontalAlign, suppportsHorizontalAlign,
} from "../element/textElement"; } from "../element/textElement";
import "./Actions.scss";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
elements, elements,
renderAction, renderAction,
}: { }: {
appState: UIAppState; appState: AppState;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
}) => { }) => {
@@ -216,10 +215,10 @@ export const ShapesSwitcher = ({
appState, appState,
}: { }: {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
activeTool: UIAppState["activeTool"]; activeTool: AppState["activeTool"];
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void; onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: UIAppState; appState: AppState;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
+181 -310
View File
@@ -60,7 +60,6 @@ import {
ENV, ENV,
EVENT, EVENT,
GRID_SIZE, GRID_SIZE,
IMAGE_MIME_TYPES,
IMAGE_RENDER_TIMEOUT, IMAGE_RENDER_TIMEOUT,
isAndroid, isAndroid,
isBrave, isBrave,
@@ -128,11 +127,7 @@ import {
} from "../element/binding"; } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
deepCopyElement,
duplicateElements,
newFreeDrawElement,
} from "../element/newElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
isArrowElement, isArrowElement,
@@ -210,8 +205,6 @@ import {
PointerDownState, PointerDownState,
SceneData, SceneData,
Device, Device,
SidebarName,
SidebarTabName,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@@ -236,7 +229,6 @@ import {
updateActiveTool, updateActiveTool,
getShortcutKey, getShortcutKey,
isTransparent, isTransparent,
easeToValuesRAF,
} from "../utils"; } from "../utils";
import { import {
ContextMenu, ContextMenu,
@@ -268,14 +260,13 @@ import throttle from "lodash.throttle";
import { fileOpen, FileSystemHandle } from "../data/filesystem"; import { fileOpen, FileSystemHandle } from "../data/filesystem";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getApproxLineHeight,
getApproxMinLineHeight, getApproxMinLineHeight,
getApproxMinLineWidth, getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
getDefaultLineHeight,
getLineHeightInPx,
getTextBindableContainerAtPosition, getTextBindableContainerAtPosition,
isMeasureTextSupported, isMeasureTextSupported,
isValidTextContainer, isValidTextContainer,
@@ -292,18 +283,12 @@ import {
import { shouldShowBoundingBox } from "../element/transformHandles"; import { shouldShowBoundingBox } from "../element/transformHandles";
import { Fonts } from "../scene/Fonts"; import { Fonts } from "../scene/Fonts";
import { actionPaste } from "../actions/actionClipboard"; import { actionPaste } from "../actions/actionClipboard";
import { import { actionToggleHandTool } from "../actions/actionCanvas";
actionToggleHandTool,
zoomToFitElements,
} from "../actions/actionCanvas";
import { jotaiStore } from "../jotai"; import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionWrapTextInContainer } from "../actions/actionBoundText"; import { actionCreateContainerFromText } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError"; import BraveMeasureTextError from "./BraveMeasureTextError";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
const deviceContextInitialValue = { const deviceContextInitialValue = {
isSmScreen: false, isSmScreen: false,
isMobile: false, isMobile: false,
@@ -345,8 +330,6 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
); );
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext"; ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
export const useApp = () => useContext(AppContext);
export const useAppProps = () => useContext(AppPropsContext);
export const useDevice = () => useContext<Device>(DeviceContext); export const useDevice = () => useContext<Device>(DeviceContext);
export const useExcalidrawContainer = () => export const useExcalidrawContainer = () =>
useContext(ExcalidrawContainerContext); useContext(ExcalidrawContainerContext);
@@ -407,7 +390,7 @@ class App extends React.Component<AppProps, AppState> {
private nearestScrollableContainer: HTMLElement | Document | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined; public libraryItemsFromStorage: LibraryItems | undefined;
public id: string; private id: string;
private history: History; private history: History;
private excalidrawContainerValue: { private excalidrawContainerValue: {
container: HTMLDivElement | null; container: HTMLDivElement | null;
@@ -445,7 +428,7 @@ class App extends React.Component<AppProps, AppState> {
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight, height: window.innerHeight,
showHyperlinkPopup: false, showHyperlinkPopup: false,
defaultSidebarDockedPreference: false, isSidebarDocked: false,
}; };
this.id = nanoid(); this.id = nanoid();
@@ -476,7 +459,7 @@ class App extends React.Component<AppProps, AppState> {
setActiveTool: this.setActiveTool, setActiveTool: this.setActiveTool,
setCursor: this.setCursor, setCursor: this.setCursor,
resetCursor: this.resetCursor, resetCursor: this.resetCursor,
toggleSidebar: this.toggleSidebar, toggleMenu: this.toggleMenu,
} as const; } as const;
if (typeof excalidrawRef === "function") { if (typeof excalidrawRef === "function") {
excalidrawRef(api); excalidrawRef(api);
@@ -584,91 +567,101 @@ class App extends React.Component<AppProps, AppState> {
this.props.handleKeyboardGlobally ? undefined : this.onKeyDown this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
} }
> >
<AppContext.Provider value={this}> <ExcalidrawContainerContext.Provider
<AppPropsContext.Provider value={this.props}> value={this.excalidrawContainerValue}
<ExcalidrawContainerContext.Provider >
value={this.excalidrawContainerValue} <DeviceContext.Provider value={this.device}>
> <ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
<DeviceContext.Provider value={this.device}> <ExcalidrawAppStateContext.Provider value={this.state}>
<ExcalidrawSetAppStateContext.Provider value={this.setAppState}> <ExcalidrawElementsContext.Provider
<ExcalidrawAppStateContext.Provider value={this.state}> value={this.scene.getNonDeletedElements()}
<ExcalidrawElementsContext.Provider >
value={this.scene.getNonDeletedElements()} <ExcalidrawActionManagerContext.Provider
value={this.actionManager}
>
<LayerUI
canvas={this.canvas}
appState={this.state}
files={this.files}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getNonDeletedElements()}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle}
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary({
elements,
position: "center",
files: null,
})
}
langCode={getLanguage().code}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderCustomSidebar={this.props.renderSidebar}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" &&
this.state.zenModeEnabled
}
libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions}
focusContainer={this.focusContainer}
library={this.library}
id={this.id}
onImageAction={this.onImageAction}
renderWelcomeScreen={
!this.state.isLoading &&
this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" &&
!this.scene.getElementsIncludingDeleted().length
}
> >
<ExcalidrawActionManagerContext.Provider {this.props.children}
value={this.actionManager} </LayerUI>
> <div className="excalidraw-textEditorContainer" />
<LayerUI <div className="excalidraw-contextMenuContainer" />
canvas={this.canvas} {selectedElement.length === 1 &&
appState={this.state} !this.state.contextMenu &&
files={this.files} this.state.showHyperlinkPopup && (
<Hyperlink
key={selectedElement[0].id}
element={selectedElement[0]}
setAppState={this.setAppState} setAppState={this.setAppState}
actionManager={this.actionManager} onLinkOpen={this.props.onLinkOpen}
elements={this.scene.getNonDeletedElements()} />
onLockToggle={this.toggleLock} )}
onPenModeToggle={this.togglePenMode} {this.state.toast !== null && (
onHandToolToggle={this.onHandToolToggle} <Toast
langCode={getLanguage().code} message={this.state.toast.message}
renderTopRightUI={renderTopRightUI} onClose={() => this.setToast(null)}
renderCustomStats={renderCustomStats} duration={this.state.toast.duration}
showExitZenModeBtn={ closable={this.state.toast.closable}
typeof this.props?.zenModeEnabled === "undefined" && />
this.state.zenModeEnabled )}
} {this.state.contextMenu && (
UIOptions={this.props.UIOptions} <ContextMenu
onImageAction={this.onImageAction} items={this.state.contextMenu.items}
renderWelcomeScreen={ top={this.state.contextMenu.top}
!this.state.isLoading && left={this.state.contextMenu.left}
this.state.showWelcomeScreen && actionManager={this.actionManager}
this.state.activeTool.type === "selection" && />
!this.scene.getElementsIncludingDeleted().length )}
} <main>{this.renderCanvas()}</main>
> </ExcalidrawActionManagerContext.Provider>
{this.props.children} </ExcalidrawElementsContext.Provider>{" "}
</LayerUI> </ExcalidrawAppStateContext.Provider>
<div className="excalidraw-textEditorContainer" /> </ExcalidrawSetAppStateContext.Provider>
<div className="excalidraw-contextMenuContainer" /> </DeviceContext.Provider>
{selectedElement.length === 1 && </ExcalidrawContainerContext.Provider>
!this.state.contextMenu &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={selectedElement[0].id}
element={selectedElement[0]}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
/>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={() => this.setToast(null)}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
/>
)}
<main>{this.renderCanvas()}</main>
</ExcalidrawActionManagerContext.Provider>
</ExcalidrawElementsContext.Provider>{" "}
</ExcalidrawAppStateContext.Provider>
</ExcalidrawSetAppStateContext.Provider>
</DeviceContext.Provider>
</ExcalidrawContainerContext.Provider>
</AppPropsContext.Provider>
</AppContext.Provider>
</div> </div>
); );
} }
public focusContainer: AppClassProperties["focusContainer"] = () => { public focusContainer: AppClassProperties["focusContainer"] = () => {
this.excalidrawContainerRef.current?.focus(); if (this.props.autoFocus) {
this.excalidrawContainerRef.current?.focus();
}
}; };
public getSceneElementsIncludingDeleted = () => { public getSceneElementsIncludingDeleted = () => {
@@ -679,14 +672,6 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getNonDeletedElements(); return this.scene.getNonDeletedElements();
}; };
public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
this.addElementsFromPasteOrLibrary({
elements,
position: "center",
files: null,
});
};
private syncActionResult = withBatchedUpdates( private syncActionResult = withBatchedUpdates(
(actionResult: ActionResult) => { (actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) { if (this.unmounted || actionResult === false) {
@@ -956,7 +941,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.addCallback(this.onSceneUpdated); this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners(); this.addEventListeners();
if (this.props.autoFocus && this.excalidrawContainerRef.current) { if (this.excalidrawContainerRef.current) {
this.focusContainer(); this.focusContainer();
} }
@@ -1595,7 +1580,6 @@ class App extends React.Component<AppProps, AppState> {
elements: data.elements, elements: data.elements,
files: data.files || null, files: data.files || null,
position: "cursor", position: "cursor",
retainSeed: isPlainPaste,
}); });
} else if (data.text) { } else if (data.text) {
this.addTextFromPaste(data.text, isPlainPaste); this.addTextFromPaste(data.text, isPlainPaste);
@@ -1609,7 +1593,6 @@ class App extends React.Component<AppProps, AppState> {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
files: BinaryFiles | null; files: BinaryFiles | null;
position: { clientX: number; clientY: number } | "cursor" | "center"; position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean;
}) => { }) => {
const elements = restoreElements(opts.elements, null); const elements = restoreElements(opts.elements, null);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -1637,39 +1620,45 @@ class App extends React.Component<AppProps, AppState> {
const dx = x - elementsCenterX; const dx = x - elementsCenterX;
const dy = y - elementsCenterY; const dy = y - elementsCenterY;
const groupIdMap = new Map();
const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize); const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize);
const newElements = duplicateElements( const oldIdToDuplicatedId = new Map();
elements.map((element) => { const newElements = elements.map((element) => {
return newElementWith(element, { const newElement = duplicateElement(
this.state.editingGroupId,
groupIdMap,
element,
{
x: element.x + gridX - minX, x: element.x + gridX - minX,
y: element.y + gridY - minY, y: element.y + gridY - minY,
}); },
}), );
{ oldIdToDuplicatedId.set(element.id, newElement.id);
randomizeSeed: !opts.retainSeed, return newElement;
}, });
);
bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId);
const nextElements = [ const nextElements = [
...this.scene.getElementsIncludingDeleted(), ...this.scene.getElementsIncludingDeleted(),
...newElements, ...newElements,
]; ];
fixBindingsAfterDuplication(nextElements, elements, oldIdToDuplicatedId);
this.scene.replaceAllElements(nextElements);
newElements.forEach((newElement) => {
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
const container = getContainerElement(newElement);
redrawTextBoundingBox(newElement, container);
}
});
if (opts.files) { if (opts.files) {
this.files = { ...this.files, ...opts.files }; this.files = { ...this.files, ...opts.files };
} }
this.scene.replaceAllElements(nextElements);
newElements.forEach((newElement) => {
if (isTextElement(newElement)) {
const container = getContainerElement(newElement);
redrawTextBoundingBox(newElement, container);
}
});
this.history.resumeRecording(); this.history.resumeRecording();
this.setState( this.setState(
@@ -1684,7 +1673,7 @@ class App extends React.Component<AppProps, AppState> {
openSidebar: openSidebar:
this.state.openSidebar && this.state.openSidebar &&
this.device.canDeviceFitSidebar && this.device.canDeviceFitSidebar &&
this.state.defaultSidebarDockedPreference this.state.isSidebarDocked
? this.state.openSidebar ? this.state.openSidebar
: null, : null,
selectedElementIds: newElements.reduce( selectedElementIds: newElements.reduce(
@@ -1742,14 +1731,12 @@ class App extends React.Component<AppProps, AppState> {
(acc: ExcalidrawTextElement[], line, idx) => { (acc: ExcalidrawTextElement[], line, idx) => {
const text = line.trim(); const text = line.trim();
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
if (text.length) { if (text.length) {
const element = newTextElement({ const element = newTextElement({
...textElementProps, ...textElementProps,
x, x,
y: currentY, y: currentY,
text, text,
lineHeight,
}); });
acc.push(element); acc.push(element);
currentY += element.height + LINE_GAP; currentY += element.height + LINE_GAP;
@@ -1758,9 +1745,14 @@ class App extends React.Component<AppProps, AppState> {
// add paragraph only if previous line was not empty, IOW don't add // add paragraph only if previous line was not empty, IOW don't add
// more than one empty line // more than one empty line
if (prevLine) { if (prevLine) {
currentY += const defaultLineHeight = getApproxLineHeight(
getLineHeightInPx(textElementProps.fontSize, lineHeight) + getFontString({
LINE_GAP; fontSize: textElementProps.fontSize,
fontFamily: textElementProps.fontFamily,
}),
);
currentY += defaultLineHeight + LINE_GAP;
} }
} }
@@ -1853,89 +1845,18 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.executeAction(actionToggleHandTool); this.actionManager.executeAction(actionToggleHandTool);
}; };
/**
* Zooms on canvas viewport center
*/
zoomCanvas = (
/** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */
value: number,
) => {
this.setState({
...getStateForZoom(
{
viewportX: this.state.width / 2 + this.state.offsetLeft,
viewportY: this.state.height / 2 + this.state.offsetTop,
nextZoom: getNormalizedZoom(value),
},
this.state,
),
});
};
private cancelInProgresAnimation: (() => void) | null = null;
scrollToContent = ( scrollToContent = (
target: target:
| ExcalidrawElement | ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
) => { ) => {
this.cancelInProgresAnimation?.(); this.setState({
...calculateScrollCenter(
// convert provided target into ExcalidrawElement[] if necessary Array.isArray(target) ? target : [target],
const targets = Array.isArray(target) ? target : [target]; this.state,
this.canvas,
let zoom = this.state.zoom; ),
let scrollX = this.state.scrollX; });
let scrollY = this.state.scrollY;
if (opts?.fitToContent) {
// compute an appropriate viewport location (scroll X, Y) and zoom level
// that fit the target elements on the scene
const { appState } = zoomToFitElements(targets, this.state, false);
zoom = appState.zoom;
scrollX = appState.scrollX;
scrollY = appState.scrollY;
} else {
// compute only the viewport location, without any zoom adjustment
const scroll = calculateScrollCenter(targets, this.state, this.canvas);
scrollX = scroll.scrollX;
scrollY = scroll.scrollY;
}
// when animating, we use RequestAnimationFrame to prevent the animation
// from slowing down other processes
if (opts?.animate) {
const origScrollX = this.state.scrollX;
const origScrollY = this.state.scrollY;
// zoom animation could become problematic on scenes with large number
// of elements, setting it to its final value to improve user experience.
//
// using zoomCanvas() to zoom on current viewport center
this.zoomCanvas(zoom.value);
const cancel = easeToValuesRAF(
[origScrollX, origScrollY],
[scrollX, scrollY],
(scrollX, scrollY) => this.setState({ scrollX, scrollY }),
{ duration: opts?.duration ?? 500 },
);
this.cancelInProgresAnimation = () => {
cancel();
this.cancelInProgresAnimation = null;
};
} else {
this.setState({ scrollX, scrollY, zoom });
}
};
/** use when changing scrollX/scrollY/zoom based on user interaction */
private translateCanvas: React.Component<any, AppState>["setState"] = (
state,
) => {
this.cancelInProgresAnimation?.();
this.setState(state);
}; };
setToast = ( setToast = (
@@ -2022,24 +1943,30 @@ class App extends React.Component<AppProps, AppState> {
/** /**
* @returns whether the menu was toggled on or off * @returns whether the menu was toggled on or off
*/ */
public toggleSidebar = ({ public toggleMenu = (
name, type: "library" | "customSidebar",
tab, force?: boolean,
force, ): boolean => {
}: { if (type === "customSidebar" && !this.props.renderSidebar) {
name: SidebarName; console.warn(
tab?: SidebarTabName; `attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`,
force?: boolean; );
}): boolean => { return false;
let nextName;
if (force === undefined) {
nextName = this.state.openSidebar?.name === name ? null : name;
} else {
nextName = force ? name : null;
} }
this.setState({ openSidebar: nextName ? { name: nextName, tab } : null });
return !!nextName; if (type === "library" || type === "customSidebar") {
let nextValue;
if (force === undefined) {
nextValue = this.state.openSidebar === type ? null : type;
} else {
nextValue = force ? type : null;
}
this.setState({ openSidebar: nextValue });
return !!nextValue;
}
return false;
}; };
private updateCurrentCursorPosition = withBatchedUpdates( private updateCurrentCursorPosition = withBatchedUpdates(
@@ -2130,13 +2057,9 @@ class App extends React.Component<AppProps, AppState> {
offset = -offset; offset = -offset;
} }
if (event.shiftKey) { if (event.shiftKey) {
this.translateCanvas((state) => ({ this.setState((state) => ({ scrollX: state.scrollX + offset }));
scrollX: state.scrollX + offset,
}));
} else { } else {
this.translateCanvas((state) => ({ this.setState((state) => ({ scrollY: state.scrollY + offset }));
scrollY: state.scrollY + offset,
}));
} }
} }
@@ -2684,13 +2607,6 @@ class App extends React.Component<AppProps, AppState> {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
} }
const fontFamily =
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
const lineHeight =
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
const fontSize = this.state.currentItemFontSize;
if ( if (
!existingTextElement && !existingTextElement &&
shouldBindToContainer && shouldBindToContainer &&
@@ -2698,14 +2614,11 @@ class App extends React.Component<AppProps, AppState> {
!isArrowElement(container) !isArrowElement(container)
) { ) {
const fontString = { const fontString = {
fontSize, fontSize: this.state.currentItemFontSize,
fontFamily, fontFamily: this.state.currentItemFontFamily,
}; };
const minWidth = getApproxMinLineWidth( const minWidth = getApproxMinLineWidth(getFontString(fontString));
getFontString(fontString), const minHeight = getApproxMinLineHeight(getFontString(fontString));
lineHeight,
);
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
const containerDims = getContainerDims(container); const containerDims = getContainerDims(container);
const newHeight = Math.max(containerDims.height, minHeight); const newHeight = Math.max(containerDims.height, minHeight);
const newWidth = Math.max(containerDims.width, minWidth); const newWidth = Math.max(containerDims.width, minWidth);
@@ -2737,9 +2650,10 @@ class App extends React.Component<AppProps, AppState> {
strokeStyle: this.state.currentItemStrokeStyle, strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness, roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
roundness: null,
text: "", text: "",
fontSize, fontSize: this.state.currentItemFontSize,
fontFamily, fontFamily: this.state.currentItemFontFamily,
textAlign: parentCenterPosition textAlign: parentCenterPosition
? "center" ? "center"
: this.state.currentItemTextAlign, : this.state.currentItemTextAlign,
@@ -2748,8 +2662,7 @@ class App extends React.Component<AppProps, AppState> {
: DEFAULT_VERTICAL_ALIGN, : DEFAULT_VERTICAL_ALIGN,
containerId: shouldBindToContainer ? container?.id : undefined, containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [], groupIds: container?.groupIds ?? [],
lineHeight, locked: false,
angle: container?.angle ?? 0,
}); });
if (!existingTextElement && shouldBindToContainer && container) { if (!existingTextElement && shouldBindToContainer && container) {
@@ -3016,12 +2929,12 @@ class App extends React.Component<AppProps, AppState> {
state, state,
); );
this.translateCanvas({ return {
zoom: zoomState.zoom, zoom: zoomState.zoom,
scrollX: zoomState.scrollX + deltaX / nextZoom, scrollX: zoomState.scrollX + deltaX / nextZoom,
scrollY: zoomState.scrollY + deltaY / nextZoom, scrollY: zoomState.scrollY + deltaY / nextZoom,
shouldCacheIgnoreZoom: true, shouldCacheIgnoreZoom: true,
}); };
}); });
this.resetShouldCacheIgnoreZoomDebounced(); this.resetShouldCacheIgnoreZoomDebounced();
} else { } else {
@@ -3499,43 +3412,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ contextMenu: null }); this.setState({ contextMenu: null });
} }
this.updateGestureOnPointerDown(event);
// if dragging element is freedraw and another pointerdown event occurs
// a second finger is on the screen
// discard the freedraw element if it is very short because it is likely
// just a spike, otherwise finalize the freedraw element when the second
// finger is lifted
if (
event.pointerType === "touch" &&
this.state.draggingElement &&
this.state.draggingElement.type === "freedraw"
) {
const element = this.state.draggingElement as ExcalidrawFreeDrawElement;
this.updateScene({
...(element.points.length < 10
? {
elements: this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== element.id),
}
: {}),
appState: {
draggingElement: null,
editingElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds: Object.keys(this.state.selectedElementIds)
.filter((key) => key !== element.id)
.reduce((obj: { [id: string]: boolean }, key) => {
obj[key] = this.state.selectedElementIds[key];
return obj;
}, {}),
},
});
return;
}
// remove any active selection when we start to interact with canvas // remove any active selection when we start to interact with canvas
// (mainly, we care about removing selection outside the component which // (mainly, we care about removing selection outside the component which
// would prevent our copy handling otherwise) // would prevent our copy handling otherwise)
@@ -3575,6 +3451,8 @@ class App extends React.Component<AppProps, AppState> {
}); });
this.savePointer(event.clientX, event.clientY, "down"); this.savePointer(event.clientX, event.clientY, "down");
this.updateGestureOnPointerDown(event);
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) { if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
return; return;
} }
@@ -3832,7 +3710,7 @@ class App extends React.Component<AppProps, AppState> {
window.addEventListener(EVENT.POINTER_UP, enableNextPaste); window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
} }
this.translateCanvas({ this.setState({
scrollX: this.state.scrollX - deltaX / this.state.zoom.value, scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
scrollY: this.state.scrollY - deltaY / this.state.zoom.value, scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
}); });
@@ -4725,12 +4603,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.drag.hasOccurred = true; pointerDownState.drag.hasOccurred = true;
// prevent dragging even if we're no longer holding cmd/ctrl otherwise // prevent dragging even if we're no longer holding cmd/ctrl otherwise
// it would have weird results (stuff jumping all over the screen) // it would have weird results (stuff jumping all over the screen)
// Checking for editingElement to avoid jump while editing on mobile #6503 if (selectedElements.length > 0 && !pointerDownState.withCmdOrCtrl) {
if (
selectedElements.length > 0 &&
!pointerDownState.withCmdOrCtrl &&
!this.state.editingElement
) {
const [dragX, dragY] = getGridPoint( const [dragX, dragY] = getGridPoint(
pointerCoords.x - pointerDownState.drag.offset.x, pointerCoords.x - pointerDownState.drag.offset.x,
pointerCoords.y - pointerDownState.drag.offset.y, pointerCoords.y - pointerDownState.drag.offset.y,
@@ -4983,7 +4856,7 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.scrollbars.isOverHorizontal) { if (pointerDownState.scrollbars.isOverHorizontal) {
const x = event.clientX; const x = event.clientX;
const dx = x - pointerDownState.lastCoords.x; const dx = x - pointerDownState.lastCoords.x;
this.translateCanvas({ this.setState({
scrollX: this.state.scrollX - dx / this.state.zoom.value, scrollX: this.state.scrollX - dx / this.state.zoom.value,
}); });
pointerDownState.lastCoords.x = x; pointerDownState.lastCoords.x = x;
@@ -4993,7 +4866,7 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.scrollbars.isOverVertical) { if (pointerDownState.scrollbars.isOverVertical) {
const y = event.clientY; const y = event.clientY;
const dy = y - pointerDownState.lastCoords.y; const dy = y - pointerDownState.lastCoords.y;
this.translateCanvas({ this.setState({
scrollY: this.state.scrollY - dy / this.state.zoom.value, scrollY: this.state.scrollY - dy / this.state.zoom.value,
}); });
pointerDownState.lastCoords.y = y; pointerDownState.lastCoords.y = y;
@@ -5753,9 +5626,7 @@ class App extends React.Component<AppProps, AppState> {
const imageFile = await fileOpen({ const imageFile = await fileOpen({
description: "Image", description: "Image",
extensions: Object.keys( extensions: ["jpg", "png", "svg", "gif"],
IMAGE_MIME_TYPES,
) as (keyof typeof IMAGE_MIME_TYPES)[],
}); });
const imageElement = this.createImageElement({ const imageElement = this.createImageElement({
@@ -6377,7 +6248,7 @@ class App extends React.Component<AppProps, AppState> {
actionGroup, actionGroup,
actionUnbindText, actionUnbindText,
actionBindText, actionBindText,
actionWrapTextInContainer, actionCreateContainerFromText,
actionUngroup, actionUngroup,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionAddToLibrary, actionAddToLibrary,
@@ -6424,7 +6295,7 @@ class App extends React.Component<AppProps, AppState> {
// reduced amplification for small deltas (small movements on a trackpad) // reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20); Math.min(1, absDelta / 20);
this.translateCanvas((state) => ({ this.setState((state) => ({
...getStateForZoom( ...getStateForZoom(
{ {
viewportX: cursorX, viewportX: cursorX,
@@ -6441,14 +6312,14 @@ class App extends React.Component<AppProps, AppState> {
// scroll horizontally when shift pressed // scroll horizontally when shift pressed
if (event.shiftKey) { if (event.shiftKey) {
this.translateCanvas(({ zoom, scrollX }) => ({ this.setState(({ zoom, scrollX }) => ({
// on Mac, shift+wheel tends to result in deltaX // on Mac, shift+wheel tends to result in deltaX
scrollX: scrollX - (deltaY || deltaX) / zoom.value, scrollX: scrollX - (deltaY || deltaX) / zoom.value,
})); }));
return; return;
} }
this.translateCanvas(({ zoom, scrollX, scrollY }) => ({ this.setState(({ zoom, scrollX, scrollY }) => ({
scrollX: scrollX - deltaX / zoom.value, scrollX: scrollX - deltaX / zoom.value,
scrollY: scrollY - deltaY / zoom.value, scrollY: scrollY - deltaY / zoom.value,
})); }));
+28 -29
View File
@@ -1,40 +1,39 @@
import Trans from "./Trans"; import { t } from "../i18n";
const BraveMeasureTextError = () => { const BraveMeasureTextError = () => {
return ( return (
<div data-testid="brave-measure-text-error"> <div data-testid="brave-measure-text-error">
<p> <p>
<Trans {t("errors.brave_measure_text_error.start")} &nbsp;
i18nKey="errors.brave_measure_text_error.line1" <span style={{ fontWeight: 600 }}>
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>} {t("errors.brave_measure_text_error.aggressive_block_fingerprint")}
/> </span>{" "}
{t("errors.brave_measure_text_error.setting_enabled")}.
<br />
<br />
{t("errors.brave_measure_text_error.break")}{" "}
<span style={{ fontWeight: 600 }}>
{t("errors.brave_measure_text_error.text_elements")}
</span>{" "}
{t("errors.brave_measure_text_error.in_your_drawings")}.
</p> </p>
<p> <p>
<Trans {t("errors.brave_measure_text_error.strongly_recommend")}{" "}
i18nKey="errors.brave_measure_text_error.line2" <a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>} {" "}
/> {t("errors.brave_measure_text_error.steps")}
</a>{" "}
{t("errors.brave_measure_text_error.how")}.
</p> </p>
<p> <p>
<Trans {t("errors.brave_measure_text_error.disable_setting")}{" "}
i18nKey="errors.brave_measure_text_error.line3" <a href="https://github.com/excalidraw/excalidraw/issues/new">
link={(el) => ( {t("errors.brave_measure_text_error.issue")}
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"> </a>{" "}
{el} {t("errors.brave_measure_text_error.write")}{" "}
</a> <a href="https://discord.gg/UexuTaE">
)} {t("errors.brave_measure_text_error.discord")}
/> </a>
</p> .
<p>
<Trans
i18nKey="errors.brave_measure_text_error.line4"
issueLink={(el) => (
<a href="https://github.com/excalidraw/excalidraw/issues/new">
{el}
</a>
)}
discordLink={(el) => <a href="https://discord.gg/UexuTaE">{el}.</a>}
/>
</p> </p>
</div> </div>
); );
+4 -8
View File
@@ -1,12 +1,8 @@
import clsx from "clsx";
import { composeEventHandlers } from "../utils";
import "./Button.scss"; import "./Button.scss";
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> { interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
type?: "button" | "submit" | "reset"; type?: "button" | "submit" | "reset";
onSelect: () => any; onSelect: () => any;
/** whether button is in active state */
selected?: boolean;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
} }
@@ -19,18 +15,18 @@ interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
export const Button = ({ export const Button = ({
type = "button", type = "button",
onSelect, onSelect,
selected,
children, children,
className = "", className = "",
...rest ...rest
}: ButtonProps) => { }: ButtonProps) => {
return ( return (
<button <button
onClick={composeEventHandlers(rest.onClick, (event) => { onClick={(event) => {
onSelect(); onSelect();
})} rest.onClick?.(event);
}}
type={type} type={type}
className={clsx("excalidraw-button", className, { selected })} className={`excalidraw-button ${className}`}
{...rest} {...rest}
> >
{children} {children}
+26 -52
View File
@@ -1,59 +1,33 @@
import clsx from "clsx"; import clsx from "clsx";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect /> // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>( export const ButtonIconSelect = <T extends Object>({
props: { options,
options: { value,
value: T; onChange,
text: string; group,
icon: JSX.Element; }: {
testId?: string; options: { value: T; text: string; icon: JSX.Element; testId?: string }[];
/** if not supplied, defaults to value identity check */ value: T | null;
active?: boolean; onChange: (value: T) => void;
}[]; group: string;
value: T | null; }) => (
type?: "radio" | "button";
} & (
| { type?: "radio"; group: string; onChange: (value: T) => void }
| {
type: "button";
onClick: (
value: T,
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => void;
}
),
) => (
<div className="buttonList buttonListIcon"> <div className="buttonList buttonListIcon">
{props.options.map((option) => {options.map((option) => (
props.type === "button" ? ( <label
<button key={option.text}
key={option.text} className={clsx({ active: value === option.value })}
onClick={(event) => props.onClick(option.value, event)} title={option.text}
className={clsx({ >
active: option.active ?? props.value === option.value, <input
})} type="radio"
name={group}
onChange={() => onChange(option.value)}
checked={value === option.value}
data-testid={option.testId} data-testid={option.testId}
title={option.text} />
> {option.icon}
{option.icon} </label>
</button> ))}
) : (
<label
key={option.text}
className={clsx({ active: props.value === option.value })}
title={option.text}
>
<input
type="radio"
name={props.group}
onChange={() => props.onChange(option.value)}
checked={props.value === option.value}
data-testid={option.testId}
/>
{option.icon}
</label>
),
)}
</div> </div>
); );
-1
View File
@@ -183,7 +183,6 @@
width: 100%; width: 100%;
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
font-family: inherit;
background-color: transparent; background-color: transparent;
color: var(--text-primary-color); color: var(--text-primary-color);
border: 0; border: 0;
+2 -5
View File
@@ -4,8 +4,8 @@ import { Dialog, DialogProps } from "./Dialog";
import "./ConfirmDialog.scss"; import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu"; import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App"; import { useExcalidrawSetAppState } from "./App";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> { interface Props extends Omit<DialogProps, "onCloseRequest"> {
@@ -26,7 +26,6 @@ const ConfirmDialog = (props: Props) => {
} = props; } = props;
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
const { container } = useExcalidrawContainer();
return ( return (
<Dialog <Dialog
@@ -43,7 +42,6 @@ const ConfirmDialog = (props: Props) => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
setIsLibraryMenuOpen(false); setIsLibraryMenuOpen(false);
onCancel(); onCancel();
container?.focus();
}} }}
/> />
<DialogActionButton <DialogActionButton
@@ -52,7 +50,6 @@ const ConfirmDialog = (props: Props) => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
setIsLibraryMenuOpen(false); setIsLibraryMenuOpen(false);
onConfirm(); onConfirm();
container?.focus();
}} }}
actionType="danger" actionType="danger"
/> />
-1
View File
@@ -30,7 +30,6 @@
background-color: transparent; background-color: transparent;
border: none; border: none;
white-space: nowrap; white-space: nowrap;
font-family: inherit;
display: grid; display: grid;
grid-template-columns: 1fr 0.2fr; grid-template-columns: 1fr 0.2fr;
-144
View File
@@ -1,144 +0,0 @@
import React from "react";
import { DEFAULT_SIDEBAR } from "../constants";
import { DefaultSidebar } from "../packages/excalidraw/index";
import {
fireEvent,
waitFor,
withExcalidrawDimensions,
} from "../tests/test-utils";
import {
assertExcalidrawWithSidebar,
assertSidebarDockButton,
} from "./Sidebar/Sidebar.test";
const { h } = window;
describe("DefaultSidebar", () => {
it("when `docked={undefined}` & `onDock={undefined}`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `docked={undefined}` & `onDock`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={() => {}} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `docked={true}` & `onDock`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={() => {}} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `onDock={false}`, should disable docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={false} />,
DEFAULT_SIDEBAR.name,
async () => {
await withExcalidrawDimensions(
{ width: 1920, height: 1080 },
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
await assertSidebarDockButton(false);
},
);
},
);
});
it("when `docked={true}` & `onDock={false}`, should force-dock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked onDock={false} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).toHaveClass("sidebar--docked");
},
);
});
it("when `docked={true}` & `onDock={undefined}`, should force-dock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).toHaveClass("sidebar--docked");
},
);
});
it("when `docked={false}` & `onDock={undefined}`, should force-undock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked={false} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).not.toHaveClass("sidebar--docked");
},
);
});
});
-118
View File
@@ -1,118 +0,0 @@
import clsx from "clsx";
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
import { useTunnels } from "../context/tunnels";
import { useUIAppState } from "../context/ui-appState";
import { t } from "../i18n";
import { MarkOptional, Merge } from "../utility-types";
import { composeEventHandlers } from "../utils";
import { useExcalidrawSetAppState } from "./App";
import { withInternalFallback } from "./hoc/withInternalFallback";
import { LibraryMenu } from "./LibraryMenu";
import { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
import { Sidebar } from "./Sidebar/Sidebar";
const DefaultSidebarTrigger = withInternalFallback(
"DefaultSidebarTrigger",
(
props: Omit<SidebarTriggerProps, "name"> &
React.HTMLAttributes<HTMLDivElement>,
) => {
const { DefaultSidebarTriggerTunnel } = useTunnels();
return (
<DefaultSidebarTriggerTunnel.In>
<Sidebar.Trigger
{...props}
className="default-sidebar-trigger"
name={DEFAULT_SIDEBAR.name}
/>
</DefaultSidebarTriggerTunnel.In>
);
},
);
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
const DefaultTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return (
<DefaultSidebarTabTriggersTunnel.In>
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
</DefaultSidebarTabTriggersTunnel.In>
);
};
DefaultTabTriggers.displayName = "DefaultTabTriggers";
export const DefaultSidebar = Object.assign(
withInternalFallback(
"DefaultSidebar",
({
children,
className,
onDock,
docked,
...rest
}: Merge<
MarkOptional<Omit<SidebarProps, "name">, "children">,
{
/** pass `false` to disable docking */
onDock?: SidebarProps["onDock"] | false;
}
>) => {
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return (
<Sidebar
{...rest}
name="default"
key="default"
className={clsx("default-sidebar", className)}
docked={docked ?? appState.defaultSidebarDockedPreference}
onDock={
// `onDock=false` disables docking.
// if `docked` passed, but no onDock passed, disable manual docking.
onDock === false || (!onDock && docked != null)
? undefined
: // compose to allow the host app to listen on default behavior
composeEventHandlers(onDock, (docked) => {
setAppState({ defaultSidebarDockedPreference: docked });
})
}
>
<Sidebar.Tabs>
<Sidebar.Header>
{rest.__fallback && (
<div
style={{
color: "var(--color-primary)",
fontSize: "1.2em",
fontWeight: "bold",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
paddingRight: "1em",
}}
>
{t("toolBar.library")}
</div>
)}
<DefaultSidebarTabTriggersTunnel.Out />
</Sidebar.Header>
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
<LibraryMenu />
</Sidebar.Tab>
{children}
</Sidebar.Tabs>
</Sidebar>
);
},
),
{
Trigger: DefaultSidebarTrigger,
TabTriggers: DefaultTabTriggers,
},
);
+1 -1
View File
@@ -15,7 +15,7 @@ import { Modal } from "./Modal";
import { AppState } from "../types"; import { AppState } from "../types";
import { queryFocusableElements } from "../utils"; import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu"; import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
export interface DialogProps { export interface DialogProps {
+5 -6
View File
@@ -165,12 +165,11 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[KEYS.E, KEYS["0"]]} shortcuts={[KEYS.E, KEYS["0"]]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.editLineArrowPoints")} label={t("helpDialog.editSelectedShape")}
shortcuts={[getShortcutKey("CtrlOrCmd+Enter")]} shortcuts={[
/> getShortcutKey("CtrlOrCmd+Enter"),
<Shortcut getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`),
label={t("helpDialog.editText")} ]}
shortcuts={[getShortcutKey("Enter")]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.textNewLine")} label={t("helpDialog.textNewLine")}
+5 -5
View File
@@ -1,7 +1,9 @@
import { t } from "../i18n"; import { t } from "../i18n";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { Device, UIAppState } from "../types";
import "./HintViewer.scss";
import { AppState, Device } from "../types";
import { import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
@@ -11,10 +13,8 @@ import {
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { isEraserActive } from "../appState"; import { isEraserActive } from "../appState";
import "./HintViewer.scss";
interface HintViewerProps { interface HintViewerProps {
appState: UIAppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean; isMobile: boolean;
device: Device; device: Device;
@@ -29,7 +29,7 @@ const getHints = ({
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
if (appState.openSidebar && !device.canDeviceFitSidebar) { if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) {
return null; return null;
} }
+18 -17
View File
@@ -4,18 +4,17 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { BinaryFiles, UIAppState } from "../types"; import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { clipboard } from "./icons"; import { clipboard } from "./icons";
import Stack from "./Stack"; import Stack from "./Stack";
import "./ExportDialog.scss";
import OpenColor from "open-color"; import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants"; import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { exportToCanvas } from "../packages/utils";
import "./ExportDialog.scss";
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@@ -71,7 +70,7 @@ const ImageExportModal = ({
onExportToSvg, onExportToSvg,
onExportToClipboard, onExportToClipboard,
}: { }: {
appState: UIAppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
@@ -84,6 +83,7 @@ const ImageExportModal = ({
const someElementIsSelected = isSomeElementSelected(elements, appState); const someElementIsSelected = isSomeElementSelected(elements, appState);
const [exportSelected, setExportSelected] = useState(someElementIsSelected); const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState;
const [renderError, setRenderError] = useState<Error | null>(null); const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected const exportedElements = exportSelected
@@ -99,16 +99,10 @@ const ImageExportModal = ({
if (!previewNode) { if (!previewNode) {
return; return;
} }
const maxWidth = previewNode.offsetWidth; exportToCanvas(exportedElements, appState, files, {
if (!maxWidth) { exportBackground,
return; viewBackgroundColor,
}
exportToCanvas({
elements: exportedElements,
appState,
files,
exportPadding, exportPadding,
maxWidthOrHeight: maxWidth,
}) })
.then((canvas) => { .then((canvas) => {
setRenderError(null); setRenderError(null);
@@ -122,7 +116,14 @@ const ImageExportModal = ({
console.error(error); console.error(error);
setRenderError(error); setRenderError(error);
}); });
}, [appState, files, exportedElements, exportPadding]); }, [
appState,
files,
exportedElements,
exportBackground,
exportPadding,
viewBackgroundColor,
]);
return ( return (
<div className="ExportDialog"> <div className="ExportDialog">
@@ -217,8 +218,8 @@ export const ImageExportDialog = ({
onExportToSvg, onExportToSvg,
onExportToClipboard, onExportToClipboard,
}: { }: {
appState: UIAppState; appState: AppState;
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
+4 -4
View File
@@ -2,7 +2,7 @@ import React from "react";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { ExportOpts, BinaryFiles, UIAppState } from "../types"; import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { exportToFileIcon, LinkIcon } from "./icons"; import { exportToFileIcon, LinkIcon } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@@ -28,7 +28,7 @@ const JSONExportModal = ({
exportOpts, exportOpts,
canvas, canvas,
}: { }: {
appState: UIAppState; appState: AppState;
files: BinaryFiles; files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionManager; actionManager: ActionManager;
@@ -96,12 +96,12 @@ export const JSONExportDialog = ({
setAppState, setAppState,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
appState: UIAppState; appState: AppState;
files: BinaryFiles; files: BinaryFiles;
actionManager: ActionManager; actionManager: ActionManager;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
}) => { }) => {
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
setAppState({ openDialog: null }); setAppState({ openDialog: null });
+78 -91
View File
@@ -1,21 +1,15 @@
import clsx from "clsx"; import clsx from "clsx";
import React from "react"; import React from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants"; import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
import { exportCanvas } from "../data"; import { exportCanvas } from "../data";
import { isTextElement, showSelectedShapeActions } from "../element"; import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
AppProps, import { isShallowEqual, muteFSAbortError } from "../utils";
AppState,
ExcalidrawProps,
BinaryFiles,
UIAppState,
} from "../types";
import { capitalizeString, isShallowEqual, muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { ErrorDialog } from "./ErrorDialog"; import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
@@ -30,32 +24,32 @@ import { Section } from "./Section";
import { HelpDialog } from "./HelpDialog"; import { HelpDialog } from "./HelpDialog";
import Stack from "./Stack"; import Stack from "./Stack";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog"; import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { LibraryMenu } from "./LibraryMenu";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats"; import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer"; import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import { Provider, useAtomValue } from "jotai"; import { Provider, useAtom } from "jotai";
import MainMenu from "./main-menu/MainMenu"; import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels"; import { TunnelsContext, useInitializeTunnels } from "./context/tunnels";
import { LibraryIcon } from "./icons";
import { UIAppStateContext } from "../context/ui-appState";
import { DefaultSidebar } from "./DefaultSidebar";
import "./LayerUI.scss";
import "./Toolbar.scss";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
appState: UIAppState; appState: AppState;
files: BinaryFiles; files: BinaryFiles;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
@@ -63,11 +57,17 @@ interface LayerUIProps {
onLockToggle: () => void; onLockToggle: () => void;
onHandToolToggle: () => void; onHandToolToggle: () => void;
onPenModeToggle: () => void; onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
langCode: Language["code"]; langCode: Language["code"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
focusContainer: () => void;
library: Library;
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
children?: React.ReactNode; children?: React.ReactNode;
@@ -109,10 +109,16 @@ const LayerUI = ({
onLockToggle, onLockToggle,
onHandToolToggle, onHandToolToggle,
onPenModeToggle, onPenModeToggle,
onInsertElements,
showExitZenModeBtn, showExitZenModeBtn,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
renderCustomSidebar,
libraryReturnUrl,
UIOptions, UIOptions,
focusContainer,
library,
id,
onImageAction, onImageAction,
renderWelcomeScreen, renderWelcomeScreen,
children, children,
@@ -150,8 +156,7 @@ const LayerUI = ({
const fileHandle = await exportCanvas( const fileHandle = await exportCanvas(
type, type,
exportedElements, exportedElements,
// FIXME once we split UI canvas from element canvas appState,
appState as AppState,
files, files,
{ {
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
@@ -192,8 +197,8 @@ const LayerUI = ({
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{/* wrapping to Fragment stops React from occasionally complaining {/* wrapping to Fragment stops React from occasionally complaining
about identical Keys */} about identical Keys */}
<tunnels.MainMenuTunnel.Out /> <tunnels.mainMenuTunnel.Out />
{renderWelcomeScreen && <tunnels.WelcomeScreenMenuHintTunnel.Out />} {renderWelcomeScreen && <tunnels.welcomeScreenMenuHintTunnel.Out />}
</div> </div>
); );
@@ -245,7 +250,7 @@ const LayerUI = ({
{(heading: React.ReactNode) => ( {(heading: React.ReactNode) => (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderWelcomeScreen && ( {renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out /> <tunnels.welcomeScreenToolbarHintTunnel.Out />
)} )}
<Stack.Col gap={4} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row
@@ -319,12 +324,9 @@ const LayerUI = ({
> >
<UserList collaborators={appState.collaborators} /> <UserList collaborators={appState.collaborators} />
{renderTopRightUI?.(device.isMobile, appState)} {renderTopRightUI?.(device.isMobile, appState)}
{!appState.viewModeEnabled && {!appState.viewModeEnabled && (
// hide button when sidebar docked <LibraryButton appState={appState} setAppState={setAppState} />
(!isSidebarDocked || )}
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
<tunnels.DefaultSidebarTriggerTunnel.Out />
)}
</div> </div>
</div> </div>
</FixedSideContainer> </FixedSideContainer>
@@ -332,21 +334,21 @@ const LayerUI = ({
}; };
const renderSidebars = () => { const renderSidebars = () => {
return ( return appState.openSidebar === "customSidebar" ? (
<DefaultSidebar renderCustomSidebar?.() || null
__fallback ) : appState.openSidebar === "library" ? (
onDock={(docked) => { <LibraryMenu
trackEvent( appState={appState}
"sidebar", onInsertElements={onInsertElements}
`toggleDock (${docked ? "dock" : "undock"})`, libraryReturnUrl={libraryReturnUrl}
`(${device.isMobile ? "mobile" : "desktop"})`, focusContainer={focusContainer}
); library={library}
}} id={id}
/> />
); ) : null;
}; };
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope); const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
const layerUIJSX = ( const layerUIJSX = (
<> <>
@@ -356,25 +358,8 @@ const LayerUI = ({
{children} {children}
{/* render component fallbacks. Can be rendered anywhere as they'll be {/* render component fallbacks. Can be rendered anywhere as they'll be
tunneled away. We only render tunneled components that actually tunneled away. We only render tunneled components that actually
have defaults when host do not render anything. */} have defaults when host do not render anything. */}
<DefaultMainMenu UIOptions={UIOptions} /> <DefaultMainMenu UIOptions={UIOptions} />
<DefaultSidebar.Trigger
__fallback
icon={LibraryIcon}
title={capitalizeString(t("toolBar.library"))}
onToggle={(open) => {
if (open) {
trackEvent(
"sidebar",
`${DEFAULT_SIDEBAR.name} (open)`,
`button (${device.isMobile ? "mobile" : "desktop"})`,
);
}
}}
tab={DEFAULT_SIDEBAR.defaultTab}
>
{t("toolBar.library")}
</DefaultSidebar.Trigger>
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />} {appState.isLoading && <LoadingMessage delay={250} />}
@@ -397,6 +382,7 @@ const LayerUI = ({
<PasteChartDialog <PasteChartDialog
setAppState={setAppState} setAppState={setAppState}
appState={appState} appState={appState}
onInsertChart={onInsertElements}
onClose={() => onClose={() =>
setAppState({ setAppState({
pasteDialog: { shown: false, data: null }, pasteDialog: { shown: false, data: null },
@@ -424,6 +410,7 @@ const LayerUI = ({
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
/> />
)} )}
{!device.isMobile && ( {!device.isMobile && (
<> <>
<div <div
@@ -435,14 +422,15 @@ const LayerUI = ({
!isTextElement(appState.editingElement)), !isTextElement(appState.editingElement)),
})} })}
style={ style={
appState.openSidebar && ((appState.openSidebar === "library" &&
isSidebarDocked && appState.isSidebarDocked) ||
hostSidebarCounters.docked) &&
device.canDeviceFitSidebar device.canDeviceFitSidebar
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` } ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
: {} : {}
} }
> >
{renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />} {renderWelcomeScreen && <tunnels.welcomeScreenCenterTunnel.Out />}
{renderFixedSideContainer()} {renderFixedSideContainer()}
<Footer <Footer
appState={appState} appState={appState}
@@ -465,9 +453,9 @@ const LayerUI = ({
<button <button
className="scroll-back-to-content" className="scroll-back-to-content"
onClick={() => { onClick={() => {
setAppState((appState) => ({ setAppState({
...calculateScrollCenter(elements, appState, canvas), ...calculateScrollCenter(elements, appState, canvas),
})); });
}} }}
> >
{t("buttons.scrollBackToContent")} {t("buttons.scrollBackToContent")}
@@ -481,25 +469,19 @@ const LayerUI = ({
); );
return ( return (
<UIAppStateContext.Provider value={appState}> <Provider scope={tunnels.jotaiScope}>
<Provider scope={tunnels.jotaiScope}> <TunnelsContext.Provider value={tunnels}>
<TunnelsContext.Provider value={tunnels}> {layerUIJSX}
{layerUIJSX} </TunnelsContext.Provider>
</TunnelsContext.Provider> </Provider>
</Provider>
</UIAppStateContext.Provider>
); );
}; };
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => { const stripIrrelevantAppStateProps = (
const { appState: AppState,
suggestedBindings, ): Partial<AppState> => {
startBoundElement, const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
cursorButton, appState;
scrollX,
scrollY,
...ret
} = appState;
return ret; return ret;
}; };
@@ -509,19 +491,24 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
return false; return false;
} }
const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps; const {
const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps; canvas: _prevCanvas,
// not stable, but shouldn't matter in our case
onInsertElements: _prevOnInsertElements,
appState: prevAppState,
...prev
} = prevProps;
const {
canvas: _nextCanvas,
onInsertElements: _nextOnInsertElements,
appState: nextAppState,
...next
} = nextProps;
return ( return (
isShallowEqual( isShallowEqual(
// asserting AppState because we're being passed the whole AppState stripIrrelevantAppStateProps(prevAppState),
// but resolve to only the UI-relevant props stripIrrelevantAppStateProps(nextAppState),
stripIrrelevantAppStateProps(prevAppState as AppState),
stripIrrelevantAppStateProps(nextAppState as AppState),
{
selectedElementIds: isShallowEqual,
selectedGroupIds: isShallowEqual,
},
) && isShallowEqual(prev, next) ) && isShallowEqual(prev, next)
); );
}; };
+32
View File
@@ -0,0 +1,32 @@
@import "../css/variables.module";
.library-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: auto;
height: var(--lg-button-size);
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 0;
font-size: 0.75rem;
letter-spacing: 0.4px;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
&__label {
display: none;
@media screen and (min-width: 1024px) {
display: block;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import React from "react";
import { t } from "../i18n";
import { AppState } from "../types";
import { capitalizeString } from "../utils";
import { trackEvent } from "../analytics";
import { useDevice } from "./App";
import "./LibraryButton.scss";
import { LibraryIcon } from "./icons";
export const LibraryButton: React.FC<{
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
isMobile?: boolean;
}> = ({ appState, setAppState, isMobile }) => {
const device = useDevice();
const showLabel = !isMobile;
// TODO barnabasmolnar/redesign
// not great, toolbar jumps in a jarring manner
if (appState.isSidebarDocked && appState.openSidebar === "library") {
return null;
}
return (
<label title={`${capitalizeString(t("toolBar.library"))}`}>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name="editor-library"
onChange={(event) => {
document
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const isOpen = event.target.checked;
setAppState({ openSidebar: isOpen ? "library" : null });
// track only openings
if (isOpen) {
trackEvent(
"library",
"toggleLibrary (open)",
`toolbar (${device.isMobile ? "mobile" : "desktop"})`,
);
}
}}
checked={appState.openSidebar === "library"}
aria-label={capitalizeString(t("toolBar.library"))}
aria-keyshortcuts="0"
/>
<div className="library-button">
<div>{LibraryIcon}</div>
{showLabel && (
<div className="library-button__label">{t("toolBar.library")}</div>
)}
</div>
</label>
);
};
+48 -22
View File
@@ -1,9 +1,9 @@
@import "open-color/open-color"; @import "open-color/open-color";
.excalidraw { .excalidraw {
.library-menu-items-container { .layer-ui__library-sidebar {
height: 100%; display: flex;
width: 100%; flex-direction: column;
} }
.layer-ui__library { .layer-ui__library {
@@ -11,6 +11,28 @@
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
.layer-ui__library-header {
display: flex;
align-items: center;
width: 100%;
margin: 2px 0 15px 0;
.Spinner {
margin-right: 1rem;
}
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
}
}
.layer-ui__sidebar {
.library-menu-items-container {
height: 100%;
width: 100%;
}
} }
.library-actions-counter { .library-actions-counter {
@@ -65,17 +87,10 @@
} }
} }
.library-menu-control-buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
}
.library-menu-browse-button { .library-menu-browse-button {
flex: 1; margin: 1rem auto;
height: var(--lg-button-size); padding: 0.875rem 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -107,19 +122,30 @@
} }
} }
&.excalidraw--mobile .library-menu-browse-button { .library-menu-browse-button--mobile {
height: var(--default-button-size); min-height: 22px;
margin-left: auto;
a {
padding-right: 0;
}
} }
.layer-ui__library .dropdown-menu { .layer-ui__sidebar__header .dropdown-menu {
width: auto; &.dropdown-menu--mobile {
top: initial; top: 100%;
right: 0; }
left: initial;
bottom: 100%;
margin-bottom: 0.625rem;
.dropdown-menu-container { .dropdown-menu-container {
--gap: 0;
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0;
left: auto;
}
width: 196px; width: 196px;
box-shadow: var(--library-dropdown-shadow); box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
+180 -43
View File
@@ -1,39 +1,77 @@
import React, { useState, useCallback } from "react"; import {
useRef,
useState,
useEffect,
useCallback,
RefObject,
forwardRef,
} from "react";
import Library, { import Library, {
distributeLibraryItemsOnSquareGrid, distributeLibraryItemsOnSquareGrid,
libraryItemsAtom, libraryItemsAtom,
} from "../data/library"; } from "../data/library";
import { t } from "../i18n"; import { t } from "../i18n";
import { randomId } from "../random"; import { randomId } from "../random";
import { import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
LibraryItems,
LibraryItem, import "./LibraryMenu.scss";
ExcalidrawProps,
UIAppState,
} from "../types";
import LibraryMenuItems from "./LibraryMenuItems"; import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants";
import { KEYS } from "../keys";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { atom, useAtom } from "jotai"; import { useAtom } from "jotai";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import { import {
useApp, useDevice,
useAppProps,
useExcalidrawElements, useExcalidrawElements,
useExcalidrawSetAppState, useExcalidrawSetAppState,
} from "./App"; } from "./App";
import { Sidebar } from "./Sidebar/Sidebar";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { useUIAppState } from "../context/ui-appState"; import { NonDeletedExcalidrawElement } from "../element/types";
import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
import "./LibraryMenu.scss"; const useOnClickOutside = (
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; ref: RefObject<HTMLElement>,
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
export const isLibraryMenuOpenAtom = atom(false); if (
event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => { cb(event);
return <div className="layer-ui__library">{children}</div>; };
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
}; };
const LibraryMenuWrapper = forwardRef<
HTMLDivElement,
{ children: React.ReactNode }
>(({ children }, ref) => {
return (
<div ref={ref} className="layer-ui__library">
{children}
</div>
);
});
export const LibraryMenuContent = ({ export const LibraryMenuContent = ({
onInsertLibraryItems, onInsertLibraryItems,
pendingElements, pendingElements,
@@ -49,11 +87,11 @@ export const LibraryMenuContent = ({
pendingElements: LibraryItem["elements"]; pendingElements: LibraryItem["elements"];
onInsertLibraryItems: (libraryItems: LibraryItems) => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: () => void; onAddToLibrary: () => void;
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library; library: Library;
id: string; id: string;
appState: UIAppState; appState: AppState;
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
}) => { }) => {
@@ -120,31 +158,81 @@ export const LibraryMenuContent = ({
theme={appState.theme} theme={appState.theme}
/> />
{showBtn && ( {showBtn && (
<LibraryMenuControlButtons <LibraryMenuBrowseButton
style={{ padding: "16px 12px 0 12px" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={appState.theme} theme={appState.theme}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/> />
)} )}
</LibraryMenuWrapper> </LibraryMenuWrapper>
); );
}; };
/** export const LibraryMenu: React.FC<{
* This component is meant to be rendered inside <Sidebar.Tab/> inside our appState: AppState;
* <DefaultSidebar/> or host apps Sidebar components. onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
*/ libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
export const LibraryMenu = () => { focusContainer: () => void;
const { library, id, onInsertElements } = useApp(); library: Library;
const appProps = useAppProps(); id: string;
const appState = useUIAppState(); }> = ({
appState,
onInsertElements,
libraryReturnUrl,
focusContainer,
library,
id,
}) => {
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements(); const elements = useExcalidrawElements();
const device = useDevice();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const ref = useRef<HTMLDivElement | null>(null);
const closeLibrary = useCallback(() => {
const isDialogOpen = !!document.querySelector(".Dialog");
// Prevent closing if any dialog is open
if (isDialogOpen) {
return;
}
setAppState({ openSidebar: null });
}, [setAppState]);
useOnClickOutside(
ref,
useCallback(
(event) => {
// If click on the library icon, do nothing so that LibraryButton
// can toggle library menu
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
closeLibrary();
}
},
[closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar],
),
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!appState.isSidebarDocked || !device.canDeviceFitSidebar)
) {
closeLibrary();
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]);
const deselectItems = useCallback(() => { const deselectItems = useCallback(() => {
setAppState({ setAppState({
@@ -153,20 +241,69 @@ export const LibraryMenu = () => {
}); });
}, [setAppState]); }, [setAppState]);
const removeFromLibrary = useCallback(
async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
},
[library, setAppState, selectedItems, setSelectedItems],
);
const resetLibrary = useCallback(() => {
library.resetLibrary();
focusContainer();
}, [library, focusContainer]);
return ( return (
<LibraryMenuContent <Sidebar
pendingElements={getSelectedElements(elements, appState, true)} __isInternal
onInsertLibraryItems={(libraryItems) => { // necessary to remount when switching between internal
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); // and custom (host app) sidebar, so that the `props.onClose`
// is colled correctly
key="library"
className="layer-ui__library-sidebar"
initialDockedState={appState.isSidebarDocked}
onDock={(docked) => {
trackEvent(
"library",
`toggleLibraryDock (${docked ? "dock" : "undock"})`,
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
);
}} }}
onAddToLibrary={deselectItems} ref={ref}
setAppState={setAppState} >
libraryReturnUrl={appProps.libraryReturnUrl} <Sidebar.Header className="layer-ui__library-header">
library={library} <LibraryMenuHeader
id={id} appState={appState}
appState={appState} setAppState={setAppState}
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={setSelectedItems} onSelectItems={setSelectedItems}
/> library={library}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
resetLibrary={resetLibrary}
/>
</Sidebar.Header>
<LibraryMenuContent
pendingElements={getSelectedElements(elements, appState, true)}
onInsertLibraryItems={(libraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
}}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
id={id}
appState={appState}
selectedItems={selectedItems}
onSelectItems={setSelectedItems}
/>
</Sidebar>
); );
}; };
+2 -2
View File
@@ -1,6 +1,6 @@
import { VERSIONS } from "../constants"; import { VERSIONS } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { ExcalidrawProps, UIAppState } from "../types"; import { AppState, ExcalidrawProps } from "../types";
const LibraryMenuBrowseButton = ({ const LibraryMenuBrowseButton = ({
theme, theme,
@@ -8,7 +8,7 @@ const LibraryMenuBrowseButton = ({
libraryReturnUrl, libraryReturnUrl,
}: { }: {
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"]; theme: AppState["theme"];
id: string; id: string;
}) => { }) => {
const referrer = const referrer =
@@ -1,33 +0,0 @@
import { LibraryItem, ExcalidrawProps, UIAppState } from "../types";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
export const LibraryMenuControlButtons = ({
selectedItems,
onSelectItems,
libraryReturnUrl,
theme,
id,
style,
}: {
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"];
id: string;
style: React.CSSProperties;
}) => {
return (
<div className="library-menu-control-buttons" style={style}>
<LibraryMenuBrowseButton
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
/>
<LibraryDropdownMenu
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/>
</div>
);
};
+22 -72
View File
@@ -1,11 +1,8 @@
import { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { t } from "../i18n";
import Trans from "./Trans";
import { jotaiScope } from "../jotai";
import { LibraryItem, LibraryItems, UIAppState } from "../types";
import { useApp, useExcalidrawSetAppState } from "./App";
import { saveLibraryAsJSON } from "../data/json"; import { saveLibraryAsJSON } from "../data/json";
import Library, { libraryItemsAtom } from "../data/library"; import Library, { libraryItemsAtom } from "../data/library";
import { t } from "../i18n";
import { AppState, LibraryItem, LibraryItems } from "../types";
import { import {
DotsIcon, DotsIcon,
ExportIcon, ExportIcon,
@@ -16,27 +13,29 @@ import {
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import PublishLibrary from "./PublishLibrary"; import PublishLibrary from "./PublishLibrary";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import DropdownMenu from "./dropdownMenu/DropdownMenu"; import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { useUIAppState } from "../context/ui-appState"; export const isLibraryMenuOpenAtom = atom(false);
const getSelectedItems = ( const getSelectedItems = (
libraryItems: LibraryItems, libraryItems: LibraryItems,
selectedItems: LibraryItem["id"][], selectedItems: LibraryItem["id"][],
) => libraryItems.filter((item) => selectedItems.includes(item.id)); ) => libraryItems.filter((item) => selectedItems.includes(item.id));
export const LibraryDropdownMenuButton: React.FC<{ export const LibraryMenuHeader: React.FC<{
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
library: Library; library: Library;
onRemoveFromLibrary: () => void; onRemoveFromLibrary: () => void;
resetLibrary: () => void; resetLibrary: () => void;
onSelectItems: (items: LibraryItem["id"][]) => void; onSelectItems: (items: LibraryItem["id"][]) => void;
appState: UIAppState; appState: AppState;
}> = ({ }> = ({
setAppState, setAppState,
selectedItems, selectedItems,
@@ -51,7 +50,6 @@ export const LibraryDropdownMenuButton: React.FC<{
isLibraryMenuOpenAtom, isLibraryMenuOpenAtom,
jotaiScope, jotaiScope,
); );
const renderRemoveLibAlert = useCallback(() => { const renderRemoveLibAlert = useCallback(() => {
const content = selectedItems.length const content = selectedItems.length
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length }) ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
@@ -106,19 +104,16 @@ export const LibraryDropdownMenuButton: React.FC<{
small={true} small={true}
> >
<p> <p>
<Trans {t("publishSuccessDialog.content", {
i18nKey="publishSuccessDialog.content" authorName: publishLibSuccess!.authorName,
authorName={publishLibSuccess!.authorName} })}{" "}
link={(el) => ( <a
<a href={publishLibSuccess?.url}
href={publishLibSuccess?.url} target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" >
> {t("publishSuccessDialog.link")}
{el} </a>
</a>
)}
/>
</p> </p>
<ToolButton <ToolButton
type="button" type="button"
@@ -186,6 +181,7 @@ export const LibraryDropdownMenuButton: React.FC<{
return ( return (
<DropdownMenu open={isLibraryMenuOpen}> <DropdownMenu open={isLibraryMenuOpen}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
className="Sidebar__dropdown-btn"
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)} onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
> >
{DotsIcon} {DotsIcon}
@@ -234,7 +230,6 @@ export const LibraryDropdownMenuButton: React.FC<{
</DropdownMenu> </DropdownMenu>
); );
}; };
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderLibraryMenu()} {renderLibraryMenu()}
@@ -266,48 +261,3 @@ export const LibraryDropdownMenuButton: React.FC<{
</div> </div>
); );
}; };
export const LibraryDropdownMenu = ({
selectedItems,
onSelectItems,
}: {
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) => {
const { library } = useApp();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const removeFromLibrary = useCallback(
async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
onSelectItems([]);
},
[library, setAppState, selectedItems, onSelectItems],
);
const resetLibrary = useCallback(() => {
library.resetLibrary();
}, [library]);
return (
<LibraryDropdownMenuButton
appState={appState}
setAppState={setAppState}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
library={library}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
resetLibrary={resetLibrary}
/>
);
};
+2 -2
View File
@@ -47,7 +47,7 @@
&__items { &__items {
row-gap: 0.5rem; row-gap: 0.5rem;
padding: var(--container-padding-y) 0; padding: var(--container-padding-y) var(--container-padding-x);
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@@ -61,7 +61,7 @@
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
&--excal { &--excal {
margin-top: 2rem; margin-top: 2.5rem;
} }
} }
+19 -26
View File
@@ -2,21 +2,16 @@ import React, { useState } from "react";
import { serializeLibraryAsJSON } from "../data/json"; import { serializeLibraryAsJSON } from "../data/json";
import { ExcalidrawElement, NonDeleted } from "../element/types"; import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types";
ExcalidrawProps,
LibraryItem,
LibraryItems,
UIAppState,
} from "../types";
import { arrayToMap, chunk } from "../utils"; import { arrayToMap, chunk } from "../utils";
import { LibraryUnit } from "./LibraryUnit"; import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack"; import Stack from "./Stack";
import { MIME_TYPES } from "../constants";
import Spinner from "./Spinner";
import { duplicateElements } from "../element/newElement";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import "./LibraryMenuItems.scss"; import "./LibraryMenuItems.scss";
import { MIME_TYPES } from "../constants";
import Spinner from "./Spinner";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
import clsx from "clsx";
const CELLS_PER_ROW = 4; const CELLS_PER_ROW = 4;
@@ -40,7 +35,7 @@ const LibraryMenuItems = ({
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"]; theme: AppState["theme"];
id: string; id: string;
}) => { }) => {
const [lastSelectedItem, setLastSelectedItem] = useState< const [lastSelectedItem, setLastSelectedItem] = useState<
@@ -101,14 +96,7 @@ const LibraryMenuItems = ({
} else { } else {
targetElements = libraryItems.filter((item) => item.id === id); targetElements = libraryItems.filter((item) => item.id === id);
} }
return targetElements.map((item) => { return targetElements;
return {
...item,
// duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements, { randomizeSeed: true }),
};
});
}; };
const createLibraryItemCompo = (params: { const createLibraryItemCompo = (params: {
@@ -205,7 +193,11 @@ const LibraryMenuItems = ({
(item) => item.status === "published", (item) => item.status === "published",
); );
const showBtn = !libraryItems.length && !pendingElements.length; const showBtn =
!libraryItems.length &&
!unpublishedItems.length &&
!publishedItems.length &&
!pendingElements.length;
return ( return (
<div <div
@@ -215,7 +207,7 @@ const LibraryMenuItems = ({
unpublishedItems.length || unpublishedItems.length ||
publishedItems.length publishedItems.length
? { justifyContent: "flex-start" } ? { justifyContent: "flex-start" }
: { borderBottom: 0 } : {}
} }
> >
<Stack.Col <Stack.Col
@@ -251,7 +243,11 @@ const LibraryMenuItems = ({
</div> </div>
{!pendingElements.length && !unpublishedItems.length ? ( {!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items"> <div className="library-menu-items__no-items">
<div className="library-menu-items__no-items__label"> <div
className={clsx({
"library-menu-items__no-items__label": showBtn,
})}
>
{t("library.noItems")} {t("library.noItems")}
</div> </div>
<div className="library-menu-items__no-items__hint"> <div className="library-menu-items__no-items__hint">
@@ -299,13 +295,10 @@ const LibraryMenuItems = ({
</> </>
{showBtn && ( {showBtn && (
<LibraryMenuControlButtons <LibraryMenuBrowseButton
style={{ padding: "16px 0", width: "100%" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={theme} theme={theme}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/> />
)} )}
</Stack.Col> </Stack.Col>
+5 -5
View File
@@ -2,7 +2,7 @@ import clsx from "clsx";
import oc from "open-color"; import oc from "open-color";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { exportToSvg } from "../packages/utils"; import { exportToSvg } from "../scene/export";
import { LibraryItem } from "../types"; import { LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
@@ -36,14 +36,14 @@ export const LibraryUnit = ({
if (!elements) { if (!elements) {
return; return;
} }
const svg = await exportToSvg({ const svg = await exportToSvg(
elements, elements,
appState: { {
exportBackground: false, exportBackground: false,
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
}, },
files: null, null,
}); );
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();
+17 -16
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { AppState, Device, ExcalidrawProps, UIAppState } from "../types"; import { AppState, Device, ExcalidrawProps } from "../types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { t } from "../i18n"; import { t } from "../i18n";
import Stack from "./Stack"; import Stack from "./Stack";
@@ -13,15 +13,16 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section"; import { Section } from "./Section";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
import { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "./context/tunnels";
type MobileMenuProps = { type MobileMenuProps = {
appState: UIAppState; appState: AppState;
actionManager: ActionManager; actionManager: ActionManager;
renderJSONExportDialog: () => React.ReactNode; renderJSONExportDialog: () => React.ReactNode;
renderImageExportDialog: () => React.ReactNode; renderImageExportDialog: () => React.ReactNode;
@@ -35,7 +36,7 @@ type MobileMenuProps = {
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: ( renderTopRightUI?: (
isMobile: boolean, isMobile: boolean,
appState: UIAppState, appState: AppState,
) => JSX.Element | null; ) => JSX.Element | null;
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
@@ -59,15 +60,11 @@ export const MobileMenu = ({
device, device,
renderWelcomeScreen, renderWelcomeScreen,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const { const { welcomeScreenCenterTunnel, mainMenuTunnel } = useTunnels();
WelcomeScreenCenterTunnel,
MainMenuTunnel,
DefaultSidebarTriggerTunnel,
} = useTunnels();
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
<FixedSideContainer side="top" className="App-top-bar"> <FixedSideContainer side="top" className="App-top-bar">
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />} {renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />}
<Section heading="shapes"> <Section heading="shapes">
{(heading: React.ReactNode) => ( {(heading: React.ReactNode) => (
<Stack.Col gap={4} align="center"> <Stack.Col gap={4} align="center">
@@ -91,7 +88,11 @@ export const MobileMenu = ({
{renderTopRightUI && renderTopRightUI(true, appState)} {renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container"> <div className="mobile-misc-tools-container">
{!appState.viewModeEnabled && ( {!appState.viewModeEnabled && (
<DefaultSidebarTriggerTunnel.Out /> <LibraryButton
appState={appState}
setAppState={setAppState}
isMobile
/>
)} )}
<PenModeButton <PenModeButton
checked={appState.penMode} checked={appState.penMode}
@@ -131,14 +132,14 @@ export const MobileMenu = ({
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
<MainMenuTunnel.Out /> <mainMenuTunnel.Out />
</div> </div>
); );
} }
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
<MainMenuTunnel.Out /> <mainMenuTunnel.Out />
{actionManager.renderAction("toggleEditMenu")} {actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")} {actionManager.renderAction("undo")}
{actionManager.renderAction("redo")} {actionManager.renderAction("redo")}
@@ -189,13 +190,13 @@ export const MobileMenu = ({
{renderAppToolbar()} {renderAppToolbar()}
{appState.scrolledOutside && {appState.scrolledOutside &&
!appState.openMenu && !appState.openMenu &&
!appState.openSidebar && ( appState.openSidebar !== "library" && (
<button <button
className="scroll-back-to-content" className="scroll-back-to-content"
onClick={() => { onClick={() => {
setAppState((appState) => ({ setAppState({
...calculateScrollCenter(elements, appState, canvas), ...calculateScrollCenter(elements, appState, canvas),
})); });
}} }}
> >
{t("buttons.scrollBackToContent")} {t("buttons.scrollBackToContent")}
+6 -7
View File
@@ -5,10 +5,8 @@ import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
import { ChartType } from "../element/types"; import { ChartType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import { UIAppState } from "../types"; import { AppState, LibraryItem } from "../types";
import { useApp } from "./App";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import "./PasteChartDialog.scss"; import "./PasteChartDialog.scss";
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void; type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
@@ -80,12 +78,13 @@ export const PasteChartDialog = ({
setAppState, setAppState,
appState, appState,
onClose, onClose,
onInsertChart,
}: { }: {
appState: UIAppState; appState: AppState;
onClose: () => void; onClose: () => void;
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
onInsertChart: (elements: LibraryItem["elements"]) => void;
}) => { }) => {
const { onInsertElements } = useApp();
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
if (onClose) { if (onClose) {
onClose(); onClose();
@@ -93,7 +92,7 @@ export const PasteChartDialog = ({
}, [onClose]); }, [onClose]);
const handleChartClick = (chartType: ChartType, elements: ChartElements) => { const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertElements(elements); onInsertChart(elements);
trackEvent("magic", "chart", chartType); trackEvent("magic", "chart", chartType);
setAppState({ setAppState({
currentChartType: chartType, currentChartType: chartType,
-1
View File
@@ -3,6 +3,5 @@
position: absolute; position: absolute;
z-index: 10; z-index: 10;
padding: 5px 0 5px; padding: 5px 0 5px;
outline: none;
} }
} }
+25 -65
View File
@@ -29,21 +29,13 @@ export const Popover = ({
}: Props) => { }: Props) => {
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => { const container = popoverRef.current;
const container = popoverRef.current;
useEffect(() => {
if (!container) { if (!container) {
return; return;
} }
// focus popover only if the caller didn't focus on something else nested
// within the popover, which should take precedence. Fixes cases
// like color picker listening to keydown events on containers nested
// in the popover.
if (!container.contains(document.activeElement)) {
container.focus();
}
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.TAB) { if (event.key === KEYS.TAB) {
const focusableElements = queryFocusableElements(container); const focusableElements = queryFocusableElements(container);
@@ -52,23 +44,15 @@ export const Popover = ({
(element) => element === activeElement, (element) => element === activeElement,
); );
if (activeElement === container) { if (currentIndex === 0 && event.shiftKey) {
if (event.shiftKey) { focusableElements[focusableElements.length - 1].focus();
focusableElements[focusableElements.length - 1]?.focus();
} else {
focusableElements[0].focus();
}
event.preventDefault();
event.stopImmediatePropagation();
} else if (currentIndex === 0 && event.shiftKey) {
focusableElements[focusableElements.length - 1]?.focus();
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
} else if ( } else if (
currentIndex === focusableElements.length - 1 && currentIndex === focusableElements.length - 1 &&
!event.shiftKey !event.shiftKey
) { ) {
focusableElements[0]?.focus(); focusableElements[0].focus();
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
} }
@@ -78,59 +62,35 @@ export const Popover = ({
container.addEventListener("keydown", handleKeyDown); container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown); return () => container.removeEventListener("keydown", handleKeyDown);
}, []); }, [container]);
const lastInitializedPosRef = useRef<{ top: number; left: number } | null>(
null,
);
// ensure the popover doesn't overflow the viewport // ensure the popover doesn't overflow the viewport
useLayoutEffect(() => { useLayoutEffect(() => {
if (fitInViewport && popoverRef.current && top != null && left != null) { if (fitInViewport && popoverRef.current) {
const container = popoverRef.current; const element = popoverRef.current;
const { width, height } = container.getBoundingClientRect(); const { x, y, width, height } = element.getBoundingClientRect();
// hack for StrictMode so this effect only runs once for //Position correctly when clicked on rightmost part or the bottom part of viewport
// the same top/left position, otherwise if (x + width - offsetLeft > viewportWidth) {
// we'd potentically reposition twice (once for viewport overflow) element.style.left = `${viewportWidth - width - 10}px`;
// and once for top/left position afterwards
if (
lastInitializedPosRef.current?.top === top &&
lastInitializedPosRef.current?.left === left
) {
return;
} }
lastInitializedPosRef.current = { top, left }; if (y + height - offsetTop > viewportHeight) {
element.style.top = `${viewportHeight - height}px`;
if (width >= viewportWidth) {
container.style.width = `${viewportWidth}px`;
container.style.left = "0px";
container.style.overflowX = "scroll";
} else if (left + width - offsetLeft > viewportWidth) {
container.style.left = `${viewportWidth - width - 10}px`;
} else {
container.style.left = `${left}px`;
} }
//Resize to fit viewport on smaller screens
if (height >= viewportHeight) { if (height >= viewportHeight) {
container.style.height = `${viewportHeight - 20}px`; element.style.height = `${viewportHeight - 20}px`;
container.style.top = "10px"; element.style.top = "10px";
container.style.overflowY = "scroll"; element.style.overflowY = "scroll";
} else if (top + height - offsetTop > viewportHeight) { }
container.style.top = `${viewportHeight - height}px`; if (width >= viewportWidth) {
} else { element.style.width = `${viewportWidth}px`;
container.style.top = `${top}px`; element.style.left = "0px";
element.style.overflowX = "scroll";
} }
} }
}, [ }, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
top,
left,
fitInViewport,
viewportWidth,
viewportHeight,
offsetLeft,
offsetTop,
]);
useEffect(() => { useEffect(() => {
if (onCloseRequest) { if (onCloseRequest) {
@@ -145,7 +105,7 @@ export const Popover = ({
}, [onCloseRequest]); }, [onCloseRequest]);
return ( return (
<div className="popover" ref={popoverRef} tabIndex={-1}> <div className="popover" style={{ top, left }} ref={popoverRef}>
{children} {children}
</div> </div>
); );
-76
View File
@@ -93,80 +93,4 @@
display: block; display: block;
} }
} }
.single-library-item {
position: relative;
&-status {
position: absolute;
top: 0.3rem;
left: 0.3rem;
font-size: 0.7rem;
color: $oc-red-7;
background: rgba(255, 255, 255, 0.9);
padding: 0.1rem 0.2rem;
border-radius: 0.2rem;
}
&__svg {
background-color: $oc-white;
padding: 0.3rem;
width: 7.5rem;
height: 7.5rem;
border: 1px solid var(--button-gray-2);
svg {
width: 100%;
height: 100%;
}
}
.ToolIcon__icon {
background-color: $oc-white;
width: auto;
height: auto;
margin: 0 0.5rem;
}
.ToolIcon,
.ToolIcon_type_button:hover {
background-color: white;
}
.required,
.error {
color: $oc-red-8;
font-weight: bold;
font-size: 1rem;
margin: 0.2rem;
}
.error {
font-weight: 500;
margin: 0;
padding: 0.3em 0;
}
&--remove {
position: absolute;
top: 0.2rem;
right: 1rem;
.ToolIcon__icon {
margin: 0;
}
.ToolIcon__icon {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-7;
}
&:active {
background-color: $oc-red-8;
}
}
svg {
color: $oc-white;
padding: 0.26rem;
border-radius: 0.3em;
width: 1rem;
height: 1rem;
}
}
}
} }
+34 -138
View File
@@ -1,12 +1,11 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { ReactNode, useCallback, useEffect, useState } from "react";
import OpenColor from "open-color"; import OpenColor from "open-color";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { t } from "../i18n"; import { t } from "../i18n";
import Trans from "./Trans";
import { LibraryItems, LibraryItem, UIAppState } from "../types"; import { AppState, LibraryItems, LibraryItem } from "../types";
import { exportToCanvas, exportToSvg } from "../packages/utils"; import { exportToCanvas } from "../packages/utils";
import { import {
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
EXPORT_SOURCE, EXPORT_SOURCE,
@@ -14,13 +13,12 @@ import {
VERSIONS, VERSIONS,
} from "../constants"; } from "../constants";
import { ExportedLibraryData } from "../data/types"; import { ExportedLibraryData } from "../data/types";
import "./PublishLibrary.scss";
import SingleLibraryItem from "./SingleLibraryItem";
import { canvasToBlob, resizeImageFile } from "../data/blob"; import { canvasToBlob, resizeImageFile } from "../data/blob";
import { chunk } from "../utils"; import { chunk } from "../utils";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { CloseIcon } from "./icons";
import { ToolButton } from "./ToolButton";
import "./PublishLibrary.scss";
interface PublishLibraryDataParams { interface PublishLibraryDataParams {
authorName: string; authorName: string;
@@ -128,99 +126,6 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
); );
}; };
const SingleLibraryItem = ({
libItem,
appState,
index,
onChange,
onRemove,
}: {
libItem: LibraryItem;
appState: UIAppState;
index: number;
onChange: (val: string, index: number) => void;
onRemove: (id: string) => void;
}) => {
const svgRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const node = svgRef.current;
if (!node) {
return;
}
(async () => {
const svg = await exportToSvg({
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: OpenColor.white,
exportBackground: true,
},
files: null,
});
node.innerHTML = svg.outerHTML;
})();
}, [libItem.elements, appState]);
return (
<div className="single-library-item">
{libItem.status === "published" && (
<span className="single-library-item-status">
{t("labels.statusPublished")}
</span>
)}
<div ref={svgRef} className="single-library-item__svg" />
<ToolButton
aria-label={t("buttons.remove")}
type="button"
icon={CloseIcon}
className="single-library-item--remove"
onClick={onRemove.bind(null, libItem.id)}
title={t("buttons.remove")}
/>
<div
style={{
display: "flex",
margin: "0.8rem 0",
width: "100%",
fontSize: "14px",
fontWeight: 500,
flexDirection: "column",
}}
>
<label
style={{
display: "flex",
justifyContent: "space-between",
flexDirection: "column",
}}
>
<div style={{ padding: "0.5em 0" }}>
<span style={{ fontWeight: 500, color: OpenColor.gray[6] }}>
{t("publishDialog.itemName")}
</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
ref={inputRef}
style={{ width: "80%", padding: "0.2rem" }}
defaultValue={libItem.name}
placeholder="Item name"
onChange={(event) => {
onChange(event.target.value, index);
}}
/>
</label>
<span className="error">{libItem.error}</span>
</div>
</div>
);
};
const PublishLibrary = ({ const PublishLibrary = ({
onClose, onClose,
libraryItems, libraryItems,
@@ -232,7 +137,7 @@ const PublishLibrary = ({
}: { }: {
onClose: () => void; onClose: () => void;
libraryItems: LibraryItems; libraryItems: LibraryItems;
appState: UIAppState; appState: AppState;
onSuccess: (data: { onSuccess: (data: {
url: string; url: string;
authorName: string; authorName: string;
@@ -403,32 +308,26 @@ const PublishLibrary = ({
{shouldRenderForm ? ( {shouldRenderForm ? (
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<div className="publish-library-note"> <div className="publish-library-note">
<Trans {t("publishDialog.noteDescription.pre")}
i18nKey="publishDialog.noteDescription" <a
link={(el) => ( href="https://libraries.excalidraw.com"
<a target="_blank"
href="https://libraries.excalidraw.com" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" {t("publishDialog.noteDescription.link")}
> </a>{" "}
{el} {t("publishDialog.noteDescription.post")}
</a>
)}
/>
</div> </div>
<span className="publish-library-note"> <span className="publish-library-note">
<Trans {t("publishDialog.noteGuidelines.pre")}
i18nKey="publishDialog.noteGuidelines" <a
link={(el) => ( href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
<a target="_blank"
href="https://github.com/excalidraw/excalidraw-libraries#guidelines" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" {t("publishDialog.noteGuidelines.link")}
> </a>
{el} {t("publishDialog.noteGuidelines.post")}
</a>
)}
/>
</span> </span>
<div className="publish-library-note"> <div className="publish-library-note">
@@ -522,18 +421,15 @@ const PublishLibrary = ({
/> />
</label> </label>
<span className="publish-library-note"> <span className="publish-library-note">
<Trans {t("publishDialog.noteLicense.pre")}
i18nKey="publishDialog.noteLicense" <a
link={(el) => ( href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
<a target="_blank"
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" {t("publishDialog.noteLicense.link")}
> </a>
{el} {t("publishDialog.noteLicense.post")}
</a>
)}
/>
</span> </span>
</div> </div>
<div className="publish-library__buttons"> <div className="publish-library__buttons">
+85 -126
View File
@@ -2,26 +2,67 @@
@import "../../css/variables.module"; @import "../../css/variables.module";
.excalidraw { .excalidraw {
.sidebar { .Sidebar {
display: flex; &__close-btn,
flex-direction: column; &__pin-btn,
&__dropdown-btn {
@include outlineButtonStyles;
width: var(--lg-button-size);
height: var(--lg-button-size);
padding: 0;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
&__pin-btn {
&--pinned {
background-color: var(--color-primary);
border-color: var(--color-primary);
svg {
color: #fff;
}
&:hover,
&:active {
background-color: var(--color-primary-darker);
}
}
}
}
&.theme--dark {
.Sidebar {
&__pin-btn {
&--pinned {
svg {
color: var(--color-gray-90);
}
}
}
}
}
.layer-ui__sidebar {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: 5; z-index: 5;
margin: 0; margin: 0;
padding: 0;
box-sizing: border-box;
background-color: var(--sidebar-bg-color);
box-shadow: var(--sidebar-shadow);
:root[dir="rtl"] & { :root[dir="rtl"] & {
left: 0; left: 0;
right: auto; right: auto;
} }
background-color: var(--sidebar-bg-color);
box-shadow: var(--sidebar-shadow);
&--docked { &--docked {
box-shadow: none; box-shadow: none;
} }
@@ -36,134 +77,52 @@
border-right: 1px solid var(--sidebar-border-color); border-right: 1px solid var(--sidebar-border-color);
border-left: 0; border-left: 0;
} }
padding: 0;
box-sizing: border-box;
.Island {
box-shadow: none;
}
.ToolIcon__icon {
border-radius: var(--border-radius-md);
}
.ToolIcon__icon__close {
.Modal__close {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text);
}
}
.Island {
--padding: 0;
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
}
} }
// ---------------------------- sidebar header ------------------------------ .layer-ui__sidebar__header {
.sidebar__header {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
padding-top: 1rem; padding: 1rem;
padding-bottom: 1rem; border-bottom: 1px solid var(--sidebar-border-color);
} }
.sidebar__header__buttons { .layer-ui__sidebar__header__buttons {
gap: 0;
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: auto; gap: 0.625rem;
button {
@include outlineButtonStyles;
--button-bg: transparent;
border: 0 !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
padding: 0;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
&:hover {
background: var(--button-hover-bg, var(--island-bg-color));
}
}
.sidebar__dock.selected {
svg {
stroke: var(--color-primary);
fill: var(--color-primary);
}
}
}
// ---------------------------- sidebar tabs ------------------------------
.sidebar-tabs-root {
display: flex;
flex-direction: column;
flex: 1 1 auto;
padding: 1rem 0.75rem;
[role="tabpanel"] {
flex: 1;
outline: none;
flex: 1 1 auto;
display: flex;
flex-direction: column;
outline: none;
}
[role="tabpanel"][data-state="inactive"] {
display: none !important;
}
[role="tablist"] {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
}
}
.sidebar-tabs-root > .sidebar__header {
padding-top: 0;
padding-bottom: 1rem;
}
.sidebar-tab-trigger {
--button-width: auto;
--button-bg: transparent;
--button-hover-bg: transparent;
--button-active-bg: var(--color-primary);
--button-hover-color: var(--color-primary);
--button-hover-border: var(--color-primary);
&[data-state="active"] {
--button-bg: var(--color-primary);
--button-hover-bg: var(--color-primary-darker);
--button-hover-color: var(--color-icon-white);
--button-border: var(--color-primary);
color: var(--color-icon-white);
}
}
// ---------------------------- default sidebar ------------------------------
.default-sidebar {
display: flex;
flex-direction: column;
.sidebar-triggers {
$padding: 2px;
$border: 1px;
display: flex;
gap: 0;
padding: $padding;
// offset by padding + border to vertically center the list with sibling
// buttons (both from top and bototm, due to flex layout)
margin-top: -#{$padding + $border};
margin-bottom: -#{$padding + $border};
border: $border solid var(--sidebar-border-color);
background: var(--default-bg-color);
border-radius: 0.625rem;
.sidebar-tab-trigger {
height: var(--lg-button-size);
width: var(--lg-button-size);
border: none;
}
}
.sidebar__header {
border-bottom: 1px solid var(--sidebar-border-color);
}
} }
} }
+304 -280
View File
@@ -1,9 +1,8 @@
import React from "react"; import React from "react";
import { DEFAULT_SIDEBAR } from "../../constants";
import { Excalidraw, Sidebar } from "../../packages/excalidraw/index"; import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
import { import {
act,
fireEvent, fireEvent,
GlobalTestState,
queryAllByTestId, queryAllByTestId,
queryByTestId, queryByTestId,
render, render,
@@ -11,321 +10,346 @@ import {
withExcalidrawDimensions, withExcalidrawDimensions,
} from "../../tests/test-utils"; } from "../../tests/test-utils";
export const assertSidebarDockButton = async <T extends boolean>(
hasDockButton: T,
): Promise<
T extends false
? { dockButton: null; sidebar: HTMLElement }
: { dockButton: HTMLElement; sidebar: HTMLElement }
> => {
const sidebar =
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
".sidebar",
);
expect(sidebar).not.toBe(null);
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
if (hasDockButton) {
expect(dockButton).not.toBe(null);
return { dockButton: dockButton!, sidebar: sidebar! } as any;
}
expect(dockButton).toBe(null);
return { dockButton: null, sidebar: sidebar! } as any;
};
export const assertExcalidrawWithSidebar = async (
sidebar: React.ReactNode,
name: string,
test: () => void,
) => {
await render(
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
{sidebar}
</Excalidraw>,
);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
};
describe("Sidebar", () => { describe("Sidebar", () => {
describe("General behavior", () => { it("should render custom sidebar", async () => {
it("should render custom sidebar", async () => { const { container } = await render(
const { container } = await render( <Excalidraw
<Excalidraw initialData={{ appState: { openSidebar: "customSidebar" } }}
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }} renderSidebar={() => (
> <Sidebar>
<Sidebar name="customSidebar">
<div id="test-sidebar-content">42</div> <div id="test-sidebar-content">42</div>
</Sidebar> </Sidebar>
</Excalidraw>, )}
); />,
);
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null); expect(node).not.toBe(null);
});
it("should render only one sidebar and prefer the custom one", async () => {
const { container } = await render(
<Excalidraw
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
>
<Sidebar name="customSidebar">
<div id="test-sidebar-content">42</div>
</Sidebar>
</Excalidraw>,
);
await waitFor(() => {
// make sure the custom sidebar is rendered
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
// make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".sidebar");
expect(sidebars.length).toBe(1);
});
});
it("should toggle sidebar using props.toggleMenu()", async () => {
const { container } = await render(
<Excalidraw>
<Sidebar name="customSidebar">
<div id="test-sidebar-content">42</div>
</Sidebar>
</Excalidraw>,
);
// sidebar isn't rendered initially
// -------------------------------------------------------------------------
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// toggle sidebar on
// -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
// toggle sidebar off
// -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar off (=> still hidden)
// -------------------------------------------------------------------------
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar on
// -------------------------------------------------------------------------
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
).toBe(true);
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
// toggle library (= hide custom sidebar)
// -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
true,
);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
// make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".sidebar");
expect(sidebars.length).toBe(1);
});
});
}); });
describe("<Sidebar.Header/>", () => { it("should render custom sidebar header", async () => {
it("should render custom sidebar header", async () => { const { container } = await render(
const { container } = await render( <Excalidraw
<Excalidraw initialData={{ appState: { openSidebar: "customSidebar" } }}
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }} renderSidebar={() => (
> <Sidebar>
<Sidebar name="customSidebar">
<Sidebar.Header> <Sidebar.Header>
<div id="test-sidebar-header-content">42</div> <div id="test-sidebar-header-content">42</div>
</Sidebar.Header> </Sidebar.Header>
</Sidebar> </Sidebar>
</Excalidraw>, )}
); />,
);
const node = container.querySelector("#test-sidebar-header-content"); const node = container.querySelector("#test-sidebar-header-content");
expect(node).not.toBe(null);
// make sure we don't render the default fallback header,
// just the custom one
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
});
it("should render only one sidebar and prefer the custom one", async () => {
const { container } = await render(
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar>
<div id="test-sidebar-content">42</div>
</Sidebar>
)}
/>,
);
await waitFor(() => {
// make sure the custom sidebar is rendered
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null); expect(node).not.toBe(null);
// make sure we don't render the default fallback header,
// just the custom one
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
});
it("should not render <Sidebar.Header> for custom sidebars by default", async () => { // make sure only one sidebar is rendered
const CustomExcalidraw = () => { const sidebars = container.querySelectorAll(".layer-ui__sidebar");
return ( expect(sidebars.length).toBe(1);
<Excalidraw });
initialData={{ });
appState: { openSidebar: { name: "customSidebar" } },
}} it("should always render custom sidebar with close button & close on click", async () => {
> const onClose = jest.fn();
<Sidebar name="customSidebar" className="test-sidebar"> const CustomExcalidraw = () => {
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar className="test-sidebar" onClose={onClose}>
hello hello
</Sidebar> </Sidebar>
</Excalidraw> )}
); />
}; );
};
const { container } = await render(<CustomExcalidraw />); const { container } = await render(<CustomExcalidraw />);
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
expect(closeButton).not.toBe(null);
fireEvent.click(closeButton);
await waitFor(() => {
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
expect(onClose).toHaveBeenCalled();
});
});
it("should render custom sidebar with dock (irrespective of onDock prop)", async () => {
const CustomExcalidraw = () => {
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar className="test-sidebar">hello</Sidebar>
)}
/>
);
};
const { container } = await render(<CustomExcalidraw />);
// should show dock button when the sidebar fits to be docked
// -------------------------------------------------------------------------
await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar"); const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null); expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close"); const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).toBe(null); expect(closeButton).not.toBe(null);
}); });
it("<Sidebar.Header> should render close button", async () => { // should not show dock button when the sidebar does not fit to be docked
const onStateChange = jest.fn(); // -------------------------------------------------------------------------
const CustomExcalidraw = () => {
return (
<Excalidraw
initialData={{
appState: { openSidebar: { name: "customSidebar" } },
}}
>
<Sidebar
name="customSidebar"
className="test-sidebar"
onStateChange={onStateChange}
>
<Sidebar.Header />
</Sidebar>
</Excalidraw>
);
};
const { container } = await render(<CustomExcalidraw />);
// initial open
expect(onStateChange).toHaveBeenCalledWith({ name: "customSidebar" });
await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar"); const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null); expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close")!; const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).not.toBe(null); expect(closeButton).toBe(null);
});
});
it("should support controlled docking", async () => {
let _setDockable: (dockable: boolean) => void = null!;
const CustomExcalidraw = () => {
const [dockable, setDockable] = React.useState(false);
_setDockable = setDockable;
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar
className="test-sidebar"
docked={false}
dockable={dockable}
>
hello
</Sidebar>
)}
/>
);
};
const { container } = await render(<CustomExcalidraw />);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
// should not show dock button when `dockable` is `false`
// -------------------------------------------------------------------------
act(() => {
_setDockable(false);
});
fireEvent.click(closeButton);
await waitFor(() => { await waitFor(() => {
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe( const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
null, expect(sidebar).not.toBe(null);
); const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(onStateChange).toHaveBeenCalledWith(null); expect(closeButton).toBe(null);
});
// should show dock button when `dockable` is `true`, even if `docked`
// prop is set
// -------------------------------------------------------------------------
act(() => {
_setDockable(true);
});
await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).not.toBe(null);
}); });
}); });
}); });
describe("Docking behavior", () => { it("should support controlled docking", async () => {
it("shouldn't be user-dockable if `onDock` not supplied", async () => { let _setDocked: (docked?: boolean) => void = null!;
await assertExcalidrawWithSidebar(
<Sidebar name="customSidebar">
<Sidebar.Header />
</Sidebar>,
"customSidebar",
async () => {
await assertSidebarDockButton(false);
},
);
});
it("shouldn't be user-dockable if `onDock` not supplied & `docked={true}`", async () => { const CustomExcalidraw = () => {
await assertExcalidrawWithSidebar( const [docked, setDocked] = React.useState<boolean | undefined>();
<Sidebar name="customSidebar" docked={true}> _setDocked = setDocked;
<Sidebar.Header /> return (
</Sidebar>,
"customSidebar",
async () => {
await assertSidebarDockButton(false);
},
);
});
it("shouldn't be user-dockable if `onDock` not supplied & docked={false}`", async () => {
await assertExcalidrawWithSidebar(
<Sidebar name="customSidebar" docked={false}>
<Sidebar.Header />
</Sidebar>,
"customSidebar",
async () => {
await assertSidebarDockButton(false);
},
);
});
it("should be user-dockable when both `onDock` and `docked` supplied", async () => {
await render(
<Excalidraw <Excalidraw
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }} initialData={{ appState: { openSidebar: "customSidebar" } }}
> renderSidebar={() => (
<Sidebar <Sidebar className="test-sidebar" docked={docked}>
name="customSidebar" hello
className="test-sidebar" </Sidebar>
onDock={() => {}} )}
docked />
>
<Sidebar.Header />
</Sidebar>
</Excalidraw>,
); );
};
await withExcalidrawDimensions( const { container } = await render(<CustomExcalidraw />);
{ width: 1920, height: 1080 },
async () => { const { h } = window;
await assertSidebarDockButton(true);
}, await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
); const dockButton = await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const dockBotton = queryByTestId(sidebar!, "sidebar-dock");
expect(dockBotton).not.toBe(null);
return dockBotton!;
});
const dockButtonInput = dockButton.querySelector("input")!;
// should not show dock button when `dockable` is `false`
// -------------------------------------------------------------------------
expect(h.state.isSidebarDocked).toBe(false);
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(true);
expect(dockButtonInput).toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).not.toBeChecked();
});
// shouldn't update `appState.isSidebarDocked` when the sidebar
// is controlled (`docked` prop is set), as host apps should handle
// the state themselves
// -------------------------------------------------------------------------
act(() => {
_setDocked(true);
});
await waitFor(() => {
expect(dockButtonInput).toBeChecked();
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).toBeChecked();
});
// the `appState.isSidebarDocked` should remain untouched when
// `props.docked` is set to `false`, and user toggles
// -------------------------------------------------------------------------
act(() => {
_setDocked(false);
h.setState({ isSidebarDocked: true });
});
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(true);
expect(dockButtonInput).not.toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(dockButtonInput).not.toBeChecked();
expect(h.state.isSidebarDocked).toBe(true);
});
});
});
it("should toggle sidebar using props.toggleMenu()", async () => {
const { container } = await render(
<Excalidraw
renderSidebar={() => (
<Sidebar>
<div id="test-sidebar-content">42</div>
</Sidebar>
)}
/>,
);
// sidebar isn't rendered initially
// -------------------------------------------------------------------------
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
}); });
it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => { // toggle sidebar on
await render( // -------------------------------------------------------------------------
<Excalidraw expect(window.h.app.toggleMenu("customSidebar")).toBe(true);
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
>
<Sidebar
name="customSidebar"
className="test-sidebar"
onDock={() => {}}
>
<Sidebar.Header />
</Sidebar>
</Excalidraw>,
);
await withExcalidrawDimensions( await waitFor(() => {
{ width: 1920, height: 1080 }, const node = container.querySelector("#test-sidebar-content");
async () => { expect(node).not.toBe(null);
await assertSidebarDockButton(false); });
},
); // toggle sidebar off
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar")).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar off (=> still hidden)
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar on
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
// toggle library (= hide custom sidebar)
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("library")).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
// make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
expect(sidebars.length).toBe(1);
}); });
}); });
}); });
+126 -221
View File
@@ -1,246 +1,151 @@
import React, { import {
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
forwardRef, forwardRef,
useImperativeHandle,
useCallback,
RefObject,
} from "react"; } from "react";
import { Island } from ".././Island"; import { Island } from ".././Island";
import { atom, useSetAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { jotaiScope } from "../../jotai"; import { jotaiScope } from "../../jotai";
import { import {
SidebarPropsContext, SidebarPropsContext,
SidebarProps, SidebarProps,
SidebarPropsContextValue, SidebarPropsContextValue,
} from "./common"; } from "./common";
import { SidebarHeader } from "./SidebarHeader";
import clsx from "clsx"; import { SidebarHeaderComponents } from "./SidebarHeader";
import { useDevice, useExcalidrawSetAppState } from "../App";
import { updateObject } from "../../utils";
import { KEYS } from "../../keys";
import { EVENT } from "../../constants";
import { SidebarTrigger } from "./SidebarTrigger";
import { SidebarTabTriggers } from "./SidebarTabTriggers";
import { SidebarTabTrigger } from "./SidebarTabTrigger";
import { SidebarTabs } from "./SidebarTabs";
import { SidebarTab } from "./SidebarTab";
import "./Sidebar.scss"; import "./Sidebar.scss";
import { useUIAppState } from "../../context/ui-appState"; import clsx from "clsx";
import { useExcalidrawSetAppState } from "../App";
import { updateObject } from "../../utils";
// FIXME replace this with the implem from ColorPicker once it's merged /** using a counter instead of boolean to handle race conditions where
const useOnClickOutside = ( * the host app may render (mount/unmount) multiple different sidebar */
ref: RefObject<HTMLElement>, export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
if (
event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
cb(event);
};
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
};
/**
* Flags whether the currently rendered Sidebar is docked or not, for use
* in upstream components that need to act on this (e.g. LayerUI to shift the
* UI). We use an atom because of potential host app sidebars (for the default
* sidebar we could just read from appState.defaultSidebarDockedPreference).
*
* Since we can only render one Sidebar at a time, we can use a simple flag.
*/
export const isSidebarDockedAtom = atom(false);
export const SidebarInner = forwardRef(
(
{
name,
children,
onDock,
docked,
className,
...rest
}: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
ref: React.ForwardedRef<HTMLDivElement>,
) => {
if (process.env.NODE_ENV === "development" && onDock && docked == null) {
console.warn(
"Sidebar: `docked` must be set when `onDock` is supplied for the sidebar to be user-dockable. To hide this message, either pass `docked` or remove `onDock`",
);
}
const setAppState = useExcalidrawSetAppState();
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
useLayoutEffect(() => {
setIsSidebarDockedAtom(!!docked);
return () => {
setIsSidebarDockedAtom(false);
};
}, [setIsSidebarDockedAtom, docked]);
const headerPropsRef = useRef<SidebarPropsContextValue>(
{} as SidebarPropsContextValue,
);
headerPropsRef.current.onCloseRequest = () => {
setAppState({ openSidebar: null });
};
headerPropsRef.current.onDock = (isDocked) => onDock?.(isDocked);
// renew the ref object if the following props change since we want to
// rerender. We can't pass down as component props manually because
// the <Sidebar.Header/> can be rendered upstream.
headerPropsRef.current = updateObject(headerPropsRef.current, {
docked,
// explicit prop to rerender on update
shouldRenderDockButton: !!onDock && docked != null,
});
const islandRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => {
return islandRef.current!;
});
const device = useDevice();
const closeLibrary = useCallback(() => {
const isDialogOpen = !!document.querySelector(".Dialog");
// Prevent closing if any dialog is open
if (isDialogOpen) {
return;
}
setAppState({ openSidebar: null });
}, [setAppState]);
useOnClickOutside(
islandRef,
useCallback(
(event) => {
// If click on the library icon, do nothing so that LibraryButton
// can toggle library menu
if ((event.target as Element).closest(".sidebar-trigger")) {
return;
}
if (!docked || !device.canDeviceFitSidebar) {
closeLibrary();
}
},
[closeLibrary, docked, device.canDeviceFitSidebar],
),
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!docked || !device.canDeviceFitSidebar)
) {
closeLibrary();
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [closeLibrary, docked, device.canDeviceFitSidebar]);
return (
<Island
{...rest}
className={clsx("sidebar", { "sidebar--docked": docked }, className)}
ref={islandRef}
>
<SidebarPropsContext.Provider value={headerPropsRef.current}>
{children}
</SidebarPropsContext.Provider>
</Island>
);
},
);
SidebarInner.displayName = "SidebarInner";
export const Sidebar = Object.assign( export const Sidebar = Object.assign(
forwardRef((props: SidebarProps, ref: React.ForwardedRef<HTMLDivElement>) => { forwardRef(
const appState = useUIAppState(); (
{
children,
onClose,
onDock,
docked,
/** Undocumented, may be removed later. Generally should either be
* `props.docked` or `appState.isSidebarDocked`. Currently serves to
* prevent unwanted animation of the shadow if initially docked. */
//
// NOTE we'll want to remove this after we sort out how to subscribe to
// individual appState properties
initialDockedState = docked,
dockable = true,
className,
__isInternal,
}: SidebarProps<{
// NOTE sidebars we use internally inside the editor must have this flag set.
// It indicates that this sidebar should have lower precedence over host
// sidebars, if both are open.
/** @private internal */
__isInternal?: boolean;
}>,
ref: React.ForwardedRef<HTMLDivElement>,
) => {
const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
hostSidebarCountersAtom,
jotaiScope,
);
const { onStateChange } = props; const setAppState = useExcalidrawSetAppState();
const refPrevOpenSidebar = useRef(appState.openSidebar); const [isDockedFallback, setIsDockedFallback] = useState(
useEffect(() => { docked ?? initialDockedState ?? false,
if ( );
// closing sidebar
((!appState.openSidebar && useLayoutEffect(() => {
refPrevOpenSidebar?.current?.name === props.name) || if (docked === undefined) {
// opening current sidebar // ugly hack to get initial state out of AppState without subscribing
(appState.openSidebar?.name === props.name && // to it as a whole (once we have granular subscriptions, we'll move
refPrevOpenSidebar?.current?.name !== props.name) || // to that)
// switching tabs or switching to a different sidebar //
refPrevOpenSidebar.current?.name === props.name) && // NOTE this means that is updated `state.isSidebarDocked` changes outside
appState.openSidebar !== refPrevOpenSidebar.current // of this compoent, it won't be reflected here. Currently doesn't happen.
) { setAppState((state) => {
onStateChange?.( setIsDockedFallback(state.isSidebarDocked);
appState.openSidebar?.name !== props.name // bail from update
? null return null;
: appState.openSidebar, });
); }
}, [setAppState, docked]);
useLayoutEffect(() => {
if (!__isInternal) {
setHostSidebarCounters((s) => ({
rendered: s.rendered + 1,
docked: isDockedFallback ? s.docked + 1 : s.docked,
}));
return () => {
setHostSidebarCounters((s) => ({
rendered: s.rendered - 1,
docked: isDockedFallback ? s.docked - 1 : s.docked,
}));
};
}
}, [__isInternal, setHostSidebarCounters, isDockedFallback]);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
return () => {
onCloseRef.current?.();
};
}, []);
const headerPropsRef = useRef<SidebarPropsContextValue>({});
headerPropsRef.current.onClose = () => {
setAppState({ openSidebar: null });
};
headerPropsRef.current.onDock = (isDocked) => {
if (docked === undefined) {
setAppState({ isSidebarDocked: isDocked });
setIsDockedFallback(isDocked);
}
onDock?.(isDocked);
};
// renew the ref object if the following props change since we want to
// rerender. We can't pass down as component props manually because
// the <Sidebar.Header/> can be rendered upsream.
headerPropsRef.current = updateObject(headerPropsRef.current, {
docked: docked ?? isDockedFallback,
dockable,
});
if (hostSidebarCounters.rendered > 0 && __isInternal) {
return null;
} }
refPrevOpenSidebar.current = appState.openSidebar;
}, [appState.openSidebar, onStateChange, props.name]);
const [mounted, setMounted] = useState(false); return (
useLayoutEffect(() => { <Island
setMounted(true); className={clsx(
return () => setMounted(false); "layer-ui__sidebar",
}, []); { "layer-ui__sidebar--docked": isDockedFallback },
className,
// We want to render in the next tick (hence `mounted` flag) so that it's )}
// guaranteed to happen after unmount of the previous sidebar (in case the ref={ref}
// previous sidebar is mounted after the next one). This is necessary to >
// prevent flicker of subcomponents that support fallbacks <SidebarPropsContext.Provider value={headerPropsRef.current}>
// (e.g. SidebarHeader). This is because we're using flags to determine <SidebarHeaderComponents.Context>
// whether prefer the fallback component or not (otherwise both will render <SidebarHeaderComponents.Component __isFallback />
// initially), and the flag won't be reset in time if the unmount order {children}
// it not correct. </SidebarHeaderComponents.Context>
// </SidebarPropsContext.Provider>
// Alternative, and more general solution would be to namespace the fallback </Island>
// HoC so that state is not shared between subcomponents when the wrapping );
// component is of the same type (e.g. Sidebar -> SidebarHeader). },
const shouldRender = mounted && appState.openSidebar?.name === props.name; ),
if (!shouldRender) {
return null;
}
return <SidebarInner {...props} ref={ref} key={props.name} />;
}),
{ {
Header: SidebarHeader, Header: SidebarHeaderComponents.Component,
TabTriggers: SidebarTabTriggers,
TabTrigger: SidebarTabTrigger,
Tabs: SidebarTabs,
Tab: SidebarTab,
Trigger: SidebarTrigger,
}, },
); );
Sidebar.displayName = "Sidebar";
+74 -42
View File
@@ -4,54 +4,86 @@ import { t } from "../../i18n";
import { useDevice } from "../App"; import { useDevice } from "../App";
import { SidebarPropsContext } from "./common"; import { SidebarPropsContext } from "./common";
import { CloseIcon, PinIcon } from "../icons"; import { CloseIcon, PinIcon } from "../icons";
import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
import { Tooltip } from "../Tooltip"; import { Tooltip } from "../Tooltip";
import { Button } from "../Button";
export const SidebarHeader = ({ export const SidebarDockButton = (props: {
children, checked: boolean;
className, onChange?(): void;
}: {
children?: React.ReactNode;
className?: string;
}) => { }) => {
const device = useDevice();
const props = useContext(SidebarPropsContext);
const renderDockButton = !!(
device.canDeviceFitSidebar && props.shouldRenderDockButton
);
return ( return (
<div <div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
className={clsx("sidebar__header", className)} <Tooltip label={t("labels.sidebarLock")}>
data-testid="sidebar-header" <label
> className={clsx(
{children} "ToolIcon ToolIcon__lock ToolIcon_type_floating",
<div className="sidebar__header__buttons"> `ToolIcon_size_medium`,
{renderDockButton && ( )}
<Tooltip label={t("labels.sidebarLock")}>
<Button
onSelect={() => props.onDock?.(!props.docked)}
selected={!!props.docked}
className="sidebar__dock"
data-testid="sidebar-dock"
aria-label={t("labels.sidebarLock")}
>
{PinIcon}
</Button>
</Tooltip>
)}
<Button
data-testid="sidebar-close"
className="sidebar__close"
onSelect={props.onCloseRequest}
aria-label={t("buttons.close")}
> >
{CloseIcon} <input
</Button> className="ToolIcon_type_checkbox"
</div> type="checkbox"
onChange={props.onChange}
checked={props.checked}
aria-label={t("labels.sidebarLock")}
/>{" "}
<div
className={clsx("Sidebar__pin-btn", {
"Sidebar__pin-btn--pinned": props.checked,
})}
tabIndex={0}
>
{PinIcon}
</div>{" "}
</label>{" "}
</Tooltip>
</div> </div>
); );
}; };
SidebarHeader.displayName = "SidebarHeader"; const _SidebarHeader: React.FC<{
children?: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const device = useDevice();
const props = useContext(SidebarPropsContext);
const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable);
const renderCloseButton = !!props.onClose;
return (
<div
className={clsx("layer-ui__sidebar__header", className)}
data-testid="sidebar-header"
>
{children}
{(renderDockButton || renderCloseButton) && (
<div className="layer-ui__sidebar__header__buttons">
{renderDockButton && (
<SidebarDockButton
checked={!!props.docked}
onChange={() => {
props.onDock?.(!props.docked);
}}
/>
)}
{renderCloseButton && (
<button
data-testid="sidebar-close"
className="Sidebar__close-btn"
onClick={props.onClose}
aria-label={t("buttons.close")}
>
{CloseIcon}
</button>
)}
</div>
)}
</div>
);
};
const [Context, Component] = withUpstreamOverride(_SidebarHeader);
/** @private */
export const SidebarHeaderComponents = { Context, Component };
-18
View File
@@ -1,18 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { SidebarTabName } from "../../types";
export const SidebarTab = ({
tab,
children,
...rest
}: {
tab: SidebarTabName;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.Content {...rest} value={tab}>
{children}
</RadixTabs.Content>
);
};
SidebarTab.displayName = "SidebarTab";
@@ -1,26 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { SidebarTabName } from "../../types";
export const SidebarTabTrigger = ({
children,
tab,
onSelect,
...rest
}: {
children: React.ReactNode;
tab: SidebarTabName;
onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
return (
<RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
<button
type={"button"}
className={`excalidraw-button sidebar-tab-trigger`}
{...rest}
>
{children}
</button>
</RadixTabs.Trigger>
);
};
SidebarTabTrigger.displayName = "SidebarTabTrigger";
@@ -1,16 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const SidebarTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & Omit<
React.RefAttributes<HTMLDivElement>,
"onSelect"
>) => {
return (
<RadixTabs.List className="sidebar-triggers" {...rest}>
{children}
</RadixTabs.List>
);
};
SidebarTabTriggers.displayName = "SidebarTabTriggers";
-36
View File
@@ -1,36 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { useUIAppState } from "../../context/ui-appState";
import { useExcalidrawSetAppState } from "../App";
export const SidebarTabs = ({
children,
...rest
}: {
children: React.ReactNode;
} & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">) => {
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
if (!appState.openSidebar) {
return null;
}
const { name } = appState.openSidebar;
return (
<RadixTabs.Root
className="sidebar-tabs-root"
value={appState.openSidebar.tab}
onValueChange={(tab) =>
setAppState((state) => ({
...state,
openSidebar: { ...state.openSidebar, name, tab },
}))
}
{...rest}
>
{children}
</RadixTabs.Root>
);
};
SidebarTabs.displayName = "SidebarTabs";
@@ -1,34 +0,0 @@
@import "../../css/variables.module";
.excalidraw {
.sidebar-trigger {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: auto;
height: var(--lg-button-size);
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 0;
font-size: 0.75rem;
letter-spacing: 0.4px;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
.default-sidebar-trigger .sidebar-trigger__label {
display: none;
@media screen and (min-width: 1024px) {
display: block;
}
}
}
-45
View File
@@ -1,45 +0,0 @@
import { useExcalidrawSetAppState } from "../App";
import { SidebarTriggerProps } from "./common";
import { useUIAppState } from "../../context/ui-appState";
import clsx from "clsx";
import "./SidebarTrigger.scss";
export const SidebarTrigger = ({
name,
tab,
icon,
title,
children,
onToggle,
className,
style,
}: SidebarTriggerProps) => {
const setAppState = useExcalidrawSetAppState();
const appState = useUIAppState();
return (
<label title={title}>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
onChange={(event) => {
document
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const isOpen = event.target.checked;
setAppState({ openSidebar: isOpen ? { name, tab } : null });
onToggle?.(isOpen);
}}
checked={appState.openSidebar?.name === name}
aria-label={title}
aria-keyshortcuts="0"
/>
<div className={clsx("sidebar-trigger", className)} style={style}>
{icon && <div>{icon}</div>}
{children && <div className="sidebar-trigger__label">{children}</div>}
</div>
</label>
);
};
SidebarTrigger.displayName = "SidebarTrigger";
+8 -26
View File
@@ -1,41 +1,23 @@
import React from "react"; import React from "react";
import { AppState, SidebarName, SidebarTabName } from "../../types";
export type SidebarTriggerProps = {
name: SidebarName;
tab?: SidebarTabName;
icon?: JSX.Element;
children?: React.ReactNode;
title?: string;
className?: string;
onToggle?: (open: boolean) => void;
style?: React.CSSProperties;
};
export type SidebarProps<P = {}> = { export type SidebarProps<P = {}> = {
name: SidebarName;
children: React.ReactNode; children: React.ReactNode;
/** /**
* Called on sidebar open/close or tab change. * Called on sidebar close (either by user action or by the editor).
*/
onStateChange?: (state: AppState["openSidebar"]) => void;
/**
* supply alongside `docked` prop in order to make the Sidebar user-dockable
*/ */
onClose?: () => void | boolean;
/** if not supplied, sidebar won't be dockable */
onDock?: (docked: boolean) => void; onDock?: (docked: boolean) => void;
docked?: boolean; docked?: boolean;
initialDockedState?: boolean;
dockable?: boolean;
className?: string; className?: string;
// NOTE sidebars we use internally inside the editor must have this flag set.
// It indicates that this sidebar should have lower precedence over host
// sidebars, if both are open.
/** @private internal */
__fallback?: boolean;
} & P; } & P;
export type SidebarPropsContextValue = Pick< export type SidebarPropsContextValue = Pick<
SidebarProps, SidebarProps,
"onDock" | "docked" "onClose" | "onDock" | "docked" | "dockable"
> & { onCloseRequest: () => void; shouldRenderDockButton: boolean }; >;
export const SidebarPropsContext = export const SidebarPropsContext =
React.createContext<SidebarPropsContextValue>({} as SidebarPropsContextValue); React.createContext<SidebarPropsContextValue>({});
+79
View File
@@ -0,0 +1,79 @@
@import "../css/variables.module";
.excalidraw {
.single-library-item {
position: relative;
&-status {
position: absolute;
top: 0.3rem;
left: 0.3rem;
font-size: 0.7rem;
color: $oc-red-7;
background: rgba(255, 255, 255, 0.9);
padding: 0.1rem 0.2rem;
border-radius: 0.2rem;
}
&__svg {
background-color: $oc-white;
padding: 0.3rem;
width: 7.5rem;
height: 7.5rem;
border: 1px solid var(--button-gray-2);
svg {
width: 100%;
height: 100%;
}
}
.ToolIcon__icon {
background-color: $oc-white;
width: auto;
height: auto;
margin: 0 0.5rem;
}
.ToolIcon,
.ToolIcon_type_button:hover {
background-color: white;
}
.required,
.error {
color: $oc-red-8;
font-weight: bold;
font-size: 1rem;
margin: 0.2rem;
}
.error {
font-weight: 500;
margin: 0;
padding: 0.3em 0;
}
&--remove {
position: absolute;
top: 0.2rem;
right: 1rem;
.ToolIcon__icon {
margin: 0;
}
.ToolIcon__icon {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-7;
}
&:active {
background-color: $oc-red-8;
}
}
svg {
color: $oc-white;
padding: 0.26rem;
border-radius: 0.3em;
width: 1rem;
height: 1rem;
}
}
}
}
+104
View File
@@ -0,0 +1,104 @@
import oc from "open-color";
import { useEffect, useRef } from "react";
import { t } from "../i18n";
import { exportToSvg } from "../packages/utils";
import { AppState, LibraryItem } from "../types";
import { CloseIcon } from "./icons";
import "./SingleLibraryItem.scss";
import { ToolButton } from "./ToolButton";
const SingleLibraryItem = ({
libItem,
appState,
index,
onChange,
onRemove,
}: {
libItem: LibraryItem;
appState: AppState;
index: number;
onChange: (val: string, index: number) => void;
onRemove: (id: string) => void;
}) => {
const svgRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const node = svgRef.current;
if (!node) {
return;
}
(async () => {
const svg = await exportToSvg({
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: oc.white,
exportBackground: true,
},
files: null,
});
node.innerHTML = svg.outerHTML;
})();
}, [libItem.elements, appState]);
return (
<div className="single-library-item">
{libItem.status === "published" && (
<span className="single-library-item-status">
{t("labels.statusPublished")}
</span>
)}
<div ref={svgRef} className="single-library-item__svg" />
<ToolButton
aria-label={t("buttons.remove")}
type="button"
icon={CloseIcon}
className="single-library-item--remove"
onClick={onRemove.bind(null, libItem.id)}
title={t("buttons.remove")}
/>
<div
style={{
display: "flex",
margin: "0.8rem 0",
width: "100%",
fontSize: "14px",
fontWeight: 500,
flexDirection: "column",
}}
>
<label
style={{
display: "flex",
justifyContent: "space-between",
flexDirection: "column",
}}
>
<div style={{ padding: "0.5em 0" }}>
<span style={{ fontWeight: 500, color: oc.gray[6] }}>
{t("publishDialog.itemName")}
</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
ref={inputRef}
style={{ width: "80%", padding: "0.2rem" }}
defaultValue={libItem.name}
placeholder="Item name"
onChange={(event) => {
onChange(event.target.value, index);
}}
/>
</label>
<span className="error">{libItem.error}</span>
</div>
</div>
);
};
export default SingleLibraryItem;
+3 -3
View File
@@ -3,14 +3,14 @@ import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import { ExcalidrawProps, UIAppState } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { CloseIcon } from "./icons"; import { CloseIcon } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import "./Stats.scss"; import "./Stats.scss";
export const Stats = (props: { export const Stats = (props: {
appState: UIAppState; appState: AppState;
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
-3
View File
@@ -2,9 +2,6 @@
// container in body where the actual tooltip is appended to // container in body where the actual tooltip is appended to
.excalidraw-tooltip { .excalidraw-tooltip {
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
+25 -33
View File
@@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import * as Sentry from "@sentry/browser"; import * as Sentry from "@sentry/browser";
import { t } from "../i18n"; import { t } from "../i18n";
import Trans from "./Trans";
interface TopErrorBoundaryState { interface TopErrorBoundaryState {
hasError: boolean; hasError: boolean;
@@ -75,31 +74,25 @@ export class TopErrorBoundary extends React.Component<
<div className="ErrorSplash excalidraw"> <div className="ErrorSplash excalidraw">
<div className="ErrorSplash-messageContainer"> <div className="ErrorSplash-messageContainer">
<div className="ErrorSplash-paragraph bigger align-center"> <div className="ErrorSplash-paragraph bigger align-center">
<Trans {t("errorSplash.headingMain_pre")}
i18nKey="errorSplash.headingMain" <button onClick={() => window.location.reload()}>
button={(el) => ( {t("errorSplash.headingMain_button")}
<button onClick={() => window.location.reload()}>{el}</button> </button>
)}
/>
</div> </div>
<div className="ErrorSplash-paragraph align-center"> <div className="ErrorSplash-paragraph align-center">
<Trans {t("errorSplash.clearCanvasMessage")}
i18nKey="errorSplash.clearCanvasMessage" <button
button={(el) => ( onClick={() => {
<button try {
onClick={() => { localStorage.clear();
try { window.location.reload();
localStorage.clear(); } catch (error: any) {
window.location.reload(); console.error(error);
} catch (error: any) { }
console.error(error); }}
} >
}} {t("errorSplash.clearCanvasMessage_button")}
> </button>
{el}
</button>
)}
/>
<br /> <br />
<div className="smaller"> <div className="smaller">
<span role="img" aria-label="warning"> <span role="img" aria-label="warning">
@@ -113,17 +106,16 @@ export class TopErrorBoundary extends React.Component<
</div> </div>
<div> <div>
<div className="ErrorSplash-paragraph"> <div className="ErrorSplash-paragraph">
{t("errorSplash.trackedToSentry", { {t("errorSplash.trackedToSentry_pre")}
eventId: this.state.sentryEventId, {this.state.sentryEventId}
})} {t("errorSplash.trackedToSentry_post")}
</div> </div>
<div className="ErrorSplash-paragraph"> <div className="ErrorSplash-paragraph">
<Trans {t("errorSplash.openIssueMessage_pre")}
i18nKey="errorSplash.openIssueMessage" <button onClick={() => this.createGithubIssue()}>
button={(el) => ( {t("errorSplash.openIssueMessage_button")}
<button onClick={() => this.createGithubIssue()}>{el}</button> </button>
)} {t("errorSplash.openIssueMessage_post")}
/>
</div> </div>
<div className="ErrorSplash-paragraph"> <div className="ErrorSplash-paragraph">
<div className="ErrorSplash-details"> <div className="ErrorSplash-details">
-67
View File
@@ -1,67 +0,0 @@
import { render } from "@testing-library/react";
import fallbackLangData from "../locales/en.json";
import Trans from "./Trans";
describe("Test <Trans/>", () => {
it("should translate the the strings correctly", () => {
//@ts-ignore
fallbackLangData.transTest = {
key1: "Hello {{audience}}",
key2: "Please <link>click the button</link> to continue.",
key3: "Please <link>click {{location}}</link> to continue.",
key4: "Please <link>click <bold>{{location}}</bold></link> to continue.",
key5: "Please <connect-link>click the button</connect-link> to continue.",
};
const { getByTestId } = render(
<>
<div data-testid="test1">
<Trans i18nKey="transTest.key1" audience="world" />
</div>
<div data-testid="test2">
<Trans
i18nKey="transTest.key2"
link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
<div data-testid="test3">
<Trans
i18nKey="transTest.key3"
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
/>
</div>
<div data-testid="test4">
<Trans
i18nKey="transTest.key4"
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
bold={(el) => <strong>{el}</strong>}
/>
</div>
<div data-testid="test5">
<Trans
i18nKey="transTest.key5"
connect-link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
</>,
);
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
expect(getByTestId("test2").innerHTML).toEqual(
`Please <a href="https://example.com">click the button</a> to continue.`,
);
expect(getByTestId("test3").innerHTML).toEqual(
`Please <a href="https://example.com">click the button</a> to continue.`,
);
expect(getByTestId("test4").innerHTML).toEqual(
`Please <a href="https://example.com">click <strong>the button</strong></a> to continue.`,
);
expect(getByTestId("test5").innerHTML).toEqual(
`Please <a href="https://example.com">click the button</a> to continue.`,
);
});
});
-169
View File
@@ -1,169 +0,0 @@
import React from "react";
import { useI18n } from "../i18n";
// Used for splitting i18nKey into tokens in Trans component
// Example:
// "Please <link>click {{location}}</link> to continue.".split(SPLIT_REGEX).filter(Boolean)
// produces
// ["Please ", "<link>", "click ", "{{location}}", "</link>", " to continue."]
const SPLIT_REGEX = /({{[\w-]+}})|(<[\w-]+>)|(<\/[\w-]+>)/g;
// Used for extracting "location" from "{{location}}"
const KEY_REGEXP = /{{([\w-]+)}}/;
// Used for extracting "link" from "<link>"
const TAG_START_REGEXP = /<([\w-]+)>/;
// Used for extracting "link" from "</link>"
const TAG_END_REGEXP = /<\/([\w-]+)>/;
const getTransChildren = (
format: string,
props: {
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
},
): React.ReactNode[] => {
const stack: { name: string; children: React.ReactNode[] }[] = [
{
name: "",
children: [],
},
];
format
.split(SPLIT_REGEX)
.filter(Boolean)
.forEach((match) => {
const tagStartMatch = match.match(TAG_START_REGEXP);
const tagEndMatch = match.match(TAG_END_REGEXP);
const keyMatch = match.match(KEY_REGEXP);
if (tagStartMatch !== null) {
// The match is <tag>. Set the tag name as the name if it's one of the
// props, e.g. for "Please <link>click the button</link> to continue"
// tagStartMatch[1] = "link" and props contain "link" then it will be
// pushed to stack.
const name = tagStartMatch[1];
if (props.hasOwnProperty(name)) {
stack.push({
name,
children: [],
});
} else {
console.warn(
`Trans: missed to pass in prop ${name} for interpolating ${format}`,
);
}
} else if (tagEndMatch !== null) {
// If tag end match is found, this means we need to replace the content with
// its actual value in prop e.g. format = "Please <link>click the
// button</link> to continue", tagEndMatch is for "</link>", stack last item name =
// "link" and props.link = (el) => <a
// href="https://example.com">{el}</a> then its prop value will be
// pushed to "link"'s children so on DOM when rendering it's rendered as
// <a href="https://example.com">click the button</a>
const name = tagEndMatch[1];
if (name === stack[stack.length - 1].name) {
const item = stack.pop()!;
const itemChildren = React.createElement(
React.Fragment,
{},
...item.children,
);
const fn = props[item.name];
if (typeof fn === "function") {
stack[stack.length - 1].children.push(fn(itemChildren));
}
} else {
console.warn(
`Trans: unexpected end tag ${match} for interpolating ${format}`,
);
}
} else if (keyMatch !== null) {
// The match is for {{key}}. Check if the key is present in props and set
// the prop value as children of last stack item e.g. format = "Hello
// {{name}}", key = "name" and props.name = "Excalidraw" then its prop
// value will be pushed to "name"'s children so it's rendered on DOM as
// "Hello Excalidraw"
const name = keyMatch[1];
if (props.hasOwnProperty(name)) {
stack[stack.length - 1].children.push(props[name] as React.ReactNode);
} else {
console.warn(
`Trans: key ${name} not in props for interpolating ${format}`,
);
}
} else {
// If none of cases match means we just need to push the string
// to stack eg - "Hello {{name}} Whats up?" "Hello", "Whats up" will be pushed
stack[stack.length - 1].children.push(match);
}
});
if (stack.length !== 1) {
console.warn(`Trans: stack not empty for interpolating ${format}`);
}
return stack[0].children;
};
/*
Trans component is used for translating JSX.
```json
{
"example1": "Hello {{audience}}",
"example2": "Please <link>click the button</link> to continue.",
"example3": "Please <link>click {{location}}</link> to continue.",
"example4": "Please <link>click <bold>{{location}}</bold></link> to continue.",
}
```
```jsx
<Trans i18nKey="example1" audience="world" />
<Trans
i18nKey="example2"
connectLink={(el) => <a href="https://example.com">{el}</a>}
/>
<Trans
i18nKey="example3"
connectLink={(el) => <a href="https://example.com">{el}</a>}
location="the button"
/>
<Trans
i18nKey="example4"
connectLink={(el) => <a href="https://example.com">{el}</a>}
location="the button"
bold={(el) => <strong>{el}</strong>}
/>
```
Output:
```html
Hello world
Please <a href="https://example.com">click the button</a> to continue.
Please <a href="https://example.com">click the button</a> to continue.
Please <a href="https://example.com">click <strong>the button</strong></a> to continue.
```
*/
const Trans = ({
i18nKey,
children,
...props
}: {
i18nKey: string;
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
}) => {
const { t } = useI18n();
// This is needed to avoid unique key error in list which gets rendered from getTransChildren
return React.createElement(
React.Fragment,
{},
...getTransChildren(t(i18nKey), props),
);
};
export default Trans;
+24 -11
View File
@@ -5,46 +5,59 @@ exports[`Test <App/> should show error modal when using brave and measureText AP
data-testid="brave-measure-text-error" data-testid="brave-measure-text-error"
> >
<p> <p>
Looks like you are using Brave browser with the Looks like you are using Brave browser with the
 
<span <span
style="font-weight: 600;" style="font-weight: 600;"
> >
Aggressively Block Fingerprinting Aggressively Block Fingerprinting
</span> </span>
setting enabled.
</p> setting enabled
<p> .
This could result in breaking the <br />
<br />
This could result in breaking the
<span <span
style="font-weight: 600;" style="font-weight: 600;"
> >
Text Elements Text Elements
</span> </span>
in your drawings.
in your drawings
.
</p> </p>
<p> <p>
We strongly recommend disabling this setting. You can follow We strongly recommend disabling this setting. You can follow
<a <a
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser" href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
> >
these steps these steps
</a> </a>
on how to do so.
on how to do so
.
</p> </p>
<p> <p>
If disabling this setting doesn't fix the display of text elements, please open an If disabling this setting doesn't fix the display of text elements, please open an
<a <a
href="https://github.com/excalidraw/excalidraw/issues/new" href="https://github.com/excalidraw/excalidraw/issues/new"
> >
issue issue
</a> </a>
on our GitHub, or write us on
on our GitHub, or write us on
<a <a
href="https://discord.gg/UexuTaE" href="https://discord.gg/UexuTaE"
> >
Discord Discord
.
</a> </a>
.
</p> </p>
</div> </div>
`; `;
+32
View File
@@ -0,0 +1,32 @@
import React from "react";
import tunnel from "@dwelle/tunnel-rat";
type Tunnel = ReturnType<typeof tunnel>;
type TunnelsContextValue = {
mainMenuTunnel: Tunnel;
welcomeScreenMenuHintTunnel: Tunnel;
welcomeScreenToolbarHintTunnel: Tunnel;
welcomeScreenHelpHintTunnel: Tunnel;
welcomeScreenCenterTunnel: Tunnel;
footerCenterTunnel: Tunnel;
jotaiScope: symbol;
};
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
export const useTunnels = () => React.useContext(TunnelsContext);
export const useInitializeTunnels = () => {
return React.useMemo((): TunnelsContextValue => {
return {
mainMenuTunnel: tunnel(),
welcomeScreenMenuHintTunnel: tunnel(),
welcomeScreenToolbarHintTunnel: tunnel(),
welcomeScreenHelpHintTunnel: tunnel(),
welcomeScreenCenterTunnel: tunnel(),
footerCenterTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
};
@@ -1,4 +1,4 @@
import { useOutsideClick } from "../../hooks/useOutsideClick"; import { useOutsideClickHook } from "../../hooks/useOutsideClick";
import { Island } from "../Island"; import { Island } from "../Island";
import { useDevice } from "../App"; import { useDevice } from "../App";
@@ -24,7 +24,7 @@ const MenuContent = ({
style?: React.CSSProperties; style?: React.CSSProperties;
}) => { }) => {
const device = useDevice(); const device = useDevice();
const menuRef = useOutsideClick(() => { const menuRef = useOutsideClickHook(() => {
onClickOutside?.(); onClickOutside?.();
}); });
@@ -1,6 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useUIAppState } from "../../context/ui-appState"; import { useDevice, useExcalidrawAppState } from "../App";
import { useDevice } from "../App";
const MenuTrigger = ({ const MenuTrigger = ({
className = "", className = "",
@@ -11,7 +10,7 @@ const MenuTrigger = ({
children: React.ReactNode; children: React.ReactNode;
onToggle: () => void; onToggle: () => void;
}) => { }) => {
const appState = useUIAppState(); const appState = useExcalidrawAppState();
const device = useDevice(); const device = useDevice();
const classNames = clsx( const classNames = clsx(
`dropdown-menu-button ${className}`, `dropdown-menu-button ${className}`,
+6 -6
View File
@@ -1,6 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { actionShortcuts } from "../../actions"; import { actionShortcuts } from "../../actions";
import { ActionManager } from "../../actions/manager"; import { ActionManager } from "../../actions/manager";
import { AppState } from "../../types";
import { import {
ExitZenModeAction, ExitZenModeAction,
FinalizeAction, FinalizeAction,
@@ -8,11 +9,10 @@ import {
ZoomActions, ZoomActions,
} from "../Actions"; } from "../Actions";
import { useDevice } from "../App"; import { useDevice } from "../App";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { HelpButton } from "../HelpButton"; import { HelpButton } from "../HelpButton";
import { Section } from "../Section"; import { Section } from "../Section";
import Stack from "../Stack"; import Stack from "../Stack";
import { UIAppState } from "../../types";
const Footer = ({ const Footer = ({
appState, appState,
@@ -20,12 +20,12 @@ const Footer = ({
showExitZenModeBtn, showExitZenModeBtn,
renderWelcomeScreen, renderWelcomeScreen,
}: { }: {
appState: UIAppState; appState: AppState;
actionManager: ActionManager; actionManager: ActionManager;
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
}) => { }) => {
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels(); const { footerCenterTunnel, welcomeScreenHelpHintTunnel } = useTunnels();
const device = useDevice(); const device = useDevice();
const showFinalize = const showFinalize =
@@ -70,14 +70,14 @@ const Footer = ({
</Section> </Section>
</Stack.Col> </Stack.Col>
</div> </div>
<FooterCenterTunnel.Out /> <footerCenterTunnel.Out />
<div <div
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", { className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
"transition-right disable-pointerEvents": appState.zenModeEnabled, "transition-right disable-pointerEvents": appState.zenModeEnabled,
})} })}
> >
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderWelcomeScreen && <WelcomeScreenHelpHintTunnel.Out />} {renderWelcomeScreen && <welcomeScreenHelpHintTunnel.Out />}
<HelpButton <HelpButton
onClick={() => actionManager.executeAction(actionShortcuts)} onClick={() => actionManager.executeAction(actionShortcuts)}
/> />
+6 -6
View File
@@ -1,13 +1,13 @@
import clsx from "clsx"; import clsx from "clsx";
import { useTunnels } from "../../context/tunnels"; import { useExcalidrawAppState } from "../App";
import { useTunnels } from "../context/tunnels";
import "./FooterCenter.scss"; import "./FooterCenter.scss";
import { useUIAppState } from "../../context/ui-appState";
const FooterCenter = ({ children }: { children?: React.ReactNode }) => { const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
const { FooterCenterTunnel } = useTunnels(); const { footerCenterTunnel } = useTunnels();
const appState = useUIAppState(); const appState = useExcalidrawAppState();
return ( return (
<FooterCenterTunnel.In> <footerCenterTunnel.In>
<div <div
className={clsx("footer-center zen-mode-transition", { className={clsx("footer-center zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom": "layer-ui__wrapper__footer-left--transition-bottom":
@@ -16,7 +16,7 @@ const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
> >
{children} {children}
</div> </div>
</FooterCenterTunnel.In> </footerCenterTunnel.In>
); );
}; };
+6 -20
View File
@@ -1,46 +1,32 @@
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import React, { useLayoutEffect } from "react"; import React, { useLayoutEffect } from "react";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../context/tunnels";
export const withInternalFallback = <P,>( export const withInternalFallback = <P,>(
componentName: string, componentName: string,
Component: React.FC<P>, Component: React.FC<P>,
) => { ) => {
const renderAtom = atom(0); const counterAtom = atom(0);
// flag set on initial render to tell the fallback component to skip the // flag set on initial render to tell the fallback component to skip the
// render until mount counter are initialized. This is because the counter // render until mount counter are initialized. This is because the counter
// is initialized in an effect, and thus we could end rendering both // is initialized in an effect, and thus we could end rendering both
// components at the same time until counter is initialized. // components at the same time until counter is initialized.
let preferHost = false; let preferHost = false;
let counter = 0;
const WrapperComponent: React.FC< const WrapperComponent: React.FC<
P & { P & {
__fallback?: boolean; __fallback?: boolean;
} }
> = (props) => { > = (props) => {
const { jotaiScope } = useTunnels(); const { jotaiScope } = useTunnels();
const [, setRender] = useAtom(renderAtom, jotaiScope); const [counter, setCounter] = useAtom(counterAtom, jotaiScope);
useLayoutEffect(() => { useLayoutEffect(() => {
setRender((c) => { setCounter((counter) => counter + 1);
const next = c + 1;
counter = next;
return next;
});
return () => { return () => {
setRender((c) => { setCounter((counter) => counter - 1);
const next = c - 1;
counter = next;
if (!next) {
preferHost = false;
}
return next;
});
}; };
}, [setRender]); }, [setCounter]);
if (!props.__fallback) { if (!props.__fallback) {
preferHost = true; preferHost = true;
@@ -0,0 +1,63 @@
import React, {
useMemo,
useContext,
useLayoutEffect,
useState,
createContext,
} from "react";
export const withUpstreamOverride = <P,>(Component: React.ComponentType<P>) => {
type ContextValue = [boolean, React.Dispatch<React.SetStateAction<boolean>>];
const DefaultComponentContext = createContext<ContextValue>([
false,
() => {},
]);
const ComponentContext: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [isRenderedUpstream, setIsRenderedUpstream] = useState(false);
const contextValue: ContextValue = useMemo(
() => [isRenderedUpstream, setIsRenderedUpstream],
[isRenderedUpstream],
);
return (
<DefaultComponentContext.Provider value={contextValue}>
{children}
</DefaultComponentContext.Provider>
);
};
const DefaultComponent = (
props: P & {
// indicates whether component should render when not rendered upstream
/** @private internal */
__isFallback?: boolean;
},
) => {
const [isRenderedUpstream, setIsRenderedUpstream] = useContext(
DefaultComponentContext,
);
useLayoutEffect(() => {
if (!props.__isFallback) {
setIsRenderedUpstream(true);
return () => setIsRenderedUpstream(false);
}
}, [props.__isFallback, setIsRenderedUpstream]);
if (props.__isFallback && isRenderedUpstream) {
return null;
}
return <Component {...props} />;
};
if (Component.name) {
DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`;
ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`;
}
return [ComponentContext, DefaultComponent] as const;
};
-7
View File
@@ -1008,13 +1008,6 @@ export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
), ),
); );
export const FillZigZagIcon = createIcon(
<g strokeWidth={1.25}>
<path d="M5.879 2.625h8.242a3.27 3.27 0 0 1 3.254 3.254v8.242a3.27 3.27 0 0 1-3.254 3.254H5.88a3.27 3.27 0 0 1-3.254-3.254V5.88A3.27 3.27 0 0 1 5.88 2.626l-.001-.001ZM4.518 16.118l7.608-12.83m.198 13.934 5.051-9.897M2.778 9.675l9.348-6.387m-7.608 12.83 12.857-8.793" />
</g>,
modifiedTablerIconProps,
);
export const FillHachureIcon = createIcon( export const FillHachureIcon = createIcon(
<> <>
<path <path
@@ -3,9 +3,9 @@ import { usersIcon } from "../icons";
import { Button } from "../Button"; import { Button } from "../Button";
import clsx from "clsx"; import clsx from "clsx";
import { useExcalidrawAppState } from "../App";
import "./LiveCollaborationTrigger.scss"; import "./LiveCollaborationTrigger.scss";
import { useUIAppState } from "../../context/ui-appState";
const LiveCollaborationTrigger = ({ const LiveCollaborationTrigger = ({
isCollaborating, isCollaborating,
@@ -15,7 +15,7 @@ const LiveCollaborationTrigger = ({
isCollaborating: boolean; isCollaborating: boolean;
onSelect: () => void; onSelect: () => void;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => { } & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const appState = useUIAppState(); const appState = useExcalidrawAppState();
return ( return (
<Button <Button
+7 -4
View File
@@ -1,6 +1,10 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App"; import {
useExcalidrawAppState,
useExcalidrawSetAppState,
useExcalidrawActionManager,
} from "../App";
import { import {
ExportIcon, ExportIcon,
ExportImageIcon, ExportImageIcon,
@@ -28,7 +32,6 @@ import clsx from "clsx";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai"; import { jotaiScope } from "../../jotai";
import { useUIAppState } from "../../context/ui-appState";
export const LoadScene = () => { export const LoadScene = () => {
const { t } = useI18n(); const { t } = useI18n();
@@ -136,7 +139,7 @@ ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => { export const ToggleTheme = () => {
const { t } = useI18n(); const { t } = useI18n();
const appState = useUIAppState(); const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionToggleTheme)) { if (!actionManager.isActionEnabled(actionToggleTheme)) {
@@ -169,7 +172,7 @@ ToggleTheme.displayName = "ToggleTheme";
export const ChangeCanvasBackground = () => { export const ChangeCanvasBackground = () => {
const { t } = useI18n(); const { t } = useI18n();
const appState = useUIAppState(); const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
+10 -7
View File
@@ -1,5 +1,9 @@
import React from "react"; import React from "react";
import { useDevice, useExcalidrawSetAppState } from "../App"; import {
useDevice,
useExcalidrawAppState,
useExcalidrawSetAppState,
} from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu"; import DropdownMenu from "../dropdownMenu/DropdownMenu";
import * as DefaultItems from "./DefaultItems"; import * as DefaultItems from "./DefaultItems";
@@ -9,8 +13,7 @@ import { t } from "../../i18n";
import { HamburgerMenuIcon } from "../icons"; import { HamburgerMenuIcon } from "../icons";
import { withInternalFallback } from "../hoc/withInternalFallback"; import { withInternalFallback } from "../hoc/withInternalFallback";
import { composeEventHandlers } from "../../utils"; import { composeEventHandlers } from "../../utils";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { useUIAppState } from "../../context/ui-appState";
const MainMenu = Object.assign( const MainMenu = Object.assign(
withInternalFallback( withInternalFallback(
@@ -25,16 +28,16 @@ const MainMenu = Object.assign(
*/ */
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
}) => { }) => {
const { MainMenuTunnel } = useTunnels(); const { mainMenuTunnel } = useTunnels();
const device = useDevice(); const device = useDevice();
const appState = useUIAppState(); const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.isMobile const onClickOutside = device.isMobile
? undefined ? undefined
: () => setAppState({ openMenu: null }); : () => setAppState({ openMenu: null });
return ( return (
<MainMenuTunnel.In> <mainMenuTunnel.In>
<DropdownMenu open={appState.openMenu === "canvas"}> <DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
onToggle={() => { onToggle={() => {
@@ -63,7 +66,7 @@ const MainMenu = Object.assign(
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu> </DropdownMenu>
</MainMenuTunnel.In> </mainMenuTunnel.In>
); );
}, },
), ),
@@ -1,10 +1,13 @@
import { actionLoadScene, actionShortcuts } from "../../actions"; import { actionLoadScene, actionShortcuts } from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t, useI18n } from "../../i18n"; import { t, useI18n } from "../../i18n";
import { useDevice, useExcalidrawActionManager } from "../App"; import {
import { useTunnels } from "../../context/tunnels"; useDevice,
useExcalidrawActionManager,
useExcalidrawAppState,
} from "../App";
import { useTunnels } from "../context/tunnels";
import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons"; import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
import { useUIAppState } from "../../context/ui-appState";
const WelcomeScreenMenuItemContent = ({ const WelcomeScreenMenuItemContent = ({
icon, icon,
@@ -86,9 +89,9 @@ const WelcomeScreenMenuItemLink = ({
WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink"; WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink";
const Center = ({ children }: { children?: React.ReactNode }) => { const Center = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenCenterTunnel } = useTunnels(); const { welcomeScreenCenterTunnel } = useTunnels();
return ( return (
<WelcomeScreenCenterTunnel.In> <welcomeScreenCenterTunnel.In>
<div className="welcome-screen-center"> <div className="welcome-screen-center">
{children || ( {children || (
<> <>
@@ -101,7 +104,7 @@ const Center = ({ children }: { children?: React.ReactNode }) => {
</> </>
)} )}
</div> </div>
</WelcomeScreenCenterTunnel.In> </welcomeScreenCenterTunnel.In>
); );
}; };
Center.displayName = "Center"; Center.displayName = "Center";
@@ -145,7 +148,7 @@ const MenuItemHelp = () => {
MenuItemHelp.displayName = "MenuItemHelp"; MenuItemHelp.displayName = "MenuItemHelp";
const MenuItemLoadScene = () => { const MenuItemLoadScene = () => {
const appState = useUIAppState(); const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
@@ -1,5 +1,5 @@
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { import {
WelcomeScreenHelpArrow, WelcomeScreenHelpArrow,
WelcomeScreenMenuArrow, WelcomeScreenMenuArrow,
@@ -7,44 +7,44 @@ import {
} from "../icons"; } from "../icons";
const MenuHint = ({ children }: { children?: React.ReactNode }) => { const MenuHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenMenuHintTunnel } = useTunnels(); const { welcomeScreenMenuHintTunnel } = useTunnels();
return ( return (
<WelcomeScreenMenuHintTunnel.In> <welcomeScreenMenuHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu"> <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
{WelcomeScreenMenuArrow} {WelcomeScreenMenuArrow}
<div className="welcome-screen-decor-hint__label"> <div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.menuHint")} {children || t("welcomeScreen.defaults.menuHint")}
</div> </div>
</div> </div>
</WelcomeScreenMenuHintTunnel.In> </welcomeScreenMenuHintTunnel.In>
); );
}; };
MenuHint.displayName = "MenuHint"; MenuHint.displayName = "MenuHint";
const ToolbarHint = ({ children }: { children?: React.ReactNode }) => { const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenToolbarHintTunnel } = useTunnels(); const { welcomeScreenToolbarHintTunnel } = useTunnels();
return ( return (
<WelcomeScreenToolbarHintTunnel.In> <welcomeScreenToolbarHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar"> <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
<div className="welcome-screen-decor-hint__label"> <div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.toolbarHint")} {children || t("welcomeScreen.defaults.toolbarHint")}
</div> </div>
{WelcomeScreenTopToolbarArrow} {WelcomeScreenTopToolbarArrow}
</div> </div>
</WelcomeScreenToolbarHintTunnel.In> </welcomeScreenToolbarHintTunnel.In>
); );
}; };
ToolbarHint.displayName = "ToolbarHint"; ToolbarHint.displayName = "ToolbarHint";
const HelpHint = ({ children }: { children?: React.ReactNode }) => { const HelpHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenHelpHintTunnel } = useTunnels(); const { welcomeScreenHelpHintTunnel } = useTunnels();
return ( return (
<WelcomeScreenHelpHintTunnel.In> <welcomeScreenHelpHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help"> <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
<div>{children || t("welcomeScreen.defaults.helpHint")}</div> <div>{children || t("welcomeScreen.defaults.helpHint")}</div>
{WelcomeScreenHelpArrow} {WelcomeScreenHelpArrow}
</div> </div>
</WelcomeScreenHelpHintTunnel.In> </welcomeScreenHelpHintTunnel.In>
); );
}; };
HelpHint.displayName = "HelpHint"; HelpHint.displayName = "HelpHint";
+17 -45
View File
@@ -1,7 +1,6 @@
import cssVariables from "./css/variables.module.scss"; import cssVariables from "./css/variables.module.scss";
import { AppProps } from "./types"; import { AppProps } from "./types";
import { ExcalidrawElement, FontFamilyValues } from "./element/types"; import { FontFamilyValues } from "./element/types";
import oc from "open-color";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform);
@@ -105,30 +104,20 @@ export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20; // TODO make it configurable? export const GRID_SIZE = 20; // TODO make it configurable?
export const IMAGE_MIME_TYPES = { export const MIME_TYPES = {
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
json: "application/json",
svg: "image/svg+xml", svg: "image/svg+xml",
"excalidraw.svg": "image/svg+xml",
png: "image/png", png: "image/png",
"excalidraw.png": "image/png",
jpg: "image/jpeg", jpg: "image/jpeg",
gif: "image/gif", gif: "image/gif",
webp: "image/webp", webp: "image/webp",
bmp: "image/bmp", bmp: "image/bmp",
ico: "image/x-icon", ico: "image/x-icon",
avif: "image/avif",
jfif: "image/jfif",
} as const;
export const MIME_TYPES = {
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
// image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png",
// binary
binary: "application/octet-stream", binary: "application/octet-stream",
// image
...IMAGE_MIME_TYPES,
} as const; } as const;
export const EXPORT_DATA_TYPES = { export const EXPORT_DATA_TYPES = {
@@ -199,6 +188,16 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const ALLOWED_IMAGE_MIME_TYPES = [
MIME_TYPES.png,
MIME_TYPES.jpg,
MIME_TYPES.svg,
MIME_TYPES.gif,
MIME_TYPES.webp,
MIME_TYPES.bmp,
MIME_TYPES.ico,
] as const;
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024; export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS = "http://www.w3.org/2000/svg";
@@ -255,30 +254,3 @@ export const ROUNDNESS = {
/** key containt id of precedeing elemnt id we use in reconciliation during /** key containt id of precedeing elemnt id we use in reconciliation during
* collaboration */ * collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__"; export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"];
fillStyle: ExcalidrawElement["fillStyle"];
strokeWidth: ExcalidrawElement["strokeWidth"];
strokeStyle: ExcalidrawElement["strokeStyle"];
roughness: ExcalidrawElement["roughness"];
opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"];
} = {
strokeColor: oc.black,
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
locked: false,
};
export const LIBRARY_SIDEBAR_TAB = "library";
export const DEFAULT_SIDEBAR = {
name: "default",
defaultTab: LIBRARY_SIDEBAR_TAB,
} as const;
-36
View File
@@ -1,36 +0,0 @@
import React from "react";
import tunnel from "tunnel-rat";
export type Tunnel = ReturnType<typeof tunnel>;
type TunnelsContextValue = {
MainMenuTunnel: Tunnel;
WelcomeScreenMenuHintTunnel: Tunnel;
WelcomeScreenToolbarHintTunnel: Tunnel;
WelcomeScreenHelpHintTunnel: Tunnel;
WelcomeScreenCenterTunnel: Tunnel;
FooterCenterTunnel: Tunnel;
DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel;
jotaiScope: symbol;
};
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
export const useTunnels = () => React.useContext(TunnelsContext);
export const useInitializeTunnels = () => {
return React.useMemo((): TunnelsContextValue => {
return {
MainMenuTunnel: tunnel(),
WelcomeScreenMenuHintTunnel: tunnel(),
WelcomeScreenToolbarHintTunnel: tunnel(),
WelcomeScreenHelpHintTunnel: tunnel(),
WelcomeScreenCenterTunnel: tunnel(),
FooterCenterTunnel: tunnel(),
DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
};
-5
View File
@@ -1,5 +0,0 @@
import React from "react";
import { UIAppState } from "../types";
export const UIAppStateContext = React.createContext<UIAppState>(null!);
export const useUIAppState = () => React.useContext(UIAppStateContext);
+1 -6
View File
@@ -155,9 +155,6 @@
margin: 1px; margin: 1px;
} }
.welcome-screen-menu-item:focus-visible,
.dropdown-menu-item:focus-visible,
button:focus-visible,
.buttonList label:focus-within, .buttonList label:focus-within,
input:focus-visible { input:focus-visible {
outline: transparent; outline: transparent;
@@ -354,7 +351,6 @@
border-radius: var(--space-factor); border-radius: var(--space-factor);
border: 1px solid var(--button-gray-2); border: 1px solid var(--button-gray-2);
font-size: 0.8rem; font-size: 0.8rem;
font-family: inherit;
outline: none; outline: none;
appearance: none; appearance: none;
background-image: var(--dropdown-icon); background-image: var(--dropdown-icon);
@@ -414,7 +410,6 @@
bottom: 30px; bottom: 30px;
transform: translateX(-50%); transform: translateX(-50%);
pointer-events: all; pointer-events: all;
font-family: inherit;
&:hover { &:hover {
background-color: var(--button-hover-bg); background-color: var(--button-hover-bg);
@@ -567,7 +562,7 @@
border-radius: 0; border-radius: 0;
} }
.default-sidebar-trigger { .library-button {
border: 0; border: 0;
} }
} }
-6
View File
@@ -78,13 +78,10 @@
--color-selection: #6965db; --color-selection: #6965db;
--color-icon-white: #{$oc-white};
--color-primary: #6965db; --color-primary: #6965db;
--color-primary-darker: #5b57d1; --color-primary-darker: #5b57d1;
--color-primary-darkest: #4a47b1; --color-primary-darkest: #4a47b1;
--color-primary-light: #e3e2fe; --color-primary-light: #e3e2fe;
--color-primary-light-darker: #d7d5ff;
--color-gray-10: #f5f5f5; --color-gray-10: #f5f5f5;
--color-gray-20: #ebebeb; --color-gray-20: #ebebeb;
@@ -164,13 +161,10 @@
// will be inverted to a lighter color. // will be inverted to a lighter color.
--color-selection: #3530c4; --color-selection: #3530c4;
--color-icon-white: var(--color-gray-90);
--color-primary: #a8a5ff; --color-primary: #a8a5ff;
--color-primary-darker: #b2aeff; --color-primary-darker: #b2aeff;
--color-primary-darkest: #beb9ff; --color-primary-darkest: #beb9ff;
--color-primary-light: #4f4d6f; --color-primary-light: #4f4d6f;
--color-primary-light-darker: #43415e;
--color-text-warning: var(--color-gray-80); --color-text-warning: var(--color-gray-80);
+4 -14
View File
@@ -72,14 +72,7 @@
&:hover { &:hover {
background-color: var(--button-hover-bg, var(--island-bg-color)); background-color: var(--button-hover-bg, var(--island-bg-color));
border-color: var( border-color: var(--button-hover-border, var(--default-border-color));
--button-hover-border,
var(--button-border, var(--default-border-color))
);
color: var(
--button-hover-color,
var(--button-color, var(--text-primary-color, inherit))
);
} }
&:active { &:active {
@@ -88,14 +81,11 @@
} }
&.active { &.active {
background-color: var(--button-selected-bg, var(--color-primary-light)); background-color: var(--color-primary-light);
border-color: var(--button-selected-border, var(--color-primary-light)); border-color: var(--color-primary-light);
&:hover { &:hover {
background-color: var( background-color: var(--color-primary-light);
--button-selected-hover-bg,
var(--color-primary-light)
);
} }
svg { svg {
+6 -4
View File
@@ -1,6 +1,6 @@
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types"; import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
@@ -117,9 +117,11 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
export const isSupportedImageFile = ( export const isSupportedImageFile = (
blob: Blob | null | undefined, blob: Blob | null | undefined,
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => { ): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
const { type } = blob || {}; const { type } = blob || {};
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type); return (
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
);
}; };
export const loadSceneOrLibraryFromBlob = async ( export const loadSceneOrLibraryFromBlob = async (
@@ -155,7 +157,7 @@ export const loadSceneOrLibraryFromBlob = async (
}, },
localAppState, localAppState,
localElements, localElements,
{ repairBindings: true, refreshDimensions: false }, { repairBindings: true, refreshDimensions: true },
), ),
}; };
} else if (isValidLibrary(data)) { } else if (isValidLibrary(data)) {
+10 -1
View File
@@ -8,7 +8,16 @@ import { EVENT, MIME_TYPES } from "../constants";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
import { debounce } from "../utils"; import { debounce } from "../utils";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION =
| "gif"
| "jpg"
| "png"
| "excalidraw.png"
| "svg"
| "excalidraw.svg"
| "json"
| "excalidraw"
| "excalidrawlib";
const INPUT_CHANGE_INTERVAL_MS = 500; const INPUT_CHANGE_INTERVAL_MS = 500;
+1 -3
View File
@@ -89,9 +89,7 @@ export const exportCanvas = async (
return await fileSave(blob, { return await fileSave(blob, {
description: "Export to PNG", description: "Export to PNG",
name, name,
// FIXME reintroduce `excalidraw.png` when most people upgrade away extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
// from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
fileHandle, fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {
+4 -18
View File
@@ -14,14 +14,7 @@ import { getCommonBoundingBox } from "../element/bounds";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants";
URL_HASH_KEYS,
URL_QUERY_KEYS,
APP_NAME,
EVENT,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
} from "../constants";
export const libraryItemsAtom = atom<{ export const libraryItemsAtom = atom<{
status: "loading" | "loaded"; status: "loading" | "loaded";
@@ -155,9 +148,7 @@ class Library {
defaultStatus?: "unpublished" | "published"; defaultStatus?: "unpublished" | "published";
}): Promise<LibraryItems> => { }): Promise<LibraryItems> => {
if (openLibraryMenu) { if (openLibraryMenu) {
this.app.setState({ this.app.setState({ openSidebar: "library" });
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB },
});
} }
return this.setLibrary(() => { return this.setLibrary(() => {
@@ -183,13 +174,6 @@ class Library {
}), }),
) )
) { ) {
if (prompt) {
// focus container if we've prompted. We focus conditionally
// lest `props.autoFocus` is disabled (in which case we should
// focus only on user action such as prompt confirm)
this.app.focusContainer();
}
if (merge) { if (merge) {
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems)); resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
} else { } else {
@@ -202,6 +186,8 @@ class Library {
reject(error); reject(error);
} }
}); });
}).finally(() => {
this.app.focusContainer();
}); });
}; };
+25 -54
View File
@@ -27,20 +27,14 @@ import {
PRECEDING_ELEMENT_KEY, PRECEDING_ELEMENT_KEY,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
DEFAULT_SIDEBAR,
} from "../constants"; } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement"; import { bumpVersion } from "../element/mutateElement";
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils"; import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import oc from "open-color"; import oc from "open-color";
import { MarkOptional, Mutable } from "../utility-types"; import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getDefaultLineHeight,
measureBaseline,
} from "../element/textElement";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -171,40 +165,17 @@ const restoreElement = (
const [fontPx, _fontFamily]: [string, string] = ( const [fontPx, _fontFamily]: [string, string] = (
element as any element as any
).font.split(" "); ).font.split(" ");
fontSize = parseFloat(fontPx); fontSize = parseInt(fontPx, 10);
fontFamily = getFontFamilyByName(_fontFamily); fontFamily = getFontFamilyByName(_fontFamily);
} }
const text = element.text ?? "";
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams.
// For the latter we want to detect the original line height which
// will likely differ from our per-font fixed line height we now use,
// to maintain backward compatibility.
const lineHeight =
element.lineHeight ||
(element.height
? // detect line-height from current element height and font-size
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
const baseline = measureBaseline(
element.text,
getFontString(element),
lineHeight,
);
element = restoreElementWithProperties(element, { element = restoreElementWithProperties(element, {
fontSize, fontSize,
fontFamily, fontFamily,
text, text: element.text ?? "",
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null, containerId: element.containerId ?? null,
originalText: element.originalText || text, originalText: element.originalText || element.text,
lineHeight,
baseline,
}); });
if (refreshDimensions) { if (refreshDimensions) {
@@ -370,9 +341,6 @@ export const restoreElements = (
localElements: readonly ExcalidrawElement[] | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined, opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
// used to detect duplicate top-level element ids
const existingIds = new Set<string>();
const localElementsMap = localElements ? arrayToMap(localElements) : null; const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = (elements || []).reduce((elements, element) => { const restoredElements = (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements, // filtering out selection, which is legacy, no longer kept in elements,
@@ -387,10 +355,6 @@ export const restoreElements = (
if (localElement && localElement.version > migratedElement.version) { if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(migratedElement, localElement.version); migratedElement = bumpVersion(migratedElement, localElement.version);
} }
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement); elements.push(migratedElement);
} }
} }
@@ -432,15 +396,21 @@ const LegacyAppStateMigrations: {
defaultAppState: ReturnType<typeof getDefaultAppState>, defaultAppState: ReturnType<typeof getDefaultAppState>,
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]]; ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
} = { } = {
isSidebarDocked: (appState, defaultAppState) => { isLibraryOpen: (appState, defaultAppState) => {
return [ return [
"defaultSidebarDockedPreference", "openSidebar",
appState.isSidebarDocked ?? "isLibraryOpen" in appState
coalesceAppStateValue( ? appState.isLibraryOpen
"defaultSidebarDockedPreference", ? "library"
appState, : null
defaultAppState, : coalesceAppStateValue("openSidebar", appState, defaultAppState),
), ];
},
isLibraryMenuDocked: (appState, defaultAppState) => {
return [
"isSidebarDocked",
appState.isLibraryMenuDocked ??
coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
]; ];
}, },
}; };
@@ -509,13 +479,14 @@ export const restoreAppState = (
? { ? {
value: appState.zoom as NormalizedZoomValue, value: appState.zoom as NormalizedZoomValue,
} }
: appState.zoom?.value : appState.zoom || defaultAppState.zoom,
? appState.zoom // when sidebar docked and user left it open in last session,
: defaultAppState.zoom, // keep it open. If not docked, keep it closed irrespective of last state.
openSidebar: openSidebar:
// string (legacy) nextAppState.openSidebar === "library"
typeof (appState.openSidebar as any as string) === "string" ? nextAppState.isSidebarDocked
? { name: DEFAULT_SIDEBAR.name } ? "library"
: null
: nextAppState.openSidebar, : nextAppState.openSidebar,
}; };
}; };
+4 -2
View File
@@ -25,8 +25,10 @@ export interface ExportedDataState {
* Don't consume on its own. * Don't consume on its own.
*/ */
export type LegacyAppState = { export type LegacyAppState = {
/** @deprecated #6213 TODO remove 23-06-01 */ /** @deprecated #5663 TODO remove 22-12-15 */
isSidebarDocked: [boolean, "defaultSidebarDockedPreference"]; isLibraryOpen: [boolean, "openSidebar"];
/** @deprecated #5663 TODO remove 22-12-15 */
isLibraryMenuDocked: [boolean, "isSidebarDocked"];
}; };
export interface ImportedDataState { export interface ImportedDataState {
+2 -3
View File
@@ -1,4 +1,4 @@
import { AppState, ExcalidrawProps, Point, UIAppState } from "../types"; import { AppState, ExcalidrawProps, Point } from "../types";
import { import {
getShortcutKey, getShortcutKey,
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
@@ -297,11 +297,10 @@ export const getContextMenuLabel = (
: "labels.link.create"; : "labels.link.create";
return label; return label;
}; };
export const getLinkHandleFromCoords = ( export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds, [x1, y1, x2, y2]: Bounds,
angle: number, angle: number,
appState: UIAppState, appState: AppState,
): [x: number, y: number, width: number, height: number] => { ): [x: number, y: number, width: number, height: number] => {
const size = DEFAULT_LINK_SIZE; const size = DEFAULT_LINK_SIZE;
const linkWidth = size / appState.zoom.value; const linkWidth = size / appState.zoom.value;
+1 -6
View File
@@ -786,12 +786,7 @@ export const findFocusPointForEllipse = (
orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) / orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
squares; squares;
let n = (-m * px - 1) / py; const n = (-m * px - 1) / py;
if (n === 0) {
// if zero {-0, 0}, fall back to a same-sign value in the similar range
n = (Object.is(n, -0) ? -1 : 1) * 0.01;
}
const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2); const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2);
return GA.point(x, (-m * x - 1) / n); return GA.point(x, (-m * x - 1) / n);

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