Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4112a8ad5 | |||
| 1184a8c0e9 | |||
| e9cae918a7 | |||
| b1311a407a | |||
| 2a39d0b9a7 | |||
| 6b0218b012 | |||
| 45a57d70de | |||
| da8dd389a9 | |||
| dae81c0a2c | |||
| 1e9943323a | |||
| 1815cf3213 | |||
| d35386755f | |||
| 9d5cfbbfb7 | |||
| fee760d38c | |||
| 2a4799d8c8 | |||
| c4445c181b | |||
| d12a9fdd40 | |||
| 9368a9ce3e | |||
| 851b9b7aec | |||
| 5ddb28d378 | |||
| 404a79e241 | |||
| eea30da05a | |||
| 98a77d7426 | |||
| ff3c2e5a16 | |||
| b64beaf5ba | |||
| 89304c9f66 | |||
| 1d0653ce50 | |||
| c9c79646c5 | |||
| 979312f779 | |||
| 4d0d844e39 | |||
| 801412bf6b | |||
| 21726e22cc | |||
| c3e8ddaf58 | |||
| f640ddc2aa | |||
| e7e54814e7 | |||
| e9064a4a87 | |||
| 034113772d | |||
| d34cd3072f | |||
| e31230f78c | |||
| 399c92d882 | |||
| b0b23353cf | |||
| 6164b5273c | |||
| ca3be2c678 | |||
| 13b27afe0f | |||
| 372743f59f | |||
| fc601347cf | |||
| e4d8ba226f | |||
| ec215362a1 | |||
| 0b8fc4f4b6 | |||
| c170403b13 | |||
| 705ac9c1ab | |||
| 68692b9d4c | |||
| d61b3cf83d | |||
| d2b8f4d2f8 | |||
| f8e65bb77e | |||
| 3030e96d62 | |||
| 44453b725d | |||
| 25bb6738ea | |||
| 9e52c30ce8 | |||
| 83383977f5 | |||
| ac4c8b3ca7 | |||
| 5c8941467d | |||
| 0726911fa6 |
@@ -23,6 +23,11 @@ REACT_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
|
||||
FAST_REFRESH=false
|
||||
|
||||
# MATOMO
|
||||
REACT_APP_MATOMO_URL=
|
||||
REACT_APP_CDN_MATOMO_TRACKER_URL=
|
||||
REACT_APP_MATOMO_SITE_ID=
|
||||
|
||||
#Debug flags
|
||||
|
||||
# To enable bounding box for text containers
|
||||
|
||||
@@ -12,6 +12,13 @@ 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"}'
|
||||
|
||||
# production-only vars
|
||||
# GOOGLE ANALYTICS
|
||||
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
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
An open source virtual hand-drawn style whiteboard. </br>
|
||||
Collaborative and end-to-end encrypted. </br>
|
||||
<br />
|
||||
</h3>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
# ref
|
||||
|
||||
<pre>
|
||||
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">createRef</a> | <a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a> | <a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">callbackRef</a> | <br/>{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">resolvablePromise</a> } }
|
||||
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">
|
||||
createRef
|
||||
</a>{" "}
|
||||
|{" "}
|
||||
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
|
||||
|{" "}
|
||||
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
|
||||
callbackRef
|
||||
</a>{" "}
|
||||
| <br />
|
||||
{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
|
||||
resolvablePromise
|
||||
</a> } }
|
||||
</pre>
|
||||
|
||||
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs:
|
||||
@@ -139,7 +152,9 @@ function App() {
|
||||
return (
|
||||
<div style={{ height: "500px" }}>
|
||||
<p style={{ fontSize: "16px" }}> Click to update the scene</p>
|
||||
<button className="custom-button" onClick={updateScene}>Update Scene</button>
|
||||
<button className="custom-button" onClick={updateScene}>
|
||||
Update Scene
|
||||
</button>
|
||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
|
||||
</div>
|
||||
);
|
||||
@@ -187,7 +202,8 @@ function App() {
|
||||
return (
|
||||
<div style={{ height: "500px" }}>
|
||||
<p style={{ fontSize: "16px" }}> Click to update the library items</p>
|
||||
<button className="custom-button"
|
||||
<button
|
||||
className="custom-button"
|
||||
onClick={() => {
|
||||
const libraryItems = [
|
||||
{
|
||||
@@ -205,10 +221,8 @@ function App() {
|
||||
];
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems,
|
||||
openLibraryMenu: true
|
||||
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
|
||||
}}
|
||||
>
|
||||
Update Library
|
||||
@@ -250,7 +264,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
|
||||
|
||||
<pre>
|
||||
() =>{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||
ExcalidrawElement[]
|
||||
</a>
|
||||
</pre>
|
||||
@@ -261,7 +275,7 @@ Returns all the elements including the deleted in the scene.
|
||||
|
||||
<pre>
|
||||
() => NonDeleted<
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||
ExcalidrawElement
|
||||
</a>
|
||||
[]>
|
||||
@@ -293,18 +307,31 @@ This is the history API. history.clear() will clear the history.
|
||||
## scrollToContent
|
||||
|
||||
<pre>
|
||||
(target?:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
||||
(<br />
|
||||
{" "}
|
||||
target?:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||
ExcalidrawElement
|
||||
</a>{" "}
|
||||
|{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||
ExcalidrawElement
|
||||
</a>
|
||||
[]) => void
|
||||
[],
|
||||
<br />
|
||||
{" "}opts?: { fitToContent?: boolean; animate?: boolean; duration?: number
|
||||
}
|
||||
<br />) => void
|
||||
</pre>
|
||||
|
||||
Scroll the nearest element out of the elements supplied to the center. Defaults to the elements on the scene.
|
||||
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
|
||||
|
||||
| Attribute | type | default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| target | <code>ExcalidrawElement | 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
|
||||
|
||||
@@ -323,7 +350,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.
|
||||
|
||||
```tsx
|
||||
({ message: string, closable?:boolean,duration?:number
|
||||
({ message: string, closable?:boolean,duration?:number
|
||||
} | null) => void
|
||||
```
|
||||
|
||||
@@ -358,15 +385,18 @@ This API can be used to get the files present in the scene. It may contain files
|
||||
|
||||
This API has the below signature. It sets the `tool` passed in param as the active tool.
|
||||
|
||||
|
||||
<pre>
|
||||
(tool: <br/> { type: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">SHAPES</a>[number]["value"]| "eraser" } |<br/> { type: "custom"; customType: string }) => void
|
||||
(tool: <br /> { type:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
|
||||
SHAPES
|
||||
</a>
|
||||
[number]["value"]| "eraser" } |
|
||||
<br /> { type: "custom"; customType: string }) => void
|
||||
</pre>
|
||||
|
||||
## 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
|
||||
(cursor: string) => void
|
||||
|
||||
@@ -31,10 +31,29 @@ You can pass `null` / `undefined` if not applicable.
|
||||
restoreElements(
|
||||
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>
|
||||
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/>
|
||||
refreshDimensions?: boolean<br/>
|
||||
opts: { refreshDimensions?: boolean, repairBindings?: boolean }<br/>
|
||||
)
|
||||
</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> | null | 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_**
|
||||
|
||||
```js
|
||||
@@ -43,9 +62,6 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
### restore
|
||||
@@ -56,7 +72,9 @@ Parameter `refreshDimensions` indicates whether we should also `recalculate` tex
|
||||
restore(
|
||||
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState</a>,<br/>
|
||||
localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>> | null | undefined,<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>
|
||||
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/>
|
||||
opts: { refreshDimensions?: boolean, repairBindings?: boolean }<br/>
|
||||
|
||||
)
|
||||
</pre>
|
||||
|
||||
|
||||
@@ -339,3 +339,47 @@ The `device` has the following `attributes`
|
||||
| `isMobile` | `boolean` | Set to `true` when the device is `mobile` |
|
||||
| `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
|
||||
| `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |
|
||||
|
||||
### i18n
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@docusaurus/core": "2.2.0",
|
||||
"@docusaurus/preset-classic": "2.2.0",
|
||||
"@docusaurus/theme-live-codeblock": "2.2.0",
|
||||
"@excalidraw/excalidraw": "0.14.2",
|
||||
"@excalidraw/excalidraw": "0.15.2",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-plugin-sass": "0.2.3",
|
||||
|
||||
@@ -24,6 +24,7 @@ const ExcalidrawScope = {
|
||||
Sidebar: ExcalidrawComp.Sidebar,
|
||||
exportToCanvas: ExcalidrawComp.exportToCanvas,
|
||||
initialData,
|
||||
useI18n: ExcalidrawComp.useI18n,
|
||||
};
|
||||
|
||||
export default ExcalidrawScope;
|
||||
|
||||
+7
-7
@@ -1631,10 +1631,10 @@
|
||||
url-loader "^4.1.1"
|
||||
webpack "^5.73.0"
|
||||
|
||||
"@excalidraw/excalidraw@0.14.2":
|
||||
version "0.14.2"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.14.2.tgz#150cb4b7a1bf0d11cd64295936c930e7e0db8375"
|
||||
integrity sha512-8LdjpTBWEK5waDWB7Bt/G9YBI4j0OxkstUhvaDGz7dwQGfzF6FW5CXBoYHNEoX0qmb+Fg/NPOlZ7FrKsrSVCqg==
|
||||
"@excalidraw/excalidraw@0.15.2":
|
||||
version "0.15.2"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2.tgz#7dba4f6e10c52015a007efb75a9fc1afe598574c"
|
||||
integrity sha512-rTI02kgWSTXiUdIkBxt9u/581F3eXcqQgJdIxmz54TFtG3ughoxO5fr4t7Fr2LZIturBPqfocQHGKZ0t2KLKgw==
|
||||
|
||||
"@hapi/hoek@^9.0.0":
|
||||
version "9.3.0"
|
||||
@@ -7159,9 +7159,9 @@ typescript@^4.7.4:
|
||||
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
|
||||
|
||||
ua-parser-js@^0.7.30:
|
||||
version "0.7.31"
|
||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.31.tgz#649a656b191dffab4f21d5e053e27ca17cbff5c6"
|
||||
integrity sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==
|
||||
version "0.7.33"
|
||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532"
|
||||
integrity sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw==
|
||||
|
||||
unescape@^1.0.1:
|
||||
version "1.0.1"
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@dwelle/tunnel-rat": "0.1.1",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
@@ -51,7 +51,7 @@
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
"socket.io-client": "2.3.1",
|
||||
"tunnel-rat": "0.1.0",
|
||||
"tunnel-rat": "0.1.2",
|
||||
"workbox-background-sync": "^6.5.4",
|
||||
"workbox-broadcast-update": "^6.5.4",
|
||||
"workbox-cacheable-response": "^6.5.4",
|
||||
|
||||
+28
-2
@@ -79,6 +79,7 @@
|
||||
</style>
|
||||
<!------------------------------------------------------------------------->
|
||||
|
||||
<% if (process.env.NODE_ENV === "production") { %>
|
||||
<script>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
@@ -97,6 +98,7 @@
|
||||
window.location.href = "https://app.excalidraw.com";
|
||||
}
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||
|
||||
@@ -146,8 +148,18 @@
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' &&
|
||||
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
|
||||
|
||||
<!-- 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
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
|
||||
@@ -161,6 +173,8 @@
|
||||
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
|
||||
</script>
|
||||
<% } %>
|
||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||
<% } %>
|
||||
|
||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||
<style>
|
||||
@@ -213,5 +227,17 @@
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
const fs = require("fs");
|
||||
const { execSync } = require("child_process");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
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 = () => {
|
||||
try {
|
||||
execSync(`yarn --frozen-lockfile`);
|
||||
@@ -30,15 +17,8 @@ const publish = () => {
|
||||
};
|
||||
|
||||
const release = () => {
|
||||
updateReadme();
|
||||
console.info("Note for stable readme removed");
|
||||
|
||||
publish();
|
||||
console.info(`Published ${pkg.version}!`);
|
||||
|
||||
// revert readme after release
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
|
||||
console.info("Readme reverted");
|
||||
};
|
||||
|
||||
release();
|
||||
|
||||
+113
-83
@@ -1,7 +1,13 @@
|
||||
import { BOUND_TEXT_PADDING, ROUNDNESS, VERTICAL_ALIGN } from "../constants";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
ROUNDNESS,
|
||||
VERTICAL_ALIGN,
|
||||
TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import { getNonDeletedElements, isTextElement, newElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
@@ -10,6 +16,7 @@ import {
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "../element/textWysiwyg";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
@@ -23,6 +30,7 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
@@ -32,6 +40,7 @@ export const actionUnbindText = register({
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
return selectedElements.some((element) => hasBoundTextElement(element));
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
@@ -42,20 +51,24 @@ export const actionUnbindText = register({
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const { width, height } = measureText(
|
||||
const { width, height, baseline } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
||||
element.id,
|
||||
);
|
||||
resetOriginalContainerCache(element.id);
|
||||
|
||||
const { x, y } = computeBoundTextPosition(element, boundTextElement);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
@@ -125,6 +138,7 @@ export const actionBindText = register({
|
||||
mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
@@ -132,7 +146,11 @@ export const actionBindText = register({
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
const originalContainerHeight = container.height;
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
// overwritting the cache with original container height so
|
||||
// it can be restored when unbind
|
||||
updateOriginalContainerCache(container.id, originalContainerHeight);
|
||||
|
||||
return {
|
||||
elements: pushTextAboveContainer(elements, container, textElement),
|
||||
@@ -178,105 +196,117 @@ const pushContainerBelowText = (
|
||||
return updatedElements;
|
||||
};
|
||||
|
||||
export const actionCreateContainerFromText = register({
|
||||
name: "createContainerFromText",
|
||||
export const actionWrapTextInContainer = register({
|
||||
name: "wrapTextInContainer",
|
||||
contextItemLabel: "labels.createContainerFromText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return selectedElements.length === 1 && isTextElement(selectedElements[0]);
|
||||
const areTextElements = selectedElements.every((el) => isTextElement(el));
|
||||
return selectedElements.length > 0 && areTextElements;
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const updatedElements = elements.slice();
|
||||
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,
|
||||
});
|
||||
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
|
||||
const containerIds: AppState["selectedElementIds"] = {};
|
||||
|
||||
// update bindings
|
||||
if (textElement.boundElements?.length) {
|
||||
const linearElementIds = textElement.boundElements
|
||||
.filter((ele) => ele.type === "arrow")
|
||||
.map((el) => el.id);
|
||||
const linearElements = updatedElements.filter((ele) =>
|
||||
linearElementIds.includes(ele.id),
|
||||
) as ExcalidrawLinearElement[];
|
||||
linearElements.forEach((ele) => {
|
||||
let startBinding = null;
|
||||
let endBinding = null;
|
||||
if (ele.startBinding) {
|
||||
startBinding = { ...ele.startBinding, elementId: container.id };
|
||||
}
|
||||
if (ele.endBinding) {
|
||||
endBinding = { ...ele.endBinding, elementId: container.id };
|
||||
}
|
||||
mutateElement(ele, { startBinding, endBinding });
|
||||
for (const textElement of selectedElements) {
|
||||
if (isTextElement(textElement)) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
});
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
// update bindings
|
||||
if (textElement.boundElements?.length) {
|
||||
const linearElementIds = textElement.boundElements
|
||||
.filter((ele) => ele.type === "arrow")
|
||||
.map((el) => el.id);
|
||||
const linearElements = updatedElements.filter((ele) =>
|
||||
linearElementIds.includes(ele.id),
|
||||
) as ExcalidrawLinearElement[];
|
||||
linearElements.forEach((ele) => {
|
||||
let startBinding = ele.startBinding;
|
||||
let endBinding = ele.endBinding;
|
||||
|
||||
return {
|
||||
elements: pushContainerBelowText(
|
||||
[...elements, container],
|
||||
if (startBinding?.elementId === textElement.id) {
|
||||
startBinding = {
|
||||
...startBinding,
|
||||
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,
|
||||
textElement,
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {
|
||||
[container.id]: true,
|
||||
[textElement.id]: false,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
);
|
||||
containerIds[container.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: updatedElements,
|
||||
appState,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: containerIds,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -226,7 +226,7 @@ const zoomValueToFitBoundsOnViewport = (
|
||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||
};
|
||||
|
||||
const zoomToFitElements = (
|
||||
export const zoomToFitElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
zoomToSelection: boolean,
|
||||
|
||||
@@ -18,7 +18,7 @@ export const actionCopy = register({
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
|
||||
copyToClipboard(selectedElements, appState, app.files);
|
||||
copyToClipboard(selectedElements, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppState } from "../../src/types";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
TextAlignLeftIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignRightIcon,
|
||||
FillZigZagIcon,
|
||||
} from "../components/icons";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
@@ -54,6 +56,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
@@ -81,7 +84,7 @@ import {
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
@@ -293,7 +296,12 @@ export const actionChangeBackgroundColor = register({
|
||||
export const actionChangeFillStyle = register({
|
||||
name: "changeFillStyle",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
perform: (elements, appState, value, app) => {
|
||||
trackEvent(
|
||||
"element",
|
||||
"changeFillStyle",
|
||||
`${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -304,40 +312,57 @@ export const actionChangeFillStyle = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fill")}</legend>
|
||||
<ButtonIconSelect
|
||||
options={[
|
||||
{
|
||||
value: "hachure",
|
||||
text: t("labels.hachure"),
|
||||
icon: FillHachureIcon,
|
||||
},
|
||||
{
|
||||
value: "cross-hatch",
|
||||
text: t("labels.crossHatch"),
|
||||
icon: FillCrossHatchIcon,
|
||||
},
|
||||
{
|
||||
value: "solid",
|
||||
text: t("labels.solid"),
|
||||
icon: FillSolidIcon,
|
||||
},
|
||||
]}
|
||||
group="fill"
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.fillStyle,
|
||||
appState.currentItemFillStyle,
|
||||
)}
|
||||
onChange={(value) => {
|
||||
updateData(value);
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
),
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const allElementsZigZag =
|
||||
selectedElements.length > 0 &&
|
||||
selectedElements.every((el) => el.fillStyle === "zigzag");
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fill")}</legend>
|
||||
<ButtonIconSelect
|
||||
type="button"
|
||||
options={[
|
||||
{
|
||||
value: "hachure",
|
||||
text: `${
|
||||
allElementsZigZag ? t("labels.zigzag") : t("labels.hachure")
|
||||
} (${getShortcutKey("Alt-Click")})`,
|
||||
icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
|
||||
active: allElementsZigZag ? true : undefined,
|
||||
},
|
||||
{
|
||||
value: "cross-hatch",
|
||||
text: t("labels.crossHatch"),
|
||||
icon: FillCrossHatchIcon,
|
||||
},
|
||||
{
|
||||
value: "solid",
|
||||
text: t("labels.solid"),
|
||||
icon: FillSolidIcon,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
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({
|
||||
@@ -637,6 +662,7 @@ export const actionChangeFontFamily = register({
|
||||
oldElement,
|
||||
{
|
||||
fontFamily: value,
|
||||
lineHeight: getDefaultLineHeight(value),
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
@@ -92,12 +95,18 @@ export const actionPasteStyles = register({
|
||||
});
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
const fontSize =
|
||||
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const fontFamily =
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
newElement = newElementWith(newElement, {
|
||||
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
|
||||
fontFamily:
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign:
|
||||
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
elementStylesToCopyFrom.lineHeight ||
|
||||
getDefaultLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
|
||||
@@ -115,7 +115,7 @@ export type ActionName =
|
||||
| "toggleLinearEditor"
|
||||
| "toggleEraserTool"
|
||||
| "toggleHandTool"
|
||||
| "createContainerFromText";
|
||||
| "wrapTextInContainer";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
||||
+41
-22
@@ -1,22 +1,41 @@
|
||||
export const trackEvent =
|
||||
typeof process !== "undefined" &&
|
||||
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
||||
typeof window !== "undefined" &&
|
||||
window.gtag
|
||||
? (category: string, action: string, label?: string, value?: number) => {
|
||||
try {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("error logging to ga", error);
|
||||
}
|
||||
}
|
||||
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
||||
? (category: string, action: string, label?: string, value?: number) => {}
|
||||
: (category: string, action: string, label?: string, value?: number) => {
|
||||
// Uncomment the next line to track locally
|
||||
// console.log("Track Event", { category, action, label, value });
|
||||
};
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
action: string,
|
||||
label?: string,
|
||||
value?: number,
|
||||
) => {
|
||||
try {
|
||||
// Uncomment the next line to track locally
|
||||
// console.log("Track Event", { category, action, label, value });
|
||||
|
||||
if (typeof window === "undefined" || process.env.JEST_WORKER_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: 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);
|
||||
}
|
||||
};
|
||||
|
||||
+15
-10
@@ -1,5 +1,6 @@
|
||||
import oc from "open-color";
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
@@ -23,18 +24,18 @@ export const getDefaultAppState = (): Omit<
|
||||
theme: THEME.LIGHT,
|
||||
collaborators: new Map(),
|
||||
currentChartType: "bar",
|
||||
currentItemBackgroundColor: "transparent",
|
||||
currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||
currentItemEndArrowhead: "arrow",
|
||||
currentItemFillStyle: "hachure",
|
||||
currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
||||
currentItemFontSize: DEFAULT_FONT_SIZE,
|
||||
currentItemOpacity: 100,
|
||||
currentItemRoughness: 1,
|
||||
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
|
||||
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
|
||||
currentItemStartArrowhead: null,
|
||||
currentItemStrokeColor: oc.black,
|
||||
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
currentItemRoundness: "round",
|
||||
currentItemStrokeStyle: "solid",
|
||||
currentItemStrokeWidth: 1,
|
||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
cursorButton: "up",
|
||||
draggingElement: null,
|
||||
@@ -44,7 +45,7 @@ export const getDefaultAppState = (): Omit<
|
||||
activeTool: {
|
||||
type: "selection",
|
||||
customType: null,
|
||||
locked: false,
|
||||
locked: DEFAULT_ELEMENT_PROPS.locked,
|
||||
lastActiveTool: null,
|
||||
},
|
||||
penMode: false,
|
||||
@@ -57,7 +58,7 @@ export const getDefaultAppState = (): Omit<
|
||||
fileHandle: null,
|
||||
gridSize: null,
|
||||
isBindingEnabled: true,
|
||||
isSidebarDocked: false,
|
||||
defaultSidebarDockedPreference: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
@@ -149,7 +150,11 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
gridSize: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, export: false, server: false },
|
||||
isSidebarDocked: { browser: true, export: false, server: false },
|
||||
defaultSidebarDockedPreference: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
isLoading: { browser: false, export: false, server: false },
|
||||
isResizing: { browser: false, export: false, server: false },
|
||||
isRotating: { browser: false, export: false, server: false },
|
||||
|
||||
+1
-17
@@ -1,10 +1,5 @@
|
||||
import colors from "./colors";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
ENV,
|
||||
VERTICAL_ALIGN,
|
||||
} from "./constants";
|
||||
import { DEFAULT_FONT_SIZE, ENV } from "./constants";
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import { randomId } from "./random";
|
||||
@@ -166,17 +161,7 @@ const bgColors = colors.elementBackground.slice(
|
||||
// Put all the common properties here so when the whole chart is selected
|
||||
// the properties dialog shows the correct selected values
|
||||
const commonProps = {
|
||||
fillStyle: "hachure",
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
opacity: 100,
|
||||
roughness: 1,
|
||||
strokeColor: colors.elementStroke[0],
|
||||
roundness: null,
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
locked: false,
|
||||
} as const;
|
||||
|
||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||
@@ -323,7 +308,6 @@ const chartBaseElements = (
|
||||
x: x + chartWidth / 2,
|
||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
||||
roundness: null,
|
||||
strokeStyle: "solid",
|
||||
textAlign: "center",
|
||||
})
|
||||
: null;
|
||||
|
||||
+27
-11
@@ -2,12 +2,12 @@ import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { AppState, BinaryFiles } from "./types";
|
||||
import { BinaryFiles } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
import { isPromiseLike } from "./utils";
|
||||
import { isPromiseLike, isTestEnv } from "./utils";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
@@ -55,24 +55,40 @@ const clipboardContainsElements = (
|
||||
|
||||
export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
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
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements,
|
||||
files: files
|
||||
? elements.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
acc[element.fileId] = files[element.fileId];
|
||||
}
|
||||
return acc;
|
||||
}, {} as BinaryFiles)
|
||||
: undefined,
|
||||
files: files ? _files : undefined,
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
|
||||
if (isTestEnv()) {
|
||||
return json;
|
||||
}
|
||||
|
||||
CLIPBOARD = json;
|
||||
|
||||
try {
|
||||
PREFER_APP_CLIPBOARD = false;
|
||||
await copyTextToSystemClipboard(json);
|
||||
|
||||
+305
-176
@@ -60,6 +60,7 @@ import {
|
||||
ENV,
|
||||
EVENT,
|
||||
GRID_SIZE,
|
||||
IMAGE_MIME_TYPES,
|
||||
IMAGE_RENDER_TIMEOUT,
|
||||
isAndroid,
|
||||
isBrave,
|
||||
@@ -127,7 +128,11 @@ import {
|
||||
} from "../element/binding";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
|
||||
import {
|
||||
deepCopyElement,
|
||||
duplicateElements,
|
||||
newFreeDrawElement,
|
||||
} from "../element/newElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
@@ -205,6 +210,8 @@ import {
|
||||
PointerDownState,
|
||||
SceneData,
|
||||
Device,
|
||||
SidebarName,
|
||||
SidebarTabName,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
@@ -229,6 +236,7 @@ import {
|
||||
updateActiveTool,
|
||||
getShortcutKey,
|
||||
isTransparent,
|
||||
easeToValuesRAF,
|
||||
} from "../utils";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -260,13 +268,14 @@ import throttle from "lodash.throttle";
|
||||
import { fileOpen, FileSystemHandle } from "../data/filesystem";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getApproxLineHeight,
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
getTextBindableContainerAtPosition,
|
||||
isMeasureTextSupported,
|
||||
isValidTextContainer,
|
||||
@@ -283,12 +292,18 @@ import {
|
||||
import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||
import { Fonts } from "../scene/Fonts";
|
||||
import { actionPaste } from "../actions/actionClipboard";
|
||||
import { actionToggleHandTool } from "../actions/actionCanvas";
|
||||
import {
|
||||
actionToggleHandTool,
|
||||
zoomToFitElements,
|
||||
} from "../actions/actionCanvas";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import { actionCreateContainerFromText } from "../actions/actionBoundText";
|
||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
|
||||
const deviceContextInitialValue = {
|
||||
isSmScreen: false,
|
||||
isMobile: false,
|
||||
@@ -330,6 +345,8 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
|
||||
);
|
||||
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
|
||||
|
||||
export const useApp = () => useContext(AppContext);
|
||||
export const useAppProps = () => useContext(AppPropsContext);
|
||||
export const useDevice = () => useContext<Device>(DeviceContext);
|
||||
export const useExcalidrawContainer = () =>
|
||||
useContext(ExcalidrawContainerContext);
|
||||
@@ -390,7 +407,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private nearestScrollableContainer: HTMLElement | Document | undefined;
|
||||
public library: AppClassProperties["library"];
|
||||
public libraryItemsFromStorage: LibraryItems | undefined;
|
||||
private id: string;
|
||||
public id: string;
|
||||
private history: History;
|
||||
private excalidrawContainerValue: {
|
||||
container: HTMLDivElement | null;
|
||||
@@ -428,7 +445,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
showHyperlinkPopup: false,
|
||||
isSidebarDocked: false,
|
||||
defaultSidebarDockedPreference: false,
|
||||
};
|
||||
|
||||
this.id = nanoid();
|
||||
@@ -459,7 +476,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
setActiveTool: this.setActiveTool,
|
||||
setCursor: this.setCursor,
|
||||
resetCursor: this.resetCursor,
|
||||
toggleMenu: this.toggleMenu,
|
||||
toggleSidebar: this.toggleSidebar,
|
||||
} as const;
|
||||
if (typeof excalidrawRef === "function") {
|
||||
excalidrawRef(api);
|
||||
@@ -567,101 +584,91 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
|
||||
}
|
||||
>
|
||||
<ExcalidrawContainerContext.Provider
|
||||
value={this.excalidrawContainerValue}
|
||||
>
|
||||
<DeviceContext.Provider value={this.device}>
|
||||
<ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
|
||||
<ExcalidrawAppStateContext.Provider value={this.state}>
|
||||
<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
|
||||
}
|
||||
<AppContext.Provider value={this}>
|
||||
<AppPropsContext.Provider value={this.props}>
|
||||
<ExcalidrawContainerContext.Provider
|
||||
value={this.excalidrawContainerValue}
|
||||
>
|
||||
<DeviceContext.Provider value={this.device}>
|
||||
<ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
|
||||
<ExcalidrawAppStateContext.Provider value={this.state}>
|
||||
<ExcalidrawElementsContext.Provider
|
||||
value={this.scene.getNonDeletedElements()}
|
||||
>
|
||||
{this.props.children}
|
||||
</LayerUI>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
{selectedElement.length === 1 &&
|
||||
!this.state.contextMenu &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
key={selectedElement[0].id}
|
||||
element={selectedElement[0]}
|
||||
<ExcalidrawActionManagerContext.Provider
|
||||
value={this.actionManager}
|
||||
>
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
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>
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getNonDeletedElements()}
|
||||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onHandToolToggle={this.onHandToolToggle}
|
||||
langCode={getLanguage().code}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||
this.state.zenModeEnabled
|
||||
}
|
||||
UIOptions={this.props.UIOptions}
|
||||
onImageAction={this.onImageAction}
|
||||
renderWelcomeScreen={
|
||||
!this.state.isLoading &&
|
||||
this.state.showWelcomeScreen &&
|
||||
this.state.activeTool.type === "selection" &&
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
}
|
||||
>
|
||||
{this.props.children}
|
||||
</LayerUI>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
{selectedElement.length === 1 &&
|
||||
!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>
|
||||
);
|
||||
}
|
||||
|
||||
public focusContainer: AppClassProperties["focusContainer"] = () => {
|
||||
if (this.props.autoFocus) {
|
||||
this.excalidrawContainerRef.current?.focus();
|
||||
}
|
||||
this.excalidrawContainerRef.current?.focus();
|
||||
};
|
||||
|
||||
public getSceneElementsIncludingDeleted = () => {
|
||||
@@ -672,6 +679,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return this.scene.getNonDeletedElements();
|
||||
};
|
||||
|
||||
public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
position: "center",
|
||||
files: null,
|
||||
});
|
||||
};
|
||||
|
||||
private syncActionResult = withBatchedUpdates(
|
||||
(actionResult: ActionResult) => {
|
||||
if (this.unmounted || actionResult === false) {
|
||||
@@ -941,7 +956,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.addCallback(this.onSceneUpdated);
|
||||
this.addEventListeners();
|
||||
|
||||
if (this.excalidrawContainerRef.current) {
|
||||
if (this.props.autoFocus && this.excalidrawContainerRef.current) {
|
||||
this.focusContainer();
|
||||
}
|
||||
|
||||
@@ -1580,6 +1595,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elements: data.elements,
|
||||
files: data.files || null,
|
||||
position: "cursor",
|
||||
retainSeed: isPlainPaste,
|
||||
});
|
||||
} else if (data.text) {
|
||||
this.addTextFromPaste(data.text, isPlainPaste);
|
||||
@@ -1593,6 +1609,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
position: { clientX: number; clientY: number } | "cursor" | "center";
|
||||
retainSeed?: boolean;
|
||||
}) => {
|
||||
const elements = restoreElements(opts.elements, null);
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
@@ -1620,35 +1637,25 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const dx = x - elementsCenterX;
|
||||
const dy = y - elementsCenterY;
|
||||
const groupIdMap = new Map();
|
||||
|
||||
const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize);
|
||||
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const newElements = elements.map((element) => {
|
||||
const newElement = duplicateElement(
|
||||
this.state.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
const newElements = duplicateElements(
|
||||
elements.map((element) => {
|
||||
return newElementWith(element, {
|
||||
x: element.x + gridX - minX,
|
||||
y: element.y + gridY - minY,
|
||||
},
|
||||
);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
return newElement;
|
||||
});
|
||||
});
|
||||
}),
|
||||
{
|
||||
randomizeSeed: !opts.retainSeed,
|
||||
},
|
||||
);
|
||||
|
||||
bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId);
|
||||
const nextElements = [
|
||||
...this.scene.getElementsIncludingDeleted(),
|
||||
...newElements,
|
||||
];
|
||||
fixBindingsAfterDuplication(nextElements, elements, oldIdToDuplicatedId);
|
||||
|
||||
if (opts.files) {
|
||||
this.files = { ...this.files, ...opts.files };
|
||||
}
|
||||
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
|
||||
@@ -1659,6 +1666,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
});
|
||||
|
||||
if (opts.files) {
|
||||
this.files = { ...this.files, ...opts.files };
|
||||
}
|
||||
|
||||
this.history.resumeRecording();
|
||||
|
||||
this.setState(
|
||||
@@ -1673,7 +1684,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
openSidebar:
|
||||
this.state.openSidebar &&
|
||||
this.device.canDeviceFitSidebar &&
|
||||
this.state.isSidebarDocked
|
||||
this.state.defaultSidebarDockedPreference
|
||||
? this.state.openSidebar
|
||||
: null,
|
||||
selectedElementIds: newElements.reduce(
|
||||
@@ -1731,12 +1742,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(acc: ExcalidrawTextElement[], line, idx) => {
|
||||
const text = line.trim();
|
||||
|
||||
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
|
||||
if (text.length) {
|
||||
const element = newTextElement({
|
||||
...textElementProps,
|
||||
x,
|
||||
y: currentY,
|
||||
text,
|
||||
lineHeight,
|
||||
});
|
||||
acc.push(element);
|
||||
currentY += element.height + LINE_GAP;
|
||||
@@ -1745,14 +1758,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// add paragraph only if previous line was not empty, IOW don't add
|
||||
// more than one empty line
|
||||
if (prevLine) {
|
||||
const defaultLineHeight = getApproxLineHeight(
|
||||
getFontString({
|
||||
fontSize: textElementProps.fontSize,
|
||||
fontFamily: textElementProps.fontFamily,
|
||||
}),
|
||||
);
|
||||
|
||||
currentY += defaultLineHeight + LINE_GAP;
|
||||
currentY +=
|
||||
getLineHeightInPx(textElementProps.fontSize, lineHeight) +
|
||||
LINE_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1845,18 +1853,89 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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 = (
|
||||
target:
|
||||
| ExcalidrawElement
|
||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
||||
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
|
||||
) => {
|
||||
this.setState({
|
||||
...calculateScrollCenter(
|
||||
Array.isArray(target) ? target : [target],
|
||||
this.state,
|
||||
this.canvas,
|
||||
),
|
||||
});
|
||||
this.cancelInProgresAnimation?.();
|
||||
|
||||
// convert provided target into ExcalidrawElement[] if necessary
|
||||
const targets = Array.isArray(target) ? target : [target];
|
||||
|
||||
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 = (
|
||||
@@ -1943,30 +2022,24 @@ class App extends React.Component<AppProps, AppState> {
|
||||
/**
|
||||
* @returns whether the menu was toggled on or off
|
||||
*/
|
||||
public toggleMenu = (
|
||||
type: "library" | "customSidebar",
|
||||
force?: boolean,
|
||||
): boolean => {
|
||||
if (type === "customSidebar" && !this.props.renderSidebar) {
|
||||
console.warn(
|
||||
`attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`,
|
||||
);
|
||||
return false;
|
||||
public toggleSidebar = ({
|
||||
name,
|
||||
tab,
|
||||
force,
|
||||
}: {
|
||||
name: SidebarName;
|
||||
tab?: SidebarTabName;
|
||||
force?: boolean;
|
||||
}): boolean => {
|
||||
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 });
|
||||
|
||||
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;
|
||||
return !!nextName;
|
||||
};
|
||||
|
||||
private updateCurrentCursorPosition = withBatchedUpdates(
|
||||
@@ -2057,9 +2130,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
offset = -offset;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
this.setState((state) => ({ scrollX: state.scrollX + offset }));
|
||||
this.translateCanvas((state) => ({
|
||||
scrollX: state.scrollX + offset,
|
||||
}));
|
||||
} else {
|
||||
this.setState((state) => ({ scrollY: state.scrollY + offset }));
|
||||
this.translateCanvas((state) => ({
|
||||
scrollY: state.scrollY + offset,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2607,6 +2684,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
|
||||
}
|
||||
|
||||
const fontFamily =
|
||||
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
|
||||
|
||||
const lineHeight =
|
||||
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const fontSize = this.state.currentItemFontSize;
|
||||
|
||||
if (
|
||||
!existingTextElement &&
|
||||
shouldBindToContainer &&
|
||||
@@ -2614,11 +2698,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isArrowElement(container)
|
||||
) {
|
||||
const fontString = {
|
||||
fontSize: this.state.currentItemFontSize,
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
};
|
||||
const minWidth = getApproxMinLineWidth(getFontString(fontString));
|
||||
const minHeight = getApproxMinLineHeight(getFontString(fontString));
|
||||
const minWidth = getApproxMinLineWidth(
|
||||
getFontString(fontString),
|
||||
lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
|
||||
const containerDims = getContainerDims(container);
|
||||
const newHeight = Math.max(containerDims.height, minHeight);
|
||||
const newWidth = Math.max(containerDims.width, minWidth);
|
||||
@@ -2650,10 +2737,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
strokeStyle: this.state.currentItemStrokeStyle,
|
||||
roughness: this.state.currentItemRoughness,
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness: null,
|
||||
text: "",
|
||||
fontSize: this.state.currentItemFontSize,
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign: parentCenterPosition
|
||||
? "center"
|
||||
: this.state.currentItemTextAlign,
|
||||
@@ -2662,7 +2748,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: DEFAULT_VERTICAL_ALIGN,
|
||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||
groupIds: container?.groupIds ?? [],
|
||||
locked: false,
|
||||
lineHeight,
|
||||
angle: container?.angle ?? 0,
|
||||
});
|
||||
|
||||
if (!existingTextElement && shouldBindToContainer && container) {
|
||||
@@ -2929,12 +3016,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
state,
|
||||
);
|
||||
|
||||
return {
|
||||
this.translateCanvas({
|
||||
zoom: zoomState.zoom,
|
||||
scrollX: zoomState.scrollX + deltaX / nextZoom,
|
||||
scrollY: zoomState.scrollY + deltaY / nextZoom,
|
||||
shouldCacheIgnoreZoom: true,
|
||||
};
|
||||
});
|
||||
});
|
||||
this.resetShouldCacheIgnoreZoomDebounced();
|
||||
} else {
|
||||
@@ -3412,6 +3499,43 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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
|
||||
// (mainly, we care about removing selection outside the component which
|
||||
// would prevent our copy handling otherwise)
|
||||
@@ -3451,8 +3575,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
this.savePointer(event.clientX, event.clientY, "down");
|
||||
|
||||
this.updateGestureOnPointerDown(event);
|
||||
|
||||
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
|
||||
return;
|
||||
}
|
||||
@@ -3710,7 +3832,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
this.translateCanvas({
|
||||
scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
|
||||
scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
|
||||
});
|
||||
@@ -4603,7 +4725,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState.drag.hasOccurred = true;
|
||||
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
|
||||
// it would have weird results (stuff jumping all over the screen)
|
||||
if (selectedElements.length > 0 && !pointerDownState.withCmdOrCtrl) {
|
||||
// Checking for editingElement to avoid jump while editing on mobile #6503
|
||||
if (
|
||||
selectedElements.length > 0 &&
|
||||
!pointerDownState.withCmdOrCtrl &&
|
||||
!this.state.editingElement
|
||||
) {
|
||||
const [dragX, dragY] = getGridPoint(
|
||||
pointerCoords.x - pointerDownState.drag.offset.x,
|
||||
pointerCoords.y - pointerDownState.drag.offset.y,
|
||||
@@ -4856,7 +4983,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (pointerDownState.scrollbars.isOverHorizontal) {
|
||||
const x = event.clientX;
|
||||
const dx = x - pointerDownState.lastCoords.x;
|
||||
this.setState({
|
||||
this.translateCanvas({
|
||||
scrollX: this.state.scrollX - dx / this.state.zoom.value,
|
||||
});
|
||||
pointerDownState.lastCoords.x = x;
|
||||
@@ -4866,7 +4993,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (pointerDownState.scrollbars.isOverVertical) {
|
||||
const y = event.clientY;
|
||||
const dy = y - pointerDownState.lastCoords.y;
|
||||
this.setState({
|
||||
this.translateCanvas({
|
||||
scrollY: this.state.scrollY - dy / this.state.zoom.value,
|
||||
});
|
||||
pointerDownState.lastCoords.y = y;
|
||||
@@ -5626,7 +5753,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const imageFile = await fileOpen({
|
||||
description: "Image",
|
||||
extensions: ["jpg", "png", "svg", "gif"],
|
||||
extensions: Object.keys(
|
||||
IMAGE_MIME_TYPES,
|
||||
) as (keyof typeof IMAGE_MIME_TYPES)[],
|
||||
});
|
||||
|
||||
const imageElement = this.createImageElement({
|
||||
@@ -6248,7 +6377,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
actionGroup,
|
||||
actionUnbindText,
|
||||
actionBindText,
|
||||
actionCreateContainerFromText,
|
||||
actionWrapTextInContainer,
|
||||
actionUngroup,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionAddToLibrary,
|
||||
@@ -6295,7 +6424,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// reduced amplification for small deltas (small movements on a trackpad)
|
||||
Math.min(1, absDelta / 20);
|
||||
|
||||
this.setState((state) => ({
|
||||
this.translateCanvas((state) => ({
|
||||
...getStateForZoom(
|
||||
{
|
||||
viewportX: cursorX,
|
||||
@@ -6312,14 +6441,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// scroll horizontally when shift pressed
|
||||
if (event.shiftKey) {
|
||||
this.setState(({ zoom, scrollX }) => ({
|
||||
this.translateCanvas(({ zoom, scrollX }) => ({
|
||||
// on Mac, shift+wheel tends to result in deltaX
|
||||
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(({ zoom, scrollX, scrollY }) => ({
|
||||
this.translateCanvas(({ zoom, scrollX, scrollY }) => ({
|
||||
scrollX: scrollX - deltaX / zoom.value,
|
||||
scrollY: scrollY - deltaY / zoom.value,
|
||||
}));
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import { t } from "../i18n";
|
||||
import Trans from "./Trans";
|
||||
|
||||
const BraveMeasureTextError = () => {
|
||||
return (
|
||||
<div data-testid="brave-measure-text-error">
|
||||
<p>
|
||||
{t("errors.brave_measure_text_error.start")}
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{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")}.
|
||||
<Trans
|
||||
i18nKey="errors.brave_measure_text_error.line1"
|
||||
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
{t("errors.brave_measure_text_error.strongly_recommend")}{" "}
|
||||
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
|
||||
{" "}
|
||||
{t("errors.brave_measure_text_error.steps")}
|
||||
</a>{" "}
|
||||
{t("errors.brave_measure_text_error.how")}.
|
||||
<Trans
|
||||
i18nKey="errors.brave_measure_text_error.line2"
|
||||
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
{t("errors.brave_measure_text_error.disable_setting")}{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/issues/new">
|
||||
{t("errors.brave_measure_text_error.issue")}
|
||||
</a>{" "}
|
||||
{t("errors.brave_measure_text_error.write")}{" "}
|
||||
<a href="https://discord.gg/UexuTaE">
|
||||
{t("errors.brave_measure_text_error.discord")}
|
||||
</a>
|
||||
.
|
||||
<Trans
|
||||
i18nKey="errors.brave_measure_text_error.line3"
|
||||
link={(el) => (
|
||||
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
|
||||
{el}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import clsx from "clsx";
|
||||
import { composeEventHandlers } from "../utils";
|
||||
import "./Button.scss";
|
||||
|
||||
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
type?: "button" | "submit" | "reset";
|
||||
onSelect: () => any;
|
||||
/** whether button is in active state */
|
||||
selected?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -15,18 +19,18 @@ interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
export const Button = ({
|
||||
type = "button",
|
||||
onSelect,
|
||||
selected,
|
||||
children,
|
||||
className = "",
|
||||
...rest
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
onClick={(event) => {
|
||||
onClick={composeEventHandlers(rest.onClick, (event) => {
|
||||
onSelect();
|
||||
rest.onClick?.(event);
|
||||
}}
|
||||
})}
|
||||
type={type}
|
||||
className={`excalidraw-button ${className}`}
|
||||
className={clsx("excalidraw-button", className, { selected })}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,33 +1,59 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||
export const ButtonIconSelect = <T extends Object>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
group,
|
||||
}: {
|
||||
options: { value: T; text: string; icon: JSX.Element; testId?: string }[];
|
||||
value: T | null;
|
||||
onChange: (value: T) => void;
|
||||
group: string;
|
||||
}) => (
|
||||
export const ButtonIconSelect = <T extends Object>(
|
||||
props: {
|
||||
options: {
|
||||
value: T;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
testId?: string;
|
||||
/** if not supplied, defaults to value identity check */
|
||||
active?: boolean;
|
||||
}[];
|
||||
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">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.text}
|
||||
className={clsx({ active: value === option.value })}
|
||||
title={option.text}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={group}
|
||||
onChange={() => onChange(option.value)}
|
||||
checked={value === option.value}
|
||||
{props.options.map((option) =>
|
||||
props.type === "button" ? (
|
||||
<button
|
||||
key={option.text}
|
||||
onClick={(event) => props.onClick(option.value, event)}
|
||||
className={clsx({
|
||||
active: option.active ?? props.value === option.value,
|
||||
})}
|
||||
data-testid={option.testId}
|
||||
/>
|
||||
{option.icon}
|
||||
</label>
|
||||
))}
|
||||
title={option.text}
|
||||
>
|
||||
{option.icon}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary-color);
|
||||
border: 0;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { Dialog, DialogProps } from "./Dialog";
|
||||
import "./ConfirmDialog.scss";
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
|
||||
import { useExcalidrawSetAppState } from "./App";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
|
||||
import { jotaiScope } from "../jotai";
|
||||
|
||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||
@@ -26,6 +26,7 @@ const ConfirmDialog = (props: Props) => {
|
||||
} = props;
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||
const { container } = useExcalidrawContainer();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -42,6 +43,7 @@ const ConfirmDialog = (props: Props) => {
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
onCancel();
|
||||
container?.focus();
|
||||
}}
|
||||
/>
|
||||
<DialogActionButton
|
||||
@@ -50,6 +52,7 @@ const ConfirmDialog = (props: Props) => {
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
onConfirm();
|
||||
container?.focus();
|
||||
}}
|
||||
actionType="danger"
|
||||
/>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 0.2fr;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
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,
|
||||
},
|
||||
);
|
||||
@@ -15,7 +15,7 @@ import { Modal } from "./Modal";
|
||||
import { AppState } from "../types";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { jotaiScope } from "../jotai";
|
||||
|
||||
export interface DialogProps {
|
||||
|
||||
@@ -165,11 +165,12 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.editSelectedShape")}
|
||||
shortcuts={[
|
||||
getShortcutKey("CtrlOrCmd+Enter"),
|
||||
getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`),
|
||||
]}
|
||||
label={t("helpDialog.editLineArrowPoints")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Enter")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.editText")}
|
||||
shortcuts={[getShortcutKey("Enter")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.textNewLine")}
|
||||
|
||||
@@ -29,7 +29,7 @@ const getHints = ({
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) {
|
||||
if (appState.openSidebar && !device.canDeviceFitSidebar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { canvasToBlob } from "../data/blob";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { clipboard } from "./icons";
|
||||
@@ -15,6 +14,7 @@ import { CheckboxItem } from "./CheckboxItem";
|
||||
import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { exportToCanvas } from "../packages/utils";
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
@@ -83,7 +83,6 @@ const ImageExportModal = ({
|
||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const { exportBackground, viewBackgroundColor } = appState;
|
||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
|
||||
const exportedElements = exportSelected
|
||||
@@ -99,10 +98,16 @@ const ImageExportModal = ({
|
||||
if (!previewNode) {
|
||||
return;
|
||||
}
|
||||
exportToCanvas(exportedElements, appState, files, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
const maxWidth = previewNode.offsetWidth;
|
||||
if (!maxWidth) {
|
||||
return;
|
||||
}
|
||||
exportToCanvas({
|
||||
elements: exportedElements,
|
||||
appState,
|
||||
files,
|
||||
exportPadding,
|
||||
maxWidthOrHeight: maxWidth,
|
||||
})
|
||||
.then((canvas) => {
|
||||
setRenderError(null);
|
||||
@@ -116,14 +121,7 @@ const ImageExportModal = ({
|
||||
console.error(error);
|
||||
setRenderError(error);
|
||||
});
|
||||
}, [
|
||||
appState,
|
||||
files,
|
||||
exportedElements,
|
||||
exportBackground,
|
||||
exportPadding,
|
||||
viewBackgroundColor,
|
||||
]);
|
||||
}, [appState, files, exportedElements, exportPadding]);
|
||||
|
||||
return (
|
||||
<div className="ExportDialog">
|
||||
|
||||
+70
-67
@@ -1,7 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||
import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
@@ -9,7 +9,7 @@ import { Language, t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
import { isShallowEqual, muteFSAbortError } from "../utils";
|
||||
import { capitalizeString, isShallowEqual, muteFSAbortError } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||
@@ -24,28 +24,28 @@ import { Section } from "./Section";
|
||||
import { HelpDialog } from "./HelpDialog";
|
||||
import Stack from "./Stack";
|
||||
import { UserList } from "./UserList";
|
||||
import Library from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { LibraryMenu } from "./LibraryMenu";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "../components/App";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||
import Footer from "./footer/Footer";
|
||||
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
|
||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { Provider, useAtom } from "jotai";
|
||||
import { Provider, useAtomValue } from "jotai";
|
||||
import MainMenu from "./main-menu/MainMenu";
|
||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||
import { HandButton } from "./HandButton";
|
||||
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 {
|
||||
actionManager: ActionManager;
|
||||
@@ -57,17 +57,11 @@ interface LayerUIProps {
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
showExitZenModeBtn: boolean;
|
||||
langCode: Language["code"];
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderWelcomeScreen: boolean;
|
||||
children?: React.ReactNode;
|
||||
@@ -109,16 +103,10 @@ const LayerUI = ({
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
onInsertElements,
|
||||
showExitZenModeBtn,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
renderCustomSidebar,
|
||||
libraryReturnUrl,
|
||||
UIOptions,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
onImageAction,
|
||||
renderWelcomeScreen,
|
||||
children,
|
||||
@@ -197,8 +185,8 @@ const LayerUI = ({
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* wrapping to Fragment stops React from occasionally complaining
|
||||
about identical Keys */}
|
||||
<tunnels.mainMenuTunnel.Out />
|
||||
{renderWelcomeScreen && <tunnels.welcomeScreenMenuHintTunnel.Out />}
|
||||
<tunnels.MainMenuTunnel.Out />
|
||||
{renderWelcomeScreen && <tunnels.WelcomeScreenMenuHintTunnel.Out />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -250,7 +238,7 @@ const LayerUI = ({
|
||||
{(heading: React.ReactNode) => (
|
||||
<div style={{ position: "relative" }}>
|
||||
{renderWelcomeScreen && (
|
||||
<tunnels.welcomeScreenToolbarHintTunnel.Out />
|
||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||
)}
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row
|
||||
@@ -324,9 +312,12 @@ const LayerUI = ({
|
||||
>
|
||||
<UserList collaborators={appState.collaborators} />
|
||||
{renderTopRightUI?.(device.isMobile, appState)}
|
||||
{!appState.viewModeEnabled && (
|
||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
||||
)}
|
||||
{!appState.viewModeEnabled &&
|
||||
// hide button when sidebar docked
|
||||
(!isSidebarDocked ||
|
||||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
||||
<tunnels.DefaultSidebarTriggerTunnel.Out />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
@@ -334,21 +325,21 @@ const LayerUI = ({
|
||||
};
|
||||
|
||||
const renderSidebars = () => {
|
||||
return appState.openSidebar === "customSidebar" ? (
|
||||
renderCustomSidebar?.() || null
|
||||
) : appState.openSidebar === "library" ? (
|
||||
<LibraryMenu
|
||||
appState={appState}
|
||||
onInsertElements={onInsertElements}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
id={id}
|
||||
return (
|
||||
<DefaultSidebar
|
||||
__fallback
|
||||
onDock={(docked) => {
|
||||
trackEvent(
|
||||
"sidebar",
|
||||
`toggleDock (${docked ? "dock" : "undock"})`,
|
||||
`(${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
|
||||
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
|
||||
|
||||
const layerUIJSX = (
|
||||
<>
|
||||
@@ -358,8 +349,25 @@ const LayerUI = ({
|
||||
{children}
|
||||
{/* render component fallbacks. Can be rendered anywhere as they'll be
|
||||
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} />
|
||||
<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} />}
|
||||
@@ -382,7 +390,6 @@ const LayerUI = ({
|
||||
<PasteChartDialog
|
||||
setAppState={setAppState}
|
||||
appState={appState}
|
||||
onInsertChart={onInsertElements}
|
||||
onClose={() =>
|
||||
setAppState({
|
||||
pasteDialog: { shown: false, data: null },
|
||||
@@ -410,7 +417,6 @@ const LayerUI = ({
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!device.isMobile && (
|
||||
<>
|
||||
<div
|
||||
@@ -422,15 +428,14 @@ const LayerUI = ({
|
||||
!isTextElement(appState.editingElement)),
|
||||
})}
|
||||
style={
|
||||
((appState.openSidebar === "library" &&
|
||||
appState.isSidebarDocked) ||
|
||||
hostSidebarCounters.docked) &&
|
||||
appState.openSidebar &&
|
||||
isSidebarDocked &&
|
||||
device.canDeviceFitSidebar
|
||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{renderWelcomeScreen && <tunnels.welcomeScreenCenterTunnel.Out />}
|
||||
{renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
|
||||
{renderFixedSideContainer()}
|
||||
<Footer
|
||||
appState={appState}
|
||||
@@ -469,17 +474,22 @@ const LayerUI = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Provider scope={tunnels.jotaiScope}>
|
||||
<TunnelsContext.Provider value={tunnels}>
|
||||
{layerUIJSX}
|
||||
</TunnelsContext.Provider>
|
||||
</Provider>
|
||||
<UIAppStateContext.Provider value={appState}>
|
||||
<Provider scope={tunnels.jotaiScope}>
|
||||
<TunnelsContext.Provider value={tunnels}>
|
||||
{layerUIJSX}
|
||||
</TunnelsContext.Provider>
|
||||
</Provider>
|
||||
</UIAppStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const stripIrrelevantAppStateProps = (
|
||||
appState: AppState,
|
||||
): Partial<AppState> => {
|
||||
): Omit<
|
||||
AppState,
|
||||
"suggestedBindings" | "startBoundElement" | "cursorButton"
|
||||
> => {
|
||||
const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
|
||||
appState;
|
||||
return ret;
|
||||
@@ -491,24 +501,17 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
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;
|
||||
const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
|
||||
const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
|
||||
|
||||
return (
|
||||
isShallowEqual(
|
||||
stripIrrelevantAppStateProps(prevAppState),
|
||||
stripIrrelevantAppStateProps(nextAppState),
|
||||
{
|
||||
selectedElementIds: isShallowEqual,
|
||||
selectedGroupIds: isShallowEqual,
|
||||
},
|
||||
) && isShallowEqual(prev, next)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__library-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.library-menu-items-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layer-ui__library {
|
||||
@@ -11,28 +11,6 @@
|
||||
flex-direction: column;
|
||||
|
||||
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 {
|
||||
@@ -87,10 +65,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.library-menu-browse-button {
|
||||
margin: 1rem auto;
|
||||
.library-menu-control-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
padding: 0.875rem 1rem;
|
||||
.library-menu-browse-button {
|
||||
flex: 1;
|
||||
|
||||
height: var(--lg-button-size);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -122,30 +107,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.library-menu-browse-button--mobile {
|
||||
min-height: 22px;
|
||||
margin-left: auto;
|
||||
a {
|
||||
padding-right: 0;
|
||||
}
|
||||
&.excalidraw--mobile .library-menu-browse-button {
|
||||
height: var(--default-button-size);
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header .dropdown-menu {
|
||||
&.dropdown-menu--mobile {
|
||||
top: 100%;
|
||||
}
|
||||
.layer-ui__library .dropdown-menu {
|
||||
width: auto;
|
||||
top: initial;
|
||||
right: 0;
|
||||
left: initial;
|
||||
bottom: 100%;
|
||||
margin-bottom: 0.625rem;
|
||||
|
||||
.dropdown-menu-container {
|
||||
--gap: 0;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
width: 196px;
|
||||
box-shadow: var(--library-dropdown-shadow);
|
||||
border-radius: var(--border-radius-lg);
|
||||
|
||||
+35
-177
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
RefObject,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import Library, {
|
||||
distributeLibraryItemsOnSquareGrid,
|
||||
libraryItemsAtom,
|
||||
@@ -13,65 +6,29 @@ import Library, {
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
|
||||
|
||||
import "./LibraryMenu.scss";
|
||||
import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { EVENT } from "../constants";
|
||||
import { KEYS } from "../keys";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useAtom } from "jotai";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import Spinner from "./Spinner";
|
||||
import {
|
||||
useDevice,
|
||||
useApp,
|
||||
useAppProps,
|
||||
useExcalidrawElements,
|
||||
useExcalidrawSetAppState,
|
||||
} from "./App";
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
|
||||
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
cb: (event: MouseEvent) => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent) => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
import "./LibraryMenu.scss";
|
||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
(ref.current.contains(event.target) ||
|
||||
!document.body.contains(event.target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
|
||||
cb(event);
|
||||
};
|
||||
document.addEventListener("pointerdown", listener, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", listener);
|
||||
};
|
||||
}, [ref, cb]);
|
||||
const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div className="layer-ui__library">{children}</div>;
|
||||
};
|
||||
|
||||
const LibraryMenuWrapper = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode }
|
||||
>(({ children }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="layer-ui__library">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const LibraryMenuContent = ({
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
@@ -158,81 +115,31 @@ export const LibraryMenuContent = ({
|
||||
theme={appState.theme}
|
||||
/>
|
||||
{showBtn && (
|
||||
<LibraryMenuBrowseButton
|
||||
<LibraryMenuControlButtons
|
||||
style={{ padding: "16px 12px 0 12px" }}
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={appState.theme}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
/>
|
||||
)}
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const LibraryMenu: React.FC<{
|
||||
appState: AppState;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}> = ({
|
||||
appState,
|
||||
onInsertElements,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}) => {
|
||||
/**
|
||||
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
|
||||
* <DefaultSidebar/> or host apps Sidebar components.
|
||||
*/
|
||||
export const LibraryMenu = () => {
|
||||
const { library, id, onInsertElements } = useApp();
|
||||
const appProps = useAppProps();
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
const device = useDevice();
|
||||
|
||||
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(() => {
|
||||
setAppState({
|
||||
@@ -241,69 +148,20 @@ export const LibraryMenu: React.FC<{
|
||||
});
|
||||
}, [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 (
|
||||
<Sidebar
|
||||
__isInternal
|
||||
// necessary to remount when switching between internal
|
||||
// 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"})`,
|
||||
);
|
||||
<LibraryMenuContent
|
||||
pendingElements={getSelectedElements(elements, appState, true)}
|
||||
onInsertLibraryItems={(libraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<Sidebar.Header className="layer-ui__library-header">
|
||||
<LibraryMenuHeader
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
selectedItems={selectedItems}
|
||||
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>
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={appProps.libraryReturnUrl}
|
||||
library={library}
|
||||
id={id}
|
||||
appState={appState}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { LibraryItem, ExcalidrawProps, AppState } 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: AppState["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>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||
import { useApp, useExcalidrawAppState, useExcalidrawSetAppState } from "./App";
|
||||
import { saveLibraryAsJSON } from "../data/json";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||
import {
|
||||
DotsIcon,
|
||||
ExportIcon,
|
||||
@@ -13,22 +15,19 @@ import {
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import PublishLibrary from "./PublishLibrary";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
|
||||
const getSelectedItems = (
|
||||
libraryItems: LibraryItems,
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
export const LibraryMenuHeader: React.FC<{
|
||||
export const LibraryDropdownMenuButton: React.FC<{
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
selectedItems: LibraryItem["id"][];
|
||||
library: Library;
|
||||
@@ -50,6 +49,7 @@ export const LibraryMenuHeader: React.FC<{
|
||||
isLibraryMenuOpenAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
const renderRemoveLibAlert = useCallback(() => {
|
||||
const content = selectedItems.length
|
||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||
@@ -181,7 +181,6 @@ export const LibraryMenuHeader: React.FC<{
|
||||
return (
|
||||
<DropdownMenu open={isLibraryMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
className="Sidebar__dropdown-btn"
|
||||
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
|
||||
>
|
||||
{DotsIcon}
|
||||
@@ -230,6 +229,7 @@ export const LibraryMenuHeader: React.FC<{
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
{renderLibraryMenu()}
|
||||
@@ -261,3 +261,48 @@ export const LibraryMenuHeader: React.FC<{
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LibraryDropdownMenu = ({
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
}: {
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) => {
|
||||
const { library } = useApp();
|
||||
const appState = useExcalidrawAppState();
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
&__items {
|
||||
row-gap: 0.5rem;
|
||||
padding: var(--container-padding-y) var(--container-padding-x);
|
||||
padding: var(--container-padding-y) 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -61,7 +61,7 @@
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&--excal {
|
||||
margin-top: 2.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import Stack from "./Stack";
|
||||
import "./LibraryMenuItems.scss";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import Spinner from "./Spinner";
|
||||
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
|
||||
import clsx from "clsx";
|
||||
import { duplicateElements } from "../element/newElement";
|
||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
|
||||
const CELLS_PER_ROW = 4;
|
||||
|
||||
@@ -96,7 +96,14 @@ const LibraryMenuItems = ({
|
||||
} else {
|
||||
targetElements = libraryItems.filter((item) => item.id === id);
|
||||
}
|
||||
return targetElements;
|
||||
return targetElements.map((item) => {
|
||||
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: {
|
||||
@@ -193,11 +200,7 @@ const LibraryMenuItems = ({
|
||||
(item) => item.status === "published",
|
||||
);
|
||||
|
||||
const showBtn =
|
||||
!libraryItems.length &&
|
||||
!unpublishedItems.length &&
|
||||
!publishedItems.length &&
|
||||
!pendingElements.length;
|
||||
const showBtn = !libraryItems.length && !pendingElements.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -207,7 +210,7 @@ const LibraryMenuItems = ({
|
||||
unpublishedItems.length ||
|
||||
publishedItems.length
|
||||
? { justifyContent: "flex-start" }
|
||||
: {}
|
||||
: { borderBottom: 0 }
|
||||
}
|
||||
>
|
||||
<Stack.Col
|
||||
@@ -243,11 +246,7 @@ const LibraryMenuItems = ({
|
||||
</div>
|
||||
{!pendingElements.length && !unpublishedItems.length ? (
|
||||
<div className="library-menu-items__no-items">
|
||||
<div
|
||||
className={clsx({
|
||||
"library-menu-items__no-items__label": showBtn,
|
||||
})}
|
||||
>
|
||||
<div className="library-menu-items__no-items__label">
|
||||
{t("library.noItems")}
|
||||
</div>
|
||||
<div className="library-menu-items__no-items__hint">
|
||||
@@ -295,10 +294,13 @@ const LibraryMenuItems = ({
|
||||
</>
|
||||
|
||||
{showBtn && (
|
||||
<LibraryMenuBrowseButton
|
||||
<LibraryMenuControlButtons
|
||||
style={{ padding: "16px 0", width: "100%" }}
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={theme}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
/>
|
||||
)}
|
||||
</Stack.Col>
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDevice } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { exportToSvg } from "../packages/utils";
|
||||
import { LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
@@ -36,14 +36,14 @@ export const LibraryUnit = ({
|
||||
if (!elements) {
|
||||
return;
|
||||
}
|
||||
const svg = await exportToSvg(
|
||||
const svg = await exportToSvg({
|
||||
elements,
|
||||
{
|
||||
appState: {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null,
|
||||
);
|
||||
files: null,
|
||||
});
|
||||
svg.querySelector(".style-fonts")?.remove();
|
||||
node.innerHTML = svg.outerHTML;
|
||||
})();
|
||||
|
||||
@@ -13,13 +13,12 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
import { HandButton } from "./HandButton";
|
||||
import { isHandToolActive } from "../appState";
|
||||
import { useTunnels } from "./context/tunnels";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
@@ -60,11 +59,15 @@ export const MobileMenu = ({
|
||||
device,
|
||||
renderWelcomeScreen,
|
||||
}: MobileMenuProps) => {
|
||||
const { welcomeScreenCenterTunnel, mainMenuTunnel } = useTunnels();
|
||||
const {
|
||||
WelcomeScreenCenterTunnel,
|
||||
MainMenuTunnel,
|
||||
DefaultSidebarTriggerTunnel,
|
||||
} = useTunnels();
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
<FixedSideContainer side="top" className="App-top-bar">
|
||||
{renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />}
|
||||
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
|
||||
<Section heading="shapes">
|
||||
{(heading: React.ReactNode) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
@@ -88,11 +91,7 @@ export const MobileMenu = ({
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<div className="mobile-misc-tools-container">
|
||||
{!appState.viewModeEnabled && (
|
||||
<LibraryButton
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
isMobile
|
||||
/>
|
||||
<DefaultSidebarTriggerTunnel.Out />
|
||||
)}
|
||||
<PenModeButton
|
||||
checked={appState.penMode}
|
||||
@@ -132,14 +131,14 @@ export const MobileMenu = ({
|
||||
if (appState.viewModeEnabled) {
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
<mainMenuTunnel.Out />
|
||||
<MainMenuTunnel.Out />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
<mainMenuTunnel.Out />
|
||||
<MainMenuTunnel.Out />
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
@@ -190,7 +189,7 @@ export const MobileMenu = ({
|
||||
{renderAppToolbar()}
|
||||
{appState.scrolledOutside &&
|
||||
!appState.openMenu &&
|
||||
appState.openSidebar !== "library" && (
|
||||
!appState.openSidebar && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
|
||||
import { ChartType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { AppState, LibraryItem } from "../types";
|
||||
import { AppState } from "../types";
|
||||
import { useApp } from "./App";
|
||||
import { Dialog } from "./Dialog";
|
||||
import "./PasteChartDialog.scss";
|
||||
|
||||
@@ -78,13 +79,12 @@ export const PasteChartDialog = ({
|
||||
setAppState,
|
||||
appState,
|
||||
onClose,
|
||||
onInsertChart,
|
||||
}: {
|
||||
appState: AppState;
|
||||
onClose: () => void;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onInsertChart: (elements: LibraryItem["elements"]) => void;
|
||||
}) => {
|
||||
const { onInsertElements } = useApp();
|
||||
const handleClose = React.useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
@@ -92,7 +92,7 @@ export const PasteChartDialog = ({
|
||||
}, [onClose]);
|
||||
|
||||
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
|
||||
onInsertChart(elements);
|
||||
onInsertElements(elements);
|
||||
trackEvent("magic", "chart", chartType);
|
||||
setAppState({
|
||||
currentChartType: chartType,
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
padding: 5px 0 5px;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
+66
-26
@@ -29,13 +29,21 @@ export const Popover = ({
|
||||
}: Props) => {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const container = popoverRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
const container = popoverRef.current;
|
||||
|
||||
if (!container) {
|
||||
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) => {
|
||||
if (event.key === KEYS.TAB) {
|
||||
const focusableElements = queryFocusableElements(container);
|
||||
@@ -44,15 +52,23 @@ export const Popover = ({
|
||||
(element) => element === activeElement,
|
||||
);
|
||||
|
||||
if (currentIndex === 0 && event.shiftKey) {
|
||||
focusableElements[focusableElements.length - 1].focus();
|
||||
if (activeElement === container) {
|
||||
if (event.shiftKey) {
|
||||
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.stopImmediatePropagation();
|
||||
} else if (
|
||||
currentIndex === focusableElements.length - 1 &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
focusableElements[0].focus();
|
||||
focusableElements[0]?.focus();
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
@@ -62,35 +78,59 @@ export const Popover = ({
|
||||
container.addEventListener("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
|
||||
useLayoutEffect(() => {
|
||||
if (fitInViewport && popoverRef.current) {
|
||||
const element = popoverRef.current;
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
if (fitInViewport && popoverRef.current && top != null && left != null) {
|
||||
const container = popoverRef.current;
|
||||
const { width, height } = container.getBoundingClientRect();
|
||||
|
||||
//Position correctly when clicked on rightmost part or the bottom part of viewport
|
||||
if (x + width - offsetLeft > viewportWidth) {
|
||||
element.style.left = `${viewportWidth - width - 10}px`;
|
||||
}
|
||||
if (y + height - offsetTop > viewportHeight) {
|
||||
element.style.top = `${viewportHeight - height}px`;
|
||||
// hack for StrictMode so this effect only runs once for
|
||||
// the same top/left position, otherwise
|
||||
// we'd potentically reposition twice (once for viewport overflow)
|
||||
// and once for top/left position afterwards
|
||||
if (
|
||||
lastInitializedPosRef.current?.top === top &&
|
||||
lastInitializedPosRef.current?.left === left
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastInitializedPosRef.current = { top, left };
|
||||
|
||||
//Resize to fit viewport on smaller screens
|
||||
if (height >= viewportHeight) {
|
||||
element.style.height = `${viewportHeight - 20}px`;
|
||||
element.style.top = "10px";
|
||||
element.style.overflowY = "scroll";
|
||||
}
|
||||
if (width >= viewportWidth) {
|
||||
element.style.width = `${viewportWidth}px`;
|
||||
element.style.left = "0px";
|
||||
element.style.overflowX = "scroll";
|
||||
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`;
|
||||
}
|
||||
|
||||
if (height >= viewportHeight) {
|
||||
container.style.height = `${viewportHeight - 20}px`;
|
||||
container.style.top = "10px";
|
||||
container.style.overflowY = "scroll";
|
||||
} else if (top + height - offsetTop > viewportHeight) {
|
||||
container.style.top = `${viewportHeight - height}px`;
|
||||
} else {
|
||||
container.style.top = `${top}px`;
|
||||
}
|
||||
}
|
||||
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
|
||||
}, [
|
||||
top,
|
||||
left,
|
||||
fitInViewport,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onCloseRequest) {
|
||||
@@ -105,7 +145,7 @@ export const Popover = ({
|
||||
}, [onCloseRequest]);
|
||||
|
||||
return (
|
||||
<div className="popover" style={{ top, left }} ref={popoverRef}>
|
||||
<div className="popover" ref={popoverRef} tabIndex={-1}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -93,4 +93,80 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import OpenColor from "open-color";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||
import { exportToCanvas } from "../packages/utils";
|
||||
import { exportToCanvas, exportToSvg } from "../packages/utils";
|
||||
import {
|
||||
EXPORT_DATA_TYPES,
|
||||
EXPORT_SOURCE,
|
||||
@@ -13,12 +13,13 @@ import {
|
||||
VERSIONS,
|
||||
} from "../constants";
|
||||
import { ExportedLibraryData } from "../data/types";
|
||||
|
||||
import "./PublishLibrary.scss";
|
||||
import SingleLibraryItem from "./SingleLibraryItem";
|
||||
import { canvasToBlob, resizeImageFile } from "../data/blob";
|
||||
import { chunk } from "../utils";
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
import { CloseIcon } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
import "./PublishLibrary.scss";
|
||||
|
||||
interface PublishLibraryDataParams {
|
||||
authorName: string;
|
||||
@@ -126,6 +127,99 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
|
||||
);
|
||||
};
|
||||
|
||||
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: 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 = ({
|
||||
onClose,
|
||||
libraryItems,
|
||||
|
||||
@@ -2,67 +2,26 @@
|
||||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Sidebar {
|
||||
&__close-btn,
|
||||
&__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 {
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
background-color: var(--sidebar-bg-color);
|
||||
box-shadow: var(--sidebar-shadow);
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
background-color: var(--sidebar-bg-color);
|
||||
|
||||
box-shadow: var(--sidebar-shadow);
|
||||
|
||||
&--docked {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -77,52 +36,134 @@
|
||||
border-right: 1px solid var(--sidebar-border-color);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header {
|
||||
// ---------------------------- sidebar header ------------------------------
|
||||
|
||||
.sidebar__header {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--sidebar-border-color);
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header__buttons {
|
||||
.sidebar__header__buttons {
|
||||
gap: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin-left: auto;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { DEFAULT_SIDEBAR } from "../../constants";
|
||||
import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
queryAllByTestId,
|
||||
queryByTestId,
|
||||
render,
|
||||
@@ -10,346 +11,321 @@ import {
|
||||
withExcalidrawDimensions,
|
||||
} 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", () => {
|
||||
it("should render custom sidebar", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
describe("General behavior", () => {
|
||||
it("should render custom sidebar", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||
>
|
||||
<Sidebar name="customSidebar">
|
||||
<div id="test-sidebar-content">42</div>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
</Excalidraw>,
|
||||
);
|
||||
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).not.toBe(null);
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should render custom sidebar header", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
describe("<Sidebar.Header/>", () => {
|
||||
it("should render custom sidebar header", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||
>
|
||||
<Sidebar name="customSidebar">
|
||||
<Sidebar.Header>
|
||||
<div id="test-sidebar-header-content">42</div>
|
||||
</Sidebar.Header>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
</Excalidraw>,
|
||||
);
|
||||
|
||||
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");
|
||||
const node = container.querySelector("#test-sidebar-header-content");
|
||||
expect(node).not.toBe(null);
|
||||
|
||||
// make sure only one sidebar is rendered
|
||||
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
|
||||
expect(sidebars.length).toBe(1);
|
||||
// make sure we don't render the default fallback header,
|
||||
// just the custom one
|
||||
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should always render custom sidebar with close button & close on click", async () => {
|
||||
const onClose = jest.fn();
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar className="test-sidebar" onClose={onClose}>
|
||||
it("should not render <Sidebar.Header> for custom sidebars by default", async () => {
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: { openSidebar: { name: "customSidebar" } },
|
||||
}}
|
||||
>
|
||||
<Sidebar name="customSidebar" className="test-sidebar">
|
||||
hello
|
||||
</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");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).not.toBe(null);
|
||||
});
|
||||
|
||||
// should not show dock button when the sidebar does not fit to be docked
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-close");
|
||||
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={() => (
|
||||
it("<Sidebar.Header> should render close button", async () => {
|
||||
const onStateChange = jest.fn();
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: { openSidebar: { name: "customSidebar" } },
|
||||
}}
|
||||
>
|
||||
<Sidebar
|
||||
name="customSidebar"
|
||||
className="test-sidebar"
|
||||
docked={false}
|
||||
dockable={dockable}
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
hello
|
||||
<Sidebar.Header />
|
||||
</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
</Excalidraw>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
|
||||
// should not show dock button when `dockable` is `false`
|
||||
// -------------------------------------------------------------------------
|
||||
// initial open
|
||||
expect(onStateChange).toHaveBeenCalledWith({ name: "customSidebar" });
|
||||
|
||||
act(() => {
|
||||
_setDockable(false);
|
||||
});
|
||||
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(() => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
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);
|
||||
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(
|
||||
null,
|
||||
);
|
||||
expect(onStateChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should support controlled docking", async () => {
|
||||
let _setDocked: (docked?: boolean) => void = null!;
|
||||
describe("Docking behavior", () => {
|
||||
it("shouldn't be user-dockable if `onDock` not supplied", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<Sidebar name="customSidebar">
|
||||
<Sidebar.Header />
|
||||
</Sidebar>,
|
||||
"customSidebar",
|
||||
async () => {
|
||||
await assertSidebarDockButton(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const CustomExcalidraw = () => {
|
||||
const [docked, setDocked] = React.useState<boolean | undefined>();
|
||||
_setDocked = setDocked;
|
||||
return (
|
||||
it("shouldn't be user-dockable if `onDock` not supplied & `docked={true}`", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<Sidebar name="customSidebar" docked={true}>
|
||||
<Sidebar.Header />
|
||||
</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
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar className="test-sidebar" docked={docked}>
|
||||
hello
|
||||
</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
const { h } = window;
|
||||
|
||||
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>
|
||||
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||
>
|
||||
<Sidebar
|
||||
name="customSidebar"
|
||||
className="test-sidebar"
|
||||
onDock={() => {}}
|
||||
docked
|
||||
>
|
||||
<Sidebar.Header />
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
</Excalidraw>,
|
||||
);
|
||||
|
||||
// sidebar isn't rendered initially
|
||||
// -------------------------------------------------------------------------
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).toBe(null);
|
||||
await withExcalidrawDimensions(
|
||||
{ width: 1920, height: 1080 },
|
||||
async () => {
|
||||
await assertSidebarDockButton(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// toggle sidebar on
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar")).toBe(true);
|
||||
it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||
>
|
||||
<Sidebar
|
||||
name="customSidebar"
|
||||
className="test-sidebar"
|
||||
onDock={() => {}}
|
||||
>
|
||||
<Sidebar.Header />
|
||||
</Sidebar>
|
||||
</Excalidraw>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).not.toBe(null);
|
||||
});
|
||||
|
||||
// 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);
|
||||
await withExcalidrawDimensions(
|
||||
{ width: 1920, height: 1080 },
|
||||
async () => {
|
||||
await assertSidebarDockButton(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+228
-130
@@ -1,151 +1,249 @@
|
||||
import {
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
RefObject,
|
||||
} from "react";
|
||||
import { Island } from ".././Island";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { atom, useSetAtom } from "jotai";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import {
|
||||
SidebarPropsContext,
|
||||
SidebarProps,
|
||||
SidebarPropsContextValue,
|
||||
} from "./common";
|
||||
|
||||
import { SidebarHeaderComponents } from "./SidebarHeader";
|
||||
import { SidebarHeader } from "./SidebarHeader";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
useDevice,
|
||||
useExcalidrawAppState,
|
||||
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 clsx from "clsx";
|
||||
import { useExcalidrawSetAppState } from "../App";
|
||||
import { updateObject } from "../../utils";
|
||||
|
||||
/** using a counter instead of boolean to handle race conditions where
|
||||
* the host app may render (mount/unmount) multiple different sidebar */
|
||||
export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
|
||||
|
||||
export const Sidebar = Object.assign(
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
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 setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const [isDockedFallback, setIsDockedFallback] = useState(
|
||||
docked ?? initialDockedState ?? false,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (docked === undefined) {
|
||||
// ugly hack to get initial state out of AppState without subscribing
|
||||
// to it as a whole (once we have granular subscriptions, we'll move
|
||||
// to that)
|
||||
//
|
||||
// NOTE this means that is updated `state.isSidebarDocked` changes outside
|
||||
// of this compoent, it won't be reflected here. Currently doesn't happen.
|
||||
setAppState((state) => {
|
||||
setIsDockedFallback(state.isSidebarDocked);
|
||||
// bail from update
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}, [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;
|
||||
// FIXME replace this with the implem from ColorPicker once it's merged
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
cb: (event: MouseEvent) => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent) => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Island
|
||||
className={clsx(
|
||||
"layer-ui__sidebar",
|
||||
{ "layer-ui__sidebar--docked": isDockedFallback },
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
||||
<SidebarHeaderComponents.Context>
|
||||
<SidebarHeaderComponents.Component __isFallback />
|
||||
{children}
|
||||
</SidebarHeaderComponents.Context>
|
||||
</SidebarPropsContext.Provider>
|
||||
</Island>
|
||||
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`",
|
||||
);
|
||||
},
|
||||
),
|
||||
{
|
||||
Header: SidebarHeaderComponents.Component,
|
||||
}
|
||||
|
||||
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(
|
||||
forwardRef((props: SidebarProps, ref: React.ForwardedRef<HTMLDivElement>) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
|
||||
const { onStateChange } = props;
|
||||
|
||||
const refPrevOpenSidebar = useRef(appState.openSidebar);
|
||||
useEffect(() => {
|
||||
if (
|
||||
// closing sidebar
|
||||
((!appState.openSidebar &&
|
||||
refPrevOpenSidebar?.current?.name === props.name) ||
|
||||
// opening current sidebar
|
||||
(appState.openSidebar?.name === props.name &&
|
||||
refPrevOpenSidebar?.current?.name !== props.name) ||
|
||||
// switching tabs or switching to a different sidebar
|
||||
refPrevOpenSidebar.current?.name === props.name) &&
|
||||
appState.openSidebar !== refPrevOpenSidebar.current
|
||||
) {
|
||||
onStateChange?.(
|
||||
appState.openSidebar?.name !== props.name
|
||||
? null
|
||||
: appState.openSidebar,
|
||||
);
|
||||
}
|
||||
refPrevOpenSidebar.current = appState.openSidebar;
|
||||
}, [appState.openSidebar, onStateChange, props.name]);
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
// previous sidebar is mounted after the next one). This is necessary to
|
||||
// prevent flicker of subcomponents that support fallbacks
|
||||
// (e.g. SidebarHeader). This is because we're using flags to determine
|
||||
// whether prefer the fallback component or not (otherwise both will render
|
||||
// initially), and the flag won't be reset in time if the unmount order
|
||||
// it not correct.
|
||||
//
|
||||
// Alternative, and more general solution would be to namespace the fallback
|
||||
// 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,
|
||||
TabTriggers: SidebarTabTriggers,
|
||||
TabTrigger: SidebarTabTrigger,
|
||||
Tabs: SidebarTabs,
|
||||
Tab: SidebarTab,
|
||||
Trigger: SidebarTrigger,
|
||||
},
|
||||
);
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
@@ -4,86 +4,54 @@ import { t } from "../../i18n";
|
||||
import { useDevice } from "../App";
|
||||
import { SidebarPropsContext } from "./common";
|
||||
import { CloseIcon, PinIcon } from "../icons";
|
||||
import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
import { Button } from "../Button";
|
||||
|
||||
export const SidebarDockButton = (props: {
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
|
||||
<Tooltip label={t("labels.sidebarLock")}>
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||
`ToolIcon_size_medium`,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const _SidebarHeader: React.FC<{
|
||||
export const SidebarHeader = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className }) => {
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const props = useContext(SidebarPropsContext);
|
||||
|
||||
const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable);
|
||||
const renderCloseButton = !!props.onClose;
|
||||
const renderDockButton = !!(
|
||||
device.canDeviceFitSidebar && props.shouldRenderDockButton
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("layer-ui__sidebar__header", className)}
|
||||
className={clsx("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")}
|
||||
<div className="sidebar__header__buttons">
|
||||
{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")}
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{PinIcon}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
data-testid="sidebar-close"
|
||||
className="sidebar__close"
|
||||
onSelect={props.onCloseRequest}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{CloseIcon}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const [Context, Component] = withUpstreamOverride(_SidebarHeader);
|
||||
|
||||
/** @private */
|
||||
export const SidebarHeaderComponents = { Context, Component };
|
||||
SidebarHeader.displayName = "SidebarHeader";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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";
|
||||
@@ -0,0 +1,26 @@
|
||||
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";
|
||||
@@ -0,0 +1,16 @@
|
||||
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";
|
||||
@@ -0,0 +1,36 @@
|
||||
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";
|
||||
@@ -0,0 +1,34 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useExcalidrawSetAppState, useExcalidrawAppState } from "../App";
|
||||
import { SidebarTriggerProps } from "./common";
|
||||
|
||||
import "./SidebarTrigger.scss";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const SidebarTrigger = ({
|
||||
name,
|
||||
tab,
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
onToggle,
|
||||
className,
|
||||
style,
|
||||
}: SidebarTriggerProps) => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
// TODO replace with sidebar context
|
||||
const appState = useExcalidrawAppState();
|
||||
|
||||
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";
|
||||
@@ -1,23 +1,41 @@
|
||||
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 = {}> = {
|
||||
name: SidebarName;
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Called on sidebar close (either by user action or by the editor).
|
||||
* Called on sidebar open/close or tab change.
|
||||
*/
|
||||
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;
|
||||
docked?: boolean;
|
||||
initialDockedState?: boolean;
|
||||
dockable?: boolean;
|
||||
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;
|
||||
|
||||
export type SidebarPropsContextValue = Pick<
|
||||
SidebarProps,
|
||||
"onClose" | "onDock" | "docked" | "dockable"
|
||||
>;
|
||||
"onDock" | "docked"
|
||||
> & { onCloseRequest: () => void; shouldRenderDockButton: boolean };
|
||||
|
||||
export const SidebarPropsContext =
|
||||
React.createContext<SidebarPropsContextValue>({});
|
||||
React.createContext<SidebarPropsContextValue>({} as SidebarPropsContextValue);
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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;
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
// container in body where the actual tooltip is appended to
|
||||
.excalidraw-tooltip {
|
||||
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: var(--ui-font);
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
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;
|
||||
@@ -5,59 +5,46 @@ exports[`Test <App/> should show error modal when using brave and measureText AP
|
||||
data-testid="brave-measure-text-error"
|
||||
>
|
||||
<p>
|
||||
Looks like you are using Brave browser with the
|
||||
|
||||
Looks like you are using Brave browser with the
|
||||
<span
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Aggressively Block Fingerprinting
|
||||
</span>
|
||||
|
||||
setting enabled
|
||||
.
|
||||
<br />
|
||||
<br />
|
||||
This could result in breaking the
|
||||
|
||||
setting enabled.
|
||||
</p>
|
||||
<p>
|
||||
This could result in breaking the
|
||||
<span
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Text Elements
|
||||
</span>
|
||||
|
||||
in your drawings
|
||||
.
|
||||
in your drawings.
|
||||
</p>
|
||||
<p>
|
||||
We strongly recommend disabling this setting. You can follow
|
||||
|
||||
We strongly recommend disabling this setting. You can follow
|
||||
<a
|
||||
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
|
||||
>
|
||||
|
||||
these steps
|
||||
</a>
|
||||
|
||||
on how to do so
|
||||
.
|
||||
on how to do so.
|
||||
</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
|
||||
href="https://github.com/excalidraw/excalidraw/issues/new"
|
||||
>
|
||||
issue
|
||||
</a>
|
||||
|
||||
on our GitHub, or write us on
|
||||
|
||||
on our GitHub, or write us on
|
||||
<a
|
||||
href="https://discord.gg/UexuTaE"
|
||||
>
|
||||
Discord
|
||||
.
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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 { useOutsideClickHook } from "../../hooks/useOutsideClick";
|
||||
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
||||
import { Island } from "../Island";
|
||||
|
||||
import { useDevice } from "../App";
|
||||
@@ -24,7 +24,7 @@ const MenuContent = ({
|
||||
style?: React.CSSProperties;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const menuRef = useOutsideClickHook(() => {
|
||||
const menuRef = useOutsideClick(() => {
|
||||
onClickOutside?.();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ZoomActions,
|
||||
} from "../Actions";
|
||||
import { useDevice } from "../App";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import { HelpButton } from "../HelpButton";
|
||||
import { Section } from "../Section";
|
||||
import Stack from "../Stack";
|
||||
@@ -25,7 +25,7 @@ const Footer = ({
|
||||
showExitZenModeBtn: boolean;
|
||||
renderWelcomeScreen: boolean;
|
||||
}) => {
|
||||
const { footerCenterTunnel, welcomeScreenHelpHintTunnel } = useTunnels();
|
||||
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
|
||||
|
||||
const device = useDevice();
|
||||
const showFinalize =
|
||||
@@ -70,14 +70,14 @@ const Footer = ({
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
<footerCenterTunnel.Out />
|
||||
<FooterCenterTunnel.Out />
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
|
||||
"transition-right disable-pointerEvents": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
{renderWelcomeScreen && <welcomeScreenHelpHintTunnel.Out />}
|
||||
{renderWelcomeScreen && <WelcomeScreenHelpHintTunnel.Out />}
|
||||
<HelpButton
|
||||
onClick={() => actionManager.executeAction(actionShortcuts)}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawAppState } from "../App";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import "./FooterCenter.scss";
|
||||
|
||||
const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { footerCenterTunnel } = useTunnels();
|
||||
const { FooterCenterTunnel } = useTunnels();
|
||||
const appState = useExcalidrawAppState();
|
||||
return (
|
||||
<footerCenterTunnel.In>
|
||||
<FooterCenterTunnel.In>
|
||||
<div
|
||||
className={clsx("footer-center zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
@@ -16,7 +16,7 @@ const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</footerCenterTunnel.In>
|
||||
</FooterCenterTunnel.In>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import React, { useLayoutEffect } from "react";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
|
||||
export const withInternalFallback = <P,>(
|
||||
componentName: string,
|
||||
Component: React.FC<P>,
|
||||
) => {
|
||||
const counterAtom = atom(0);
|
||||
const renderAtom = atom(0);
|
||||
// flag set on initial render to tell the fallback component to skip the
|
||||
// render until mount counter are initialized. This is because the counter
|
||||
// is initialized in an effect, and thus we could end rendering both
|
||||
// components at the same time until counter is initialized.
|
||||
let preferHost = false;
|
||||
|
||||
let counter = 0;
|
||||
|
||||
const WrapperComponent: React.FC<
|
||||
P & {
|
||||
__fallback?: boolean;
|
||||
}
|
||||
> = (props) => {
|
||||
const { jotaiScope } = useTunnels();
|
||||
const [counter, setCounter] = useAtom(counterAtom, jotaiScope);
|
||||
const [, setRender] = useAtom(renderAtom, jotaiScope);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setCounter((counter) => counter + 1);
|
||||
setRender((c) => {
|
||||
const next = c + 1;
|
||||
counter = next;
|
||||
|
||||
return next;
|
||||
});
|
||||
return () => {
|
||||
setCounter((counter) => counter - 1);
|
||||
setRender((c) => {
|
||||
const next = c - 1;
|
||||
counter = next;
|
||||
if (!next) {
|
||||
preferHost = false;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
}, [setCounter]);
|
||||
}, [setRender]);
|
||||
|
||||
if (!props.__fallback) {
|
||||
preferHost = true;
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1008,6 +1008,13 @@ 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(
|
||||
<>
|
||||
<path
|
||||
|
||||
@@ -13,7 +13,7 @@ import { t } from "../../i18n";
|
||||
import { HamburgerMenuIcon } from "../icons";
|
||||
import { withInternalFallback } from "../hoc/withInternalFallback";
|
||||
import { composeEventHandlers } from "../../utils";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
|
||||
const MainMenu = Object.assign(
|
||||
withInternalFallback(
|
||||
@@ -28,7 +28,7 @@ const MainMenu = Object.assign(
|
||||
*/
|
||||
onSelect?: (event: Event) => void;
|
||||
}) => {
|
||||
const { mainMenuTunnel } = useTunnels();
|
||||
const { MainMenuTunnel } = useTunnels();
|
||||
const device = useDevice();
|
||||
const appState = useExcalidrawAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
@@ -37,7 +37,7 @@ const MainMenu = Object.assign(
|
||||
: () => setAppState({ openMenu: null });
|
||||
|
||||
return (
|
||||
<mainMenuTunnel.In>
|
||||
<MainMenuTunnel.In>
|
||||
<DropdownMenu open={appState.openMenu === "canvas"}>
|
||||
<DropdownMenu.Trigger
|
||||
onToggle={() => {
|
||||
@@ -66,7 +66,7 @@ const MainMenu = Object.assign(
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</mainMenuTunnel.In>
|
||||
</MainMenuTunnel.In>
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useExcalidrawActionManager,
|
||||
useExcalidrawAppState,
|
||||
} from "../App";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
|
||||
|
||||
const WelcomeScreenMenuItemContent = ({
|
||||
@@ -89,9 +89,9 @@ const WelcomeScreenMenuItemLink = ({
|
||||
WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink";
|
||||
|
||||
const Center = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { welcomeScreenCenterTunnel } = useTunnels();
|
||||
const { WelcomeScreenCenterTunnel } = useTunnels();
|
||||
return (
|
||||
<welcomeScreenCenterTunnel.In>
|
||||
<WelcomeScreenCenterTunnel.In>
|
||||
<div className="welcome-screen-center">
|
||||
{children || (
|
||||
<>
|
||||
@@ -104,7 +104,7 @@ const Center = ({ children }: { children?: React.ReactNode }) => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</welcomeScreenCenterTunnel.In>
|
||||
</WelcomeScreenCenterTunnel.In>
|
||||
);
|
||||
};
|
||||
Center.displayName = "Center";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../i18n";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import {
|
||||
WelcomeScreenHelpArrow,
|
||||
WelcomeScreenMenuArrow,
|
||||
@@ -7,44 +7,44 @@ import {
|
||||
} from "../icons";
|
||||
|
||||
const MenuHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { welcomeScreenMenuHintTunnel } = useTunnels();
|
||||
const { WelcomeScreenMenuHintTunnel } = useTunnels();
|
||||
return (
|
||||
<welcomeScreenMenuHintTunnel.In>
|
||||
<WelcomeScreenMenuHintTunnel.In>
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
|
||||
{WelcomeScreenMenuArrow}
|
||||
<div className="welcome-screen-decor-hint__label">
|
||||
{children || t("welcomeScreen.defaults.menuHint")}
|
||||
</div>
|
||||
</div>
|
||||
</welcomeScreenMenuHintTunnel.In>
|
||||
</WelcomeScreenMenuHintTunnel.In>
|
||||
);
|
||||
};
|
||||
MenuHint.displayName = "MenuHint";
|
||||
|
||||
const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { welcomeScreenToolbarHintTunnel } = useTunnels();
|
||||
const { WelcomeScreenToolbarHintTunnel } = useTunnels();
|
||||
return (
|
||||
<welcomeScreenToolbarHintTunnel.In>
|
||||
<WelcomeScreenToolbarHintTunnel.In>
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
|
||||
<div className="welcome-screen-decor-hint__label">
|
||||
{children || t("welcomeScreen.defaults.toolbarHint")}
|
||||
</div>
|
||||
{WelcomeScreenTopToolbarArrow}
|
||||
</div>
|
||||
</welcomeScreenToolbarHintTunnel.In>
|
||||
</WelcomeScreenToolbarHintTunnel.In>
|
||||
);
|
||||
};
|
||||
ToolbarHint.displayName = "ToolbarHint";
|
||||
|
||||
const HelpHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { welcomeScreenHelpHintTunnel } = useTunnels();
|
||||
const { WelcomeScreenHelpHintTunnel } = useTunnels();
|
||||
return (
|
||||
<welcomeScreenHelpHintTunnel.In>
|
||||
<WelcomeScreenHelpHintTunnel.In>
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
|
||||
<div>{children || t("welcomeScreen.defaults.helpHint")}</div>
|
||||
{WelcomeScreenHelpArrow}
|
||||
</div>
|
||||
</welcomeScreenHelpHintTunnel.In>
|
||||
</WelcomeScreenHelpHintTunnel.In>
|
||||
);
|
||||
};
|
||||
HelpHint.displayName = "HelpHint";
|
||||
|
||||
+45
-17
@@ -1,6 +1,7 @@
|
||||
import cssVariables from "./css/variables.module.scss";
|
||||
import { AppProps } from "./types";
|
||||
import { FontFamilyValues } from "./element/types";
|
||||
import { ExcalidrawElement, FontFamilyValues } from "./element/types";
|
||||
import oc from "open-color";
|
||||
|
||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
export const isWindows = /^Win/.test(navigator.platform);
|
||||
@@ -104,20 +105,30 @@ export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||
|
||||
export const GRID_SIZE = 20; // TODO make it configurable?
|
||||
|
||||
export const MIME_TYPES = {
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
json: "application/json",
|
||||
export const IMAGE_MIME_TYPES = {
|
||||
svg: "image/svg+xml",
|
||||
"excalidraw.svg": "image/svg+xml",
|
||||
png: "image/png",
|
||||
"excalidraw.png": "image/png",
|
||||
jpg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
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",
|
||||
// image
|
||||
...IMAGE_MIME_TYPES,
|
||||
} as const;
|
||||
|
||||
export const EXPORT_DATA_TYPES = {
|
||||
@@ -188,16 +199,6 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
|
||||
|
||||
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 SVG_NS = "http://www.w3.org/2000/svg";
|
||||
@@ -254,3 +255,30 @@ export const ROUNDNESS = {
|
||||
/** key containt id of precedeing elemnt id we use in reconciliation during
|
||||
* collaboration */
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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(),
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import React from "react";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const UIAppStateContext = React.createContext<AppState>(null!);
|
||||
export const useUIAppState = () => React.useContext(UIAppStateContext);
|
||||
+11
-1
@@ -155,12 +155,20 @@
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item:focus-visible,
|
||||
.dropdown-menu-item:focus-visible,
|
||||
button:focus-visible,
|
||||
.buttonList label:focus-within,
|
||||
input:focus-visible {
|
||||
outline: transparent;
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.buttonList {
|
||||
.ToolIcon__icon {
|
||||
all: unset !important;
|
||||
@@ -351,6 +359,7 @@
|
||||
border-radius: var(--space-factor);
|
||||
border: 1px solid var(--button-gray-2);
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background-image: var(--dropdown-icon);
|
||||
@@ -410,6 +419,7 @@
|
||||
bottom: 30px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: all;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
@@ -562,7 +572,7 @@
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.library-button {
|
||||
.default-sidebar-trigger {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +78,13 @@
|
||||
|
||||
--color-selection: #6965db;
|
||||
|
||||
--color-icon-white: #{$oc-white};
|
||||
|
||||
--color-primary: #6965db;
|
||||
--color-primary-darker: #5b57d1;
|
||||
--color-primary-darkest: #4a47b1;
|
||||
--color-primary-light: #e3e2fe;
|
||||
--color-primary-light-darker: #d7d5ff;
|
||||
|
||||
--color-gray-10: #f5f5f5;
|
||||
--color-gray-20: #ebebeb;
|
||||
@@ -161,10 +164,13 @@
|
||||
// will be inverted to a lighter color.
|
||||
--color-selection: #3530c4;
|
||||
|
||||
--color-icon-white: var(--color-gray-90);
|
||||
|
||||
--color-primary: #a8a5ff;
|
||||
--color-primary-darker: #b2aeff;
|
||||
--color-primary-darkest: #beb9ff;
|
||||
--color-primary-light: #4f4d6f;
|
||||
--color-primary-light-darker: #43415e;
|
||||
|
||||
--color-text-warning: var(--color-gray-80);
|
||||
|
||||
|
||||
@@ -72,7 +72,14 @@
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg, var(--island-bg-color));
|
||||
border-color: var(--button-hover-border, var(--default-border-color));
|
||||
border-color: var(
|
||||
--button-hover-border,
|
||||
var(--button-border, var(--default-border-color))
|
||||
);
|
||||
color: var(
|
||||
--button-hover-color,
|
||||
var(--button-color, var(--text-primary-color, inherit))
|
||||
);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -81,11 +88,14 @@
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-primary-light);
|
||||
border-color: var(--color-primary-light);
|
||||
background-color: var(--button-selected-bg, var(--color-primary-light));
|
||||
border-color: var(--button-selected-border, var(--color-primary-light));
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-light);
|
||||
background-color: var(
|
||||
--button-selected-hover-bg,
|
||||
var(--color-primary-light)
|
||||
);
|
||||
}
|
||||
|
||||
svg {
|
||||
|
||||
+4
-6
@@ -1,6 +1,6 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||
import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement, FileId } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
@@ -117,11 +117,9 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
|
||||
|
||||
export const isSupportedImageFile = (
|
||||
blob: Blob | null | undefined,
|
||||
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
|
||||
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
|
||||
const { type } = blob || {};
|
||||
return (
|
||||
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
|
||||
);
|
||||
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
|
||||
};
|
||||
|
||||
export const loadSceneOrLibraryFromBlob = async (
|
||||
@@ -157,7 +155,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
{ repairBindings: true, refreshDimensions: true },
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
),
|
||||
};
|
||||
} else if (isValidLibrary(data)) {
|
||||
|
||||
+1
-10
@@ -8,16 +8,7 @@ import { EVENT, MIME_TYPES } from "../constants";
|
||||
import { AbortError } from "../errors";
|
||||
import { debounce } from "../utils";
|
||||
|
||||
type FILE_EXTENSION =
|
||||
| "gif"
|
||||
| "jpg"
|
||||
| "png"
|
||||
| "excalidraw.png"
|
||||
| "svg"
|
||||
| "excalidraw.svg"
|
||||
| "json"
|
||||
| "excalidraw"
|
||||
| "excalidrawlib";
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
|
||||
|
||||
+3
-1
@@ -89,7 +89,9 @@ export const exportCanvas = async (
|
||||
return await fileSave(blob, {
|
||||
description: "Export to PNG",
|
||||
name,
|
||||
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
|
||||
// FIXME reintroduce `excalidraw.png` when most people upgrade away
|
||||
// from 111.0.5563.64 (arm64), see #6349
|
||||
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
|
||||
fileHandle,
|
||||
});
|
||||
} else if (type === "clipboard") {
|
||||
|
||||
+18
-4
@@ -14,7 +14,14 @@ import { getCommonBoundingBox } from "../element/bounds";
|
||||
import { AbortError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants";
|
||||
import {
|
||||
URL_HASH_KEYS,
|
||||
URL_QUERY_KEYS,
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
DEFAULT_SIDEBAR,
|
||||
LIBRARY_SIDEBAR_TAB,
|
||||
} from "../constants";
|
||||
|
||||
export const libraryItemsAtom = atom<{
|
||||
status: "loading" | "loaded";
|
||||
@@ -148,7 +155,9 @@ class Library {
|
||||
defaultStatus?: "unpublished" | "published";
|
||||
}): Promise<LibraryItems> => {
|
||||
if (openLibraryMenu) {
|
||||
this.app.setState({ openSidebar: "library" });
|
||||
this.app.setState({
|
||||
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB },
|
||||
});
|
||||
}
|
||||
|
||||
return this.setLibrary(() => {
|
||||
@@ -174,6 +183,13 @@ 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) {
|
||||
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
|
||||
} else {
|
||||
@@ -186,8 +202,6 @@ class Library {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}).finally(() => {
|
||||
this.app.focusContainer();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+54
-25
@@ -27,14 +27,20 @@ import {
|
||||
PRECEDING_ELEMENT_KEY,
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
DEFAULT_SIDEBAR,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { bumpVersion } from "../element/mutateElement";
|
||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { arrayToMap } from "../utils";
|
||||
import oc from "open-color";
|
||||
import { MarkOptional, Mutable } from "../utility-types";
|
||||
import {
|
||||
detectLineHeight,
|
||||
getDefaultLineHeight,
|
||||
measureBaseline,
|
||||
} from "../element/textElement";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@@ -165,17 +171,40 @@ const restoreElement = (
|
||||
const [fontPx, _fontFamily]: [string, string] = (
|
||||
element as any
|
||||
).font.split(" ");
|
||||
fontSize = parseInt(fontPx, 10);
|
||||
fontSize = parseFloat(fontPx);
|
||||
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, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
text: element.text ?? "",
|
||||
text,
|
||||
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||
containerId: element.containerId ?? null,
|
||||
originalText: element.originalText || element.text,
|
||||
originalText: element.originalText || text,
|
||||
|
||||
lineHeight,
|
||||
baseline,
|
||||
});
|
||||
|
||||
if (refreshDimensions) {
|
||||
@@ -341,6 +370,9 @@ export const restoreElements = (
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
||||
): ExcalidrawElement[] => {
|
||||
// used to detect duplicate top-level element ids
|
||||
const existingIds = new Set<string>();
|
||||
|
||||
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||
const restoredElements = (elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
@@ -355,6 +387,10 @@ export const restoreElements = (
|
||||
if (localElement && localElement.version > migratedElement.version) {
|
||||
migratedElement = bumpVersion(migratedElement, localElement.version);
|
||||
}
|
||||
if (existingIds.has(migratedElement.id)) {
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
}
|
||||
@@ -396,21 +432,15 @@ const LegacyAppStateMigrations: {
|
||||
defaultAppState: ReturnType<typeof getDefaultAppState>,
|
||||
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
|
||||
} = {
|
||||
isLibraryOpen: (appState, defaultAppState) => {
|
||||
isSidebarDocked: (appState, defaultAppState) => {
|
||||
return [
|
||||
"openSidebar",
|
||||
"isLibraryOpen" in appState
|
||||
? appState.isLibraryOpen
|
||||
? "library"
|
||||
: null
|
||||
: coalesceAppStateValue("openSidebar", appState, defaultAppState),
|
||||
];
|
||||
},
|
||||
isLibraryMenuDocked: (appState, defaultAppState) => {
|
||||
return [
|
||||
"isSidebarDocked",
|
||||
appState.isLibraryMenuDocked ??
|
||||
coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
|
||||
"defaultSidebarDockedPreference",
|
||||
appState.isSidebarDocked ??
|
||||
coalesceAppStateValue(
|
||||
"defaultSidebarDockedPreference",
|
||||
appState,
|
||||
defaultAppState,
|
||||
),
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -479,14 +509,13 @@ export const restoreAppState = (
|
||||
? {
|
||||
value: appState.zoom as NormalizedZoomValue,
|
||||
}
|
||||
: appState.zoom || defaultAppState.zoom,
|
||||
// when sidebar docked and user left it open in last session,
|
||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||
: appState.zoom?.value
|
||||
? appState.zoom
|
||||
: defaultAppState.zoom,
|
||||
openSidebar:
|
||||
nextAppState.openSidebar === "library"
|
||||
? nextAppState.isSidebarDocked
|
||||
? "library"
|
||||
: null
|
||||
// string (legacy)
|
||||
typeof (appState.openSidebar as any as string) === "string"
|
||||
? { name: DEFAULT_SIDEBAR.name }
|
||||
: nextAppState.openSidebar,
|
||||
};
|
||||
};
|
||||
|
||||
+2
-4
@@ -25,10 +25,8 @@ export interface ExportedDataState {
|
||||
* Don't consume on its own.
|
||||
*/
|
||||
export type LegacyAppState = {
|
||||
/** @deprecated #5663 TODO remove 22-12-15 */
|
||||
isLibraryOpen: [boolean, "openSidebar"];
|
||||
/** @deprecated #5663 TODO remove 22-12-15 */
|
||||
isLibraryMenuDocked: [boolean, "isSidebarDocked"];
|
||||
/** @deprecated #6213 TODO remove 23-06-01 */
|
||||
isSidebarDocked: [boolean, "defaultSidebarDockedPreference"];
|
||||
};
|
||||
|
||||
export interface ImportedDataState {
|
||||
|
||||
@@ -786,7 +786,12 @@ export const findFocusPointForEllipse = (
|
||||
orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
|
||||
squares;
|
||||
|
||||
const n = (-m * px - 1) / py;
|
||||
let 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);
|
||||
return GA.point(x, (-m * x - 1) / n);
|
||||
|
||||
+339
-64
@@ -1,8 +1,9 @@
|
||||
import { duplicateElement } from "./newElement";
|
||||
import { duplicateElement, duplicateElements } from "./newElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { FONT_FAMILY, ROUNDNESS } from "../constants";
|
||||
import { isPrimitive } from "../utils";
|
||||
import { ExcalidrawLinearElement } from "./types";
|
||||
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
@@ -15,79 +16,353 @@ const assertCloneObjects = (source: any, clone: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
it("clones arrow element", () => {
|
||||
const element = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
describe("duplicating single elements", () => {
|
||||
it("clones arrow element", () => {
|
||||
const element = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
element.__proto__ = { hello: "world" };
|
||||
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
// assert we clone the object's prototype
|
||||
// @ts-ignore
|
||||
expect(copy.__proto__).toEqual({ hello: "world" });
|
||||
expect(copy.hasOwnProperty("hello")).toBe(false);
|
||||
|
||||
expect(copy.points).not.toBe(element.points);
|
||||
expect(copy).not.toHaveProperty("shape");
|
||||
expect(copy.id).not.toBe(element.id);
|
||||
expect(typeof copy.id).toBe("string");
|
||||
expect(copy.seed).not.toBe(element.seed);
|
||||
expect(typeof copy.seed).toBe("number");
|
||||
expect(copy).toEqual({
|
||||
...element,
|
||||
id: copy.id,
|
||||
seed: copy.seed,
|
||||
});
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
element.__proto__ = { hello: "world" };
|
||||
it("clones text element", () => {
|
||||
const element = API.createElement({
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roundness: null,
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
text: "hello",
|
||||
fontSize: 20,
|
||||
fontFamily: FONT_FAMILY.Virgil,
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
});
|
||||
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
});
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
// @ts-ignore
|
||||
expect(copy.__proto__).toEqual({ hello: "world" });
|
||||
expect(copy.hasOwnProperty("hello")).toBe(false);
|
||||
|
||||
expect(copy.points).not.toBe(element.points);
|
||||
expect(copy).not.toHaveProperty("shape");
|
||||
expect(copy.id).not.toBe(element.id);
|
||||
expect(typeof copy.id).toBe("string");
|
||||
expect(copy.seed).not.toBe(element.seed);
|
||||
expect(typeof copy.seed).toBe("number");
|
||||
expect(copy).toEqual({
|
||||
...element,
|
||||
id: copy.id,
|
||||
seed: copy.seed,
|
||||
expect(copy).not.toHaveProperty("points");
|
||||
expect(copy).not.toHaveProperty("shape");
|
||||
expect(copy.id).not.toBe(element.id);
|
||||
expect(typeof copy.id).toBe("string");
|
||||
expect(typeof copy.seed).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
it("clones text element", () => {
|
||||
const element = API.createElement({
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roundness: null,
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
text: "hello",
|
||||
fontSize: 20,
|
||||
fontFamily: FONT_FAMILY.Virgil,
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
describe("duplicating multiple elements", () => {
|
||||
it("duplicateElements should clone bindings", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rectangle1",
|
||||
boundElements: [
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
{ id: "arrow2", type: "arrow" },
|
||||
{ id: "text1", type: "text" },
|
||||
],
|
||||
});
|
||||
|
||||
const text1 = API.createElement({
|
||||
type: "text",
|
||||
id: "text1",
|
||||
containerId: "rectangle1",
|
||||
});
|
||||
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
boundElements: [{ id: "text2", type: "text" }],
|
||||
});
|
||||
|
||||
const text2 = API.createElement({
|
||||
type: "text",
|
||||
id: "text2",
|
||||
containerId: "arrow2",
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
|
||||
const clonedElements = duplicateElements(origElements);
|
||||
|
||||
// generic id in-equality checks
|
||||
// --------------------------------------------------------------------------
|
||||
expect(origElements.map((e) => e.type)).toEqual(
|
||||
clonedElements.map((e) => e.type),
|
||||
);
|
||||
origElements.forEach((origElement, idx) => {
|
||||
const clonedElement = clonedElements[idx];
|
||||
expect(origElement).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.not.stringMatching(clonedElement.id),
|
||||
type: clonedElement.type,
|
||||
}),
|
||||
);
|
||||
if ("containerId" in origElement) {
|
||||
expect(origElement.containerId).not.toBe(
|
||||
(clonedElement as any).containerId,
|
||||
);
|
||||
}
|
||||
if ("endBinding" in origElement) {
|
||||
if (origElement.endBinding) {
|
||||
expect(origElement.endBinding.elementId).not.toBe(
|
||||
(clonedElement as any).endBinding?.elementId,
|
||||
);
|
||||
} else {
|
||||
expect((clonedElement as any).endBinding).toBeNull();
|
||||
}
|
||||
}
|
||||
if ("startBinding" in origElement) {
|
||||
if (origElement.startBinding) {
|
||||
expect(origElement.startBinding.elementId).not.toBe(
|
||||
(clonedElement as any).startBinding?.elementId,
|
||||
);
|
||||
} else {
|
||||
expect((clonedElement as any).startBinding).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const clonedArrows = clonedElements.filter(
|
||||
(e) => e.type === "arrow",
|
||||
) as ExcalidrawLinearElement[];
|
||||
|
||||
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
|
||||
clonedElements as any as typeof origElements;
|
||||
|
||||
expect(clonedText1.containerId).toBe(clonedRectangle.id);
|
||||
expect(
|
||||
clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
id: clonedText1.id,
|
||||
type: clonedText1.type,
|
||||
}),
|
||||
);
|
||||
|
||||
clonedArrows.forEach((arrow) => {
|
||||
// console.log(arrow);
|
||||
expect(
|
||||
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
id: arrow.id,
|
||||
type: arrow.type,
|
||||
}),
|
||||
);
|
||||
|
||||
if (arrow.endBinding) {
|
||||
expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
|
||||
}
|
||||
if (arrow.startBinding) {
|
||||
expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
|
||||
}
|
||||
});
|
||||
|
||||
expect(clonedArrow2.boundElements).toEqual([
|
||||
{ type: "text", id: clonedArrowLabel.id },
|
||||
]);
|
||||
expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
it("should remove id references of elements that aren't found", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rectangle1",
|
||||
boundElements: [
|
||||
// should keep
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
// should drop
|
||||
{ id: "arrow-not-exists", type: "arrow" },
|
||||
// should drop
|
||||
{ id: "text-not-exists", type: "text" },
|
||||
],
|
||||
});
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(copy).not.toHaveProperty("points");
|
||||
expect(copy).not.toHaveProperty("shape");
|
||||
expect(copy.id).not.toBe(element.id);
|
||||
expect(typeof copy.id).toBe("string");
|
||||
expect(typeof copy.seed).toBe("number");
|
||||
const text1 = API.createElement({
|
||||
type: "text",
|
||||
id: "text1",
|
||||
containerId: "rectangle-not-exists",
|
||||
});
|
||||
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const arrow3 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
startBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
||||
const clonedElements = duplicateElements(
|
||||
origElements,
|
||||
) as any as typeof origElements;
|
||||
const [
|
||||
clonedRectangle,
|
||||
clonedText1,
|
||||
clonedArrow1,
|
||||
clonedArrow2,
|
||||
clonedArrow3,
|
||||
] = clonedElements;
|
||||
|
||||
expect(clonedRectangle.boundElements).toEqual([
|
||||
{ id: clonedArrow1.id, type: "arrow" },
|
||||
]);
|
||||
|
||||
expect(clonedText1.containerId).toBe(null);
|
||||
|
||||
expect(clonedArrow2.startBinding).toEqual({
|
||||
...arrow2.startBinding,
|
||||
elementId: clonedRectangle.id,
|
||||
});
|
||||
expect(clonedArrow2.endBinding).toBe(null);
|
||||
|
||||
expect(clonedArrow3.startBinding).toBe(null);
|
||||
expect(clonedArrow3.endBinding).toEqual({
|
||||
...arrow3.endBinding,
|
||||
elementId: clonedRectangle.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe("should duplicate all group ids", () => {
|
||||
it("should regenerate all group ids and keep them consistent across elements", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g2", "g1"],
|
||||
});
|
||||
const rectangle3 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g2", "g1"],
|
||||
});
|
||||
|
||||
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
||||
const clonedElements = duplicateElements(
|
||||
origElements,
|
||||
) as any as typeof origElements;
|
||||
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
||||
clonedElements;
|
||||
|
||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
|
||||
expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
|
||||
|
||||
expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
|
||||
expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
|
||||
expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
|
||||
});
|
||||
|
||||
it("should keep and regenerate ids of groups even if invalid", () => {
|
||||
// lone element shouldn't be able to be grouped with itself,
|
||||
// but hard to check against in a performant way so we ignore it
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
|
||||
const [clonedRectangle1] = duplicateElements([rectangle1]);
|
||||
|
||||
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
|
||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+261
-51
@@ -13,9 +13,14 @@ import {
|
||||
FontFamilyValues,
|
||||
ExcalidrawTextContainer,
|
||||
} from "../element/types";
|
||||
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
|
||||
import {
|
||||
arrayToMap,
|
||||
getFontString,
|
||||
getUpdatedTimestamp,
|
||||
isTestEnv,
|
||||
} from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
@@ -28,9 +33,17 @@ import {
|
||||
measureText,
|
||||
normalizeText,
|
||||
wrapText,
|
||||
getMaxContainerWidth,
|
||||
getBoundTextMaxWidth,
|
||||
getDefaultLineHeight,
|
||||
} from "./textElement";
|
||||
import { VERTICAL_ALIGN } from "../constants";
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
|
||||
@@ -45,6 +58,15 @@ type ElementConstructorOpts = MarkOptional<
|
||||
| "version"
|
||||
| "versionNonce"
|
||||
| "link"
|
||||
| "strokeStyle"
|
||||
| "fillStyle"
|
||||
| "strokeColor"
|
||||
| "backgroundColor"
|
||||
| "roughness"
|
||||
| "strokeWidth"
|
||||
| "roundness"
|
||||
| "locked"
|
||||
| "opacity"
|
||||
>;
|
||||
|
||||
const _newElementBase = <T extends ExcalidrawElement>(
|
||||
@@ -52,13 +74,13 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
{
|
||||
x,
|
||||
y,
|
||||
strokeColor,
|
||||
backgroundColor,
|
||||
fillStyle,
|
||||
strokeWidth,
|
||||
strokeStyle,
|
||||
roughness,
|
||||
opacity,
|
||||
strokeColor = DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
backgroundColor = DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||
fillStyle = DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||
strokeWidth = DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
strokeStyle = DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
roughness = DEFAULT_ELEMENT_PROPS.roughness,
|
||||
opacity = DEFAULT_ELEMENT_PROPS.opacity,
|
||||
width = 0,
|
||||
height = 0,
|
||||
angle = 0,
|
||||
@@ -66,7 +88,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
roundness = null,
|
||||
boundElements = null,
|
||||
link = null,
|
||||
locked,
|
||||
locked = DEFAULT_ELEMENT_PROPS.locked,
|
||||
...rest
|
||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||
) => {
|
||||
@@ -132,30 +154,47 @@ const getTextElementPositionOffsets = (
|
||||
export const newTextElement = (
|
||||
opts: {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
fontFamily: FontFamilyValues;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"];
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const text = normalizeText(opts.text);
|
||||
const metrics = measureText(text, getFontString(opts));
|
||||
const offsets = getTextElementPositionOffsets(opts, metrics);
|
||||
const metrics = measureText(
|
||||
text,
|
||||
getFontString({ fontFamily, fontSize }),
|
||||
lineHeight,
|
||||
);
|
||||
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
|
||||
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
|
||||
const offsets = getTextElementPositionOffsets(
|
||||
{ textAlign, verticalAlign },
|
||||
metrics,
|
||||
);
|
||||
|
||||
const textElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||
text,
|
||||
fontSize: opts.fontSize,
|
||||
fontFamily: opts.fontFamily,
|
||||
textAlign: opts.textAlign,
|
||||
verticalAlign: opts.verticalAlign,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
x: opts.x - offsets.x,
|
||||
y: opts.y - offsets.y,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
},
|
||||
{},
|
||||
);
|
||||
@@ -170,13 +209,15 @@ const getAdjustedDimensions = (
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
baseline: number;
|
||||
} => {
|
||||
const container = getContainerElement(element);
|
||||
|
||||
const { width: nextWidth, height: nextHeight } = measureText(
|
||||
nextText,
|
||||
getFontString(element),
|
||||
);
|
||||
const {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseline,
|
||||
} = measureText(nextText, getFontString(element), element.lineHeight);
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
let y: number;
|
||||
@@ -185,7 +226,11 @@ const getAdjustedDimensions = (
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||
!element.containerId
|
||||
) {
|
||||
const prevMetrics = measureText(element.text, getFontString(element));
|
||||
const prevMetrics = measureText(
|
||||
element.text,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
width: nextWidth - prevMetrics.width,
|
||||
height: nextHeight - prevMetrics.height,
|
||||
@@ -247,6 +292,7 @@ const getAdjustedDimensions = (
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseline,
|
||||
x: Number.isFinite(x) ? x : element.x,
|
||||
y: Number.isFinite(y) ? y : element.y,
|
||||
};
|
||||
@@ -256,12 +302,15 @@ export const refreshTextDimensions = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
text = textElement.text,
|
||||
) => {
|
||||
if (textElement.isDeleted) {
|
||||
return;
|
||||
}
|
||||
const container = getContainerElement(textElement);
|
||||
if (container) {
|
||||
text = wrapText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(container),
|
||||
getBoundTextMaxWidth(container),
|
||||
);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, text);
|
||||
@@ -341,16 +390,24 @@ export const newImageElement = (
|
||||
};
|
||||
};
|
||||
|
||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
|
||||
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
|
||||
//
|
||||
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||
// Typed arrays and other non-null objects.
|
||||
//
|
||||
// Adapted from https://github.com/lukeed/klona
|
||||
export const deepCopyElement = (val: any, depth: number = 0) => {
|
||||
//
|
||||
// The reason for `deepCopyElement()` wrapper is type safety (only allow
|
||||
// passing ExcalidrawElement as the top-level argument).
|
||||
const _deepCopyElement = (val: any, depth: number = 0) => {
|
||||
// only clone non-primitives
|
||||
if (val == null || typeof val !== "object") {
|
||||
return val;
|
||||
}
|
||||
|
||||
if (Object.prototype.toString.call(val) === "[object Object]") {
|
||||
const objectType = Object.prototype.toString.call(val);
|
||||
|
||||
if (objectType === "[object Object]") {
|
||||
const tmp =
|
||||
typeof val.constructor === "function"
|
||||
? Object.create(Object.getPrototypeOf(val))
|
||||
@@ -362,7 +419,7 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
|
||||
if (depth === 0 && (key === "shape" || key === "canvas")) {
|
||||
continue;
|
||||
}
|
||||
tmp[key] = deepCopyElement(val[key], depth + 1);
|
||||
tmp[key] = _deepCopyElement(val[key], depth + 1);
|
||||
}
|
||||
}
|
||||
return tmp;
|
||||
@@ -372,14 +429,67 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
|
||||
let k = val.length;
|
||||
const arr = new Array(k);
|
||||
while (k--) {
|
||||
arr[k] = deepCopyElement(val[k], depth + 1);
|
||||
arr[k] = _deepCopyElement(val[k], depth + 1);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// we're not cloning non-array & non-plain-object objects because we
|
||||
// don't support them on excalidraw elements yet. If we do, we need to make
|
||||
// sure we start cloning them, so let's warn about it.
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
if (
|
||||
objectType !== "[object Object]" &&
|
||||
objectType !== "[object Array]" &&
|
||||
objectType.startsWith("[object ")
|
||||
) {
|
||||
console.warn(
|
||||
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
|
||||
* any value. The purpose is to to break object references for immutability
|
||||
* reasons, whenever we want to keep the original element, but ensure it's not
|
||||
* mutated.
|
||||
*
|
||||
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||
* Typed arrays and other non-null objects.
|
||||
*/
|
||||
export const deepCopyElement = <T extends ExcalidrawElement>(
|
||||
val: T,
|
||||
): Mutable<T> => {
|
||||
return _deepCopyElement(val);
|
||||
};
|
||||
|
||||
/**
|
||||
* utility wrapper to generate new id. In test env it reuses the old + postfix
|
||||
* for test assertions.
|
||||
*/
|
||||
const regenerateId = (
|
||||
/** supply null if no previous id exists */
|
||||
previousId: string | null,
|
||||
) => {
|
||||
if (isTestEnv() && previousId) {
|
||||
let nextId = `${previousId}_copy`;
|
||||
// `window.h` may not be defined in some unit tests
|
||||
if (
|
||||
window.h?.app
|
||||
?.getSceneElementsIncludingDeleted()
|
||||
.find((el) => el.id === nextId)
|
||||
) {
|
||||
nextId += "_copy";
|
||||
}
|
||||
return nextId;
|
||||
}
|
||||
return randomId();
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicate an element, often used in the alt-drag operation.
|
||||
* Note that this method has gotten a bit complicated since the
|
||||
@@ -394,27 +504,15 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
|
||||
* @param element Element to duplicate
|
||||
* @param overrides Any element properties to override
|
||||
*/
|
||||
export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
editingGroupId: AppState["editingGroupId"],
|
||||
groupIdMapForOperation: Map<GroupId, GroupId>,
|
||||
element: TElement,
|
||||
overrides?: Partial<TElement>,
|
||||
): TElement => {
|
||||
let copy: TElement = deepCopyElement(element);
|
||||
): Readonly<TElement> => {
|
||||
let copy = deepCopyElement(element);
|
||||
|
||||
if (isTestEnv()) {
|
||||
copy.id = `${copy.id}_copy`;
|
||||
// `window.h` may not be defined in some unit tests
|
||||
if (
|
||||
window.h?.app
|
||||
?.getSceneElementsIncludingDeleted()
|
||||
.find((el) => el.id === copy.id)
|
||||
) {
|
||||
copy.id += "_copy";
|
||||
}
|
||||
} else {
|
||||
copy.id = randomId();
|
||||
}
|
||||
copy.id = regenerateId(copy.id);
|
||||
copy.boundElements = null;
|
||||
copy.updated = getUpdatedTimestamp();
|
||||
copy.seed = randomInteger();
|
||||
@@ -423,7 +521,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
editingGroupId,
|
||||
(groupId) => {
|
||||
if (!groupIdMapForOperation.has(groupId)) {
|
||||
groupIdMapForOperation.set(groupId, randomId());
|
||||
groupIdMapForOperation.set(groupId, regenerateId(groupId));
|
||||
}
|
||||
return groupIdMapForOperation.get(groupId)!;
|
||||
},
|
||||
@@ -433,3 +531,115 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
}
|
||||
return copy;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clones elements, regenerating their ids (including bindings) and group ids.
|
||||
*
|
||||
* If bindings don't exist in the elements array, they are removed. Therefore,
|
||||
* it's advised to supply the whole elements array, or sets of elements that
|
||||
* are encapsulated (such as library items), if the purpose is to retain
|
||||
* bindings to the cloned elements intact.
|
||||
*
|
||||
* NOTE by default does not randomize or regenerate anything except the id.
|
||||
*/
|
||||
export const duplicateElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
opts?: {
|
||||
/** NOTE also updates version flags and `updated` */
|
||||
randomizeSeed: boolean;
|
||||
},
|
||||
) => {
|
||||
const clonedElements: ExcalidrawElement[] = [];
|
||||
|
||||
const origElementsMap = arrayToMap(elements);
|
||||
|
||||
// used for for migrating old ids to new ids
|
||||
const elementNewIdsMap = new Map<
|
||||
/* orig */ ExcalidrawElement["id"],
|
||||
/* new */ ExcalidrawElement["id"]
|
||||
>();
|
||||
|
||||
const maybeGetNewId = (id: ExcalidrawElement["id"]) => {
|
||||
// if we've already migrated the element id, return the new one directly
|
||||
if (elementNewIdsMap.has(id)) {
|
||||
return elementNewIdsMap.get(id)!;
|
||||
}
|
||||
// if we haven't migrated the element id, but an old element with the same
|
||||
// id exists, generate a new id for it and return it
|
||||
if (origElementsMap.has(id)) {
|
||||
const newId = regenerateId(id);
|
||||
elementNewIdsMap.set(id, newId);
|
||||
return newId;
|
||||
}
|
||||
// if old element doesn't exist, return null to mark it for removal
|
||||
return null;
|
||||
};
|
||||
|
||||
const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>();
|
||||
|
||||
for (const element of elements) {
|
||||
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
|
||||
|
||||
clonedElement.id = maybeGetNewId(element.id)!;
|
||||
|
||||
if (opts?.randomizeSeed) {
|
||||
clonedElement.seed = randomInteger();
|
||||
bumpVersion(clonedElement);
|
||||
}
|
||||
|
||||
if (clonedElement.groupIds) {
|
||||
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
||||
if (!groupNewIdsMap.has(groupId)) {
|
||||
groupNewIdsMap.set(groupId, regenerateId(groupId));
|
||||
}
|
||||
return groupNewIdsMap.get(groupId)!;
|
||||
});
|
||||
}
|
||||
|
||||
if ("containerId" in clonedElement && clonedElement.containerId) {
|
||||
const newContainerId = maybeGetNewId(clonedElement.containerId);
|
||||
clonedElement.containerId = newContainerId;
|
||||
}
|
||||
|
||||
if ("boundElements" in clonedElement && clonedElement.boundElements) {
|
||||
clonedElement.boundElements = clonedElement.boundElements.reduce(
|
||||
(
|
||||
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
|
||||
binding,
|
||||
) => {
|
||||
const newBindingId = maybeGetNewId(binding.id);
|
||||
if (newBindingId) {
|
||||
acc.push({ ...binding, id: newBindingId });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
if ("endBinding" in clonedElement && clonedElement.endBinding) {
|
||||
const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId);
|
||||
clonedElement.endBinding = newEndBindingId
|
||||
? {
|
||||
...clonedElement.endBinding,
|
||||
elementId: newEndBindingId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if ("startBinding" in clonedElement && clonedElement.startBinding) {
|
||||
const newEndBindingId = maybeGetNewId(
|
||||
clonedElement.startBinding.elementId,
|
||||
);
|
||||
clonedElement.startBinding = newEndBindingId
|
||||
? {
|
||||
...clonedElement.startBinding,
|
||||
elementId: newEndBindingId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
clonedElements.push(clonedElement);
|
||||
}
|
||||
|
||||
return clonedElements;
|
||||
};
|
||||
|
||||
@@ -39,13 +39,15 @@ import {
|
||||
import { Point, PointerDownState } from "../types";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getMaxContainerWidth,
|
||||
getBoundTextMaxWidth,
|
||||
getApproxMinLineHeight,
|
||||
measureText,
|
||||
getBoundTextMaxHeight,
|
||||
} from "./textElement";
|
||||
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
@@ -193,7 +195,8 @@ const MIN_FONT_SIZE = 1;
|
||||
const measureFontSizeFromWidth = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
nextWidth: number,
|
||||
): number | null => {
|
||||
nextHeight: number,
|
||||
): { size: number; baseline: number } | null => {
|
||||
// We only use width to scale font on resize
|
||||
let width = element.width;
|
||||
|
||||
@@ -201,15 +204,22 @@ const measureFontSizeFromWidth = (
|
||||
if (hasContainer) {
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
width = getMaxContainerWidth(container);
|
||||
width = getBoundTextMaxWidth(container);
|
||||
}
|
||||
}
|
||||
const nextFontSize = element.fontSize * (nextWidth / width);
|
||||
if (nextFontSize < MIN_FONT_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nextFontSize;
|
||||
const metrics = measureText(
|
||||
element.text,
|
||||
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
|
||||
element.lineHeight,
|
||||
);
|
||||
return {
|
||||
size: nextFontSize,
|
||||
baseline: metrics.baseline + (nextHeight - metrics.height),
|
||||
};
|
||||
};
|
||||
|
||||
const getSidesForTransformHandle = (
|
||||
@@ -280,8 +290,8 @@ const resizeSingleTextElement = (
|
||||
if (scale > 0) {
|
||||
const nextWidth = element.width * scale;
|
||||
const nextHeight = element.height * scale;
|
||||
const nextFontSize = measureFontSizeFromWidth(element, nextWidth);
|
||||
if (nextFontSize === null) {
|
||||
const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight);
|
||||
if (metrics === null) {
|
||||
return;
|
||||
}
|
||||
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
||||
@@ -305,9 +315,10 @@ const resizeSingleTextElement = (
|
||||
deltaY2,
|
||||
);
|
||||
mutateElement(element, {
|
||||
fontSize: nextFontSize,
|
||||
fontSize: metrics.size,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: metrics.baseline,
|
||||
x: nextElementX,
|
||||
y: nextElementY,
|
||||
});
|
||||
@@ -360,7 +371,7 @@ export const resizeSingleElement = (
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
let boundTextFont: { fontSize?: number; baseline?: number } = {};
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
@@ -412,6 +423,7 @@ export const resizeSingleElement = (
|
||||
if (stateOfBoundTextElementAtResize) {
|
||||
boundTextFont = {
|
||||
fontSize: stateOfBoundTextElementAtResize.fontSize,
|
||||
baseline: stateOfBoundTextElementAtResize.baseline,
|
||||
};
|
||||
}
|
||||
if (shouldMaintainAspectRatio) {
|
||||
@@ -421,19 +433,27 @@ export const resizeSingleElement = (
|
||||
height: eleNewHeight,
|
||||
};
|
||||
|
||||
const nextFontSize = measureFontSizeFromWidth(
|
||||
const nextFont = measureFontSizeFromWidth(
|
||||
boundTextElement,
|
||||
getMaxContainerWidth(updatedElement),
|
||||
getBoundTextMaxWidth(updatedElement),
|
||||
getBoundTextMaxHeight(updatedElement, boundTextElement),
|
||||
);
|
||||
if (nextFontSize === null) {
|
||||
if (nextFont === null) {
|
||||
return;
|
||||
}
|
||||
boundTextFont = {
|
||||
fontSize: nextFontSize,
|
||||
fontSize: nextFont.size,
|
||||
baseline: nextFont.baseline,
|
||||
};
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||
const minHeight = getApproxMinLineHeight(getFontString(boundTextElement));
|
||||
const minWidth = getApproxMinLineWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
|
||||
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
|
||||
}
|
||||
@@ -566,8 +586,11 @@ export const resizeSingleElement = (
|
||||
});
|
||||
|
||||
mutateElement(element, resizedElement);
|
||||
if (boundTextElement && boundTextFont) {
|
||||
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
baseline: boundTextFont.baseline,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(element, transformHandleDirection);
|
||||
}
|
||||
@@ -673,6 +696,7 @@ const resizeMultipleElements = (
|
||||
y: number;
|
||||
points?: Point[];
|
||||
fontSize?: number;
|
||||
baseline?: number;
|
||||
} = {
|
||||
width,
|
||||
height,
|
||||
@@ -681,7 +705,7 @@ const resizeMultipleElements = (
|
||||
...rescaledPoints,
|
||||
};
|
||||
|
||||
let boundTextUpdates: { fontSize: number } | null = null;
|
||||
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
|
||||
|
||||
const boundTextElement = getBoundTextElement(element.latest);
|
||||
|
||||
@@ -691,24 +715,29 @@ const resizeMultipleElements = (
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const fontSize = measureFontSizeFromWidth(
|
||||
const metrics = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||
boundTextElement
|
||||
? getMaxContainerWidth(updatedElement)
|
||||
? getBoundTextMaxWidth(updatedElement)
|
||||
: updatedElement.width,
|
||||
boundTextElement
|
||||
? getBoundTextMaxHeight(updatedElement, boundTextElement)
|
||||
: updatedElement.height,
|
||||
);
|
||||
|
||||
if (!fontSize) {
|
||||
if (!metrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextElement(element.orig)) {
|
||||
update.fontSize = fontSize;
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
boundTextUpdates = {
|
||||
fontSize,
|
||||
fontSize: metrics.size,
|
||||
baseline: metrics.baseline,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+154
-29
@@ -1,13 +1,17 @@
|
||||
import { BOUND_TEXT_PADDING } from "../constants";
|
||||
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getContainerCoords,
|
||||
getMaxContainerWidth,
|
||||
getMaxContainerHeight,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
wrapText,
|
||||
detectLineHeight,
|
||||
getLineHeightInPx,
|
||||
getDefaultLineHeight,
|
||||
parseTokens,
|
||||
} from "./textElement";
|
||||
import { FontString } from "./types";
|
||||
import { ExcalidrawTextElementWithContainer, FontString } from "./types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
@@ -40,9 +44,7 @@ describe("Test wrapText", () => {
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
res: `Hello \nwhats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
@@ -64,8 +66,7 @@ p`,
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 140,
|
||||
res: `Hello whats
|
||||
up`,
|
||||
res: `Hello whats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
@@ -95,9 +96,7 @@ whats up`;
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
res: `Hello\nwhats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
@@ -143,11 +142,7 @@ whats up`,
|
||||
{
|
||||
desc: "fit characters of long string as per container width",
|
||||
width: 170,
|
||||
res: `hellolongtextth
|
||||
isiswhatsupwith
|
||||
youIamtypingggg
|
||||
gandtypinggg
|
||||
break it now`,
|
||||
res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
|
||||
},
|
||||
|
||||
{
|
||||
@@ -166,8 +161,7 @@ now`,
|
||||
desc: "fit the long text when container width is greater than text length and move the rest to next line",
|
||||
|
||||
width: 600,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg
|
||||
break it now`,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
@@ -181,8 +175,64 @@ break it now`,
|
||||
const text = "Hello Excalidraw";
|
||||
// Length of "Excalidraw" is 100 and exacty equal to max width
|
||||
const res = wrapText(text, font, 100);
|
||||
expect(res).toEqual(`Hello
|
||||
Excalidraw`);
|
||||
expect(res).toEqual(`Hello \nExcalidraw`);
|
||||
});
|
||||
|
||||
it("should return the text as is if max width is invalid", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
expect(wrapText(text, font, NaN)).toEqual(text);
|
||||
expect(wrapText(text, font, -1)).toEqual(text);
|
||||
expect(wrapText(text, font, Infinity)).toEqual(text);
|
||||
});
|
||||
|
||||
it("should wrap the text correctly when text contains hyphen", () => {
|
||||
let text =
|
||||
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||
const res = wrapText(text, font, 110);
|
||||
expect(res).toBe(
|
||||
`Wikipedia \nis hosted \nby \nWikimedia-\nFoundation,\na non-\nprofit \norganizati\non that \nalso hosts\na range-of\nother \nprojects`,
|
||||
);
|
||||
|
||||
text = "Hello thereusing-now";
|
||||
expect(wrapText(text, font, 100)).toEqual("Hello \nthereusin\ng-now");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test parseTokens", () => {
|
||||
it("should split into tokens correctly", () => {
|
||||
let text = "Excalidraw is a virtual collaborative whiteboard";
|
||||
expect(parseTokens(text)).toEqual([
|
||||
"Excalidraw",
|
||||
"is",
|
||||
"a",
|
||||
"virtual",
|
||||
"collaborative",
|
||||
"whiteboard",
|
||||
]);
|
||||
|
||||
text =
|
||||
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||
expect(parseTokens(text)).toEqual([
|
||||
"Wikipedia",
|
||||
"is",
|
||||
"hosted",
|
||||
"by",
|
||||
"Wikimedia-",
|
||||
"",
|
||||
"Foundation,",
|
||||
"a",
|
||||
"non-",
|
||||
"profit",
|
||||
"organization",
|
||||
"that",
|
||||
"also",
|
||||
"hosts",
|
||||
"a",
|
||||
"range-",
|
||||
"of",
|
||||
"other",
|
||||
"projects",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,7 +311,7 @@ describe("Test measureText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getMaxContainerWidth", () => {
|
||||
describe("Test getBoundTextMaxWidth", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
@@ -269,39 +319,114 @@ describe("Test measureText", () => {
|
||||
|
||||
it("should return max width when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(168);
|
||||
expect(getBoundTextMaxWidth(container)).toBe(168);
|
||||
});
|
||||
|
||||
it("should return max width when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(116);
|
||||
expect(getBoundTextMaxWidth(container)).toBe(116);
|
||||
});
|
||||
|
||||
it("should return max width when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(79);
|
||||
expect(getBoundTextMaxWidth(container)).toBe(79);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getMaxContainerHeight", () => {
|
||||
describe("Test getBoundTextMaxHeight", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
id: '"container-id',
|
||||
};
|
||||
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
id: "text-id",
|
||||
x: 560.51171875,
|
||||
y: 202.033203125,
|
||||
width: 154,
|
||||
height: 175,
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
containerId: params.id,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
it("should return max height when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(184);
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
|
||||
});
|
||||
|
||||
it("should return max height when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(127);
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
|
||||
});
|
||||
|
||||
it("should return max height when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(87);
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
});
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow and height is less than threshold", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
height: 70,
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
});
|
||||
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
|
||||
boundTextElement.height,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
height: 175,
|
||||
});
|
||||
|
||||
describe("Test detectLineHeight", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(detectLineHeight(textElement)).toBe(1.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getLineHeightInPx", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(
|
||||
getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
|
||||
).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getDefaultLineHeight", () => {
|
||||
it("should return line height using default font family when not passed", () => {
|
||||
//@ts-ignore
|
||||
expect(getDefaultLineHeight()).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return line height using default font family for unknown font", () => {
|
||||
const UNKNOWN_FONT = 5;
|
||||
expect(getDefaultLineHeight(UNKNOWN_FONT)).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return correct line height", () => {
|
||||
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
||||
|
||||
+238
-88
@@ -4,6 +4,7 @@ import {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
isSafari,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
@@ -41,24 +44,28 @@ export const normalizeText = (text: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const splitIntoLines = (text: string) => {
|
||||
return normalizeText(text).split("\n");
|
||||
};
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawElement | null,
|
||||
) => {
|
||||
let maxWidth = undefined;
|
||||
|
||||
const boundTextUpdates = {
|
||||
x: textElement.x,
|
||||
y: textElement.y,
|
||||
text: textElement.text,
|
||||
width: textElement.width,
|
||||
height: textElement.height,
|
||||
baseline: textElement.baseline,
|
||||
};
|
||||
|
||||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
boundTextUpdates.text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
@@ -68,41 +75,36 @@ export const redrawTextBoundingBox = (
|
||||
const metrics = measureText(
|
||||
boundTextUpdates.text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
|
||||
boundTextUpdates.width = metrics.width;
|
||||
boundTextUpdates.height = metrics.height;
|
||||
boundTextUpdates.baseline = metrics.baseline;
|
||||
|
||||
if (container) {
|
||||
if (isArrowElement(container)) {
|
||||
const centerX = textElement.x + textElement.width / 2;
|
||||
const centerY = textElement.y + textElement.height / 2;
|
||||
const diffWidth = metrics.width - textElement.width;
|
||||
const diffHeight = metrics.height - textElement.height;
|
||||
boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
|
||||
boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
|
||||
} else {
|
||||
const containerDims = getContainerDims(container);
|
||||
let maxContainerHeight = getMaxContainerHeight(container);
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
|
||||
let nextHeight = containerDims.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
maxContainerHeight = getMaxContainerHeight(container);
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
|
||||
boundTextUpdates.x = x;
|
||||
boundTextUpdates.y = y;
|
||||
let nextHeight = containerDims.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
|
||||
boundTextUpdates.x = x;
|
||||
boundTextUpdates.y = y;
|
||||
}
|
||||
|
||||
mutateElement(textElement, boundTextUpdates);
|
||||
@@ -174,9 +176,13 @@ export const handleBindTextResize = (
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxWidth = getMaxContainerWidth(container);
|
||||
const maxHeight = getMaxContainerHeight(container);
|
||||
const maxWidth = getBoundTextMaxWidth(container);
|
||||
const maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
let containerHeight = containerDims.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
@@ -185,9 +191,14 @@ export const handleBindTextResize = (
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
const dimensions = measureText(text, getFontString(textElement));
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
const metrics = measureText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
nextHeight = metrics.height;
|
||||
nextWidth = metrics.width;
|
||||
nextBaseLine = metrics.baseline;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (nextHeight > maxHeight) {
|
||||
@@ -215,6 +226,7 @@ export const handleBindTextResize = (
|
||||
text,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseLine,
|
||||
});
|
||||
|
||||
if (!isArrowElement(container)) {
|
||||
@@ -229,13 +241,19 @@ export const handleBindTextResize = (
|
||||
}
|
||||
};
|
||||
|
||||
const computeBoundTextPosition = (
|
||||
export const computeBoundTextPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
);
|
||||
}
|
||||
const containerCoords = getContainerCoords(container);
|
||||
const maxContainerHeight = getMaxContainerHeight(container);
|
||||
const maxContainerWidth = getMaxContainerWidth(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||
|
||||
let x;
|
||||
let y;
|
||||
@@ -261,32 +279,103 @@ const computeBoundTextPosition = (
|
||||
|
||||
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||
|
||||
export const measureText = (text: string, font: FontString) => {
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
|
||||
const height = getTextHeight(text, font);
|
||||
const fontSize = parseFloat(font);
|
||||
const height = getTextHeight(text, fontSize, lineHeight);
|
||||
const width = getTextWidth(text, font);
|
||||
|
||||
return { width, height };
|
||||
const baseline = measureBaseline(text, font, lineHeight);
|
||||
return { width, height, baseline };
|
||||
};
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
const cacheApproxLineHeight: { [key: FontString]: number } = {};
|
||||
|
||||
export const getApproxLineHeight = (font: FontString) => {
|
||||
if (cacheApproxLineHeight[font]) {
|
||||
return cacheApproxLineHeight[font];
|
||||
export const measureBaseline = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
wrapInContainer?: boolean,
|
||||
) => {
|
||||
const container = document.createElement("div");
|
||||
container.style.position = "absolute";
|
||||
container.style.whiteSpace = "pre";
|
||||
container.style.font = font;
|
||||
container.style.minHeight = "1em";
|
||||
if (wrapInContainer) {
|
||||
container.style.overflow = "hidden";
|
||||
container.style.wordBreak = "break-word";
|
||||
container.style.whiteSpace = "pre-wrap";
|
||||
}
|
||||
const fontSize = parseInt(font);
|
||||
|
||||
// Calculate line height relative to font size
|
||||
cacheApproxLineHeight[font] = fontSize * 1.2;
|
||||
return cacheApproxLineHeight[font];
|
||||
container.style.lineHeight = String(lineHeight);
|
||||
|
||||
container.innerText = text;
|
||||
|
||||
// Baseline is important for positioning text on canvas
|
||||
document.body.appendChild(container);
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.style.display = "inline-block";
|
||||
span.style.overflow = "hidden";
|
||||
span.style.width = "1px";
|
||||
span.style.height = "1px";
|
||||
container.appendChild(span);
|
||||
let baseline = span.offsetTop + span.offsetHeight;
|
||||
const height = container.offsetHeight;
|
||||
|
||||
if (isSafari) {
|
||||
const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight);
|
||||
const fontSize = parseFloat(font);
|
||||
// In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs
|
||||
// from the actual canvas height
|
||||
const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight);
|
||||
if (canvasHeight > height) {
|
||||
baseline += canvasHeight - domHeight;
|
||||
}
|
||||
|
||||
if (height > canvasHeight) {
|
||||
baseline -= domHeight - canvasHeight;
|
||||
}
|
||||
}
|
||||
document.body.removeChild(container);
|
||||
return baseline;
|
||||
};
|
||||
|
||||
/**
|
||||
* To get unitless line-height (if unknown) we can calculate it by dividing
|
||||
* height-per-line by fontSize.
|
||||
*/
|
||||
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
||||
const lineCount = splitIntoLines(textElement.text).length;
|
||||
return (textElement.height /
|
||||
lineCount /
|
||||
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
||||
};
|
||||
|
||||
/**
|
||||
* We calculate the line height from the font size and the unitless line height,
|
||||
* aligning with the W3C spec.
|
||||
*/
|
||||
export const getLineHeightInPx = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return fontSize * lineHeight;
|
||||
};
|
||||
|
||||
// FIXME rename to getApproxMinContainerHeight
|
||||
export const getApproxMinLineHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
@@ -309,7 +398,7 @@ const getLineWidth = (text: string, font: FontString) => {
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
@@ -317,13 +406,41 @@ export const getTextWidth = (text: string, font: FontString) => {
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextHeight = (text: string, font: FontString) => {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = getApproxLineHeight(font);
|
||||
return lineHeight * lines.length;
|
||||
export const getTextHeight = (
|
||||
text: string,
|
||||
fontSize: number,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const lineCount = splitIntoLines(text).length;
|
||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||
};
|
||||
|
||||
export const parseTokens = (text: string) => {
|
||||
// Splitting words containing "-" as those are treated as separate words
|
||||
// by css wrapping algorithm eg non-profit => non-, profit
|
||||
const words = text.split("-");
|
||||
if (words.length > 1) {
|
||||
// non-proft org => ['non-', 'profit org']
|
||||
words.forEach((word, index) => {
|
||||
if (index !== words.length - 1) {
|
||||
words[index] = word += "-";
|
||||
}
|
||||
});
|
||||
}
|
||||
// Joining the words with space and splitting them again with space to get the
|
||||
// final list of tokens
|
||||
// ['non-', 'profit org'] =>,'non- proft org' => ['non-','profit','org']
|
||||
return words.join(" ").split(" ");
|
||||
};
|
||||
|
||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||
// computation, we need to make sure we don't continue as we'll end up
|
||||
// in an infinite loop
|
||||
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const spaceWidth = getLineWidth(" ", font);
|
||||
@@ -341,17 +458,16 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
};
|
||||
|
||||
originalLines.forEach((originalLine) => {
|
||||
const currentLineWidth = getTextWidth(originalLine, font);
|
||||
|
||||
//Push the line if its <= maxWidth
|
||||
// Push the line if its <= maxWidth
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
return; // continue
|
||||
}
|
||||
const words = originalLine.split(" ");
|
||||
|
||||
const words = parseTokens(originalLine);
|
||||
resetParams();
|
||||
|
||||
let index = 0;
|
||||
@@ -369,6 +485,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
else if (currentWordWidth > maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
|
||||
push(currentLine);
|
||||
|
||||
resetParams();
|
||||
@@ -389,15 +506,15 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
currentLine += currentChar;
|
||||
}
|
||||
}
|
||||
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
} else {
|
||||
// space needs to be appended before next word
|
||||
// as currentLine contains chars which couldn't be appended
|
||||
// to previous line
|
||||
// to previous line unless the line ends with hyphen to sync
|
||||
// with css word-wrap
|
||||
} else if (!currentLine.endsWith("-")) {
|
||||
currentLine += " ";
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
}
|
||||
@@ -415,12 +532,23 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
currentLine += `${word} `;
|
||||
|
||||
// if word ends with "-" then we don't need to add space
|
||||
// to sync with css word-wrap
|
||||
const shouldAppendSpace = !word.endsWith("-");
|
||||
currentLine += word;
|
||||
|
||||
if (shouldAppendSpace) {
|
||||
currentLine += " ";
|
||||
}
|
||||
|
||||
// Push the word if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
const word = currentLine.slice(0, -1);
|
||||
push(word);
|
||||
if (shouldAppendSpace) {
|
||||
lines.push(currentLine.slice(0, -1));
|
||||
} else {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
resetParams();
|
||||
break;
|
||||
}
|
||||
@@ -461,21 +589,23 @@ export const charWidth = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
export const getApproxMinLineWidth = (font: FontString) => {
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
|
||||
// FIXME rename to getApproxMinContainerWidth
|
||||
export const getApproxMinLineWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||
BOUND_TEXT_PADDING * 2
|
||||
);
|
||||
}
|
||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getApproxMinLineHeight = (font: FontString) => {
|
||||
return getApproxLineHeight(font) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMinCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
@@ -756,18 +886,10 @@ export const computeContainerDimensionForBoundText = (
|
||||
return dimension + padding;
|
||||
};
|
||||
|
||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
if (isArrowElement(container)) {
|
||||
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerWidth <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.width;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return containerWidth;
|
||||
return width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
@@ -784,16 +906,15 @@ export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
export const getBoundTextMaxHeight = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const height = getContainerDims(container).height;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.height;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
return boundTextElement.height;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
@@ -821,3 +942,32 @@ export const isMeasureTextSupported = () => {
|
||||
);
|
||||
return width > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unitless line height
|
||||
*
|
||||
* In previous versions we used `normal` line height, which browsers interpret
|
||||
* differently, and based on font-family and font-size.
|
||||
*
|
||||
* To make line heights consistent across browsers we hardcode the values for
|
||||
* each of our fonts based on most common average line-heights.
|
||||
* See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
|
||||
* where the values come from.
|
||||
*/
|
||||
const DEFAULT_LINE_HEIGHT = {
|
||||
// ~1.25 is the average for Virgil in WebKit and Blink.
|
||||
// Gecko (FF) uses ~1.28.
|
||||
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.15 is the average for Virgil in WebKit and Blink.
|
||||
// Gecko if all over the place.
|
||||
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too
|
||||
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
|
||||
};
|
||||
|
||||
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
||||
if (fontFamily in DEFAULT_LINE_HEIGHT) {
|
||||
return DEFAULT_LINE_HEIGHT[fontFamily];
|
||||
}
|
||||
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
|
||||
};
|
||||
|
||||
@@ -526,6 +526,74 @@ describe("textWysiwyg", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should set the text element angle to same as container angle when binding to rotated container", async () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 90,
|
||||
height: 75,
|
||||
angle: 45,
|
||||
});
|
||||
h.elements = [rectangle];
|
||||
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
expect(text.angle).toBe(rectangle.angle);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should compute the container height correctly and not throw error when height is updated while editing the text", async () => {
|
||||
const diamond = API.createElement({
|
||||
type: "diamond",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 90,
|
||||
height: 75,
|
||||
});
|
||||
h.elements = [diamond];
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(diamond.id);
|
||||
|
||||
API.setSelectedElements([diamond]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const value = new Array(1000).fill("1").join("\n");
|
||||
|
||||
// Pasting large text to simulate height increase
|
||||
expect(() =>
|
||||
fireEvent.input(editor, { target: { value } }),
|
||||
).not.toThrow();
|
||||
|
||||
expect(diamond.height).toBe(50020);
|
||||
|
||||
// Clearing text to simulate height decrease
|
||||
expect(() =>
|
||||
fireEvent.input(editor, { target: { value: "" } }),
|
||||
).not.toThrow();
|
||||
|
||||
expect(diamond.height).toBe(70);
|
||||
});
|
||||
|
||||
it("should bind text to container when double clicked on center of transparent container", async () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
@@ -702,6 +770,52 @@ describe("textWysiwyg", () => {
|
||||
expect(rectangle.boundElements).toBe(null);
|
||||
});
|
||||
|
||||
it("should bind text to container when triggered via context menu", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(rectangle.id);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
|
||||
API.setSelectedElements([h.elements[0], h.elements[1]]);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.click(
|
||||
queryByText(contextMenu as HTMLElement, "Bind text to the container")!,
|
||||
);
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: h.elements[1].id, type: "text" },
|
||||
]);
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
expect(text.verticalAlign).toBe(VERTICAL_ALIGN.MIDDLE);
|
||||
expect(text.textAlign).toBe(TEXT_ALIGN.CENTER);
|
||||
expect(text.x).toBe(
|
||||
h.elements[0].x + h.elements[0].width / 2 - text.width / 2,
|
||||
);
|
||||
expect(text.y).toBe(
|
||||
h.elements[0].y + h.elements[0].height / 2 - text.height / 2,
|
||||
);
|
||||
});
|
||||
|
||||
it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
@@ -783,7 +897,7 @@ describe("textWysiwyg", () => {
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
);
|
||||
expect(text.x).toBe(25);
|
||||
expect(text.height).toBe(48);
|
||||
expect(text.height).toBe(50);
|
||||
expect(text.width).toBe(60);
|
||||
|
||||
// Edit and text by removing second line and it should
|
||||
@@ -810,7 +924,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
expect(text.text).toBe("Hello");
|
||||
expect(text.originalText).toBe("Hello");
|
||||
expect(text.height).toBe(24);
|
||||
expect(text.height).toBe(25);
|
||||
expect(text.width).toBe(50);
|
||||
expect(text.y).toBe(
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
@@ -903,7 +1017,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
85,
|
||||
5,
|
||||
4.5,
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -929,7 +1043,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -1067,9 +1181,9 @@ describe("textWysiwyg", () => {
|
||||
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
|
||||
mouse.up(rectangle.x + 100, rectangle.y + 50);
|
||||
expect(rectangle.x).toBe(80);
|
||||
expect(rectangle.y).toBe(-35);
|
||||
expect(rectangle.y).toBe(-40);
|
||||
expect(text.x).toBe(85);
|
||||
expect(text.y).toBe(-30);
|
||||
expect(text.y).toBe(-35);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.Z);
|
||||
@@ -1112,7 +1226,7 @@ describe("textWysiwyg", () => {
|
||||
target: { value: "Online whiteboard collaboration made easy" },
|
||||
});
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(178);
|
||||
expect(rectangle.height).toBe(185);
|
||||
mouse.select(rectangle);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
@@ -1181,9 +1295,42 @@ describe("textWysiwyg", () => {
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
|
||||
).toEqual(36);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(
|
||||
96.39999999999999,
|
||||
);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97);
|
||||
});
|
||||
|
||||
it("should update line height when font family updated", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
editor.blur();
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.25);
|
||||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Cascadia);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.2);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/normal/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Helvetica);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.15);
|
||||
});
|
||||
|
||||
describe("should align correctly", () => {
|
||||
@@ -1245,7 +1392,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1257,7 +1404,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
30,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1269,7 +1416,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
45,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1281,7 +1428,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1292,7 +1439,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
30,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1303,7 +1450,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
45,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1333,7 +1480,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(textElement.width).toBe(600);
|
||||
expect(textElement.height).toBe(24);
|
||||
expect(textElement.height).toBe(25);
|
||||
expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT);
|
||||
expect((textElement as ExcalidrawTextElement).text).toBe(
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
@@ -1365,7 +1512,7 @@ describe("textWysiwyg", () => {
|
||||
],
|
||||
fillStyle: "hachure",
|
||||
groupIds: [],
|
||||
height: 34,
|
||||
height: 35,
|
||||
isDeleted: false,
|
||||
link: null,
|
||||
locked: false,
|
||||
@@ -1389,7 +1536,7 @@ describe("textWysiwyg", () => {
|
||||
expect.objectContaining({
|
||||
text: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.LEFT,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
boundElements: null,
|
||||
}),
|
||||
);
|
||||
|
||||
+40
-25
@@ -11,7 +11,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, VERTICAL_ALIGN } from "../constants";
|
||||
import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElementId,
|
||||
getContainerCoords,
|
||||
getContainerDims,
|
||||
@@ -33,8 +32,10 @@ import {
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
wrapText,
|
||||
getMaxContainerHeight,
|
||||
getMaxContainerWidth,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
computeContainerDimensionForBoundText,
|
||||
detectLineHeight,
|
||||
} from "./textElement";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
@@ -150,9 +151,7 @@ export const textWysiwyg = ({
|
||||
return;
|
||||
}
|
||||
const { textAlign, verticalAlign } = updatedTextElement;
|
||||
const approxLineHeight = getApproxLineHeight(
|
||||
getFontString(updatedTextElement),
|
||||
);
|
||||
|
||||
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||
let coordX = updatedTextElement.x;
|
||||
let coordY = updatedTextElement.y;
|
||||
@@ -206,16 +205,20 @@ export const textWysiwyg = ({
|
||||
}
|
||||
}
|
||||
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
maxHeight = getMaxContainerHeight(container);
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
|
||||
// autogrow container height if text exceeds
|
||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||
const diff = Math.min(
|
||||
textElementHeight - maxHeight,
|
||||
approxLineHeight,
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
textElementHeight,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height + diff });
|
||||
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
return;
|
||||
} else if (
|
||||
// autoshrink container height until original container height
|
||||
@@ -224,11 +227,11 @@ export const textWysiwyg = ({
|
||||
containerDims.height > originalContainerData.height &&
|
||||
textElementHeight < maxHeight
|
||||
) {
|
||||
const diff = Math.min(
|
||||
maxHeight - textElementHeight,
|
||||
approxLineHeight,
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
textElementHeight,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height - diff });
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
@@ -266,23 +269,30 @@ export const textWysiwyg = ({
|
||||
editable.selectionEnd = editable.value.length - diff;
|
||||
}
|
||||
|
||||
const lines = updatedTextElement.originalText.split("\n");
|
||||
const lineHeight = updatedTextElement.containerId
|
||||
? approxLineHeight
|
||||
: updatedTextElement.height / lines.length;
|
||||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
textElementWidth = Math.min(textElementWidth, maxWidth);
|
||||
} else {
|
||||
textElementWidth += 0.5;
|
||||
}
|
||||
|
||||
let lineHeight = updatedTextElement.lineHeight;
|
||||
|
||||
// In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size
|
||||
if (isSafari) {
|
||||
lineHeight = detectLineHeight({
|
||||
...updatedTextElement,
|
||||
fontSize: Math.round(updatedTextElement.fontSize),
|
||||
});
|
||||
}
|
||||
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
(appState.height - viewportY) / appState.zoom.value;
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedTextElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: `${lineHeight}px`,
|
||||
lineHeight,
|
||||
width: `${textElementWidth}px`,
|
||||
height: `${textElementHeight}px`,
|
||||
left: `${viewportX}px`,
|
||||
@@ -302,6 +312,7 @@ export const textWysiwyg = ({
|
||||
filter: "var(--theme-filter)",
|
||||
maxHeight: `${editorMaxHeight}px`,
|
||||
});
|
||||
editable.scrollTop = 0;
|
||||
// For some reason updating font attribute doesn't set font family
|
||||
// hence updating font family explicitly for test environment
|
||||
if (isTestEnv()) {
|
||||
@@ -369,7 +380,7 @@ export const textWysiwyg = ({
|
||||
const wrappedText = wrapText(
|
||||
`${editable.value}${data}`,
|
||||
font,
|
||||
getMaxContainerWidth(container),
|
||||
getBoundTextMaxWidth(container),
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
@@ -386,9 +397,13 @@ export const textWysiwyg = ({
|
||||
const wrappedText = wrapText(
|
||||
normalizeText(editable.value),
|
||||
font,
|
||||
getMaxContainerWidth(container!),
|
||||
getBoundTextMaxWidth(container!),
|
||||
);
|
||||
const { width, height } = measureText(
|
||||
wrappedText,
|
||||
font,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
const { width, height } = measureText(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
editable.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { MarkNonNullable, ValueOf } from "../utility-types";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
|
||||
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
|
||||
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
|
||||
export type Theme = typeof THEME[keyof typeof THEME];
|
||||
@@ -131,10 +131,16 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
fontSize: number;
|
||||
fontFamily: FontFamilyValues;
|
||||
text: string;
|
||||
baseline: number;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
originalText: string;
|
||||
/**
|
||||
* Unitless line height (aligned to W3C). To get line height in px, multiply
|
||||
* with font size (using `getLineHeightInPx` helper).
|
||||
*/
|
||||
lineHeight: number & { _brand: "unitlessLineHeight" };
|
||||
}>;
|
||||
|
||||
export type ExcalidrawBindableElement =
|
||||
|
||||
@@ -65,7 +65,7 @@ export const reconcileElements = (
|
||||
|
||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
||||
if (local) {
|
||||
// Unless the ramote and local elements are the same element in which case
|
||||
// Unless the remote and local elements are the same element in which case
|
||||
// we need to keep it as we'd otherwise discard it from the resulting
|
||||
// array.
|
||||
if (local[0] === remoteElement) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user