Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
685f76099a | ||
|
|
04b4c2239f | ||
|
|
c93688226f | ||
|
|
5760c2b30d | ||
|
|
06f01e11f8 | ||
|
|
51ad8951d4 | ||
|
|
7497a08270 | ||
|
|
210dc85c8c | ||
|
|
019ce4c52c | ||
|
|
c141960ada | ||
|
|
d7e63e66a7 | ||
|
|
b660478164 | ||
|
|
37882c66cb | ||
|
|
7f66e1fe89 | ||
|
|
2b4540225d | ||
|
|
dc2f25c14a | ||
|
|
8fb16669ab | ||
|
|
f2600fe3e8 | ||
|
|
95ddc66339 | ||
|
|
5bcd8280c9 | ||
|
|
c99e81678b | ||
|
|
d1f39823f1 | ||
|
|
47cbb5b6fb | ||
|
|
8fd970320e | ||
|
|
d668eaa060 | ||
|
|
0ff0efe2a7 | ||
|
|
8d8f696628 | ||
|
|
19b3dc658a | ||
|
|
4e0441eeb4 | ||
|
|
8013eb5e16 | ||
|
|
725412ebd3 | ||
|
|
7da176ff7d | ||
|
|
5fffc4743f | ||
|
|
8608d7b2e0 | ||
|
|
19b03b4ca9 | ||
|
|
416e8b3e42 | ||
|
|
98e0cd9078 | ||
|
|
f3c16a600d | ||
|
|
835eb8d2fd | ||
|
|
fde796a7a0 | ||
|
|
7c41944856 | ||
|
|
f1b097ad06 | ||
|
|
9fcbbe0d27 | ||
|
|
ec070911b8 | ||
|
|
dcdeb2be57 | ||
|
|
a8acc8212d | ||
|
|
a89a03c66c | ||
|
|
e32836f799 | ||
|
|
06c40006db | ||
|
|
91c7748c3d | ||
|
|
f738b74791 | ||
|
|
00ae455873 | ||
|
|
06c5ea94d3 | ||
|
|
f3a60a5ef6 | ||
|
|
f90bf59ecd | ||
|
|
f5f3b61779 | ||
|
|
dee5c2ea8e | ||
|
|
14ab68af54 | ||
|
|
f55ecb96cc | ||
|
|
a6a32b9b29 | ||
|
|
ac0d3059dc | ||
|
|
1161f1b8ba | ||
|
|
204e06b77b | ||
|
|
414182f599 | ||
|
|
b9d27d308e | ||
|
|
3bdaafe4b5 |
@@ -23,23 +23,17 @@
|
|||||||
<br />
|
<br />
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
|
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
|
||||||
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
|
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" /></a>
|
||||||
</a>
|
|
||||||
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
|
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
|
||||||
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
|
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" /></a>
|
||||||
</a>
|
|
||||||
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
|
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
|
||||||
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
|
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
|
||||||
</a>
|
|
||||||
<a href="https://discord.gg/UexuTaE">
|
<a href="https://discord.gg/UexuTaE">
|
||||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
|
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
|
||||||
</a>
|
|
||||||
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
||||||
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
|
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||||
</a>
|
|
||||||
<a href="https://twitter.com/excalidraw">
|
<a href="https://twitter.com/excalidraw">
|
||||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
|
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/></a>
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ You will need to import the `Footer` component from the package and wrap your co
|
|||||||
```jsx live
|
```jsx live
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: "500px"}}>
|
<div style={{ height: "500px" }}>
|
||||||
<Excalidraw>
|
<Excalidraw>
|
||||||
<Footer>
|
<Footer>
|
||||||
<button
|
<button
|
||||||
@@ -27,19 +27,19 @@ function App() {
|
|||||||
|
|
||||||
This will only work for `Desktop` devices.
|
This will only work for `Desktop` devices.
|
||||||
|
|
||||||
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
|
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useEditorInterface`](#useEditorInterface) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
|
||||||
|
|
||||||
Open the `Menu` in the below playground and you will see the `custom footer` rendered.
|
Open the `Menu` in the below playground and you will see the `custom footer` rendered.
|
||||||
|
|
||||||
```jsx live noInline
|
```jsx live noInline
|
||||||
const MobileFooter = ({}) => {
|
const MobileFooter = ({}) => {
|
||||||
const device = useDevice();
|
const editorInterface = useEditorInterface();
|
||||||
if (device.editor.isMobile) {
|
if (editorInterface.formFactor === "phone") {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<button
|
<button
|
||||||
className="custom-footer"
|
className="custom-footer"
|
||||||
style= {{ marginLeft: '20px', height: '2rem'}}
|
style={{ marginLeft: "20px", height: "2rem" }}
|
||||||
onClick={() => alert("This is custom footer in mobile menu")}
|
onClick={() => alert("This is custom footer in mobile menu")}
|
||||||
>
|
>
|
||||||
custom footer
|
custom footer
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ viewportCoordsToSceneCoords({ clientX: number, clientY: number },<br/> 
|
|||||||
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): {x: number, y: number}
|
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): {x: number, y: number}
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
### useDevice
|
### useEditorInterface
|
||||||
|
|
||||||
This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component.
|
This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component.
|
||||||
|
|
||||||
@@ -300,8 +300,8 @@ Open the `main menu` in the below example to view the footer.
|
|||||||
|
|
||||||
```jsx live noInline
|
```jsx live noInline
|
||||||
const MobileFooter = ({}) => {
|
const MobileFooter = ({}) => {
|
||||||
const device = useDevice();
|
const editorInterface = useEditorInterface();
|
||||||
if (device.editor.isMobile) {
|
if (editorInterface.formFactor === "phone") {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<button
|
<button
|
||||||
@@ -336,12 +336,20 @@ render(<App />);
|
|||||||
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
|
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
|
||||||
|
|
||||||
| Name | Type | Description |
|
| Name | Type | Description |
|
||||||
| --- | --- | --- |
|
| ---- | ---- | ----------- |
|
||||||
| `viewport.isMobile` | `boolean` | Set to `true` when viewport is in `mobile` breakpoint |
|
|
||||||
| `viewport.isLandscape` | `boolean` | Set to `true` when the viewport is in `landscape` mode |
|
The `EditorInterface` object has the following properties:
|
||||||
| `editor.canFitSidebar` | `boolean` | Set to `true` if there's enough space to fit the `sidebar` |
|
|
||||||
| `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint |
|
| Name | Type | Description |
|
||||||
| `isTouchScreen` | `boolean` | Set to `true` for `touch` when touch event detected |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `formFactor` | `'phone' | 'tablet' | 'desktop'` | Indicates the device type based on screen size |
|
||||||
|
| `desktopUIMode` | `'compact' | 'full'` | UI mode for desktop form factor |
|
||||||
|
| `userAgent.raw` | `string` | Raw user agent string |
|
||||||
|
| `userAgent.isMobileDevice` | `boolean` | True if device is mobile |
|
||||||
|
| `userAgent.platform` | `'ios' | 'android' | 'other' | 'unknown'` | Device platform |
|
||||||
|
| `isTouchScreen` | `boolean` | True if touch events are detected |
|
||||||
|
| `canFitSidebar` | `boolean` | True if sidebar can fit in the viewport |
|
||||||
|
| `isLandscape` | `boolean` | True if viewport is in landscape mode |
|
||||||
|
|
||||||
### i18n
|
### i18n
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
excalidraw:
|
excalidraw:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ const MobileFooter = ({
|
|||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
excalidrawLib: typeof TExcalidraw;
|
excalidrawLib: typeof TExcalidraw;
|
||||||
}) => {
|
}) => {
|
||||||
const { useDevice, Footer } = excalidrawLib;
|
const { useEditorInterface, Footer } = excalidrawLib;
|
||||||
|
|
||||||
const device = useDevice();
|
const editorInterface = useEditorInterface();
|
||||||
if (device.editor.isMobile) {
|
if (editorInterface.formFactor === "phone") {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<CustomFooter
|
<CustomFooter
|
||||||
|
|||||||
+27
-3
@@ -4,6 +4,7 @@ import {
|
|||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
CaptureUpdateAction,
|
CaptureUpdateAction,
|
||||||
reconcileElements,
|
reconcileElements,
|
||||||
|
useEditorInterface,
|
||||||
} from "@excalidraw/excalidraw";
|
} from "@excalidraw/excalidraw";
|
||||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||||
@@ -119,6 +120,7 @@ import {
|
|||||||
LibraryIndexedDBAdapter,
|
LibraryIndexedDBAdapter,
|
||||||
LibraryLocalStorageMigrationAdapter,
|
LibraryLocalStorageMigrationAdapter,
|
||||||
LocalData,
|
LocalData,
|
||||||
|
localStorageQuotaExceededAtom,
|
||||||
} from "./data/LocalData";
|
} from "./data/LocalData";
|
||||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||||
@@ -136,6 +138,9 @@ import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
|||||||
|
|
||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
|
|
||||||
|
import { ExcalidrawPlusPromoBanner } from "./components/ExcalidrawPlusPromoBanner";
|
||||||
|
import { AppSidebar } from "./components/AppSidebar";
|
||||||
|
|
||||||
import type { CollabAPI } from "./collab/Collab";
|
import type { CollabAPI } from "./collab/Collab";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
@@ -341,6 +346,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const [langCode, setLangCode] = useAppLangCode();
|
const [langCode, setLangCode] = useAppLangCode();
|
||||||
|
|
||||||
|
const editorInterface = useEditorInterface();
|
||||||
|
|
||||||
// initial state
|
// initial state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -662,8 +669,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
debugRenderer(
|
debugRenderer(
|
||||||
debugCanvasRef.current,
|
debugCanvasRef.current,
|
||||||
appState,
|
appState,
|
||||||
|
elements,
|
||||||
window.devicePixelRatio,
|
window.devicePixelRatio,
|
||||||
() => forceRefresh((prev) => !prev),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -727,6 +734,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const isOffline = useAtomValue(isOfflineAtom);
|
const isOffline = useAtomValue(isOfflineAtom);
|
||||||
|
|
||||||
|
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
|
||||||
|
|
||||||
const onCollabDialogOpen = useCallback(
|
const onCollabDialogOpen = useCallback(
|
||||||
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||||
[setShareDialogState],
|
[setShareDialogState],
|
||||||
@@ -845,14 +854,22 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="top-right-ui">
|
<div className="excalidraw-ui-top-right">
|
||||||
|
{excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
|
||||||
|
<ExcalidrawPlusPromoBanner
|
||||||
|
isSignedIn={isExcalidrawPlusSignedUser}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{collabError.message && <CollabError collabError={collabError} />}
|
{collabError.message && <CollabError collabError={collabError} />}
|
||||||
<LiveCollaborationTrigger
|
<LiveCollaborationTrigger
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
setShareDialogState({ isOpen: true, type: "share" })
|
setShareDialogState({ isOpen: true, type: "share" })
|
||||||
}
|
}
|
||||||
|
editorInterface={editorInterface}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -901,10 +918,15 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
<TTDDialogTrigger />
|
<TTDDialogTrigger />
|
||||||
{isCollaborating && isOffline && (
|
{isCollaborating && isOffline && (
|
||||||
<div className="collab-offline-warning">
|
<div className="alertalert--warning">
|
||||||
{t("alerts.collabOfflineWarning")}
|
{t("alerts.collabOfflineWarning")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{localStorageQuotaExceeded && (
|
||||||
|
<div className="alert alert--danger">
|
||||||
|
{t("alerts.localStorageQuotaExceeded")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{latestShareableLink && (
|
{latestShareableLink && (
|
||||||
<ShareableLinkDialog
|
<ShareableLinkDialog
|
||||||
link={latestShareableLink}
|
link={latestShareableLink}
|
||||||
@@ -933,6 +955,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AppSidebar />
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
|||||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||||
|
|
||||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
// should be aligned with MAX_ALLOWED_FILE_BYTES
|
||||||
|
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
|
||||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||||
|
|
||||||
|
|||||||
@@ -530,7 +530,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existingRoomLinkData) {
|
if (existingRoomLinkData) {
|
||||||
|
// when joining existing room, don't merge it with current scene data
|
||||||
|
this.excalidrawAPI.resetScene();
|
||||||
|
} else {
|
||||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||||
if (isImageElement(element) && element.status === "saved") {
|
if (isImageElement(element) && element.status === "saved") {
|
||||||
return newElementWith(element, { status: "pending" });
|
return newElementWith(element, { status: "pending" });
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
|
|||||||
|
|
||||||
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
||||||
import { EncryptedIcon } from "./EncryptedIcon";
|
import { EncryptedIcon } from "./EncryptedIcon";
|
||||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
|
||||||
|
|
||||||
export const AppFooter = React.memo(
|
export const AppFooter = React.memo(
|
||||||
({ onChange }: { onChange: () => void }) => {
|
({ onChange }: { onChange: () => void }) => {
|
||||||
@@ -19,11 +18,7 @@ export const AppFooter = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||||
{isExcalidrawPlusSignedUser ? (
|
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
|
||||||
<ExcalidrawPlusAppLink />
|
|
||||||
) : (
|
|
||||||
<EncryptedIcon />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Footer>
|
</Footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.app-sidebar-promo-container {
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-promo-image {
|
||||||
|
margin: 1rem 0;
|
||||||
|
|
||||||
|
height: 16.25rem;
|
||||||
|
background-size: contain;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
|
||||||
|
background-image: radial-gradient(
|
||||||
|
circle,
|
||||||
|
transparent 60%,
|
||||||
|
var(--sidebar-bg-color) 100%
|
||||||
|
),
|
||||||
|
var(--image-source);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-promo-text {
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { DefaultSidebar, Sidebar, THEME } from "@excalidraw/excalidraw";
|
||||||
|
import {
|
||||||
|
messageCircleIcon,
|
||||||
|
presentationIcon,
|
||||||
|
} from "@excalidraw/excalidraw/components/icons";
|
||||||
|
import { LinkButton } from "@excalidraw/excalidraw/components/LinkButton";
|
||||||
|
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
|
||||||
|
|
||||||
|
import "./AppSidebar.scss";
|
||||||
|
|
||||||
|
export const AppSidebar = () => {
|
||||||
|
const { theme, openSidebar } = useUIAppState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultSidebar>
|
||||||
|
<DefaultSidebar.TabTriggers>
|
||||||
|
<Sidebar.TabTrigger
|
||||||
|
tab="comments"
|
||||||
|
style={{ opacity: openSidebar?.tab === "comments" ? 1 : 0.4 }}
|
||||||
|
>
|
||||||
|
{messageCircleIcon}
|
||||||
|
</Sidebar.TabTrigger>
|
||||||
|
<Sidebar.TabTrigger
|
||||||
|
tab="presentation"
|
||||||
|
style={{ opacity: openSidebar?.tab === "presentation" ? 1 : 0.4 }}
|
||||||
|
>
|
||||||
|
{presentationIcon}
|
||||||
|
</Sidebar.TabTrigger>
|
||||||
|
</DefaultSidebar.TabTriggers>
|
||||||
|
<Sidebar.Tab tab="comments">
|
||||||
|
<div className="app-sidebar-promo-container">
|
||||||
|
<div
|
||||||
|
className="app-sidebar-promo-image"
|
||||||
|
style={{
|
||||||
|
["--image-source" as any]: `url(/oss_promo_comments_${
|
||||||
|
theme === THEME.DARK ? "dark" : "light"
|
||||||
|
}.jpg)`,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="app-sidebar-promo-text">
|
||||||
|
Make comments with Excalidraw+
|
||||||
|
</div>
|
||||||
|
<LinkButton
|
||||||
|
href={`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=comments_promo#excalidraw-redirect`}
|
||||||
|
>
|
||||||
|
Sign up now
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
</Sidebar.Tab>
|
||||||
|
<Sidebar.Tab tab="presentation" className="px-3">
|
||||||
|
<div className="app-sidebar-promo-container">
|
||||||
|
<div
|
||||||
|
className="app-sidebar-promo-image"
|
||||||
|
style={{
|
||||||
|
["--image-source" as any]: `url(/oss_promo_presentations_${
|
||||||
|
theme === THEME.DARK ? "dark" : "light"
|
||||||
|
}.svg)`,
|
||||||
|
backgroundSize: "60%",
|
||||||
|
opacity: 0.4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="app-sidebar-promo-text">
|
||||||
|
Create presentations with Excalidraw+
|
||||||
|
</div>
|
||||||
|
<LinkButton
|
||||||
|
href={`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=presentations_promo#excalidraw-redirect`}
|
||||||
|
>
|
||||||
|
Sign up now
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
</Sidebar.Tab>
|
||||||
|
</DefaultSidebar>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,9 +8,15 @@ import {
|
|||||||
getNormalizedCanvasDimensions,
|
getNormalizedCanvasDimensions,
|
||||||
} from "@excalidraw/excalidraw/renderer/helpers";
|
} from "@excalidraw/excalidraw/renderer/helpers";
|
||||||
import { type AppState } from "@excalidraw/excalidraw/types";
|
import { type AppState } from "@excalidraw/excalidraw/types";
|
||||||
import { throttleRAF } from "@excalidraw/common";
|
import { arrayToMap, throttleRAF } from "@excalidraw/common";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getGlobalFixedPointForBindableElement,
|
||||||
|
isArrowElement,
|
||||||
|
isBindableElement,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isLineSegment,
|
isLineSegment,
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
@@ -21,8 +27,14 @@ import { isCurve } from "@excalidraw/math/curve";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import type { Curve } from "@excalidraw/math";
|
import type { Curve } from "@excalidraw/math";
|
||||||
|
import type { DebugElement } from "@excalidraw/common";
|
||||||
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawArrowElement,
|
||||||
|
ExcalidrawBindableElement,
|
||||||
|
FixedPointBinding,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
|
|
||||||
@@ -75,6 +87,176 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
|||||||
context.save();
|
context.save();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _renderBinding = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
binding: FixedPointBinding,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
zoom: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
color: string,
|
||||||
|
) => {
|
||||||
|
if (!binding.fixedPoint) {
|
||||||
|
console.warn("Binding must have a fixedPoint");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindable = elementsMap.get(
|
||||||
|
binding.elementId,
|
||||||
|
) as ExcalidrawBindableElement;
|
||||||
|
const [x, y] = getGlobalFixedPointForBindableElement(
|
||||||
|
binding.fixedPoint,
|
||||||
|
bindable,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x * zoom, y * zoom);
|
||||||
|
context.bezierCurveTo(
|
||||||
|
x * zoom - width,
|
||||||
|
y * zoom - height,
|
||||||
|
x * zoom - width,
|
||||||
|
y * zoom + height,
|
||||||
|
x * zoom,
|
||||||
|
y * zoom,
|
||||||
|
);
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const _renderBindableBinding = (
|
||||||
|
binding: FixedPointBinding,
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
zoom: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
color: string,
|
||||||
|
) => {
|
||||||
|
const bindable = elementsMap.get(
|
||||||
|
binding.elementId,
|
||||||
|
) as ExcalidrawBindableElement;
|
||||||
|
if (!binding.fixedPoint) {
|
||||||
|
console.warn("Binding must have a fixedPoint");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [x, y] = getGlobalFixedPointForBindableElement(
|
||||||
|
binding.fixedPoint,
|
||||||
|
bindable,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x * zoom, y * zoom);
|
||||||
|
context.bezierCurveTo(
|
||||||
|
x * zoom + width,
|
||||||
|
y * zoom + height,
|
||||||
|
x * zoom + width,
|
||||||
|
y * zoom - height,
|
||||||
|
x * zoom,
|
||||||
|
y * zoom,
|
||||||
|
);
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBindings = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
|
zoom: number,
|
||||||
|
) => {
|
||||||
|
const elementsMap = arrayToMap(elements);
|
||||||
|
const dim = 16;
|
||||||
|
elements.forEach((element) => {
|
||||||
|
if (element.isDeleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArrowElement(element)) {
|
||||||
|
if (element.startBinding) {
|
||||||
|
if (
|
||||||
|
!elementsMap
|
||||||
|
.get(element.startBinding.elementId)
|
||||||
|
?.boundElements?.find((e) => e.id === element.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderBinding(
|
||||||
|
context,
|
||||||
|
element.startBinding,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
dim,
|
||||||
|
dim,
|
||||||
|
element.startBinding?.mode === "orbit" ? "red" : "black",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.endBinding) {
|
||||||
|
if (
|
||||||
|
!elementsMap
|
||||||
|
.get(element.endBinding.elementId)
|
||||||
|
?.boundElements?.find((e) => e.id === element.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_renderBinding(
|
||||||
|
context,
|
||||||
|
element.endBinding,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
dim,
|
||||||
|
dim,
|
||||||
|
element.endBinding?.mode === "orbit" ? "red" : "black",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBindableElement(element) && element.boundElements?.length) {
|
||||||
|
element.boundElements.forEach((boundElement) => {
|
||||||
|
if (boundElement.type !== "arrow") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrow = elementsMap.get(
|
||||||
|
boundElement.id,
|
||||||
|
) as ExcalidrawArrowElement;
|
||||||
|
|
||||||
|
if (arrow && arrow.startBinding?.elementId === element.id) {
|
||||||
|
_renderBindableBinding(
|
||||||
|
arrow.startBinding,
|
||||||
|
context,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
dim,
|
||||||
|
dim,
|
||||||
|
"green",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (arrow && arrow.endBinding?.elementId === element.id) {
|
||||||
|
_renderBindableBinding(
|
||||||
|
arrow.endBinding,
|
||||||
|
context,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
dim,
|
||||||
|
dim,
|
||||||
|
"green",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const render = (
|
const render = (
|
||||||
frame: DebugElement[],
|
frame: DebugElement[],
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
@@ -107,8 +289,8 @@ const render = (
|
|||||||
const _debugRenderer = (
|
const _debugRenderer = (
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
scale: number,
|
scale: number,
|
||||||
refresh: () => void,
|
|
||||||
) => {
|
) => {
|
||||||
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
||||||
canvas,
|
canvas,
|
||||||
@@ -131,6 +313,7 @@ const _debugRenderer = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
renderOrigin(context, appState.zoom.value);
|
renderOrigin(context, appState.zoom.value);
|
||||||
|
renderBindings(context, elements, appState.zoom.value);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
window.visualDebug?.currentFrame &&
|
window.visualDebug?.currentFrame &&
|
||||||
@@ -182,10 +365,10 @@ export const debugRenderer = throttleRAF(
|
|||||||
(
|
(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
scale: number,
|
scale: number,
|
||||||
refresh: () => void,
|
|
||||||
) => {
|
) => {
|
||||||
_debugRenderer(canvas, appState, scale, refresh);
|
_debugRenderer(canvas, appState, elements, scale);
|
||||||
},
|
},
|
||||||
{ trailing: true },
|
{ trailing: true },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
|
||||||
|
|
||||||
export const ExcalidrawPlusAppLink = () => {
|
|
||||||
if (!isExcalidrawPlusSignedUser) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={`${
|
|
||||||
import.meta.env.VITE_APP_PLUS_APP
|
|
||||||
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
className="plus-button"
|
|
||||||
>
|
|
||||||
Go to Excalidraw+
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const ExcalidrawPlusPromoBanner = ({
|
||||||
|
isSignedIn,
|
||||||
|
}: {
|
||||||
|
isSignedIn: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={
|
||||||
|
isSignedIn
|
||||||
|
? import.meta.env.VITE_APP_PLUS_APP
|
||||||
|
: `${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=guestBanner#excalidraw-redirect`
|
||||||
|
}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="plus-banner"
|
||||||
|
>
|
||||||
|
Excalidraw+
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
debounce,
|
debounce,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { clearElementsForLocalStorage } from "@excalidraw/element";
|
|
||||||
import {
|
import {
|
||||||
createStore,
|
createStore,
|
||||||
entries,
|
entries,
|
||||||
@@ -27,6 +26,9 @@ import {
|
|||||||
get,
|
get,
|
||||||
} from "idb-keyval";
|
} from "idb-keyval";
|
||||||
|
|
||||||
|
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
|
||||||
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||||
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
||||||
@@ -45,6 +47,8 @@ import { updateBrowserStateVersion } from "./tabSync";
|
|||||||
|
|
||||||
const filesStore = createStore("files-db", "files-store");
|
const filesStore = createStore("files-db", "files-store");
|
||||||
|
|
||||||
|
export const localStorageQuotaExceededAtom = atom(false);
|
||||||
|
|
||||||
class LocalFileManager extends FileManager {
|
class LocalFileManager extends FileManager {
|
||||||
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
||||||
await entries(filesStore).then((entries) => {
|
await entries(filesStore).then((entries) => {
|
||||||
@@ -69,6 +73,9 @@ const saveDataStateToLocalStorage = (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
|
const localStorageQuotaExceeded = appJotaiStore.get(
|
||||||
|
localStorageQuotaExceededAtom,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const _appState = clearAppStateForLocalStorage(appState);
|
const _appState = clearAppStateForLocalStorage(appState);
|
||||||
|
|
||||||
@@ -81,19 +88,29 @@ const saveDataStateToLocalStorage = (
|
|||||||
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
JSON.stringify(getNonDeletedElements(elements)),
|
||||||
);
|
);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||||
JSON.stringify(_appState),
|
JSON.stringify(_appState),
|
||||||
);
|
);
|
||||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||||
|
if (localStorageQuotaExceeded) {
|
||||||
|
appJotaiStore.set(localStorageQuotaExceededAtom, false);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Unable to access window.localStorage
|
// Unable to access window.localStorage
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
|
||||||
|
appJotaiStore.set(localStorageQuotaExceededAtom, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isQuotaExceededError = (error: any) => {
|
||||||
|
return error instanceof DOMException && error.name === "QuotaExceededError";
|
||||||
|
};
|
||||||
|
|
||||||
type SavingLockTypes = "collaboration";
|
type SavingLockTypes = "collaboration";
|
||||||
|
|
||||||
export class LocalData {
|
export class LocalData {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
clearAppStateForLocalStorage,
|
clearAppStateForLocalStorage,
|
||||||
getDefaultAppState,
|
getDefaultAppState,
|
||||||
} from "@excalidraw/excalidraw/appState";
|
} from "@excalidraw/excalidraw/appState";
|
||||||
import { clearElementsForLocalStorage } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
@@ -50,7 +49,7 @@ export const importFromLocalStorage = () => {
|
|||||||
let elements: ExcalidrawElement[] = [];
|
let elements: ExcalidrawElement[] = [];
|
||||||
if (savedElements) {
|
if (savedElements) {
|
||||||
try {
|
try {
|
||||||
elements = clearElementsForLocalStorage(JSON.parse(savedElements));
|
elements = JSON.parse(savedElements);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
// Do nothing because elements array is already empty
|
// Do nothing because elements array is already empty
|
||||||
|
|||||||
+27
-14
@@ -1,3 +1,5 @@
|
|||||||
|
@import "../packages/excalidraw/css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
||||||
|
|
||||||
@@ -5,12 +7,6 @@
|
|||||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-right-ui {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-center {
|
.footer-center {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
@@ -58,7 +54,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.collab-offline-warning {
|
.alert {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6.5rem;
|
top: 6.5rem;
|
||||||
@@ -69,10 +65,18 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
background-color: var(--color-warning);
|
|
||||||
color: var(--color-text-warning);
|
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
color: var(--color-text-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background-color: var(--color-danger-dark);
|
||||||
|
color: var(--color-danger-text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,22 +86,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.plus-button {
|
.plus-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid var(--color-primary);
|
border: 1px solid var(--color-primary);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.875rem;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
background-color: var(--island-bg-color);
|
background-color: var(--island-bg-color);
|
||||||
color: var(--color-primary) !important;
|
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
|
|
||||||
font-size: 0.75rem;
|
font-family: var(--ui-font);
|
||||||
|
font-size: 0.8333rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: var(--lg-button-size);
|
height: var(--lg-button-size);
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||||
|
background-color: var(--color-surface-low);
|
||||||
|
color: var(--button-color, var(--color-on-surface)) !important;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
box-shadow: 0 0 0 1px var(--color-brand-active);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
color: white !important;
|
color: white !important;
|
||||||
@@ -109,7 +122,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme--dark {
|
.theme--dark {
|
||||||
.plus-button {
|
.plus-banner {
|
||||||
&:hover {
|
&:hover {
|
||||||
color: black !important;
|
color: black !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18.0.0 - 22.x.x"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/random-username": "1.0.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getFeatureFlag } from "@excalidraw/common";
|
||||||
import * as Sentry from "@sentry/browser";
|
import * as Sentry from "@sentry/browser";
|
||||||
import callsites from "callsites";
|
import callsites from "callsites";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ Sentry.init({
|
|||||||
Sentry.captureConsoleIntegration({
|
Sentry.captureConsoleIntegration({
|
||||||
levels: ["error"],
|
levels: ["error"],
|
||||||
}),
|
}),
|
||||||
|
Sentry.featureFlagsIntegration(),
|
||||||
],
|
],
|
||||||
beforeSend(event) {
|
beforeSend(event) {
|
||||||
if (event.request?.url) {
|
if (event.request?.url) {
|
||||||
@@ -79,3 +81,14 @@ Sentry.init({
|
|||||||
return event;
|
return event;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const flagsIntegration =
|
||||||
|
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
|
||||||
|
"FeatureFlags",
|
||||||
|
);
|
||||||
|
if (flagsIntegration) {
|
||||||
|
flagsIntegration.addFeatureFlag(
|
||||||
|
"COMPLEX_BINDINGS",
|
||||||
|
getFeatureFlag("COMPLEX_BINDINGS"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,30 +17,15 @@ describe("Test MobileMenu", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
// @ts-ignore
|
h.app.refreshEditorInterface();
|
||||||
h.app.refreshViewportBreakpoints();
|
|
||||||
// @ts-ignore
|
|
||||||
h.app.refreshEditorBreakpoints();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
restoreOriginalGetBoundingClientRect();
|
restoreOriginalGetBoundingClientRect();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set device correctly", () => {
|
it("should set editor interface correctly", () => {
|
||||||
expect(h.app.device).toMatchInlineSnapshot(`
|
expect(h.app.editorInterface.formFactor).toBe("phone");
|
||||||
{
|
|
||||||
"editor": {
|
|
||||||
"canFitSidebar": false,
|
|
||||||
"isMobile": true,
|
|
||||||
},
|
|
||||||
"isTouchScreen": false,
|
|
||||||
"viewport": {
|
|
||||||
"isLandscape": false,
|
|
||||||
"isMobile": true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should initialize with welcome screen and hide once user interacts", async () => {
|
it("should initialize with welcome screen and hide once user interacts", async () => {
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@
|
|||||||
"vitest-canvas-mock": "0.3.3"
|
"vitest-canvas-mock": "0.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18.0.0 - 22.x.x"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
|
|||||||
|
|
||||||
sinkDown(idx: number) {
|
sinkDown(idx: number) {
|
||||||
const node = this.content[idx];
|
const node = this.content[idx];
|
||||||
|
const nodeScore = this.scoreFunction(node);
|
||||||
while (idx > 0) {
|
while (idx > 0) {
|
||||||
const parentN = ((idx + 1) >> 1) - 1;
|
const parentN = ((idx + 1) >> 1) - 1;
|
||||||
const parent = this.content[parentN];
|
const parent = this.content[parentN];
|
||||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
if (nodeScore < this.scoreFunction(parent)) {
|
||||||
this.content[parentN] = node;
|
|
||||||
this.content[idx] = parent;
|
this.content[idx] = parent;
|
||||||
idx = parentN; // TODO: Optimize
|
idx = parentN; // TODO: Optimize
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.content[idx] = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
bubbleUp(idx: number) {
|
bubbleUp(idx: number) {
|
||||||
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
|
|||||||
const score = this.scoreFunction(node);
|
const score = this.scoreFunction(node);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const child2N = (idx + 1) << 1;
|
const child1N = ((idx + 1) << 1) - 1;
|
||||||
const child1N = child2N - 1;
|
const child2N = child1N + 1;
|
||||||
let swap = null;
|
let smallestIdx = idx;
|
||||||
let child1Score = 0;
|
let smallestScore = score;
|
||||||
|
|
||||||
|
// Check left child
|
||||||
if (child1N < length) {
|
if (child1N < length) {
|
||||||
const child1 = this.content[child1N];
|
const child1Score = this.scoreFunction(this.content[child1N]);
|
||||||
child1Score = this.scoreFunction(child1);
|
if (child1Score < smallestScore) {
|
||||||
if (child1Score < score) {
|
smallestIdx = child1N;
|
||||||
swap = child1N;
|
smallestScore = child1Score;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check right child
|
||||||
if (child2N < length) {
|
if (child2N < length) {
|
||||||
const child2 = this.content[child2N];
|
const child2Score = this.scoreFunction(this.content[child2N]);
|
||||||
const child2Score = this.scoreFunction(child2);
|
if (child2Score < smallestScore) {
|
||||||
if (child2Score < (swap === null ? score : child1Score)) {
|
smallestIdx = child2N;
|
||||||
swap = child2N;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (swap !== null) {
|
if (smallestIdx === idx) {
|
||||||
this.content[idx] = this.content[swap];
|
|
||||||
this.content[swap] = node;
|
|
||||||
idx = swap; // TODO: Optimize
|
|
||||||
} else {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move the smaller child up, continue finding position for node
|
||||||
|
this.content[idx] = this.content[smallestIdx];
|
||||||
|
idx = smallestIdx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Place node in its final position
|
||||||
|
this.content[idx] = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
push(node: T) {
|
push(node: T) {
|
||||||
|
|||||||
@@ -6,32 +6,6 @@ import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import { COLOR_PALETTE } from "./colors";
|
import { COLOR_PALETTE } from "./colors";
|
||||||
|
|
||||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
|
||||||
export const isWindows = /^Win/.test(navigator.platform);
|
|
||||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
|
||||||
export const isFirefox =
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
"netscape" in window &&
|
|
||||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
|
||||||
navigator.userAgent.indexOf("Gecko") > 1;
|
|
||||||
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
|
||||||
export const isSafari =
|
|
||||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
|
||||||
export const isIOS =
|
|
||||||
/iPad|iPhone/i.test(navigator.platform) ||
|
|
||||||
// iPadOS 13+
|
|
||||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
|
||||||
// keeping function so it can be mocked in test
|
|
||||||
export const isBrave = () =>
|
|
||||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
|
||||||
|
|
||||||
export const isMobile =
|
|
||||||
isIOS ||
|
|
||||||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
|
||||||
navigator.userAgent,
|
|
||||||
) ||
|
|
||||||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
|
|
||||||
|
|
||||||
export const supportsResizeObserver =
|
export const supportsResizeObserver =
|
||||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||||
|
|
||||||
@@ -125,10 +99,13 @@ export const ENV = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CLASSES = {
|
export const CLASSES = {
|
||||||
|
SIDEBAR: "sidebar",
|
||||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||||
ZOOM_ACTIONS: "zoom-actions",
|
ZOOM_ACTIONS: "zoom-actions",
|
||||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||||
|
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
|
||||||
|
FRAME_NAME: "frame-name",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||||
@@ -259,13 +236,20 @@ export const IMAGE_MIME_TYPES = {
|
|||||||
jfif: "image/jfif",
|
jfif: "image/jfif",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const MIME_TYPES = {
|
export const STRING_MIME_TYPES = {
|
||||||
text: "text/plain",
|
text: "text/plain",
|
||||||
html: "text/html",
|
html: "text/html",
|
||||||
json: "application/json",
|
json: "application/json",
|
||||||
// excalidraw data
|
// excalidraw data
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
|
// LEGACY: fully-qualified library JSON data
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
|
// list of excalidraw library item ids
|
||||||
|
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const MIME_TYPES = {
|
||||||
|
...STRING_MIME_TYPES,
|
||||||
// image-encoded excalidraw data
|
// image-encoded excalidraw data
|
||||||
"excalidraw.svg": "image/svg+xml",
|
"excalidraw.svg": "image/svg+xml",
|
||||||
"excalidraw.png": "image/png",
|
"excalidraw.png": "image/png",
|
||||||
@@ -340,16 +324,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// breakpoints
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// md screen
|
|
||||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
|
||||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
|
||||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
|
||||||
// sidebar
|
|
||||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||||
|
|
||||||
export const EXPORT_SCALES = [1, 2, 3];
|
export const EXPORT_SCALES = [1, 2, 3];
|
||||||
@@ -436,6 +410,7 @@ export const DEFAULT_ELEMENT_PROPS: {
|
|||||||
roughness: ExcalidrawElement["roughness"];
|
roughness: ExcalidrawElement["roughness"];
|
||||||
opacity: ExcalidrawElement["opacity"];
|
opacity: ExcalidrawElement["opacity"];
|
||||||
locked: ExcalidrawElement["locked"];
|
locked: ExcalidrawElement["locked"];
|
||||||
|
containerBehavior: ExcalidrawElement["containerBehavior"];
|
||||||
} = {
|
} = {
|
||||||
strokeColor: COLOR_PALETTE.black,
|
strokeColor: COLOR_PALETTE.black,
|
||||||
backgroundColor: COLOR_PALETTE.transparent,
|
backgroundColor: COLOR_PALETTE.transparent,
|
||||||
@@ -445,6 +420,10 @@ export const DEFAULT_ELEMENT_PROPS: {
|
|||||||
roughness: ROUGHNESS.artist,
|
roughness: ROUGHNESS.artist,
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
locked: false,
|
locked: false,
|
||||||
|
containerBehavior: {
|
||||||
|
textFlow: "growing",
|
||||||
|
margin: BOUND_TEXT_PADDING,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LIBRARY_SIDEBAR_TAB = "library";
|
export const LIBRARY_SIDEBAR_TAB = "library";
|
||||||
@@ -524,3 +503,10 @@ export enum UserIdleState {
|
|||||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||||
|
|
||||||
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
||||||
|
|
||||||
|
export const BIND_MODE_TIMEOUT = 700; // ms
|
||||||
|
|
||||||
|
// glass background for mobile action buttons
|
||||||
|
export const MOBILE_ACTION_BUTTON_BG = {
|
||||||
|
background: "var(--mobile-action-button-bg)",
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
export type StylesPanelMode = "compact" | "full" | "mobile";
|
||||||
|
|
||||||
|
export type EditorInterface = Readonly<{
|
||||||
|
formFactor: "phone" | "tablet" | "desktop";
|
||||||
|
desktopUIMode: "compact" | "full";
|
||||||
|
userAgent: Readonly<{
|
||||||
|
isMobileDevice: boolean;
|
||||||
|
platform: "ios" | "android" | "other" | "unknown";
|
||||||
|
}>;
|
||||||
|
isTouchScreen: boolean;
|
||||||
|
canFitSidebar: boolean;
|
||||||
|
isLandscape: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// storage key
|
||||||
|
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
|
||||||
|
|
||||||
|
// breakpoints
|
||||||
|
// mobile: up to 699px
|
||||||
|
export const MQ_MAX_MOBILE = 599;
|
||||||
|
|
||||||
|
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||||
|
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||||
|
|
||||||
|
// tablets
|
||||||
|
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
|
||||||
|
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
|
||||||
|
|
||||||
|
// desktop/laptop
|
||||||
|
export const MQ_MIN_WIDTH_DESKTOP = 1440;
|
||||||
|
|
||||||
|
// sidebar
|
||||||
|
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// user agent detections
|
||||||
|
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||||
|
export const isWindows = /^Win/.test(navigator.platform);
|
||||||
|
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||||
|
export const isFirefox =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
"netscape" in window &&
|
||||||
|
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||||
|
navigator.userAgent.indexOf("Gecko") > 1;
|
||||||
|
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||||
|
export const isSafari =
|
||||||
|
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||||
|
export const isIOS =
|
||||||
|
/iPad|iPhone/i.test(navigator.platform) ||
|
||||||
|
// iPadOS 13+
|
||||||
|
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||||
|
// keeping function so it can be mocked in test
|
||||||
|
export const isBrave = () =>
|
||||||
|
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||||
|
|
||||||
|
// export const isMobile =
|
||||||
|
// isIOS ||
|
||||||
|
// /android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
||||||
|
// navigator.userAgent,
|
||||||
|
// ) ||
|
||||||
|
// /android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
|
||||||
|
|
||||||
|
// utilities
|
||||||
|
export const isMobileBreakpoint = (width: number, height: number) => {
|
||||||
|
return (
|
||||||
|
width <= MQ_MAX_MOBILE ||
|
||||||
|
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTabletBreakpoint = (
|
||||||
|
editorWidth: number,
|
||||||
|
editorHeight: number,
|
||||||
|
) => {
|
||||||
|
const minSide = Math.min(editorWidth, editorHeight);
|
||||||
|
const maxSide = Math.max(editorWidth, editorHeight);
|
||||||
|
|
||||||
|
return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMobileOrTablet = (): boolean => {
|
||||||
|
const ua = navigator.userAgent || "";
|
||||||
|
const platform = navigator.platform || "";
|
||||||
|
const uaData = (navigator as any).userAgentData as
|
||||||
|
| { mobile?: boolean; platform?: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// --- 1) chromium: prefer ua client hints -------------------------------
|
||||||
|
if (uaData) {
|
||||||
|
const plat = (uaData.platform || "").toLowerCase();
|
||||||
|
const isDesktopOS =
|
||||||
|
plat === "windows" ||
|
||||||
|
plat === "macos" ||
|
||||||
|
plat === "linux" ||
|
||||||
|
plat === "chrome os";
|
||||||
|
if (uaData.mobile === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (uaData.mobile === false && plat === "android") {
|
||||||
|
const looksTouchTablet =
|
||||||
|
matchMedia?.("(hover: none)").matches &&
|
||||||
|
matchMedia?.("(pointer: coarse)").matches;
|
||||||
|
return looksTouchTablet;
|
||||||
|
}
|
||||||
|
if (isDesktopOS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2) ios (includes ipad) --------------------------------------------
|
||||||
|
if (isIOS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3) android legacy ua fallback -------------------------------------
|
||||||
|
if (isAndroid) {
|
||||||
|
const isAndroidPhone = /Mobile/i.test(ua);
|
||||||
|
const isAndroidTablet = !isAndroidPhone;
|
||||||
|
if (isAndroidPhone || isAndroidTablet) {
|
||||||
|
const looksTouchTablet =
|
||||||
|
matchMedia?.("(hover: none)").matches &&
|
||||||
|
matchMedia?.("(pointer: coarse)").matches;
|
||||||
|
return looksTouchTablet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4) last resort desktop exclusion ----------------------------------
|
||||||
|
const looksDesktopPlatform =
|
||||||
|
/Win|Linux|CrOS|Mac/.test(platform) ||
|
||||||
|
/Windows NT|X11|CrOS|Macintosh/.test(ua);
|
||||||
|
if (looksDesktopPlatform) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFormFactor = (
|
||||||
|
editorWidth: number,
|
||||||
|
editorHeight: number,
|
||||||
|
): EditorInterface["formFactor"] => {
|
||||||
|
if (isMobileBreakpoint(editorWidth, editorHeight)) {
|
||||||
|
return "phone";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTabletBreakpoint(editorWidth, editorHeight)) {
|
||||||
|
return "tablet";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "desktop";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deriveStylesPanelMode = (
|
||||||
|
editorInterface: EditorInterface,
|
||||||
|
): StylesPanelMode => {
|
||||||
|
if (editorInterface.formFactor === "phone") {
|
||||||
|
return "mobile";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editorInterface.formFactor === "tablet") {
|
||||||
|
return "compact";
|
||||||
|
}
|
||||||
|
|
||||||
|
return editorInterface.desktopUIMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUserAgentDescriptor = (
|
||||||
|
userAgentString: string,
|
||||||
|
): EditorInterface["userAgent"] => {
|
||||||
|
const normalizedUA = userAgentString ?? "";
|
||||||
|
let platform: EditorInterface["userAgent"]["platform"] = "unknown";
|
||||||
|
|
||||||
|
if (isIOS) {
|
||||||
|
platform = "ios";
|
||||||
|
} else if (isAndroid) {
|
||||||
|
platform = "android";
|
||||||
|
} else if (normalizedUA) {
|
||||||
|
platform = "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMobileDevice: isMobileOrTablet(),
|
||||||
|
platform,
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadDesktopUIModePreference = () => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(DESKTOP_UI_MODE_STORAGE_KEY);
|
||||||
|
if (stored === "compact" || stored === "full") {
|
||||||
|
return stored as EditorInterface["desktopUIMode"];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// ignore storage access issues (e.g., Safari private mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DESKTOP_UI_MODE_STORAGE_KEY, mode);
|
||||||
|
} catch (error) {
|
||||||
|
// ignore storage access issues (e.g., Safari private mode)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
|
||||||
|
if (mode !== "compact" && mode !== "full") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistDesktopUIMode(mode);
|
||||||
|
|
||||||
|
return mode;
|
||||||
|
};
|
||||||
@@ -10,3 +10,5 @@ export * from "./random";
|
|||||||
export * from "./url";
|
export * from "./url";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./emitter";
|
export * from "./emitter";
|
||||||
|
export * from "./visualdebug";
|
||||||
|
export * from "./editorInterface";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isDarwin } from "./constants";
|
import { isDarwin } from "./editorInterface";
|
||||||
|
|
||||||
import type { ValueOf } from "./utility-types";
|
import type { ValueOf } from "./utility-types";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { average } from "@excalidraw/math";
|
import { average } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
|
||||||
ExcalidrawBindableElement,
|
|
||||||
FontFamilyValues,
|
|
||||||
FontString,
|
|
||||||
} from "@excalidraw/element/types";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ActiveTool,
|
ActiveTool,
|
||||||
@@ -20,7 +16,6 @@ import {
|
|||||||
ENV,
|
ENV,
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
getFontFamilyFallbacks,
|
getFontFamilyFallbacks,
|
||||||
isDarwin,
|
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
|
||||||
@@ -91,7 +86,8 @@ export const isWritableElement = (
|
|||||||
(target instanceof HTMLInputElement &&
|
(target instanceof HTMLInputElement &&
|
||||||
(target.type === "text" ||
|
(target.type === "text" ||
|
||||||
target.type === "number" ||
|
target.type === "number" ||
|
||||||
target.type === "password"));
|
target.type === "password" ||
|
||||||
|
target.type === "search"));
|
||||||
|
|
||||||
export const getFontFamilyString = ({
|
export const getFontFamilyString = ({
|
||||||
fontFamily,
|
fontFamily,
|
||||||
@@ -119,6 +115,11 @@ export const getFontString = ({
|
|||||||
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** executes callback in the frame that's after the current one */
|
||||||
|
export const nextAnimationFrame = async (cb: () => any) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(cb));
|
||||||
|
};
|
||||||
|
|
||||||
export const debounce = <T extends any[]>(
|
export const debounce = <T extends any[]>(
|
||||||
fn: (...args: T) => void,
|
fn: (...args: T) => void,
|
||||||
timeout: number,
|
timeout: number,
|
||||||
@@ -377,6 +378,10 @@ export const removeSelection = () => {
|
|||||||
|
|
||||||
export const distance = (x: number, y: number) => Math.abs(x - y);
|
export const distance = (x: number, y: number) => Math.abs(x - y);
|
||||||
|
|
||||||
|
export const isSelectionLikeTool = (type: ToolType | "custom") => {
|
||||||
|
return type === "selection" || type === "lasso";
|
||||||
|
};
|
||||||
|
|
||||||
export const updateActiveTool = (
|
export const updateActiveTool = (
|
||||||
appState: Pick<AppState, "activeTool">,
|
appState: Pick<AppState, "activeTool">,
|
||||||
data: ((
|
data: ((
|
||||||
@@ -418,19 +423,6 @@ export const allowFullScreen = () =>
|
|||||||
|
|
||||||
export const exitFullScreen = () => document.exitFullscreen();
|
export const exitFullScreen = () => document.exitFullscreen();
|
||||||
|
|
||||||
export const getShortcutKey = (shortcut: string): string => {
|
|
||||||
shortcut = shortcut
|
|
||||||
.replace(/\bAlt\b/i, "Alt")
|
|
||||||
.replace(/\bShift\b/i, "Shift")
|
|
||||||
.replace(/\b(Enter|Return)\b/i, "Enter");
|
|
||||||
if (isDarwin) {
|
|
||||||
return shortcut
|
|
||||||
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
|
|
||||||
.replace(/\bAlt\b/i, "Option");
|
|
||||||
}
|
|
||||||
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const viewportCoordsToSceneCoords = (
|
export const viewportCoordsToSceneCoords = (
|
||||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||||
{
|
{
|
||||||
@@ -566,9 +558,6 @@ export const isTransparent = (color: string) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
|
|
||||||
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
|
|
||||||
|
|
||||||
export type ResolvablePromise<T> = Promise<T> & {
|
export type ResolvablePromise<T> = Promise<T> & {
|
||||||
resolve: [T] extends [undefined]
|
resolve: [T] extends [undefined]
|
||||||
? (value?: MaybePromise<Awaited<T>>) => void
|
? (value?: MaybePromise<Awaited<T>>) => void
|
||||||
@@ -1278,3 +1267,47 @@ export const reduceToCommonValue = <T, R = T>(
|
|||||||
|
|
||||||
return commonValue;
|
return commonValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FEATURE_FLAGS = {
|
||||||
|
COMPLEX_BINDINGS: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_FLAGS_STORAGE_KEY = "excalidraw-feature-flags";
|
||||||
|
const DEFAULT_FEATURE_FLAGS: FEATURE_FLAGS = {
|
||||||
|
COMPLEX_BINDINGS: false,
|
||||||
|
};
|
||||||
|
let featureFlags: FEATURE_FLAGS | null = null;
|
||||||
|
|
||||||
|
export const getFeatureFlag = <F extends keyof FEATURE_FLAGS>(
|
||||||
|
flag: F,
|
||||||
|
): FEATURE_FLAGS[F] => {
|
||||||
|
if (!featureFlags) {
|
||||||
|
try {
|
||||||
|
const serializedFlags = localStorage.getItem(FEATURE_FLAGS_STORAGE_KEY);
|
||||||
|
if (serializedFlags) {
|
||||||
|
const flags = JSON.parse(serializedFlags);
|
||||||
|
featureFlags = flags ?? DEFAULT_FEATURE_FLAGS;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (featureFlags || DEFAULT_FEATURE_FLAGS)[flag];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
|
||||||
|
flag: F,
|
||||||
|
value: FEATURE_FLAGS[F],
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
featureFlags = {
|
||||||
|
...(featureFlags || DEFAULT_FEATURE_FLAGS),
|
||||||
|
[flag]: value,
|
||||||
|
};
|
||||||
|
localStorage.setItem(
|
||||||
|
FEATURE_FLAGS_STORAGE_KEY,
|
||||||
|
JSON.stringify(featureFlags),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("unable to set feature flag", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export const debugDrawPoints = (
|
|||||||
}: {
|
}: {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
points: LocalPoint[];
|
points: readonly LocalPoint[];
|
||||||
},
|
},
|
||||||
options?: any,
|
options?: any,
|
||||||
) => {
|
) => {
|
||||||
+1235
-1225
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,7 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
@@ -321,19 +322,42 @@ export const getElementLineSegments = (
|
|||||||
|
|
||||||
if (shape.type === "polycurve") {
|
if (shape.type === "polycurve") {
|
||||||
const curves = shape.data;
|
const curves = shape.data;
|
||||||
const points = curves
|
const pointsOnCurves = curves.map((curve) =>
|
||||||
.map((curve) => pointsOnBezierCurves(curve, 10))
|
pointsOnBezierCurves(curve, 10),
|
||||||
.flat();
|
);
|
||||||
let i = 0;
|
|
||||||
const segments: LineSegment<GlobalPoint>[] = [];
|
const segments: LineSegment<GlobalPoint>[] = [];
|
||||||
while (i < points.length - 1) {
|
|
||||||
segments.push(
|
if (
|
||||||
lineSegment(
|
(isLineElement(element) && !element.polygon) ||
|
||||||
pointFrom(points[i][0], points[i][1]),
|
isArrowElement(element)
|
||||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
) {
|
||||||
),
|
for (const points of pointsOnCurves) {
|
||||||
);
|
let i = 0;
|
||||||
i++;
|
|
||||||
|
while (i < points.length - 1) {
|
||||||
|
segments.push(
|
||||||
|
lineSegment(
|
||||||
|
pointFrom(points[i][0], points[i][1]),
|
||||||
|
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const points = pointsOnCurves.flat();
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < points.length - 1) {
|
||||||
|
segments.push(
|
||||||
|
lineSegment(
|
||||||
|
pointFrom(points[i][0], points[i][1]),
|
||||||
|
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
@@ -1126,7 +1150,9 @@ export interface BoundingBox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getCommonBoundingBox = (
|
export const getCommonBoundingBox = (
|
||||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
elements:
|
||||||
|
| readonly ExcalidrawElement[]
|
||||||
|
| readonly NonDeleted<ExcalidrawElement>[],
|
||||||
): BoundingBox => {
|
): BoundingBox => {
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
return {
|
return {
|
||||||
@@ -1250,6 +1276,13 @@ export const elementCenterPoint = (
|
|||||||
xOffset: number = 0,
|
xOffset: number = 0,
|
||||||
yOffset: number = 0,
|
yOffset: number = 0,
|
||||||
) => {
|
) => {
|
||||||
|
if (isLinearElement(element)) {
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||||
|
|
||||||
|
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||||
|
}
|
||||||
|
|
||||||
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||||
|
|
||||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isTransparent } from "@excalidraw/common";
|
import { invariant, isTransparent } from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
curveIntersectLineSegment,
|
curveIntersectLineSegment,
|
||||||
isPointWithinBounds,
|
isPointWithinBounds,
|
||||||
@@ -34,10 +34,14 @@ import {
|
|||||||
elementCenterPoint,
|
elementCenterPoint,
|
||||||
getCenterForBounds,
|
getCenterForBounds,
|
||||||
getCubicBezierCurveBound,
|
getCubicBezierCurveBound,
|
||||||
|
getDiamondPoints,
|
||||||
getElementBounds,
|
getElementBounds,
|
||||||
|
pointInsideBounds,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
|
isBindableElement,
|
||||||
|
isFrameLikeElement,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isImageElement,
|
isImageElement,
|
||||||
@@ -58,12 +62,17 @@ import { distanceToElement } from "./distance";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
|
ExcalidrawBindableElement,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawEllipseElement,
|
ExcalidrawEllipseElement,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
|
NonDeleted,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
Ordered,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||||
@@ -94,6 +103,7 @@ export type HitTestArgs = {
|
|||||||
threshold: number;
|
threshold: number;
|
||||||
elementsMap: ElementsMap;
|
elementsMap: ElementsMap;
|
||||||
frameNameBound?: FrameNameBounds | null;
|
frameNameBound?: FrameNameBounds | null;
|
||||||
|
overrideShouldTestInside?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementItself = ({
|
export const hitElementItself = ({
|
||||||
@@ -102,6 +112,7 @@ export const hitElementItself = ({
|
|||||||
threshold,
|
threshold,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
frameNameBound = null,
|
frameNameBound = null,
|
||||||
|
overrideShouldTestInside = false,
|
||||||
}: HitTestArgs) => {
|
}: HitTestArgs) => {
|
||||||
// Hit test against a frame's name
|
// Hit test against a frame's name
|
||||||
const hitFrameName = frameNameBound
|
const hitFrameName = frameNameBound
|
||||||
@@ -134,7 +145,9 @@ export const hitElementItself = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Do the precise (and relatively costly) hit test
|
// Do the precise (and relatively costly) hit test
|
||||||
const hitElement = shouldTestInside(element)
|
const hitElement = (
|
||||||
|
overrideShouldTestInside ? true : shouldTestInside(element)
|
||||||
|
)
|
||||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||||
// we would need `onShape` as well to include the "borders"
|
// we would need `onShape` as well to include the "borders"
|
||||||
isPointInElement(point, element, elementsMap) ||
|
isPointInElement(point, element, elementsMap) ||
|
||||||
@@ -193,6 +206,116 @@ export const hitElementBoundText = (
|
|||||||
return isPointInElement(point, boundTextElement, elementsMap);
|
return isPointInElement(point, boundTextElement, elementsMap);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bindingBorderTest = (
|
||||||
|
element: NonDeleted<ExcalidrawBindableElement>,
|
||||||
|
[x, y]: Readonly<GlobalPoint>,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
tolerance: number = 0,
|
||||||
|
): boolean => {
|
||||||
|
const p = pointFrom<GlobalPoint>(x, y);
|
||||||
|
const shouldTestInside =
|
||||||
|
// disable fullshape snapping for frame elements so we
|
||||||
|
// can bind to frame children
|
||||||
|
!isFrameLikeElement(element);
|
||||||
|
|
||||||
|
// PERF: Run a cheap test to see if the binding element
|
||||||
|
// is even close to the element
|
||||||
|
const t = Math.max(1, tolerance);
|
||||||
|
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
|
||||||
|
const elementBounds = getElementBounds(element, elementsMap);
|
||||||
|
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the element is inside a frame, we should clip the element
|
||||||
|
if (element.frameId) {
|
||||||
|
const enclosingFrame = elementsMap.get(element.frameId);
|
||||||
|
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
|
||||||
|
const enclosingFrameBounds = getElementBounds(
|
||||||
|
enclosingFrame,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
if (!pointInsideBounds(p, enclosingFrameBounds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the intersection test against the element since it's close enough
|
||||||
|
const intersections = intersectElementWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
lineSegment(elementCenterPoint(element, elementsMap), p),
|
||||||
|
);
|
||||||
|
const distance = distanceToElement(element, elementsMap, p);
|
||||||
|
|
||||||
|
return shouldTestInside
|
||||||
|
? intersections.length === 0 || distance <= tolerance
|
||||||
|
: intersections.length > 0 && distance <= t;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllHoveredElementAtPoint = (
|
||||||
|
point: Readonly<GlobalPoint>,
|
||||||
|
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
toleranceFn?: (element: ExcalidrawBindableElement) => number,
|
||||||
|
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||||
|
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||||
|
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||||
|
// because array is ordered from lower z-index to highest and we want element z-index
|
||||||
|
// with higher z-index
|
||||||
|
for (let index = elements.length - 1; index >= 0; --index) {
|
||||||
|
const element = elements[index];
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
!element.isDeleted,
|
||||||
|
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isBindableElement(element, false) &&
|
||||||
|
bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
|
||||||
|
) {
|
||||||
|
candidateElements.push(element);
|
||||||
|
|
||||||
|
if (!isTransparent(element.backgroundColor)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidateElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHoveredElementForBinding = (
|
||||||
|
point: Readonly<GlobalPoint>,
|
||||||
|
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
toleranceFn?: (element: ExcalidrawBindableElement) => number,
|
||||||
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
|
const candidateElements = getAllHoveredElementAtPoint(
|
||||||
|
point,
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
toleranceFn,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!candidateElements || candidateElements.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateElements.length === 1) {
|
||||||
|
return candidateElements[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer smaller shapes
|
||||||
|
return candidateElements
|
||||||
|
.sort(
|
||||||
|
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||||
|
)
|
||||||
|
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intersect a line with an element for binding test
|
* Intersect a line with an element for binding test
|
||||||
*
|
*
|
||||||
@@ -554,3 +677,61 @@ export const isPointInElement = (
|
|||||||
|
|
||||||
return intersections.length % 2 === 1;
|
return intersections.length % 2 === 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isBindableElementInsideOtherBindable = (
|
||||||
|
innerElement: ExcalidrawBindableElement,
|
||||||
|
outerElement: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): boolean => {
|
||||||
|
// Get corner points of the inner element based on its type
|
||||||
|
const getCornerPoints = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
offset: number,
|
||||||
|
): GlobalPoint[] => {
|
||||||
|
const { x, y, width, height, angle } = element;
|
||||||
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
|
if (element.type === "diamond") {
|
||||||
|
// Diamond has 4 corner points at the middle of each side
|
||||||
|
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||||
|
getDiamondPoints(element);
|
||||||
|
const corners: GlobalPoint[] = [
|
||||||
|
pointFrom(x + topX, y + topY - offset), // top
|
||||||
|
pointFrom(x + rightX + offset, y + rightY), // right
|
||||||
|
pointFrom(x + bottomX, y + bottomY + offset), // bottom
|
||||||
|
pointFrom(x + leftX - offset, y + leftY), // left
|
||||||
|
];
|
||||||
|
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||||
|
}
|
||||||
|
if (element.type === "ellipse") {
|
||||||
|
// For ellipse, test points at the extremes (top, right, bottom, left)
|
||||||
|
const cx = x + width / 2;
|
||||||
|
const cy = y + height / 2;
|
||||||
|
const rx = width / 2;
|
||||||
|
const ry = height / 2;
|
||||||
|
const corners: GlobalPoint[] = [
|
||||||
|
pointFrom(cx, cy - ry - offset), // top
|
||||||
|
pointFrom(cx + rx + offset, cy), // right
|
||||||
|
pointFrom(cx, cy + ry + offset), // bottom
|
||||||
|
pointFrom(cx - rx - offset, cy), // left
|
||||||
|
];
|
||||||
|
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||||
|
}
|
||||||
|
// Rectangle and other rectangular shapes (image, text, etc.)
|
||||||
|
const corners: GlobalPoint[] = [
|
||||||
|
pointFrom(x - offset, y - offset), // top-left
|
||||||
|
pointFrom(x + width + offset, y - offset), // top-right
|
||||||
|
pointFrom(x + width + offset, y + height + offset), // bottom-right
|
||||||
|
pointFrom(x - offset, y + height + offset), // bottom-left
|
||||||
|
];
|
||||||
|
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||||
|
};
|
||||||
|
|
||||||
|
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
|
||||||
|
const innerCorners = getCornerPoints(innerElement, offset);
|
||||||
|
|
||||||
|
// Check if all corner points of the inner element are inside the outer element
|
||||||
|
return innerCorners.every((corner) =>
|
||||||
|
isPointInElement(corner, outerElement, elementsMap),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export const hasBackground = (type: ElementOrToolType) =>
|
|||||||
type === "freedraw";
|
type === "freedraw";
|
||||||
|
|
||||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||||
type !== "image" && type !== "frame" && type !== "magicframe";
|
type === "rectangle" ||
|
||||||
|
type === "ellipse" ||
|
||||||
|
type === "diamond" ||
|
||||||
|
type === "freedraw" ||
|
||||||
|
type === "arrow" ||
|
||||||
|
type === "line" ||
|
||||||
|
type === "text";
|
||||||
|
|
||||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||||
type === "rectangle" ||
|
type === "rectangle" ||
|
||||||
@@ -39,6 +45,9 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
|
|||||||
type === "diamond" ||
|
type === "diamond" ||
|
||||||
type === "image";
|
type === "image";
|
||||||
|
|
||||||
|
export const hasContainerBehavior = (type: ElementOrToolType) =>
|
||||||
|
type === "rectangle" || type === "diamond" || type === "ellipse";
|
||||||
|
|
||||||
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
|
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
|
||||||
|
|
||||||
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
|
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
|
||||||
|
|||||||
@@ -2056,11 +2056,14 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static stripIrrelevantProps(
|
private static stripIrrelevantProps(partial: ElementPartial): ElementPartial {
|
||||||
partial: Partial<OrderedExcalidrawElement>,
|
const {
|
||||||
): ElementPartial {
|
id: _id,
|
||||||
const { id, updated, ...strippedPartial } = partial;
|
updated: _updated,
|
||||||
|
seed: _seed,
|
||||||
|
...strippedPartial
|
||||||
|
} = partial as any;
|
||||||
|
|
||||||
return strippedPartial;
|
return strippedPartial as ElementPartial;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
TEXT_AUTOWRAP_THRESHOLD,
|
TEXT_AUTOWRAP_THRESHOLD,
|
||||||
getGridPoint,
|
getGridPoint,
|
||||||
getFontString,
|
getFontString,
|
||||||
|
DRAGGING_THRESHOLD,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -13,7 +14,7 @@ import type {
|
|||||||
|
|
||||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { updateBoundElements } from "./binding";
|
import { unbindBindingElement, updateBoundElements } from "./binding";
|
||||||
import { getCommonBounds } from "./bounds";
|
import { getCommonBounds } from "./bounds";
|
||||||
import { getPerfectElementSize } from "./sizeHelpers";
|
import { getPerfectElementSize } from "./sizeHelpers";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
@@ -102,9 +103,26 @@ export const dragSelectedElements = (
|
|||||||
gridSize,
|
gridSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const elementsToUpdateIds = new Set(
|
||||||
|
Array.from(elementsToUpdate, (el) => el.id),
|
||||||
|
);
|
||||||
|
|
||||||
elementsToUpdate.forEach((element) => {
|
elementsToUpdate.forEach((element) => {
|
||||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
const isArrow = !isArrowElement(element);
|
||||||
|
const isStartBoundElementSelected =
|
||||||
|
isArrow ||
|
||||||
|
(element.startBinding
|
||||||
|
? elementsToUpdateIds.has(element.startBinding.elementId)
|
||||||
|
: false);
|
||||||
|
const isEndBoundElementSelected =
|
||||||
|
isArrow ||
|
||||||
|
(element.endBinding
|
||||||
|
? elementsToUpdateIds.has(element.endBinding.elementId)
|
||||||
|
: false);
|
||||||
|
|
||||||
if (!isArrowElement(element)) {
|
if (!isArrowElement(element)) {
|
||||||
|
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||||
|
|
||||||
// skip arrow labels since we calculate its position during render
|
// skip arrow labels since we calculate its position during render
|
||||||
const textElement = getBoundTextElement(
|
const textElement = getBoundTextElement(
|
||||||
element,
|
element,
|
||||||
@@ -121,6 +139,33 @@ export const dragSelectedElements = (
|
|||||||
updateBoundElements(element, scene, {
|
updateBoundElements(element, scene, {
|
||||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||||
});
|
});
|
||||||
|
} else if (
|
||||||
|
// NOTE: Add a little initial drag to the arrow dragging when the arrow
|
||||||
|
// is the single element being dragged to avoid accidentally unbinding
|
||||||
|
// the arrow when the user just wants to select it.
|
||||||
|
|
||||||
|
elementsToUpdate.size > 1 ||
|
||||||
|
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
|
||||||
|
DRAGGING_THRESHOLD ||
|
||||||
|
(!element.startBinding && !element.endBinding)
|
||||||
|
) {
|
||||||
|
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||||
|
|
||||||
|
const shouldUnbindStart =
|
||||||
|
element.startBinding && !isStartBoundElementSelected;
|
||||||
|
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
|
||||||
|
if (shouldUnbindStart || shouldUnbindEnd) {
|
||||||
|
// NOTE: Moving the bound arrow should unbind it, otherwise we would
|
||||||
|
// have weird situations, like 0 lenght arrow when the user moves
|
||||||
|
// the arrow outside a filled shape suddenly forcing the arrow start
|
||||||
|
// and end point to jump "outside" the shape.
|
||||||
|
if (shouldUnbindStart) {
|
||||||
|
unbindBindingElement(element, "start", scene);
|
||||||
|
}
|
||||||
|
if (shouldUnbindEnd) {
|
||||||
|
unbindBindingElement(element, "end", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
BinaryHeap,
|
BinaryHeap,
|
||||||
invariant,
|
invariant,
|
||||||
isAnyTrue,
|
isAnyTrue,
|
||||||
tupleToCoors,
|
|
||||||
getSizeFromPoints,
|
getSizeFromPoints,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
@@ -27,10 +26,11 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
bindPointToSnapToElementOutline,
|
bindPointToSnapToElementOutline,
|
||||||
FIXED_BINDING_DISTANCE,
|
|
||||||
getHeadingForElbowArrowSnap,
|
getHeadingForElbowArrowSnap,
|
||||||
getGlobalFixedPointForBindableElement,
|
getGlobalFixedPointForBindableElement,
|
||||||
getHoveredElementForBinding,
|
getBindingGap,
|
||||||
|
maxBindingDistance_simple,
|
||||||
|
BASE_BINDING_GAP_ELBOW,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { distanceToElement } from "./distance";
|
import { distanceToElement } from "./distance";
|
||||||
import {
|
import {
|
||||||
@@ -51,8 +51,8 @@ import {
|
|||||||
type ExcalidrawElbowArrowElement,
|
type ExcalidrawElbowArrowElement,
|
||||||
type NonDeletedSceneElementsMap,
|
type NonDeletedSceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { aabbForElement, pointInsideBounds } from "./bounds";
|
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||||
|
import { getHoveredElementForBinding } from "./collision";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type { Heading } from "./heading";
|
import type { Heading } from "./heading";
|
||||||
@@ -63,6 +63,7 @@ import type {
|
|||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
FixedSegment,
|
FixedSegment,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
|
Ordered,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
||||||
@@ -1243,6 +1244,7 @@ const getElbowArrowData = (
|
|||||||
const startGlobalPoint = getGlobalPoint(
|
const startGlobalPoint = getGlobalPoint(
|
||||||
{
|
{
|
||||||
...arrow,
|
...arrow,
|
||||||
|
angle: 0,
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
points: nextPoints,
|
points: nextPoints,
|
||||||
@@ -1257,6 +1259,7 @@ const getElbowArrowData = (
|
|||||||
const endGlobalPoint = getGlobalPoint(
|
const endGlobalPoint = getGlobalPoint(
|
||||||
{
|
{
|
||||||
...arrow,
|
...arrow,
|
||||||
|
angle: 0,
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
points: nextPoints,
|
points: nextPoints,
|
||||||
@@ -1274,6 +1277,7 @@ const getElbowArrowData = (
|
|||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
origStartGlobalPoint,
|
origStartGlobalPoint,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
options?.zoom,
|
||||||
);
|
);
|
||||||
const endHeading = getBindPointHeading(
|
const endHeading = getBindPointHeading(
|
||||||
endGlobalPoint,
|
endGlobalPoint,
|
||||||
@@ -1281,6 +1285,7 @@ const getElbowArrowData = (
|
|||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
origEndGlobalPoint,
|
origEndGlobalPoint,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
options?.zoom,
|
||||||
);
|
);
|
||||||
const startPointBounds = [
|
const startPointBounds = [
|
||||||
startGlobalPoint[0] - 2,
|
startGlobalPoint[0] - 2,
|
||||||
@@ -1301,8 +1306,8 @@ const getElbowArrowData = (
|
|||||||
offsetFromHeading(
|
offsetFromHeading(
|
||||||
startHeading,
|
startHeading,
|
||||||
arrow.startArrowhead
|
arrow.startArrowhead
|
||||||
? FIXED_BINDING_DISTANCE * 6
|
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
|
||||||
: FIXED_BINDING_DISTANCE * 2,
|
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1314,8 +1319,8 @@ const getElbowArrowData = (
|
|||||||
offsetFromHeading(
|
offsetFromHeading(
|
||||||
endHeading,
|
endHeading,
|
||||||
arrow.endArrowhead
|
arrow.endArrowhead
|
||||||
? FIXED_BINDING_DISTANCE * 6
|
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
|
||||||
: FIXED_BINDING_DISTANCE * 2,
|
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1362,8 +1367,8 @@ const getElbowArrowData = (
|
|||||||
? 0
|
? 0
|
||||||
: BASE_PADDING -
|
: BASE_PADDING -
|
||||||
(arrow.startArrowhead
|
(arrow.startArrowhead
|
||||||
? FIXED_BINDING_DISTANCE * 6
|
? BASE_BINDING_GAP_ELBOW * 6
|
||||||
: FIXED_BINDING_DISTANCE * 2),
|
: BASE_BINDING_GAP_ELBOW * 2),
|
||||||
BASE_PADDING,
|
BASE_PADDING,
|
||||||
),
|
),
|
||||||
boundsOverlap
|
boundsOverlap
|
||||||
@@ -1378,8 +1383,8 @@ const getElbowArrowData = (
|
|||||||
? 0
|
? 0
|
||||||
: BASE_PADDING -
|
: BASE_PADDING -
|
||||||
(arrow.endArrowhead
|
(arrow.endArrowhead
|
||||||
? FIXED_BINDING_DISTANCE * 6
|
? BASE_BINDING_GAP_ELBOW * 6
|
||||||
: FIXED_BINDING_DISTANCE * 2),
|
: BASE_BINDING_GAP_ELBOW * 2),
|
||||||
BASE_PADDING,
|
BASE_PADDING,
|
||||||
),
|
),
|
||||||
boundsOverlap,
|
boundsOverlap,
|
||||||
@@ -2239,6 +2244,7 @@ const getBindPointHeading = (
|
|||||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||||
origPoint: GlobalPoint,
|
origPoint: GlobalPoint,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
zoom?: AppState["zoom"],
|
||||||
): Heading =>
|
): Heading =>
|
||||||
getHeadingForElbowArrowSnap(
|
getHeadingForElbowArrowSnap(
|
||||||
p,
|
p,
|
||||||
@@ -2257,21 +2263,20 @@ const getBindPointHeading = (
|
|||||||
),
|
),
|
||||||
origPoint,
|
origPoint,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
const getHoveredElement = (
|
const getHoveredElement = (
|
||||||
origPoint: GlobalPoint,
|
origPoint: GlobalPoint,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
) => {
|
) => {
|
||||||
return getHoveredElementForBinding(
|
return getHoveredElementForBinding(
|
||||||
tupleToCoors(origPoint),
|
origPoint,
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
zoom,
|
(element) => maxBindingDistance_simple(zoom),
|
||||||
true,
|
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
PendingExcalidrawElements,
|
PendingExcalidrawElements,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { bindLinearElement } from "./binding";
|
import { bindBindingElement } from "./binding";
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
import {
|
import {
|
||||||
HEADING_DOWN,
|
HEADING_DOWN,
|
||||||
@@ -268,6 +268,7 @@ const addNewNode = (
|
|||||||
opacity: element.opacity,
|
opacity: element.opacity,
|
||||||
fillStyle: element.fillStyle,
|
fillStyle: element.fillStyle,
|
||||||
strokeStyle: element.strokeStyle,
|
strokeStyle: element.strokeStyle,
|
||||||
|
containerBehavior: element.containerBehavior,
|
||||||
});
|
});
|
||||||
|
|
||||||
invariant(
|
invariant(
|
||||||
@@ -346,6 +347,7 @@ export const addNewNodes = (
|
|||||||
opacity: startNode.opacity,
|
opacity: startNode.opacity,
|
||||||
fillStyle: startNode.fillStyle,
|
fillStyle: startNode.fillStyle,
|
||||||
strokeStyle: startNode.strokeStyle,
|
strokeStyle: startNode.strokeStyle,
|
||||||
|
containerBehavior: startNode.containerBehavior,
|
||||||
});
|
});
|
||||||
|
|
||||||
invariant(
|
invariant(
|
||||||
@@ -446,8 +448,14 @@ const createBindingArrow = (
|
|||||||
|
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
|
bindBindingElement(
|
||||||
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
|
bindingArrow,
|
||||||
|
startBindingElement,
|
||||||
|
"orbit",
|
||||||
|
"start",
|
||||||
|
scene,
|
||||||
|
);
|
||||||
|
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
|
||||||
|
|
||||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||||
changedElements.set(
|
changedElements.set(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { toIterable } from "@excalidraw/common";
|
import { toIterable } from "@excalidraw/common";
|
||||||
|
|
||||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||||
import { isLinearElementType } from "./typeChecks";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -29,6 +28,9 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
|||||||
|
|
||||||
// string hash function (using djb2). Not cryptographically secure, use only
|
// string hash function (using djb2). Not cryptographically secure, use only
|
||||||
// for versioning and such.
|
// for versioning and such.
|
||||||
|
// note: hashes individual code units (not code points),
|
||||||
|
// but for hashing purposes this is fine as it iterates through every code unit
|
||||||
|
// (as such, no need to encode to byte string first)
|
||||||
export const hashString = (s: string): number => {
|
export const hashString = (s: string): number => {
|
||||||
let hash: number = 5381;
|
let hash: number = 5381;
|
||||||
for (let i = 0; i < s.length; i++) {
|
for (let i = 0; i < s.length; i++) {
|
||||||
@@ -52,27 +54,6 @@ export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
|||||||
element: T,
|
element: T,
|
||||||
): element is NonDeleted<T> => !element.isDeleted;
|
): element is NonDeleted<T> => !element.isDeleted;
|
||||||
|
|
||||||
const _clearElements = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
): ExcalidrawElement[] =>
|
|
||||||
getNonDeletedElements(elements).map((element) =>
|
|
||||||
isLinearElementType(element.type)
|
|
||||||
? { ...element, lastCommittedPoint: null }
|
|
||||||
: element,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const clearElementsForDatabase = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
) => _clearElements(elements);
|
|
||||||
|
|
||||||
export const clearElementsForExport = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
) => _clearElements(elements);
|
|
||||||
|
|
||||||
export const clearElementsForLocalStorage = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
) => _clearElements(elements);
|
|
||||||
|
|
||||||
export * from "./align";
|
export * from "./align";
|
||||||
export * from "./binding";
|
export * from "./binding";
|
||||||
export * from "./bounds";
|
export * from "./bounds";
|
||||||
@@ -97,6 +78,7 @@ export * from "./image";
|
|||||||
export * from "./linearElementEditor";
|
export * from "./linearElementEditor";
|
||||||
export * from "./mutateElement";
|
export * from "./mutateElement";
|
||||||
export * from "./newElement";
|
export * from "./newElement";
|
||||||
|
export * from "./positionElementsOnGrid";
|
||||||
export * from "./renderElement";
|
export * from "./renderElement";
|
||||||
export * from "./resizeElements";
|
export * from "./resizeElements";
|
||||||
export * from "./resizeTest";
|
export * from "./resizeTest";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -46,16 +46,13 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
|
|
||||||
// casting to any because can't use `in` operator
|
// casting to any because can't use `in` operator
|
||||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||||
const { points, fixedSegments, startBinding, endBinding, fileId } =
|
const { points, fixedSegments, fileId } = updates as any;
|
||||||
updates as any;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isElbowArrow(element) &&
|
isElbowArrow(element) &&
|
||||||
(Object.keys(updates).length === 0 || // normalization case
|
(Object.keys(updates).length === 0 || // normalization case
|
||||||
typeof points !== "undefined" || // repositioning
|
typeof points !== "undefined" || // repositioning
|
||||||
typeof fixedSegments !== "undefined" || // segment fixing
|
typeof fixedSegments !== "undefined") // segment fixing
|
||||||
typeof startBinding !== "undefined" ||
|
|
||||||
typeof endBinding !== "undefined") // manual binding to element
|
|
||||||
) {
|
) {
|
||||||
updates = {
|
updates = {
|
||||||
...updates,
|
...updates,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getFontString,
|
getFontString,
|
||||||
getUpdatedTimestamp,
|
getUpdatedTimestamp,
|
||||||
getLineHeight,
|
getLineHeight,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
@@ -21,11 +22,11 @@ import {
|
|||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import { newElementWith } from "./mutateElement";
|
import { newElementWith } from "./mutateElement";
|
||||||
import { getBoundTextMaxWidth } from "./textElement";
|
import { getBoundTextMaxWidth, getBoundTextMaxHeight } from "./textElement";
|
||||||
import { normalizeText, measureText } from "./textMeasurements";
|
import { normalizeText, measureText } from "./textMeasurements";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
|
|
||||||
import { isLineElement } from "./typeChecks";
|
import { isFlowchartType, isLineElement } from "./typeChecks";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -48,6 +49,8 @@ import type {
|
|||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
ExcalidrawLineElement,
|
ExcalidrawLineElement,
|
||||||
|
ExcalidrawFlowchartNodeElement,
|
||||||
|
ContainerBehavior,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export type ElementConstructorOpts = MarkOptional<
|
export type ElementConstructorOpts = MarkOptional<
|
||||||
@@ -158,9 +161,26 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
export const newElement = (
|
export const newElement = (
|
||||||
opts: {
|
opts: {
|
||||||
type: ExcalidrawGenericElement["type"];
|
type: ExcalidrawGenericElement["type"];
|
||||||
|
containerBehavior?: ContainerBehavior;
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawGenericElement> =>
|
): NonDeleted<ExcalidrawGenericElement> => {
|
||||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
if (isFlowchartType(opts.type)) {
|
||||||
|
return {
|
||||||
|
..._newElementBase<ExcalidrawFlowchartNodeElement>(
|
||||||
|
opts.type as ExcalidrawFlowchartNodeElement["type"],
|
||||||
|
opts,
|
||||||
|
),
|
||||||
|
containerBehavior: {
|
||||||
|
textFlow: opts.containerBehavior?.textFlow ?? "growing",
|
||||||
|
margin: opts.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
|
},
|
||||||
|
} as NonDeleted<ExcalidrawFlowchartNodeElement>;
|
||||||
|
}
|
||||||
|
return _newElementBase<ExcalidrawGenericElement>(
|
||||||
|
opts.type,
|
||||||
|
opts,
|
||||||
|
) as NonDeleted<ExcalidrawGenericElement>;
|
||||||
|
};
|
||||||
|
|
||||||
export const newEmbeddableElement = (
|
export const newEmbeddableElement = (
|
||||||
opts: {
|
opts: {
|
||||||
@@ -417,6 +437,96 @@ const adjustXYWithRotation = (
|
|||||||
return [x, y];
|
return [x, y];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sticky note font sizing constants
|
||||||
|
export const STICKY_NOTE_FONT_STEP = 2;
|
||||||
|
export const STICKY_NOTE_MIN_FONT_SIZE = 8;
|
||||||
|
export const STICKY_NOTE_MAX_FONT_SIZE = 72;
|
||||||
|
|
||||||
|
export interface StickyNoteFontComputationResult {
|
||||||
|
fontSize: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
wrappedText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the appropriate font size (snapped to step) so that the text fits
|
||||||
|
* inside the sticky note container without resizing the container.
|
||||||
|
* It first tries to shrink if overflowing, otherwise opportunistically enlarges
|
||||||
|
* (still snapped) while it fits. Width is constrained by wrap at container width.
|
||||||
|
*/
|
||||||
|
export const computeStickyNoteFontSize = (
|
||||||
|
text: string,
|
||||||
|
element: ExcalidrawTextElement,
|
||||||
|
container: ExcalidrawTextContainer,
|
||||||
|
maxGrowFontSize?: number,
|
||||||
|
): StickyNoteFontComputationResult => {
|
||||||
|
const step = STICKY_NOTE_FONT_STEP;
|
||||||
|
const maxH = getBoundTextMaxHeight(container, element as any);
|
||||||
|
const maxW = getBoundTextMaxWidth(container, element);
|
||||||
|
|
||||||
|
const snap = (size: number) =>
|
||||||
|
Math.max(
|
||||||
|
STICKY_NOTE_MIN_FONT_SIZE,
|
||||||
|
Math.max(step, Math.floor(size / step) * step),
|
||||||
|
);
|
||||||
|
|
||||||
|
let size = snap(element.fontSize);
|
||||||
|
const growthCap = snap(
|
||||||
|
maxGrowFontSize != null ? maxGrowFontSize : STICKY_NOTE_MAX_FONT_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lineHeight = element.lineHeight;
|
||||||
|
const fontFamily = element.fontFamily;
|
||||||
|
|
||||||
|
const measure = (fontSize: number) => {
|
||||||
|
const font = getFontString({ fontFamily, fontSize });
|
||||||
|
const wrappedText = wrapText(text, font, maxW);
|
||||||
|
const metrics = measureText(wrappedText, font, lineHeight);
|
||||||
|
return {
|
||||||
|
wrappedText,
|
||||||
|
width: Math.min(metrics.width, maxW),
|
||||||
|
height: metrics.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let { wrappedText, width, height } = measure(size);
|
||||||
|
|
||||||
|
if (height > maxH) {
|
||||||
|
// shrink until fits or min
|
||||||
|
while (size > STICKY_NOTE_MIN_FONT_SIZE) {
|
||||||
|
const next = snap(size - step);
|
||||||
|
if (next === size) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
size = next;
|
||||||
|
({ wrappedText, width, height } = measure(size));
|
||||||
|
if (height <= maxH) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// grow back only up to growthCap (initial session font size)
|
||||||
|
while (size < growthCap) {
|
||||||
|
const next = snap(Math.min(size + step, growthCap));
|
||||||
|
if (next === size) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const m = measure(next);
|
||||||
|
if (m.height <= maxH) {
|
||||||
|
size = next;
|
||||||
|
wrappedText = m.wrappedText;
|
||||||
|
width = m.width;
|
||||||
|
height = m.height;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fontSize: size, width, height, wrappedText };
|
||||||
|
};
|
||||||
|
|
||||||
export const refreshTextDimensions = (
|
export const refreshTextDimensions = (
|
||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
container: ExcalidrawTextContainer | null,
|
container: ExcalidrawTextContainer | null,
|
||||||
@@ -452,7 +562,6 @@ export const newFreeDrawElement = (
|
|||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
pressures: opts.pressures || [],
|
pressures: opts.pressures || [],
|
||||||
simulatePressure: opts.simulatePressure,
|
simulatePressure: opts.simulatePressure,
|
||||||
lastCommittedPoint: null,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -466,7 +575,7 @@ export const newLinearElement = (
|
|||||||
const element = {
|
const element = {
|
||||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
lastCommittedPoint: null,
|
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
endBinding: null,
|
endBinding: null,
|
||||||
startArrowhead: null,
|
startArrowhead: null,
|
||||||
@@ -501,7 +610,6 @@ export const newArrowElement = <T extends boolean>(
|
|||||||
return {
|
return {
|
||||||
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
|
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
|
||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
lastCommittedPoint: null,
|
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
endBinding: null,
|
endBinding: null,
|
||||||
startArrowhead: opts.startArrowhead || null,
|
startArrowhead: opts.startArrowhead || null,
|
||||||
@@ -516,7 +624,6 @@ export const newArrowElement = <T extends boolean>(
|
|||||||
return {
|
return {
|
||||||
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
lastCommittedPoint: null,
|
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
endBinding: null,
|
endBinding: null,
|
||||||
startArrowhead: opts.startArrowhead || null,
|
startArrowhead: opts.startArrowhead || null,
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { getCommonBounds } from "./bounds";
|
||||||
|
import { type ElementUpdate, newElementWith } from "./mutateElement";
|
||||||
|
|
||||||
|
import type { ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
|
// TODO rewrite (mostly vibe-coded)
|
||||||
|
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
|
||||||
|
elements: TElement[] | TElement[][],
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
padding = 50,
|
||||||
|
): TElement[] => {
|
||||||
|
// Ensure there are elements to position
|
||||||
|
if (!elements || elements.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const res: TElement[] = [];
|
||||||
|
// Normalize input to work with atomic units (groups of elements)
|
||||||
|
// If elements is a flat array, treat each element as its own atomic unit
|
||||||
|
const atomicUnits: TElement[][] = Array.isArray(elements[0])
|
||||||
|
? (elements as TElement[][])
|
||||||
|
: (elements as TElement[]).map((element) => [element]);
|
||||||
|
|
||||||
|
// Determine the number of columns for atomic units
|
||||||
|
// A common approach for a "grid-like" layout without specific column constraints
|
||||||
|
// is to aim for a roughly square arrangement.
|
||||||
|
const numUnits = atomicUnits.length;
|
||||||
|
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
|
||||||
|
|
||||||
|
// Group atomic units into rows based on the calculated number of columns
|
||||||
|
const rows: TElement[][][] = [];
|
||||||
|
for (let i = 0; i < numUnits; i += numColumns) {
|
||||||
|
rows.push(atomicUnits.slice(i, i + numColumns));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate properties for each row (total width, max height)
|
||||||
|
// and the total actual height of all row content.
|
||||||
|
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
|
||||||
|
const rowProperties = rows.map((rowUnits) => {
|
||||||
|
let rowWidth = 0;
|
||||||
|
let maxUnitHeightInRow = 0;
|
||||||
|
|
||||||
|
const unitBounds = rowUnits.map((unit) => {
|
||||||
|
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
|
||||||
|
return {
|
||||||
|
elements: unit,
|
||||||
|
bounds: [minX, minY, maxX, maxY] as const,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
unitBounds.forEach((unitBound, index) => {
|
||||||
|
rowWidth += unitBound.width;
|
||||||
|
// Add padding between units in the same row, but not after the last one
|
||||||
|
if (index < unitBounds.length - 1) {
|
||||||
|
rowWidth += padding;
|
||||||
|
}
|
||||||
|
if (unitBound.height > maxUnitHeightInRow) {
|
||||||
|
maxUnitHeightInRow = unitBound.height;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
totalGridActualHeight += maxUnitHeightInRow;
|
||||||
|
return {
|
||||||
|
unitBounds,
|
||||||
|
width: rowWidth,
|
||||||
|
maxHeight: maxUnitHeightInRow,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate the total height of the grid including padding between rows
|
||||||
|
const totalGridHeightWithPadding =
|
||||||
|
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
|
||||||
|
|
||||||
|
// Calculate the starting Y position to center the entire grid vertically around centerY
|
||||||
|
let currentY = centerY - totalGridHeightWithPadding / 2;
|
||||||
|
|
||||||
|
// Position atomic units row by row
|
||||||
|
rowProperties.forEach((rowProp) => {
|
||||||
|
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
|
||||||
|
|
||||||
|
// Calculate the starting X for the current row to center it horizontally around centerX
|
||||||
|
let currentX = centerX - rowWidth / 2;
|
||||||
|
|
||||||
|
unitBounds.forEach((unitBound) => {
|
||||||
|
// Calculate the offset needed to position this atomic unit
|
||||||
|
const [originalMinX, originalMinY] = unitBound.bounds;
|
||||||
|
const offsetX = currentX - originalMinX;
|
||||||
|
const offsetY = currentY - originalMinY;
|
||||||
|
|
||||||
|
// Apply the offset to all elements in this atomic unit
|
||||||
|
unitBound.elements.forEach((element) => {
|
||||||
|
res.push(
|
||||||
|
newElementWith(element, {
|
||||||
|
x: element.x + offsetX,
|
||||||
|
y: element.y + offsetY,
|
||||||
|
} as ElementUpdate<TElement>),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move X for the next unit in the row
|
||||||
|
currentX += unitBound.width + padding;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move Y to the starting position for the next row
|
||||||
|
// This accounts for the tallest unit in the current row and the inter-row padding
|
||||||
|
currentY += rowMaxHeight + padding;
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
};
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { getStroke } from "perfect-freehand";
|
import { getStroke } from "perfect-freehand";
|
||||||
|
|
||||||
import { isRightAngleRads } from "@excalidraw/math";
|
import {
|
||||||
|
type GlobalPoint,
|
||||||
|
isRightAngleRads,
|
||||||
|
lineSegment,
|
||||||
|
pointFrom,
|
||||||
|
pointRotateRads,
|
||||||
|
type Radians,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
@@ -14,6 +21,7 @@ import {
|
|||||||
getFontString,
|
getFontString,
|
||||||
isRTL,
|
isRTL,
|
||||||
getVerticalOffset,
|
getVerticalOffset,
|
||||||
|
invariant,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -32,7 +40,7 @@ import type {
|
|||||||
InteractiveCanvasRenderConfig,
|
InteractiveCanvasRenderConfig,
|
||||||
} from "@excalidraw/excalidraw/scene/types";
|
} from "@excalidraw/excalidraw/scene/types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords } from "./bounds";
|
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||||
import { getUncroppedImageElement } from "./cropElement";
|
import { getUncroppedImageElement } from "./cropElement";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import {
|
import {
|
||||||
@@ -90,7 +98,7 @@ const isPendingImageElement = (
|
|||||||
const shouldResetImageFilter = (
|
const shouldResetImageFilter = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
appState.theme === THEME.DARK &&
|
appState.theme === THEME.DARK &&
|
||||||
@@ -217,7 +225,7 @@ const generateElementCanvas = (
|
|||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||||
): ExcalidrawElementWithCanvas | null => {
|
): ExcalidrawElementWithCanvas | null => {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const context = canvas.getContext("2d")!;
|
const context = canvas.getContext("2d")!;
|
||||||
@@ -269,7 +277,7 @@ const generateElementCanvas = (
|
|||||||
context.filter = IMAGE_INVERT_FILTER;
|
context.filter = IMAGE_INVERT_FILTER;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
|
|
||||||
@@ -321,16 +329,24 @@ const generateElementCanvas = (
|
|||||||
boundTextCanvasContext.translate(-shiftX, -shiftY);
|
boundTextCanvasContext.translate(-shiftX, -shiftY);
|
||||||
// Clear the bound text area
|
// Clear the bound text area
|
||||||
boundTextCanvasContext.clearRect(
|
boundTextCanvasContext.clearRect(
|
||||||
-(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
|
-(
|
||||||
|
boundTextElement.width / 2 +
|
||||||
|
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING)
|
||||||
|
) *
|
||||||
window.devicePixelRatio *
|
window.devicePixelRatio *
|
||||||
scale,
|
scale,
|
||||||
-(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
|
-(
|
||||||
|
boundTextElement.height / 2 +
|
||||||
|
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING)
|
||||||
|
) *
|
||||||
window.devicePixelRatio *
|
window.devicePixelRatio *
|
||||||
scale,
|
scale,
|
||||||
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
|
(boundTextElement.width +
|
||||||
|
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING) * 2) *
|
||||||
window.devicePixelRatio *
|
window.devicePixelRatio *
|
||||||
scale,
|
scale,
|
||||||
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
|
(boundTextElement.height +
|
||||||
|
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING) * 2) *
|
||||||
window.devicePixelRatio *
|
window.devicePixelRatio *
|
||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
@@ -404,7 +420,6 @@ const drawElementOnCanvas = (
|
|||||||
rc: RoughCanvas,
|
rc: RoughCanvas,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
|
||||||
) => {
|
) => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
@@ -550,7 +565,7 @@ const generateElementWithCanvas = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
const zoom: Zoom = renderConfig
|
const zoom: Zoom = renderConfig
|
||||||
? appState.zoom
|
? appState.zoom
|
||||||
@@ -607,7 +622,7 @@ const drawElementFromCanvas = (
|
|||||||
elementWithCanvas: ExcalidrawElementWithCanvas,
|
elementWithCanvas: ExcalidrawElementWithCanvas,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||||
allElementsMap: NonDeletedSceneElementsMap,
|
allElementsMap: NonDeletedSceneElementsMap,
|
||||||
) => {
|
) => {
|
||||||
const element = elementWithCanvas.element;
|
const element = elementWithCanvas.element;
|
||||||
@@ -725,7 +740,7 @@ export const renderElement = (
|
|||||||
rc: RoughCanvas,
|
rc: RoughCanvas,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
const reduceAlphaForSelection =
|
const reduceAlphaForSelection =
|
||||||
appState.openDialog?.name === "elementLinkSelector" &&
|
appState.openDialog?.name === "elementLinkSelector" &&
|
||||||
@@ -795,7 +810,7 @@ export const renderElement = (
|
|||||||
context.translate(cx, cy);
|
context.translate(cx, cy);
|
||||||
context.rotate(element.angle);
|
context.rotate(element.angle);
|
||||||
context.translate(-shiftX, -shiftY);
|
context.translate(-shiftX, -shiftY);
|
||||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||||
context.restore();
|
context.restore();
|
||||||
} else {
|
} else {
|
||||||
const elementWithCanvas = generateElementWithCanvas(
|
const elementWithCanvas = generateElementWithCanvas(
|
||||||
@@ -888,13 +903,7 @@ export const renderElement = (
|
|||||||
|
|
||||||
tempCanvasContext.translate(-shiftX, -shiftY);
|
tempCanvasContext.translate(-shiftX, -shiftY);
|
||||||
|
|
||||||
drawElementOnCanvas(
|
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
|
||||||
element,
|
|
||||||
tempRc,
|
|
||||||
tempCanvasContext,
|
|
||||||
renderConfig,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
tempCanvasContext.translate(shiftX, shiftY);
|
tempCanvasContext.translate(shiftX, shiftY);
|
||||||
|
|
||||||
@@ -933,7 +942,7 @@ export const renderElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.translate(-shiftX, -shiftY);
|
context.translate(-shiftX, -shiftY);
|
||||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
@@ -1039,6 +1048,66 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||||
|
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFreedrawOutlineAsSegments(
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
points: [number, number][],
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
) {
|
||||||
|
const bounds = getElementBounds(
|
||||||
|
{
|
||||||
|
...element,
|
||||||
|
angle: 0 as Radians,
|
||||||
|
},
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const center = pointFrom<GlobalPoint>(
|
||||||
|
(bounds[0] + bounds[2]) / 2,
|
||||||
|
(bounds[1] + bounds[3]) / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
|
||||||
|
|
||||||
|
return points.slice(2).reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
acc.push(
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
acc[acc.length - 1][1],
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
points[0][0] + element.x,
|
||||||
|
points[0][1] + element.y,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
points[1][0] + element.x,
|
||||||
|
points[1][1] + element.y,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
|
||||||
// If input points are empty (should they ever be?) return a dot
|
// If input points are empty (should they ever be?) return a dot
|
||||||
const inputPoints = element.simulatePressure
|
const inputPoints = element.simulatePressure
|
||||||
? element.points
|
? element.points
|
||||||
@@ -1054,10 +1123,10 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
|||||||
smoothing: 0.5,
|
smoothing: 0.5,
|
||||||
streamline: 0.5,
|
streamline: 0.5,
|
||||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
last: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
|
return getStroke(inputPoints as number[][], options) as [number, number][];
|
||||||
}
|
}
|
||||||
|
|
||||||
function med(A: number[], B: number[]) {
|
function med(A: number[], B: number[]) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SHIFT_LOCKING_ANGLE,
|
SHIFT_LOCKING_ANGLE,
|
||||||
rescalePoints,
|
rescalePoints,
|
||||||
getFontString,
|
getFontString,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { GlobalPoint } from "@excalidraw/math";
|
import type { GlobalPoint } from "@excalidraw/math";
|
||||||
@@ -20,7 +21,11 @@ import type { PointerDownState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
|
import {
|
||||||
|
getArrowLocalFixedPoints,
|
||||||
|
unbindBindingElement,
|
||||||
|
updateBoundElements,
|
||||||
|
} from "./binding";
|
||||||
import {
|
import {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getCommonBounds,
|
getCommonBounds,
|
||||||
@@ -46,6 +51,7 @@ import {
|
|||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
|
isBindingElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
@@ -74,7 +80,9 @@ import type {
|
|||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
ExcalidrawArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
|
|
||||||
// Returns true when transform (resizing/rotation) happened
|
// Returns true when transform (resizing/rotation) happened
|
||||||
export const transformElements = (
|
export const transformElements = (
|
||||||
@@ -220,7 +228,25 @@ const rotateSingleElement = (
|
|||||||
}
|
}
|
||||||
const boundTextElementId = getBoundTextElementId(element);
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
|
||||||
scene.mutateElement(element, { angle });
|
let update: ElementUpdate<NonDeletedExcalidrawElement> = {
|
||||||
|
angle,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isBindingElement(element)) {
|
||||||
|
update = {
|
||||||
|
...update,
|
||||||
|
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||||
|
|
||||||
|
if (element.startBinding) {
|
||||||
|
unbindBindingElement(element, "start", scene);
|
||||||
|
}
|
||||||
|
if (element.endBinding) {
|
||||||
|
unbindBindingElement(element, "end", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.mutateElement(element, update);
|
||||||
|
|
||||||
if (boundTextElementId) {
|
if (boundTextElementId) {
|
||||||
const textElement =
|
const textElement =
|
||||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||||
@@ -394,6 +420,11 @@ const rotateMultipleElements = (
|
|||||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rotatedElementsMap = new Map<
|
||||||
|
ExcalidrawElement["id"],
|
||||||
|
NonDeletedExcalidrawElement
|
||||||
|
>(elements.map((element) => [element.id, element]));
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
if (!isFrameLikeElement(element)) {
|
if (!isFrameLikeElement(element)) {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
@@ -424,6 +455,19 @@ const rotateMultipleElements = (
|
|||||||
simultaneouslyUpdated: elements,
|
simultaneouslyUpdated: elements,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isBindingElement(element)) {
|
||||||
|
if (element.startBinding) {
|
||||||
|
if (!rotatedElementsMap.has(element.startBinding.elementId)) {
|
||||||
|
unbindBindingElement(element, "start", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (element.endBinding) {
|
||||||
|
if (!rotatedElementsMap.has(element.endBinding.elementId)) {
|
||||||
|
unbindBindingElement(element, "end", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
if (boundText && !isArrowElement(element)) {
|
if (boundText && !isArrowElement(element)) {
|
||||||
const { x, y } = computeBoundTextPosition(
|
const { x, y } = computeBoundTextPosition(
|
||||||
@@ -741,10 +785,12 @@ export const resizeSingleElement = (
|
|||||||
const minWidth = getApproxMinLineWidth(
|
const minWidth = getApproxMinLineWidth(
|
||||||
getFontString(boundTextElement),
|
getFontString(boundTextElement),
|
||||||
boundTextElement.lineHeight,
|
boundTextElement.lineHeight,
|
||||||
|
latestElement.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
);
|
);
|
||||||
const minHeight = getApproxMinLineHeight(
|
const minHeight = getApproxMinLineHeight(
|
||||||
boundTextElement.fontSize,
|
boundTextElement.fontSize,
|
||||||
boundTextElement.lineHeight,
|
boundTextElement.lineHeight,
|
||||||
|
latestElement.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
);
|
);
|
||||||
nextWidth = Math.max(nextWidth, minWidth);
|
nextWidth = Math.max(nextWidth, minWidth);
|
||||||
nextHeight = Math.max(nextHeight, minHeight);
|
nextHeight = Math.max(nextHeight, minHeight);
|
||||||
@@ -835,13 +881,32 @@ export const resizeSingleElement = (
|
|||||||
Number.isFinite(newOrigin.x) &&
|
Number.isFinite(newOrigin.x) &&
|
||||||
Number.isFinite(newOrigin.y)
|
Number.isFinite(newOrigin.y)
|
||||||
) {
|
) {
|
||||||
const updates = {
|
let updates: ElementUpdate<ExcalidrawElement> = {
|
||||||
...newOrigin,
|
...newOrigin,
|
||||||
width: Math.abs(nextWidth),
|
width: Math.abs(nextWidth),
|
||||||
height: Math.abs(nextHeight),
|
height: Math.abs(nextHeight),
|
||||||
...rescaledPoints,
|
...rescaledPoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isBindingElement(latestElement)) {
|
||||||
|
if (latestElement.startBinding) {
|
||||||
|
updates = {
|
||||||
|
...updates,
|
||||||
|
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||||
|
|
||||||
|
if (latestElement.startBinding) {
|
||||||
|
unbindBindingElement(latestElement, "start", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestElement.endBinding) {
|
||||||
|
updates = {
|
||||||
|
...updates,
|
||||||
|
endBinding: null,
|
||||||
|
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
scene.mutateElement(latestElement, updates, {
|
scene.mutateElement(latestElement, updates, {
|
||||||
informMutation: shouldInformMutation,
|
informMutation: shouldInformMutation,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
@@ -859,10 +924,7 @@ export const resizeSingleElement = (
|
|||||||
shouldMaintainAspectRatio,
|
shouldMaintainAspectRatio,
|
||||||
);
|
);
|
||||||
|
|
||||||
updateBoundElements(latestElement, scene, {
|
updateBoundElements(latestElement, scene);
|
||||||
// TODO: confirm with MARK if this actually makes sense
|
|
||||||
newSize: { width: nextWidth, height: nextHeight },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1396,20 +1458,36 @@ export const resizeMultipleElements = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
||||||
|
const resizedElementsMap = new Map<
|
||||||
|
ExcalidrawElement["id"],
|
||||||
|
NonDeletedExcalidrawElement
|
||||||
|
>(elementsAndUpdates.map(({ element }) => [element.id, element]));
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
element,
|
element,
|
||||||
update: { boundTextFontSize, ...update },
|
update: { boundTextFontSize, ...update },
|
||||||
} of elementsAndUpdates) {
|
} of elementsAndUpdates) {
|
||||||
const { width, height, angle } = update;
|
const { angle } = update;
|
||||||
|
|
||||||
scene.mutateElement(element, update);
|
scene.mutateElement(element, update);
|
||||||
|
|
||||||
updateBoundElements(element, scene, {
|
updateBoundElements(element, scene, {
|
||||||
simultaneouslyUpdated: elementsToUpdate,
|
simultaneouslyUpdated: elementsToUpdate,
|
||||||
newSize: { width, height },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isBindingElement(element)) {
|
||||||
|
if (element.startBinding) {
|
||||||
|
if (!resizedElementsMap.has(element.startBinding.elementId)) {
|
||||||
|
unbindBindingElement(element, "start", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (element.endBinding) {
|
||||||
|
if (!resizedElementsMap.has(element.endBinding.elementId)) {
|
||||||
|
unbindBindingElement(element, "end", scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement && boundTextFontSize) {
|
if (boundTextElement && boundTextFontSize) {
|
||||||
scene.mutateElement(boundTextElement, {
|
scene.mutateElement(boundTextElement, {
|
||||||
|
|||||||
@@ -5,17 +5,20 @@ import {
|
|||||||
type Radians,
|
type Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { SIDE_RESIZING_THRESHOLD } from "@excalidraw/common";
|
import {
|
||||||
|
SIDE_RESIZING_THRESHOLD,
|
||||||
|
type EditorInterface,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
|
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState, Device, Zoom } from "@excalidraw/excalidraw/types";
|
import type { AppState, Zoom } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords } from "./bounds";
|
import { getElementAbsoluteCoords } from "./bounds";
|
||||||
import {
|
import {
|
||||||
getTransformHandlesFromCoords,
|
getTransformHandlesFromCoords,
|
||||||
getTransformHandles,
|
getTransformHandles,
|
||||||
getOmitSidesForDevice,
|
getOmitSidesForEditorInterface,
|
||||||
canResizeFromSides,
|
canResizeFromSides,
|
||||||
} from "./transformHandles";
|
} from "./transformHandles";
|
||||||
import { isImageElement, isLinearElement } from "./typeChecks";
|
import { isImageElement, isLinearElement } from "./typeChecks";
|
||||||
@@ -51,7 +54,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
y: number,
|
y: number,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
device: Device,
|
editorInterface: EditorInterface,
|
||||||
): MaybeTransformHandleType => {
|
): MaybeTransformHandleType => {
|
||||||
if (!appState.selectedElementIds[element.id]) {
|
if (!appState.selectedElementIds[element.id]) {
|
||||||
return false;
|
return false;
|
||||||
@@ -63,7 +66,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
zoom,
|
zoom,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
pointerType,
|
pointerType,
|
||||||
getOmitSidesForDevice(device),
|
getOmitSidesForEditorInterface(editorInterface),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -86,7 +89,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
return filter[0] as TransformHandleType;
|
return filter[0] as TransformHandleType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canResizeFromSides(device)) {
|
if (canResizeFromSides(editorInterface)) {
|
||||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -132,7 +135,7 @@ export const getElementWithTransformHandleType = (
|
|||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
device: Device,
|
editorInterface: EditorInterface,
|
||||||
) => {
|
) => {
|
||||||
return elements.reduce((result, element) => {
|
return elements.reduce((result, element) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -146,7 +149,7 @@ export const getElementWithTransformHandleType = (
|
|||||||
scenePointerY,
|
scenePointerY,
|
||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
device,
|
editorInterface,
|
||||||
);
|
);
|
||||||
return transformHandleType ? { element, transformHandleType } : null;
|
return transformHandleType ? { element, transformHandleType } : null;
|
||||||
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
||||||
@@ -160,14 +163,14 @@ export const getTransformHandleTypeFromCoords = <
|
|||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
device: Device,
|
editorInterface: EditorInterface,
|
||||||
): MaybeTransformHandleType => {
|
): MaybeTransformHandleType => {
|
||||||
const transformHandles = getTransformHandlesFromCoords(
|
const transformHandles = getTransformHandlesFromCoords(
|
||||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||||
0 as Radians,
|
0 as Radians,
|
||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
getOmitSidesForDevice(device),
|
getOmitSidesForEditorInterface(editorInterface),
|
||||||
);
|
);
|
||||||
|
|
||||||
const found = Object.keys(transformHandles).find((key) => {
|
const found = Object.keys(transformHandles).find((key) => {
|
||||||
@@ -183,7 +186,7 @@ export const getTransformHandleTypeFromCoords = <
|
|||||||
return found as MaybeTransformHandleType;
|
return found as MaybeTransformHandleType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canResizeFromSides(device)) {
|
if (canResizeFromSides(editorInterface)) {
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const redrawTextBoundingBox = (
|
|||||||
const nextHeight = computeContainerDimensionForBoundText(
|
const nextHeight = computeContainerDimensionForBoundText(
|
||||||
metrics.height,
|
metrics.height,
|
||||||
container.type,
|
container.type,
|
||||||
|
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
);
|
);
|
||||||
scene.mutateElement(container, { height: nextHeight });
|
scene.mutateElement(container, { height: nextHeight });
|
||||||
updateOriginalContainerCache(container.id, nextHeight);
|
updateOriginalContainerCache(container.id, nextHeight);
|
||||||
@@ -117,6 +118,7 @@ export const redrawTextBoundingBox = (
|
|||||||
const nextWidth = computeContainerDimensionForBoundText(
|
const nextWidth = computeContainerDimensionForBoundText(
|
||||||
metrics.width,
|
metrics.width,
|
||||||
container.type,
|
container.type,
|
||||||
|
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
);
|
);
|
||||||
scene.mutateElement(container, { width: nextWidth });
|
scene.mutateElement(container, { width: nextWidth });
|
||||||
}
|
}
|
||||||
@@ -187,6 +189,7 @@ export const handleBindTextResize = (
|
|||||||
containerHeight = computeContainerDimensionForBoundText(
|
containerHeight = computeContainerDimensionForBoundText(
|
||||||
nextHeight,
|
nextHeight,
|
||||||
container.type,
|
container.type,
|
||||||
|
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
);
|
);
|
||||||
|
|
||||||
const diff = containerHeight - container.height;
|
const diff = containerHeight - container.height;
|
||||||
@@ -353,8 +356,8 @@ export const getContainerCenter = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
|
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
|
||||||
let offsetX = BOUND_TEXT_PADDING;
|
let offsetX = container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
|
||||||
let offsetY = BOUND_TEXT_PADDING;
|
let offsetY = container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
|
||||||
|
|
||||||
if (container.type === "ellipse") {
|
if (container.type === "ellipse") {
|
||||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
|
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
|
||||||
@@ -446,9 +449,10 @@ export const isValidTextContainer = (element: {
|
|||||||
export const computeContainerDimensionForBoundText = (
|
export const computeContainerDimensionForBoundText = (
|
||||||
dimension: number,
|
dimension: number,
|
||||||
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
|
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
|
||||||
|
boundTextPadding: number,
|
||||||
) => {
|
) => {
|
||||||
dimension = Math.ceil(dimension);
|
dimension = Math.ceil(dimension);
|
||||||
const padding = BOUND_TEXT_PADDING * 2;
|
const padding = boundTextPadding * 2;
|
||||||
|
|
||||||
if (containerType === "ellipse") {
|
if (containerType === "ellipse") {
|
||||||
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
|
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
|
||||||
@@ -467,6 +471,8 @@ export const getBoundTextMaxWidth = (
|
|||||||
boundTextElement: ExcalidrawTextElement | null,
|
boundTextElement: ExcalidrawTextElement | null,
|
||||||
) => {
|
) => {
|
||||||
const { width } = container;
|
const { width } = container;
|
||||||
|
const boundTextPadding =
|
||||||
|
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
const minWidth =
|
const minWidth =
|
||||||
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
|
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
|
||||||
@@ -477,14 +483,14 @@ export const getBoundTextMaxWidth = (
|
|||||||
// The width of the largest rectangle inscribed inside an ellipse is
|
// The width of the largest rectangle inscribed inside an ellipse is
|
||||||
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
||||||
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
|
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
|
||||||
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
return Math.round((width / 2) * Math.sqrt(2)) - boundTextPadding * 2;
|
||||||
}
|
}
|
||||||
if (container.type === "diamond") {
|
if (container.type === "diamond") {
|
||||||
// The width of the largest rectangle inscribed inside a rhombus is
|
// The width of the largest rectangle inscribed inside a rhombus is
|
||||||
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||||
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
|
return Math.round(width / 2) - boundTextPadding * 2;
|
||||||
}
|
}
|
||||||
return width - BOUND_TEXT_PADDING * 2;
|
return width - boundTextPadding * 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getBoundTextMaxHeight = (
|
export const getBoundTextMaxHeight = (
|
||||||
@@ -492,8 +498,10 @@ export const getBoundTextMaxHeight = (
|
|||||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||||
) => {
|
) => {
|
||||||
const { height } = container;
|
const { height } = container;
|
||||||
|
const boundTextPadding =
|
||||||
|
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
const containerHeight = height - boundTextPadding * 8 * 2;
|
||||||
if (containerHeight <= 0) {
|
if (containerHeight <= 0) {
|
||||||
return boundTextElement.height;
|
return boundTextElement.height;
|
||||||
}
|
}
|
||||||
@@ -503,14 +511,14 @@ export const getBoundTextMaxHeight = (
|
|||||||
// The height of the largest rectangle inscribed inside an ellipse is
|
// The height of the largest rectangle inscribed inside an ellipse is
|
||||||
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
|
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
|
||||||
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
|
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
|
||||||
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
return Math.round((height / 2) * Math.sqrt(2)) - boundTextPadding * 2;
|
||||||
}
|
}
|
||||||
if (container.type === "diamond") {
|
if (container.type === "diamond") {
|
||||||
// The height of the largest rectangle inscribed inside a rhombus is
|
// The height of the largest rectangle inscribed inside a rhombus is
|
||||||
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||||
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
|
return Math.round(height / 2) - boundTextPadding * 2;
|
||||||
}
|
}
|
||||||
return height - BOUND_TEXT_PADDING * 2;
|
return height - boundTextPadding * 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** retrieves text from text elements and concatenates to a single string */
|
/** retrieves text from text elements and concatenates to a single string */
|
||||||
|
|||||||
@@ -32,22 +32,24 @@ const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
|||||||
export const getApproxMinLineWidth = (
|
export const getApproxMinLineWidth = (
|
||||||
font: FontString,
|
font: FontString,
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
boundTextPadding: number = BOUND_TEXT_PADDING,
|
||||||
) => {
|
) => {
|
||||||
const maxCharWidth = getMaxCharWidth(font);
|
const maxCharWidth = getMaxCharWidth(font);
|
||||||
if (maxCharWidth === 0) {
|
if (maxCharWidth === 0) {
|
||||||
return (
|
return (
|
||||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||||
BOUND_TEXT_PADDING * 2
|
boundTextPadding * 2
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
return maxCharWidth + boundTextPadding * 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMinTextElementWidth = (
|
export const getMinTextElementWidth = (
|
||||||
font: FontString,
|
font: FontString,
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
boundTextPadding: number = BOUND_TEXT_PADDING,
|
||||||
) => {
|
) => {
|
||||||
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
return measureText("", font, lineHeight).width + boundTextPadding * 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isMeasureTextSupported = () => {
|
export const isMeasureTextSupported = () => {
|
||||||
@@ -99,8 +101,9 @@ export const getLineHeightInPx = (
|
|||||||
export const getApproxMinLineHeight = (
|
export const getApproxMinLineHeight = (
|
||||||
fontSize: ExcalidrawTextElement["fontSize"],
|
fontSize: ExcalidrawTextElement["fontSize"],
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
boundTextPadding: number = BOUND_TEXT_PADDING,
|
||||||
) => {
|
) => {
|
||||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
return getLineHeightInPx(fontSize, lineHeight) + boundTextPadding * 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
let textMetricsProvider: TextMetricsProvider | undefined;
|
let textMetricsProvider: TextMetricsProvider | undefined;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||||
isAndroid,
|
type EditorInterface,
|
||||||
isIOS,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||||
@@ -9,7 +8,6 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
|||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Device,
|
|
||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
Zoom,
|
Zoom,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
@@ -111,20 +109,21 @@ const generateTransformHandle = (
|
|||||||
return [xx - width / 2, yy - height / 2, width, height];
|
return [xx - width / 2, yy - height / 2, width, height];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const canResizeFromSides = (device: Device) => {
|
export const canResizeFromSides = (editorInterface: EditorInterface) => {
|
||||||
if (device.viewport.isMobile) {
|
if (
|
||||||
return false;
|
editorInterface.formFactor === "phone" &&
|
||||||
}
|
editorInterface.userAgent.isMobileDevice
|
||||||
|
) {
|
||||||
if (device.isTouchScreen && (isAndroid || isIOS)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOmitSidesForDevice = (device: Device) => {
|
export const getOmitSidesForEditorInterface = (
|
||||||
if (canResizeFromSides(device)) {
|
editorInterface: EditorInterface,
|
||||||
|
) => {
|
||||||
|
if (canResizeFromSides(editorInterface)) {
|
||||||
return DEFAULT_OMIT_SIDES;
|
return DEFAULT_OMIT_SIDES;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,11 +325,15 @@ export const getTransformHandles = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldShowBoundingBox = (
|
export const hasBoundingBox = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
|
editorInterface: EditorInterface,
|
||||||
) => {
|
) => {
|
||||||
if (appState.selectedLinearElement?.isEditing) {
|
if (
|
||||||
|
appState.selectedLinearElement?.isEditing ||
|
||||||
|
appState.selectedLinearElement?.isDragging
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (elements.length > 1) {
|
if (elements.length > 1) {
|
||||||
@@ -345,5 +348,7 @@ export const shouldShowBoundingBox = (
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return element.points.length > 2;
|
// on mobile/tablet we currently don't show bbox because of resize issues
|
||||||
|
// (also prob best for simplicity's sake)
|
||||||
|
return element.points.length > 2 && !editorInterface.userAgent.isMobileDevice;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ import type {
|
|||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
ExcalidrawLineElement,
|
ExcalidrawLineElement,
|
||||||
PointBinding,
|
|
||||||
FixedPointBinding,
|
|
||||||
ExcalidrawFlowchartNodeElement,
|
ExcalidrawFlowchartNodeElement,
|
||||||
ExcalidrawLinearElementSubType,
|
ExcalidrawLinearElementSubType,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -163,7 +161,7 @@ export const isLinearElementType = (
|
|||||||
export const isBindingElement = (
|
export const isBindingElement = (
|
||||||
element?: ExcalidrawElement | null,
|
element?: ExcalidrawElement | null,
|
||||||
includeLocked = true,
|
includeLocked = true,
|
||||||
): element is ExcalidrawLinearElement => {
|
): element is ExcalidrawArrowElement => {
|
||||||
return (
|
return (
|
||||||
element != null &&
|
element != null &&
|
||||||
(!element.locked || includeLocked === true) &&
|
(!element.locked || includeLocked === true) &&
|
||||||
@@ -274,15 +272,12 @@ export const isExcalidrawElement = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isFlowchartType = (type: string): boolean =>
|
||||||
|
["rectangle", "ellipse", "diamond"].includes(type);
|
||||||
|
|
||||||
export const isFlowchartNodeElement = (
|
export const isFlowchartNodeElement = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
): element is ExcalidrawFlowchartNodeElement => {
|
): element is ExcalidrawFlowchartNodeElement => isFlowchartType(element.type);
|
||||||
return (
|
|
||||||
element.type === "rectangle" ||
|
|
||||||
element.type === "ellipse" ||
|
|
||||||
element.type === "diamond"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hasBoundTextElement = (
|
export const hasBoundTextElement = (
|
||||||
element: ExcalidrawElement | null,
|
element: ExcalidrawElement | null,
|
||||||
@@ -358,15 +353,6 @@ export const getDefaultRoundnessTypeForElement = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isFixedPointBinding = (
|
|
||||||
binding: PointBinding | FixedPointBinding,
|
|
||||||
): binding is FixedPointBinding => {
|
|
||||||
return (
|
|
||||||
Object.hasOwn(binding, "fixedPoint") &&
|
|
||||||
(binding as FixedPointBinding).fixedPoint != null
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: Move this to @excalidraw/math
|
// TODO: Move this to @excalidraw/math
|
||||||
export const isBounds = (box: unknown): box is Bounds =>
|
export const isBounds = (box: unknown): box is Bounds =>
|
||||||
Array.isArray(box) &&
|
Array.isArray(box) &&
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export type StrokeRoundness = "round" | "sharp";
|
|||||||
export type RoundnessType = ValueOf<typeof ROUNDNESS>;
|
export type RoundnessType = ValueOf<typeof ROUNDNESS>;
|
||||||
export type StrokeStyle = "solid" | "dashed" | "dotted";
|
export type StrokeStyle = "solid" | "dashed" | "dotted";
|
||||||
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
|
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
|
||||||
|
export type ContainerBehavior = {
|
||||||
|
textFlow: "growing" | "fixed";
|
||||||
|
margin?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
||||||
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
||||||
@@ -79,21 +83,26 @@ type _ExcalidrawElementBase = Readonly<{
|
|||||||
link: string | null;
|
link: string | null;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
customData?: Record<string, any>;
|
customData?: Record<string, any>;
|
||||||
|
containerBehavior?: ContainerBehavior;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
||||||
type: "selection";
|
type: "selection";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
|
type _ExcalidrawStickyNoteContainer = _ExcalidrawElementBase & {
|
||||||
|
containerBehavior: ContainerBehavior;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExcalidrawRectangleElement = _ExcalidrawStickyNoteContainer & {
|
||||||
type: "rectangle";
|
type: "rectangle";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
|
export type ExcalidrawDiamondElement = _ExcalidrawStickyNoteContainer & {
|
||||||
type: "diamond";
|
type: "diamond";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
export type ExcalidrawEllipseElement = _ExcalidrawStickyNoteContainer & {
|
||||||
type: "ellipse";
|
type: "ellipse";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -279,23 +288,22 @@ export type ExcalidrawTextElementWithContainer = {
|
|||||||
|
|
||||||
export type FixedPoint = [number, number];
|
export type FixedPoint = [number, number];
|
||||||
|
|
||||||
export type PointBinding = {
|
export type BindMode = "inside" | "orbit" | "skip";
|
||||||
elementId: ExcalidrawBindableElement["id"];
|
|
||||||
focus: number;
|
|
||||||
gap: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FixedPointBinding = Merge<
|
export type FixedPointBinding = {
|
||||||
PointBinding,
|
elementId: ExcalidrawBindableElement["id"];
|
||||||
{
|
|
||||||
// Represents the fixed point binding information in form of a vertical and
|
// Represents the fixed point binding information in form of a vertical and
|
||||||
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
||||||
// gives the user selected fixed point by multiplying the bound element width
|
// gives the user selected fixed point by multiplying the bound element width
|
||||||
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
||||||
// bound element-local point coordinate.
|
// bound element-local point coordinate.
|
||||||
fixedPoint: FixedPoint;
|
fixedPoint: FixedPoint;
|
||||||
}
|
|
||||||
>;
|
// Determines whether the arrow remains outside the shape or is allowed to
|
||||||
|
// go all the way inside the shape up to the exact fixed point.
|
||||||
|
mode: BindMode;
|
||||||
|
};
|
||||||
|
|
||||||
type Index = number;
|
type Index = number;
|
||||||
|
|
||||||
@@ -322,9 +330,8 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
|||||||
Readonly<{
|
Readonly<{
|
||||||
type: "line" | "arrow";
|
type: "line" | "arrow";
|
||||||
points: readonly LocalPoint[];
|
points: readonly LocalPoint[];
|
||||||
lastCommittedPoint: LocalPoint | null;
|
startBinding: FixedPointBinding | null;
|
||||||
startBinding: PointBinding | null;
|
endBinding: FixedPointBinding | null;
|
||||||
endBinding: PointBinding | null;
|
|
||||||
startArrowhead: Arrowhead | null;
|
startArrowhead: Arrowhead | null;
|
||||||
endArrowhead: Arrowhead | null;
|
endArrowhead: Arrowhead | null;
|
||||||
}>;
|
}>;
|
||||||
@@ -351,9 +358,9 @@ export type ExcalidrawElbowArrowElement = Merge<
|
|||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
{
|
{
|
||||||
elbowed: true;
|
elbowed: true;
|
||||||
|
fixedSegments: readonly FixedSegment[] | null;
|
||||||
startBinding: FixedPointBinding | null;
|
startBinding: FixedPointBinding | null;
|
||||||
endBinding: FixedPointBinding | null;
|
endBinding: FixedPointBinding | null;
|
||||||
fixedSegments: readonly FixedSegment[] | null;
|
|
||||||
/**
|
/**
|
||||||
* Marks that the 3rd point should be used as the 2nd point of the arrow in
|
* Marks that the 3rd point should be used as the 2nd point of the arrow in
|
||||||
* order to temporarily hide the first segment of the arrow without losing
|
* order to temporarily hide the first segment of the arrow without losing
|
||||||
@@ -379,7 +386,6 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
|||||||
points: readonly LocalPoint[];
|
points: readonly LocalPoint[];
|
||||||
pressures: readonly number[];
|
pressures: readonly number[];
|
||||||
simulatePressure: boolean;
|
simulatePressure: boolean;
|
||||||
lastCommittedPoint: LocalPoint | null;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type FileId = string & { _brand: "FileId" };
|
export type FileId = string & { _brand: "FileId" };
|
||||||
|
|||||||
+152
-15
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_ADAPTIVE_RADIUS,
|
DEFAULT_ADAPTIVE_RADIUS,
|
||||||
DEFAULT_PROPORTIONAL_RADIUS,
|
DEFAULT_PROPORTIONAL_RADIUS,
|
||||||
|
invariant,
|
||||||
LINE_CONFIRM_THRESHOLD,
|
LINE_CONFIRM_THRESHOLD,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
@@ -10,10 +11,17 @@ import {
|
|||||||
curveCatmullRomCubicApproxPoints,
|
curveCatmullRomCubicApproxPoints,
|
||||||
curveOffsetPoints,
|
curveOffsetPoints,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
|
lineSegmentIntersectionPoints,
|
||||||
pointDistance,
|
pointDistance,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointFromArray,
|
pointFromArray,
|
||||||
|
pointFromVector,
|
||||||
|
pointRotateRads,
|
||||||
|
pointTranslate,
|
||||||
rectangle,
|
rectangle,
|
||||||
|
vectorFromPoint,
|
||||||
|
vectorNormalize,
|
||||||
|
vectorScale,
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
@@ -21,11 +29,17 @@ import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
|||||||
|
|
||||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getDiamondPoints } from "./bounds";
|
import { elementCenterPoint, getDiamondPoints } from "./bounds";
|
||||||
|
|
||||||
import { generateLinearCollisionShape } from "./shape";
|
import { generateLinearCollisionShape } from "./shape";
|
||||||
|
|
||||||
|
import { isPointInElement } from "./collision";
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
import { isRectangularElement } from "./typeChecks";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
@@ -400,20 +414,10 @@ export function deconstructDiamondElement(
|
|||||||
), // TOP
|
), // TOP
|
||||||
];
|
];
|
||||||
|
|
||||||
const corners =
|
const corners = baseCorners.map(
|
||||||
offset > 0
|
(corner) =>
|
||||||
? baseCorners.map(
|
curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!,
|
||||||
(corner) =>
|
);
|
||||||
curveCatmullRomCubicApproxPoints(
|
|
||||||
curveOffsetPoints(corner, offset),
|
|
||||||
)!,
|
|
||||||
)
|
|
||||||
: [
|
|
||||||
[baseCorners[0]],
|
|
||||||
[baseCorners[1]],
|
|
||||||
[baseCorners[2]],
|
|
||||||
[baseCorners[3]],
|
|
||||||
];
|
|
||||||
|
|
||||||
const sides = [
|
const sides = [
|
||||||
lineSegment<GlobalPoint>(
|
lineSegment<GlobalPoint>(
|
||||||
@@ -481,3 +485,136 @@ export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDiagonalsForBindableElement = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
) => {
|
||||||
|
// for rectangles, shrink the diagonals a bit because there's something
|
||||||
|
// going on with the focus points around the corners. Ask Mark for details.
|
||||||
|
const OFFSET_PX = element.type === "rectangle" ? 15 : 0;
|
||||||
|
const shrinkSegment = (seg: LineSegment<GlobalPoint>) => {
|
||||||
|
const v = vectorNormalize(vectorFromPoint(seg[1], seg[0]));
|
||||||
|
const offset = vectorScale(v, OFFSET_PX);
|
||||||
|
return lineSegment<GlobalPoint>(
|
||||||
|
pointTranslate(seg[0], offset),
|
||||||
|
pointTranslate(seg[1], vectorScale(offset, -1)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
const diagonalOne = shrinkSegment(
|
||||||
|
isRectangularElement(element)
|
||||||
|
? lineSegment<GlobalPoint>(
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(element.x, element.y),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + element.width,
|
||||||
|
element.y + element.height,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: lineSegment<GlobalPoint>(
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + element.width / 2,
|
||||||
|
element.y + element.height,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const diagonalTwo = shrinkSegment(
|
||||||
|
isRectangularElement(element)
|
||||||
|
? lineSegment<GlobalPoint>(
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(element.x + element.width, element.y),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(element.x, element.y + element.height),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: lineSegment<GlobalPoint>(
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + element.width,
|
||||||
|
element.y + element.height / 2,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [diagonalOne, diagonalTwo];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const projectFixedPointOntoDiagonal = (
|
||||||
|
arrow: ExcalidrawArrowElement,
|
||||||
|
point: GlobalPoint,
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
startOrEnd: "start" | "end",
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): GlobalPoint | null => {
|
||||||
|
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
|
||||||
|
if (arrow.width < 3 && arrow.height < 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
const a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
arrow,
|
||||||
|
startOrEnd === "start" ? 1 : arrow.points.length - 2,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const b = pointFromVector<GlobalPoint>(
|
||||||
|
vectorScale(
|
||||||
|
vectorFromPoint(point, a),
|
||||||
|
2 * pointDistance(a, point) +
|
||||||
|
Math.max(
|
||||||
|
pointDistance(diagonalOne[0], diagonalOne[1]),
|
||||||
|
pointDistance(diagonalTwo[0], diagonalTwo[1]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
a,
|
||||||
|
);
|
||||||
|
const intersector = lineSegment<GlobalPoint>(point, b);
|
||||||
|
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
|
||||||
|
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
|
||||||
|
const d1 = p1 && pointDistance(a, p1);
|
||||||
|
const d2 = p2 && pointDistance(a, p2);
|
||||||
|
|
||||||
|
let p = null;
|
||||||
|
if (d1 != null && d2 != null) {
|
||||||
|
p = d1 < d2 ? p1 : p2;
|
||||||
|
} else {
|
||||||
|
p = p1 || p2 || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return p && isPointInElement(p, element, elementsMap) ? p : null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
import type { GlobalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import { isFrameLikeElement } from "./typeChecks";
|
import { isFrameLikeElement, isTextElement } from "./typeChecks";
|
||||||
|
|
||||||
import { getElementsInGroup } from "./groups";
|
import { getElementsInGroup } from "./groups";
|
||||||
|
|
||||||
import { syncMovedIndices } from "./fractionalIndex";
|
import { syncMovedIndices } from "./fractionalIndex";
|
||||||
|
|
||||||
import { getSelectedElements } from "./selection";
|
import { getSelectedElements } from "./selection";
|
||||||
|
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||||
|
import { getHoveredElementForBinding } from "./collision";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
|
import type {
|
||||||
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
ExcalidrawArrowElement,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawFrameLikeElement,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
Ordered,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
|
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
|
||||||
return element.frameId === frameId || element.id === frameId;
|
return element.frameId === frameId || element.id === frameId;
|
||||||
@@ -139,6 +146,51 @@ const getContiguousFrameRangeElements = (
|
|||||||
return allElements.slice(rangeStart, rangeEnd + 1);
|
return allElements.slice(rangeStart, rangeEnd + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the arrow element above any bindable elements it intersects with or
|
||||||
|
* hovers over.
|
||||||
|
*/
|
||||||
|
export const moveArrowAboveBindable = (
|
||||||
|
point: GlobalPoint,
|
||||||
|
arrow: ExcalidrawArrowElement,
|
||||||
|
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
scene: Scene,
|
||||||
|
): readonly OrderedExcalidrawElement[] => {
|
||||||
|
const hoveredElement = getHoveredElementForBinding(
|
||||||
|
point,
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hoveredElement) {
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(hoveredElement, elementsMap);
|
||||||
|
const containerElement = isTextElement(hoveredElement)
|
||||||
|
? getContainerElement(hoveredElement, elementsMap)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const bindableIds = [
|
||||||
|
hoveredElement.id,
|
||||||
|
boundTextElement?.id,
|
||||||
|
containerElement?.id,
|
||||||
|
].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id);
|
||||||
|
const bindableIdx = elements.findIndex((el) => bindableIds.includes(el.id));
|
||||||
|
const arrowIdx = elements.findIndex((el) => el.id === arrow.id);
|
||||||
|
|
||||||
|
if (arrowIdx !== -1 && bindableIdx !== -1 && arrowIdx < bindableIdx) {
|
||||||
|
const updatedElements = Array.from(elements);
|
||||||
|
const arrow = updatedElements.splice(arrowIdx, 1)[0];
|
||||||
|
updatedElements.splice(bindableIdx, 0, arrow);
|
||||||
|
|
||||||
|
scene.replaceAllElements(updatedElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns next candidate index that's available to be moved to. Currently that
|
* Returns next candidate index that's available to be moved to. Currently that
|
||||||
* is a non-deleted element, and not inside a group (unless we're editing it).
|
* is a non-deleted element, and not inside a group (unless we're editing it).
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -135,9 +135,9 @@ describe("getElementBounds", () => {
|
|||||||
} as ExcalidrawLinearElement;
|
} as ExcalidrawLinearElement;
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
|
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
|
||||||
expect(x1).toEqual(360.3176068760539);
|
expect(x1).toEqual(360.9291017525165);
|
||||||
expect(y1).toEqual(185.90654264413516);
|
expect(y1).toEqual(185.24770129343722);
|
||||||
expect(x2).toEqual(480.87005902729743);
|
expect(x2).toEqual(481.4815539037601);
|
||||||
expect(y2).toEqual(320.4751269334226);
|
expect(y2).toEqual(319.8162855827246);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,9 +144,8 @@ describe("duplicating multiple elements", () => {
|
|||||||
id: "arrow1",
|
id: "arrow1",
|
||||||
startBinding: {
|
startBinding: {
|
||||||
elementId: "rectangle1",
|
elementId: "rectangle1",
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
fixedPoint: [0.5, 1],
|
||||||
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,9 +154,8 @@ describe("duplicating multiple elements", () => {
|
|||||||
id: "arrow2",
|
id: "arrow2",
|
||||||
endBinding: {
|
endBinding: {
|
||||||
elementId: "rectangle1",
|
elementId: "rectangle1",
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
fixedPoint: [0.5, 1],
|
||||||
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
boundElements: [{ id: "text2", type: "text" }],
|
boundElements: [{ id: "text2", type: "text" }],
|
||||||
});
|
});
|
||||||
@@ -276,9 +274,8 @@ describe("duplicating multiple elements", () => {
|
|||||||
id: "arrow1",
|
id: "arrow1",
|
||||||
startBinding: {
|
startBinding: {
|
||||||
elementId: "rectangle1",
|
elementId: "rectangle1",
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
fixedPoint: [0.5, 1],
|
||||||
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,15 +290,13 @@ describe("duplicating multiple elements", () => {
|
|||||||
id: "arrow2",
|
id: "arrow2",
|
||||||
startBinding: {
|
startBinding: {
|
||||||
elementId: "rectangle1",
|
elementId: "rectangle1",
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
fixedPoint: [0.5, 1],
|
||||||
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
endBinding: {
|
endBinding: {
|
||||||
elementId: "rectangle-not-exists",
|
elementId: "rectangle-not-exists",
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
fixedPoint: [0.5, 1],
|
||||||
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -310,15 +305,13 @@ describe("duplicating multiple elements", () => {
|
|||||||
id: "arrow3",
|
id: "arrow3",
|
||||||
startBinding: {
|
startBinding: {
|
||||||
elementId: "rectangle-not-exists",
|
elementId: "rectangle-not-exists",
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
fixedPoint: [0.5, 1],
|
||||||
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
endBinding: {
|
endBinding: {
|
||||||
elementId: "rectangle1",
|
elementId: "rectangle1",
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
fixedPoint: [0.5, 1],
|
||||||
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -821,7 +814,7 @@ describe("duplication z-order", () => {
|
|||||||
const arrow = UI.createElement("arrow", {
|
const arrow = UI.createElement("arrow", {
|
||||||
x: -100,
|
x: -100,
|
||||||
y: 50,
|
y: 50,
|
||||||
width: 95,
|
width: 115,
|
||||||
height: 0,
|
height: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { ARROW_TYPE } from "@excalidraw/common";
|
import { ARROW_TYPE } from "@excalidraw/common";
|
||||||
import { pointFrom } from "@excalidraw/math";
|
import { pointFrom } from "@excalidraw/math";
|
||||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
|
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
|
||||||
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
|
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
|
||||||
|
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
act,
|
act,
|
||||||
fireEvent,
|
fireEvent,
|
||||||
@@ -15,13 +12,11 @@ import {
|
|||||||
queryByTestId,
|
queryByTestId,
|
||||||
render,
|
render,
|
||||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
import "@excalidraw/utils/test-utils";
|
import "@excalidraw/utils/test-utils";
|
||||||
|
import { bindBindingElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import { bindLinearElement } from "../src/binding";
|
|
||||||
|
|
||||||
import { Scene } from "../src/Scene";
|
import { Scene } from "../src/Scene";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -136,6 +131,11 @@ describe("elbow arrow segment move", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("elbow arrow routing", () => {
|
describe("elbow arrow routing", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
});
|
||||||
|
|
||||||
it("can properly generate orthogonal arrow points", () => {
|
it("can properly generate orthogonal arrow points", () => {
|
||||||
const scene = new Scene();
|
const scene = new Scene();
|
||||||
const arrow = API.createElement({
|
const arrow = API.createElement({
|
||||||
@@ -160,8 +160,8 @@ describe("elbow arrow routing", () => {
|
|||||||
expect(arrow.width).toEqual(90);
|
expect(arrow.width).toEqual(90);
|
||||||
expect(arrow.height).toEqual(200);
|
expect(arrow.height).toEqual(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can generate proper points for bound elbow arrow", () => {
|
it("can generate proper points for bound elbow arrow", () => {
|
||||||
const scene = new Scene();
|
|
||||||
const rectangle1 = API.createElement({
|
const rectangle1 = API.createElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
x: -150,
|
x: -150,
|
||||||
@@ -185,25 +185,23 @@ describe("elbow arrow routing", () => {
|
|||||||
height: 200,
|
height: 200,
|
||||||
points: [pointFrom(0, 0), pointFrom(90, 200)],
|
points: [pointFrom(0, 0), pointFrom(90, 200)],
|
||||||
}) as ExcalidrawElbowArrowElement;
|
}) as ExcalidrawElbowArrowElement;
|
||||||
scene.insertElement(rectangle1);
|
API.setElements([rectangle1, rectangle2, arrow]);
|
||||||
scene.insertElement(rectangle2);
|
|
||||||
scene.insertElement(arrow);
|
|
||||||
|
|
||||||
bindLinearElement(arrow, rectangle1, "start", scene);
|
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene);
|
||||||
bindLinearElement(arrow, rectangle2, "end", scene);
|
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene);
|
||||||
|
|
||||||
expect(arrow.startBinding).not.toBe(null);
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
|
|
||||||
h.app.scene.mutateElement(arrow, {
|
h.scene.mutateElement(arrow, {
|
||||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(arrow.points).toEqual([
|
expect(arrow.points).toCloselyEqualPoints([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[45, 0],
|
[39, 0],
|
||||||
[45, 200],
|
[39, 200],
|
||||||
[90, 200],
|
[78, 200],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -242,9 +240,9 @@ describe("elbow arrow ui", () => {
|
|||||||
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
|
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
|
||||||
|
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
mouse.moveTo(-43, -99);
|
mouse.moveTo(-53, -99);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
mouse.moveTo(43, 99);
|
mouse.moveTo(53, 99);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
|
|
||||||
const arrow = h.scene.getSelectedElements(
|
const arrow = h.scene.getSelectedElements(
|
||||||
@@ -253,11 +251,11 @@ describe("elbow arrow ui", () => {
|
|||||||
|
|
||||||
expect(arrow.type).toBe("arrow");
|
expect(arrow.type).toBe("arrow");
|
||||||
expect(arrow.elbowed).toBe(true);
|
expect(arrow.elbowed).toBe(true);
|
||||||
expect(arrow.points).toEqual([
|
expect(arrow.points).toCloselyEqualPoints([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[45, 0],
|
[39, 0],
|
||||||
[45, 200],
|
[39, 200],
|
||||||
[90, 200],
|
[78, 200],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,9 +277,9 @@ describe("elbow arrow ui", () => {
|
|||||||
UI.clickOnTestId("elbow-arrow");
|
UI.clickOnTestId("elbow-arrow");
|
||||||
|
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
mouse.moveTo(-43, -99);
|
mouse.moveTo(-53, -99);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
mouse.moveTo(43, 99);
|
mouse.moveTo(53, 99);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
|
|
||||||
const arrow = h.scene.getSelectedElements(
|
const arrow = h.scene.getSelectedElements(
|
||||||
@@ -297,9 +295,11 @@ describe("elbow arrow ui", () => {
|
|||||||
|
|
||||||
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[35, 0],
|
[36, 0],
|
||||||
[35, 165],
|
[36, 90],
|
||||||
[103, 165],
|
[28, 90],
|
||||||
|
[28, 164],
|
||||||
|
[101, 164],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -321,9 +321,9 @@ describe("elbow arrow ui", () => {
|
|||||||
UI.clickOnTestId("elbow-arrow");
|
UI.clickOnTestId("elbow-arrow");
|
||||||
|
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
mouse.moveTo(-43, -99);
|
mouse.moveTo(-53, -99);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
mouse.moveTo(43, 99);
|
mouse.moveTo(53, 99);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
|
|
||||||
const arrow = h.scene.getSelectedElements(
|
const arrow = h.scene.getSelectedElements(
|
||||||
@@ -351,11 +351,11 @@ describe("elbow arrow ui", () => {
|
|||||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||||
expect(duplicatedArrow.type).toBe("arrow");
|
expect(duplicatedArrow.type).toBe("arrow");
|
||||||
expect(duplicatedArrow.elbowed).toBe(true);
|
expect(duplicatedArrow.elbowed).toBe(true);
|
||||||
expect(duplicatedArrow.points).toEqual([
|
expect(duplicatedArrow.points).toCloselyEqualPoints([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[45, 0],
|
[39, 0],
|
||||||
[45, 200],
|
[39, 200],
|
||||||
[90, 200],
|
[78, 200],
|
||||||
]);
|
]);
|
||||||
expect(arrow.startBinding).not.toBe(null);
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
@@ -379,9 +379,9 @@ describe("elbow arrow ui", () => {
|
|||||||
UI.clickOnTestId("elbow-arrow");
|
UI.clickOnTestId("elbow-arrow");
|
||||||
|
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
mouse.moveTo(-43, -99);
|
mouse.moveTo(-53, -99);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
mouse.moveTo(43, 99);
|
mouse.moveTo(53, 99);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
|
|
||||||
const arrow = h.scene.getSelectedElements(
|
const arrow = h.scene.getSelectedElements(
|
||||||
@@ -405,11 +405,11 @@ describe("elbow arrow ui", () => {
|
|||||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||||
expect(duplicatedArrow.type).toBe("arrow");
|
expect(duplicatedArrow.type).toBe("arrow");
|
||||||
expect(duplicatedArrow.elbowed).toBe(true);
|
expect(duplicatedArrow.elbowed).toBe(true);
|
||||||
expect(duplicatedArrow.points).toEqual([
|
expect(duplicatedArrow.points).toCloselyEqualPoints([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[0, 100],
|
[0, 100],
|
||||||
[90, 100],
|
[78, 100],
|
||||||
[90, 200],
|
[78, 200],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ describe("Test Linear Elements", () => {
|
|||||||
|
|
||||||
// drag line from midpoint
|
// drag line from midpoint
|
||||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
expect(line.points.length).toEqual(3);
|
expect(line.points.length).toEqual(3);
|
||||||
expect(line.points).toMatchInlineSnapshot(`
|
expect(line.points).toMatchInlineSnapshot(`
|
||||||
@@ -329,7 +329,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
mouse.doubleClick();
|
mouse.doubleClick();
|
||||||
expect(h.state.selectedLinearElement).toBe(null);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
await getTextEditor();
|
await getTextEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -357,6 +357,7 @@ describe("Test Linear Elements", () => {
|
|||||||
const originalY = line.y;
|
const originalY = line.y;
|
||||||
enterLineEditingMode(line);
|
enterLineEditingMode(line);
|
||||||
|
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
expect(line.points.length).toEqual(2);
|
expect(line.points.length).toEqual(2);
|
||||||
|
|
||||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
@@ -379,7 +380,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`11`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
expect(line.points.length).toEqual(3);
|
expect(line.points.length).toEqual(3);
|
||||||
expect(line.points).toMatchInlineSnapshot(`
|
expect(line.points).toMatchInlineSnapshot(`
|
||||||
@@ -549,7 +550,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`14`,
|
`14`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
||||||
|
|
||||||
expect(line.points.length).toEqual(5);
|
expect(line.points.length).toEqual(5);
|
||||||
|
|
||||||
@@ -600,7 +601,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`11`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
line,
|
line,
|
||||||
@@ -641,7 +642,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`11`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
line,
|
line,
|
||||||
@@ -689,7 +690,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`17`,
|
`17`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
|
||||||
|
|
||||||
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
||||||
line,
|
line,
|
||||||
@@ -747,7 +748,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`14`,
|
`14`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
||||||
expect(line.points.length).toEqual(5);
|
expect(line.points.length).toEqual(5);
|
||||||
|
|
||||||
expect((h.elements[0] as ExcalidrawLinearElement).points)
|
expect((h.elements[0] as ExcalidrawLinearElement).points)
|
||||||
@@ -845,7 +846,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`11`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
line,
|
line,
|
||||||
@@ -1303,7 +1304,7 @@ describe("Test Linear Elements", () => {
|
|||||||
const arrow = UI.createElement("arrow", {
|
const arrow = UI.createElement("arrow", {
|
||||||
x: -10,
|
x: -10,
|
||||||
y: 250,
|
y: 250,
|
||||||
width: 400,
|
width: 410,
|
||||||
height: 1,
|
height: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1316,7 +1317,7 @@ describe("Test Linear Elements", () => {
|
|||||||
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
|
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||||
|
|
||||||
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
||||||
expect(arrow.width).toBe(400);
|
expect(arrow.width).toBeCloseTo(399);
|
||||||
expect(rect.x).toBe(400);
|
expect(rect.x).toBe(400);
|
||||||
expect(rect.y).toBe(0);
|
expect(rect.y).toBe(0);
|
||||||
expect(
|
expect(
|
||||||
@@ -1335,7 +1336,7 @@ describe("Test Linear Elements", () => {
|
|||||||
mouse.downAt(rect.x, rect.y);
|
mouse.downAt(rect.x, rect.y);
|
||||||
mouse.moveTo(200, 0);
|
mouse.moveTo(200, 0);
|
||||||
mouse.upAt(200, 0);
|
mouse.upAt(200, 0);
|
||||||
expect(arrow.width).toBeCloseTo(200, 0);
|
expect(arrow.width).toBeCloseTo(199);
|
||||||
expect(rect.x).toBe(200);
|
expect(rect.x).toBe(200);
|
||||||
expect(rect.y).toBe(0);
|
expect(rect.y).toBe(0);
|
||||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -174,29 +174,29 @@ describe("generic element", () => {
|
|||||||
expect(rectangle.angle).toBeCloseTo(0);
|
expect(rectangle.angle).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resizes with bound arrow", async () => {
|
// it("resizes with bound arrow", async () => {
|
||||||
const rectangle = UI.createElement("rectangle", {
|
// const rectangle = UI.createElement("rectangle", {
|
||||||
width: 200,
|
// width: 200,
|
||||||
height: 100,
|
// height: 100,
|
||||||
});
|
// });
|
||||||
const arrow = UI.createElement("arrow", {
|
// const arrow = UI.createElement("arrow", {
|
||||||
x: -30,
|
// x: -30,
|
||||||
y: 50,
|
// y: 50,
|
||||||
width: 28,
|
// width: 28,
|
||||||
height: 5,
|
// height: 5,
|
||||||
});
|
// });
|
||||||
|
|
||||||
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
// expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||||
|
|
||||||
UI.resize(rectangle, "e", [40, 0]);
|
// UI.resize(rectangle, "e", [40, 0]);
|
||||||
|
|
||||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
||||||
|
|
||||||
UI.resize(rectangle, "w", [50, 0]);
|
// UI.resize(rectangle, "w", [50, 0]);
|
||||||
|
|
||||||
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
// expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
|
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
|
||||||
});
|
// });
|
||||||
|
|
||||||
it("resizes with a label", async () => {
|
it("resizes with a label", async () => {
|
||||||
const rectangle = UI.createElement("rectangle", {
|
const rectangle = UI.createElement("rectangle", {
|
||||||
@@ -510,12 +510,12 @@ describe("arrow element", () => {
|
|||||||
h.state,
|
h.state,
|
||||||
)[0] as ExcalidrawElbowArrowElement;
|
)[0] as ExcalidrawElbowArrowElement;
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||||
|
|
||||||
UI.resize(rectangle, "se", [-200, -150]);
|
UI.resize(rectangle, "se", [-200, -150]);
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -538,11 +538,11 @@ describe("arrow element", () => {
|
|||||||
h.state,
|
h.state,
|
||||||
)[0] as ExcalidrawElbowArrowElement;
|
)[0] as ExcalidrawElbowArrowElement;
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||||
|
|
||||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.06);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -595,31 +595,31 @@ describe("text element", () => {
|
|||||||
expect(text.fontSize).toBeCloseTo(fontSize * scale);
|
expect(text.fontSize).toBeCloseTo(fontSize * scale);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resizes with bound arrow", async () => {
|
// it("resizes with bound arrow", async () => {
|
||||||
const text = UI.createElement("text");
|
// const text = UI.createElement("text");
|
||||||
await UI.editText(text, "hello\nworld");
|
// await UI.editText(text, "hello\nworld");
|
||||||
const boundArrow = UI.createElement("arrow", {
|
// const boundArrow = UI.createElement("arrow", {
|
||||||
x: -30,
|
// x: -30,
|
||||||
y: 25,
|
// y: 25,
|
||||||
width: 28,
|
// width: 28,
|
||||||
height: 5,
|
// height: 5,
|
||||||
});
|
// });
|
||||||
|
|
||||||
expect(boundArrow.endBinding?.elementId).toEqual(text.id);
|
// expect(boundArrow.endBinding?.elementId).toEqual(text.id);
|
||||||
|
|
||||||
UI.resize(text, "ne", [40, 0]);
|
// UI.resize(text, "ne", [40, 0]);
|
||||||
|
|
||||||
expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30);
|
// expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30);
|
||||||
|
|
||||||
const textWidth = text.width;
|
// const textWidth = text.width;
|
||||||
const scale = 20 / text.height;
|
// const scale = 20 / text.height;
|
||||||
UI.resize(text, "nw", [50, 20]);
|
// UI.resize(text, "nw", [50, 20]);
|
||||||
|
|
||||||
expect(boundArrow.endBinding?.elementId).toEqual(text.id);
|
// expect(boundArrow.endBinding?.elementId).toEqual(text.id);
|
||||||
expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(
|
// expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(
|
||||||
30 + textWidth * scale,
|
// 30 + textWidth * scale,
|
||||||
);
|
// );
|
||||||
});
|
// });
|
||||||
|
|
||||||
it("updates font size via keyboard", async () => {
|
it("updates font size via keyboard", async () => {
|
||||||
const text = UI.createElement("text");
|
const text = UI.createElement("text");
|
||||||
@@ -801,36 +801,36 @@ describe("image element", () => {
|
|||||||
expect(image.scale).toEqual([1, 1]);
|
expect(image.scale).toEqual([1, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resizes with bound arrow", async () => {
|
// it("resizes with bound arrow", async () => {
|
||||||
const image = API.createElement({
|
// const image = API.createElement({
|
||||||
type: "image",
|
// type: "image",
|
||||||
width: 100,
|
// width: 100,
|
||||||
height: 100,
|
// height: 100,
|
||||||
});
|
// });
|
||||||
API.setElements([image]);
|
// API.setElements([image]);
|
||||||
const arrow = UI.createElement("arrow", {
|
// const arrow = UI.createElement("arrow", {
|
||||||
x: -30,
|
// x: -30,
|
||||||
y: 50,
|
// y: 50,
|
||||||
width: 28,
|
// width: 28,
|
||||||
height: 5,
|
// height: 5,
|
||||||
});
|
// });
|
||||||
|
|
||||||
expect(arrow.endBinding?.elementId).toEqual(image.id);
|
// expect(arrow.endBinding?.elementId).toEqual(image.id);
|
||||||
|
|
||||||
UI.resize(image, "ne", [40, 0]);
|
// UI.resize(image, "ne", [40, 0]);
|
||||||
|
|
||||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
||||||
|
|
||||||
const imageWidth = image.width;
|
// const imageWidth = image.width;
|
||||||
const scale = 20 / image.height;
|
// const scale = 20 / image.height;
|
||||||
UI.resize(image, "nw", [50, 20]);
|
// UI.resize(image, "nw", [50, 20]);
|
||||||
|
|
||||||
expect(arrow.endBinding?.elementId).toEqual(image.id);
|
// expect(arrow.endBinding?.elementId).toEqual(image.id);
|
||||||
expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
|
// expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
|
||||||
30 + imageWidth * scale,
|
// 30 + imageWidth * scale,
|
||||||
0,
|
// 0,
|
||||||
);
|
// );
|
||||||
});
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("multiple selection", () => {
|
describe("multiple selection", () => {
|
||||||
@@ -997,68 +997,80 @@ describe("multiple selection", () => {
|
|||||||
expect(diagLine.angle).toEqual(0);
|
expect(diagLine.angle).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resizes with bound arrows", async () => {
|
// it("resizes with bound arrows", async () => {
|
||||||
const rectangle = UI.createElement("rectangle", {
|
// const rectangle = UI.createElement("rectangle", {
|
||||||
position: 0,
|
// position: 0,
|
||||||
size: 100,
|
// size: 100,
|
||||||
});
|
// });
|
||||||
const leftBoundArrow = UI.createElement("arrow", {
|
// const leftBoundArrow = UI.createElement("arrow", {
|
||||||
x: -110,
|
// x: -110,
|
||||||
y: 50,
|
// y: 50,
|
||||||
width: 100,
|
// width: 100,
|
||||||
height: 0,
|
// height: 0,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const rightBoundArrow = UI.createElement("arrow", {
|
// const rightBoundArrow = UI.createElement("arrow", {
|
||||||
x: 210,
|
// x: 210,
|
||||||
y: 50,
|
// y: 50,
|
||||||
width: -100,
|
// width: -100,
|
||||||
height: 0,
|
// height: 0,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const selectionWidth = 210;
|
// const selectionWidth = 210;
|
||||||
const selectionHeight = 100;
|
// const selectionHeight = 100;
|
||||||
const move = [40, 40] as [number, number];
|
// const move = [40, 40] as [number, number];
|
||||||
const scale = Math.max(
|
// const scale = Math.max(
|
||||||
1 - move[0] / selectionWidth,
|
// 1 - move[0] / selectionWidth,
|
||||||
1 - move[1] / selectionHeight,
|
// 1 - move[1] / selectionHeight,
|
||||||
);
|
// );
|
||||||
const leftArrowBinding = { ...leftBoundArrow.endBinding };
|
// const leftArrowBinding: {
|
||||||
const rightArrowBinding = { ...rightBoundArrow.endBinding };
|
// elementId: string;
|
||||||
delete rightArrowBinding.gap;
|
// gap?: number;
|
||||||
|
// focus?: number;
|
||||||
|
// } = {
|
||||||
|
// ...leftBoundArrow.endBinding,
|
||||||
|
// } as PointBinding;
|
||||||
|
// const rightArrowBinding: {
|
||||||
|
// elementId: string;
|
||||||
|
// gap?: number;
|
||||||
|
// focus?: number;
|
||||||
|
// } = {
|
||||||
|
// ...rightBoundArrow.endBinding,
|
||||||
|
// } as PointBinding;
|
||||||
|
// delete rightArrowBinding.gap;
|
||||||
|
|
||||||
UI.resize([rectangle, rightBoundArrow], "nw", move, {
|
// UI.resize([rectangle, rightBoundArrow], "nw", move, {
|
||||||
shift: true,
|
// shift: true,
|
||||||
});
|
// });
|
||||||
|
|
||||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
// expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
// expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||||
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
|
// expect(leftBoundArrow.width).toBeCloseTo(140, 0);
|
||||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
// expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||||
expect(leftBoundArrow.angle).toEqual(0);
|
// expect(leftBoundArrow.angle).toEqual(0);
|
||||||
expect(leftBoundArrow.startBinding).toBeNull();
|
// expect(leftBoundArrow.startBinding).toBeNull();
|
||||||
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
|
// expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
|
||||||
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
// expect(leftBoundArrow.endBinding?.elementId).toBe(
|
||||||
leftArrowBinding.elementId,
|
// leftArrowBinding.elementId,
|
||||||
);
|
// );
|
||||||
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
// expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
||||||
|
|
||||||
expect(rightBoundArrow.x).toBeCloseTo(210);
|
// expect(rightBoundArrow.x).toBeCloseTo(210);
|
||||||
expect(rightBoundArrow.y).toBeCloseTo(
|
// expect(rightBoundArrow.y).toBeCloseTo(
|
||||||
(selectionHeight - 50) * (1 - scale) + 50,
|
// (selectionHeight - 50) * (1 - scale) + 50,
|
||||||
);
|
// );
|
||||||
expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
|
// expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
|
||||||
expect(rightBoundArrow.height).toBeCloseTo(0);
|
// expect(rightBoundArrow.height).toBeCloseTo(0);
|
||||||
expect(rightBoundArrow.angle).toEqual(0);
|
// expect(rightBoundArrow.angle).toEqual(0);
|
||||||
expect(rightBoundArrow.startBinding).toBeNull();
|
// expect(rightBoundArrow.startBinding).toBeNull();
|
||||||
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
|
// expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
|
||||||
expect(rightBoundArrow.endBinding?.elementId).toBe(
|
// expect(rightBoundArrow.endBinding?.elementId).toBe(
|
||||||
rightArrowBinding.elementId,
|
// rightArrowBinding.elementId,
|
||||||
);
|
// );
|
||||||
expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
|
// expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
|
||||||
rightArrowBinding.focus!,
|
// rightArrowBinding.focus!,
|
||||||
);
|
// );
|
||||||
});
|
// });
|
||||||
|
|
||||||
it("resizes with labeled arrows", async () => {
|
it("resizes with labeled arrows", async () => {
|
||||||
const topArrow = UI.createElement("arrow", {
|
const topArrow = UI.createElement("arrow", {
|
||||||
@@ -1338,8 +1350,8 @@ describe("multiple selection", () => {
|
|||||||
|
|
||||||
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
|
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
|
||||||
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
|
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
|
||||||
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX);
|
expect(boundArrow.points[1][0]).toBeCloseTo(59.7979);
|
||||||
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY);
|
expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305);
|
||||||
|
|
||||||
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
|
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
|
||||||
boundArrow.x + boundArrow.points[1][0] / 2,
|
boundArrow.x + boundArrow.points[1][0] / 2,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getLineHeight } from "@excalidraw/common";
|
import { BOUND_TEXT_PADDING, getLineHeight } from "@excalidraw/common";
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
||||||
@@ -63,9 +63,13 @@ describe("Test measureText", () => {
|
|||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
expect(
|
||||||
160,
|
computeContainerDimensionForBoundText(
|
||||||
);
|
150,
|
||||||
|
element.type,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
|
),
|
||||||
|
).toEqual(160);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should compute container height correctly for ellipse", () => {
|
it("should compute container height correctly for ellipse", () => {
|
||||||
@@ -73,9 +77,13 @@ describe("Test measureText", () => {
|
|||||||
type: "ellipse",
|
type: "ellipse",
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
expect(
|
||||||
226,
|
computeContainerDimensionForBoundText(
|
||||||
);
|
150,
|
||||||
|
element.type,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
|
),
|
||||||
|
).toEqual(226);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should compute container height correctly for diamond", () => {
|
it("should compute container height correctly for diamond", () => {
|
||||||
@@ -83,9 +91,13 @@ describe("Test measureText", () => {
|
|||||||
type: "diamond",
|
type: "diamond",
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
expect(
|
||||||
320,
|
computeContainerDimensionForBoundText(
|
||||||
);
|
150,
|
||||||
|
element.type,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
|
),
|
||||||
|
).toEqual(320);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { isFrameLikeElement } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { alignElements } from "@excalidraw/element";
|
import { alignElements } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
getOriginalContainerHeightFromCache,
|
getOriginalContainerHeightFromCache,
|
||||||
|
isBoundToContainer,
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
@@ -225,7 +226,9 @@ export const actionWrapTextInContainer = register({
|
|||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
const someTextElements = selectedElements.some((el) => isTextElement(el));
|
const someTextElements = selectedElements.some(
|
||||||
|
(el) => isTextElement(el) && !isBoundToContainer(el),
|
||||||
|
);
|
||||||
return selectedElements.length > 0 && someTextElements;
|
return selectedElements.length > 0 && someTextElements;
|
||||||
},
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -233,8 +236,11 @@ export const actionWrapTextInContainer = register({
|
|||||||
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
|
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
|
||||||
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
||||||
|
|
||||||
|
const boundTextPadding =
|
||||||
|
appState.currentItemContainerBehavior?.margin ?? BOUND_TEXT_PADDING;
|
||||||
|
|
||||||
for (const textElement of selectedElements) {
|
for (const textElement of selectedElements) {
|
||||||
if (isTextElement(textElement)) {
|
if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
|
||||||
const container = newElement({
|
const container = newElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
backgroundColor: appState.currentItemBackgroundColor,
|
backgroundColor: appState.currentItemBackgroundColor,
|
||||||
@@ -258,18 +264,21 @@ export const actionWrapTextInContainer = register({
|
|||||||
: null,
|
: null,
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
locked: false,
|
locked: false,
|
||||||
x: textElement.x - BOUND_TEXT_PADDING,
|
x: textElement.x - boundTextPadding,
|
||||||
y: textElement.y - BOUND_TEXT_PADDING,
|
y: textElement.y - boundTextPadding,
|
||||||
width: computeContainerDimensionForBoundText(
|
width: computeContainerDimensionForBoundText(
|
||||||
textElement.width,
|
textElement.width,
|
||||||
"rectangle",
|
"rectangle",
|
||||||
|
boundTextPadding,
|
||||||
),
|
),
|
||||||
height: computeContainerDimensionForBoundText(
|
height: computeContainerDimensionForBoundText(
|
||||||
textElement.height,
|
textElement.height,
|
||||||
"rectangle",
|
"rectangle",
|
||||||
|
boundTextPadding,
|
||||||
),
|
),
|
||||||
groupIds: textElement.groupIds,
|
groupIds: textElement.groupIds,
|
||||||
frameId: textElement.frameId,
|
frameId: textElement.frameId,
|
||||||
|
containerBehavior: appState.currentItemContainerBehavior,
|
||||||
});
|
});
|
||||||
|
|
||||||
// update bindings
|
// update bindings
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
MIN_ZOOM,
|
MIN_ZOOM,
|
||||||
THEME,
|
THEME,
|
||||||
ZOOM_STEP,
|
ZOOM_STEP,
|
||||||
getShortcutKey,
|
|
||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
CODES,
|
CODES,
|
||||||
KEYS,
|
KEYS,
|
||||||
@@ -46,12 +45,13 @@ import { t } from "../i18n";
|
|||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppState, Offsets } from "../types";
|
import type { AppState, Offsets } from "../types";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
label: "labels.canvasBackground",
|
label: "labels.canvasBackground",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
@@ -64,12 +64,12 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, ...value },
|
appState: { ...appState, ...value },
|
||||||
captureUpdate: !!value.viewBackgroundColor
|
captureUpdate: !!value?.viewBackgroundColor
|
||||||
? CaptureUpdateAction.IMMEDIATELY
|
? CaptureUpdateAction.IMMEDIATELY
|
||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
|
||||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||||
return (
|
return (
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
@@ -121,7 +121,10 @@ export const actionClearCanvas = register({
|
|||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
appState.activeTool.type === "image"
|
appState.activeTool.type === "image"
|
||||||
? { ...appState.activeTool, type: app.defaultSelectionTool }
|
? {
|
||||||
|
...appState.activeTool,
|
||||||
|
type: app.state.preferredSelectionTool.type,
|
||||||
|
}
|
||||||
: appState.activeTool,
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
@@ -463,7 +466,7 @@ export const actionZoomToFit = register({
|
|||||||
!event[KEYS.CTRL_OR_CMD],
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionToggleTheme = register({
|
export const actionToggleTheme = register<AppState["theme"]>({
|
||||||
name: "toggleTheme",
|
name: "toggleTheme",
|
||||||
label: (_, appState) => {
|
label: (_, appState) => {
|
||||||
return appState.theme === THEME.DARK
|
return appState.theme === THEME.DARK
|
||||||
@@ -471,7 +474,8 @@ export const actionToggleTheme = register({
|
|||||||
: "buttons.darkMode";
|
: "buttons.darkMode";
|
||||||
},
|
},
|
||||||
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
||||||
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
|
icon: (appState, elements) =>
|
||||||
|
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
@@ -500,7 +504,7 @@ export const actionToggleEraserTool = register({
|
|||||||
if (isEraserActive(appState)) {
|
if (isEraserActive(appState)) {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: app.defaultSelectionTool,
|
type: app.state.preferredSelectionTool.type,
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
@@ -531,7 +535,7 @@ export const actionToggleLassoTool = register({
|
|||||||
icon: LassoIcon,
|
icon: LassoIcon,
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return app.defaultSelectionTool !== "lasso";
|
return app.state.preferredSelectionTool.type !== "lasso";
|
||||||
},
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ import { t } from "../i18n";
|
|||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register<ClipboardEvent | null>({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
label: "labels.copy",
|
label: "labels.copy",
|
||||||
icon: DuplicateIcon,
|
icon: DuplicateIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
perform: async (elements, appState, event, app) => {
|
||||||
const elementsToCopy = app.scene.getSelectedElements({
|
const elementsToCopy = app.scene.getSelectedElements({
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
@@ -109,12 +109,12 @@ export const actionPaste = register({
|
|||||||
keyTest: undefined,
|
keyTest: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionCut = register({
|
export const actionCut = register<ClipboardEvent | null>({
|
||||||
name: "cut",
|
name: "cut",
|
||||||
label: "labels.cut",
|
label: "labels.cut",
|
||||||
icon: cutIcon,
|
icon: cutIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
perform: (elements, appState, event, app) => {
|
||||||
actionCopy.perform(elements, appState, event, app);
|
actionCopy.perform(elements, appState, event, app);
|
||||||
return actionDeleteSelected.perform(elements, appState, null, app);
|
return actionDeleteSelected.perform(elements, appState, null, app);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
import {
|
||||||
|
KEYS,
|
||||||
|
MOBILE_ACTION_BUTTON_BG,
|
||||||
|
updateActiveTool,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
||||||
@@ -26,6 +30,8 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
|
||||||
|
import { useStylesPanelMode } from "..";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
@@ -206,12 +212,8 @@ export const actionDeleteSelected = register({
|
|||||||
trackEvent: { category: "element", action: "delete" },
|
trackEvent: { category: "element", action: "delete" },
|
||||||
perform: (elements, appState, formData, app) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
if (appState.selectedLinearElement?.isEditing) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
const {
|
const { elementId, selectedPointsIndices } =
|
||||||
elementId,
|
appState.selectedLinearElement;
|
||||||
selectedPointsIndices,
|
|
||||||
startBindingElement,
|
|
||||||
endBindingElement,
|
|
||||||
} = appState.selectedLinearElement;
|
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const linearElement = LinearElementEditor.getElement(
|
const linearElement = LinearElementEditor.getElement(
|
||||||
elementId,
|
elementId,
|
||||||
@@ -248,19 +250,6 @@ export const actionDeleteSelected = register({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// We cannot do this inside `movePoint` because it is also called
|
|
||||||
// when deleting the uncommitted point (which hasn't caused any binding)
|
|
||||||
const binding = {
|
|
||||||
startBindingElement: selectedPointsIndices?.includes(0)
|
|
||||||
? null
|
|
||||||
: startBindingElement,
|
|
||||||
endBindingElement: selectedPointsIndices?.includes(
|
|
||||||
linearElement.points.length - 1,
|
|
||||||
)
|
|
||||||
? null
|
|
||||||
: endBindingElement,
|
|
||||||
};
|
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(
|
LinearElementEditor.deletePoints(
|
||||||
linearElement,
|
linearElement,
|
||||||
app,
|
app,
|
||||||
@@ -273,7 +262,6 @@ export const actionDeleteSelected = register({
|
|||||||
...appState,
|
...appState,
|
||||||
selectedLinearElement: {
|
selectedLinearElement: {
|
||||||
...appState.selectedLinearElement,
|
...appState.selectedLinearElement,
|
||||||
...binding,
|
|
||||||
selectedPointsIndices:
|
selectedPointsIndices:
|
||||||
selectedPointsIndices?.[0] > 0
|
selectedPointsIndices?.[0] > 0
|
||||||
? [selectedPointsIndices[0] - 1]
|
? [selectedPointsIndices[0] - 1]
|
||||||
@@ -299,9 +287,10 @@ export const actionDeleteSelected = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
activeTool: updateActiveTool(appState, {
|
activeTool: updateActiveTool(appState, {
|
||||||
type: app.defaultSelectionTool,
|
type: app.state.preferredSelectionTool.type,
|
||||||
}),
|
}),
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
|
newElement: null,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
selectedLinearElement: null,
|
selectedLinearElement: null,
|
||||||
},
|
},
|
||||||
@@ -316,14 +305,25 @@ export const actionDeleteSelected = register({
|
|||||||
keyTest: (event, appState, elements) =>
|
keyTest: (event, appState, elements) =>
|
||||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
||||||
!event[KEYS.CTRL_OR_CMD],
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
<ToolButton
|
const isMobile = useStylesPanelMode() === "mobile";
|
||||||
type="button"
|
|
||||||
icon={TrashIcon}
|
return (
|
||||||
title={t("labels.delete")}
|
<ToolButton
|
||||||
aria-label={t("labels.delete")}
|
type="button"
|
||||||
onClick={() => updateData(null)}
|
icon={TrashIcon}
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
title={t("labels.delete")}
|
||||||
/>
|
aria-label={t("labels.delete")}
|
||||||
),
|
onClick={() => updateData(null)}
|
||||||
|
disabled={
|
||||||
|
!isSomeElementSelected(getNonDeletedElements(elements), appState)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...(isMobile && appState.openPopup !== "compactOtherProperties"
|
||||||
|
? MOBILE_ACTION_BUTTON_BG
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { isFrameLikeElement } from "@excalidraw/element";
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_GRID_SIZE,
|
DEFAULT_GRID_SIZE,
|
||||||
KEYS,
|
KEYS,
|
||||||
|
MOBILE_ACTION_BUTTON_BG,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
getShortcutKey,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
@@ -25,6 +25,9 @@ import { DuplicateIcon } from "../components/icons";
|
|||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
|
import { useStylesPanelMode } from "..";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@@ -106,16 +109,27 @@ export const actionDuplicateSelection = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
<ToolButton
|
const isMobile = useStylesPanelMode() === "mobile";
|
||||||
type="button"
|
|
||||||
icon={DuplicateIcon}
|
return (
|
||||||
title={`${t("labels.duplicateSelection")} — ${getShortcutKey(
|
<ToolButton
|
||||||
"CtrlOrCmd+D",
|
type="button"
|
||||||
)}`}
|
icon={DuplicateIcon}
|
||||||
aria-label={t("labels.duplicateSelection")}
|
title={`${t("labels.duplicateSelection")} — ${getShortcutKey(
|
||||||
onClick={() => updateData(null)}
|
"CtrlOrCmd+D",
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
)}`}
|
||||||
/>
|
aria-label={t("labels.duplicateSelection")}
|
||||||
),
|
onClick={() => updateData(null)}
|
||||||
|
disabled={
|
||||||
|
!isSomeElementSelected(getNonDeletedElements(elements), appState)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...(isMobile && appState.openPopup !== "compactOtherProperties"
|
||||||
|
? MOBILE_ACTION_BUTTON_BG
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CaptureUpdateAction } from "@excalidraw/element";
|
|||||||
|
|
||||||
import type { Theme } from "@excalidraw/element/types";
|
import type { Theme } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { useDevice } from "../components/App";
|
import { useEditorInterface } from "../components/App";
|
||||||
import { CheckboxItem } from "../components/CheckboxItem";
|
import { CheckboxItem } from "../components/CheckboxItem";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
@@ -31,7 +31,9 @@ import "../components/ToolIcon.scss";
|
|||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
import type { AppState } from "../types";
|
||||||
|
|
||||||
|
export const actionChangeProjectName = register<AppState["name"]>({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
label: "labels.fileTitle",
|
label: "labels.fileTitle",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
@@ -51,7 +53,7 @@ export const actionChangeProjectName = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeExportScale = register({
|
export const actionChangeExportScale = register<AppState["exportScale"]>({
|
||||||
name: "changeExportScale",
|
name: "changeExportScale",
|
||||||
label: "imageExportDialog.scale",
|
label: "imageExportDialog.scale",
|
||||||
trackEvent: { category: "export", action: "scale" },
|
trackEvent: { category: "export", action: "scale" },
|
||||||
@@ -101,7 +103,9 @@ export const actionChangeExportScale = register({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeExportBackground = register({
|
export const actionChangeExportBackground = register<
|
||||||
|
AppState["exportBackground"]
|
||||||
|
>({
|
||||||
name: "changeExportBackground",
|
name: "changeExportBackground",
|
||||||
label: "imageExportDialog.label.withBackground",
|
label: "imageExportDialog.label.withBackground",
|
||||||
trackEvent: { category: "export", action: "toggleBackground" },
|
trackEvent: { category: "export", action: "toggleBackground" },
|
||||||
@@ -121,7 +125,9 @@ export const actionChangeExportBackground = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeExportEmbedScene = register({
|
export const actionChangeExportEmbedScene = register<
|
||||||
|
AppState["exportEmbedScene"]
|
||||||
|
>({
|
||||||
name: "changeExportEmbedScene",
|
name: "changeExportEmbedScene",
|
||||||
label: "imageExportDialog.tooltip.embedScene",
|
label: "imageExportDialog.tooltip.embedScene",
|
||||||
trackEvent: { category: "export", action: "embedScene" },
|
trackEvent: { category: "export", action: "embedScene" },
|
||||||
@@ -242,7 +248,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
icon={saveAs}
|
icon={saveAs}
|
||||||
title={t("buttons.saveAs")}
|
title={t("buttons.saveAs")}
|
||||||
aria-label={t("buttons.saveAs")}
|
aria-label={t("buttons.saveAs")}
|
||||||
showAriaLabel={useDevice().editor.isMobile}
|
showAriaLabel={useEditorInterface().formFactor === "phone"}
|
||||||
hidden={!nativeFileSystemSupported}
|
hidden={!nativeFileSystemSupported}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
data-testid="save-as-button"
|
data-testid="save-as-button"
|
||||||
@@ -288,7 +294,9 @@ export const actionLoadScene = register({
|
|||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionExportWithDarkMode = register({
|
export const actionExportWithDarkMode = register<
|
||||||
|
AppState["exportWithDarkMode"]
|
||||||
|
>({
|
||||||
name: "exportWithDarkMode",
|
name: "exportWithDarkMode",
|
||||||
label: "imageExportDialog.label.darkMode",
|
label: "imageExportDialog.label.darkMode",
|
||||||
trackEvent: { category: "export", action: "toggleTheme" },
|
trackEvent: { category: "export", action: "toggleTheme" },
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { pointFrom } from "@excalidraw/math";
|
import { pointFrom } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import { bindOrUnbindBindingElement } from "@excalidraw/element/binding";
|
||||||
maybeBindLinearElement,
|
|
||||||
bindOrUnbindLinearElement,
|
|
||||||
isBindingEnabled,
|
|
||||||
} from "@excalidraw/element/binding";
|
|
||||||
import {
|
import {
|
||||||
isValidPolygon,
|
isValidPolygon,
|
||||||
LinearElementEditor,
|
LinearElementEditor,
|
||||||
@@ -21,7 +17,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
KEYS,
|
KEYS,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
tupleToCoors,
|
invariant,
|
||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { isPathALoop } from "@excalidraw/element";
|
import { isPathALoop } from "@excalidraw/element";
|
||||||
@@ -30,11 +26,12 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
|
PointsPositionUpdates,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -46,20 +43,37 @@ import { register } from "./register";
|
|||||||
|
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
type FormData = {
|
||||||
|
event: PointerEvent;
|
||||||
|
sceneCoords: { x: number; y: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actionFinalize = register<FormData>({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
label: "",
|
label: "",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, data, app) => {
|
perform: (elements, appState, data, app) => {
|
||||||
|
let newElements = elements;
|
||||||
const { interactiveCanvas, focusContainer, scene } = app;
|
const { interactiveCanvas, focusContainer, scene } = app;
|
||||||
const { event, sceneCoords } =
|
|
||||||
(data as {
|
|
||||||
event?: PointerEvent;
|
|
||||||
sceneCoords?: { x: number; y: number };
|
|
||||||
}) ?? {};
|
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
if (event && appState.selectedLinearElement) {
|
if (data && appState.selectedLinearElement) {
|
||||||
|
const { event, sceneCoords } = data;
|
||||||
|
const element = LinearElementEditor.getElement(
|
||||||
|
appState.selectedLinearElement.elementId,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
element,
|
||||||
|
"Arrow element should exist if selectedLinearElement is set",
|
||||||
|
);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
sceneCoords,
|
||||||
|
"sceneCoords should be defined if actionFinalize is called with event",
|
||||||
|
);
|
||||||
|
|
||||||
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||||
event,
|
event,
|
||||||
appState.selectedLinearElement,
|
appState.selectedLinearElement,
|
||||||
@@ -67,19 +81,48 @@ export const actionFinalize = register({
|
|||||||
app.scene,
|
app.scene,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { startBindingElement, endBindingElement } = linearElementEditor;
|
|
||||||
const element = app.scene.getElement(linearElementEditor.elementId);
|
|
||||||
if (isBindingElement(element)) {
|
if (isBindingElement(element)) {
|
||||||
bindOrUnbindLinearElement(
|
const newArrow = !!appState.newElement;
|
||||||
element,
|
|
||||||
startBindingElement,
|
const selectedPointsIndices =
|
||||||
endBindingElement,
|
newArrow || !appState.selectedLinearElement.selectedPointsIndices
|
||||||
app.scene,
|
? [element.points.length - 1] // New arrow creation
|
||||||
);
|
: appState.selectedLinearElement.selectedPointsIndices;
|
||||||
|
|
||||||
|
const draggedPoints: PointsPositionUpdates =
|
||||||
|
selectedPointsIndices.reduce((map, index) => {
|
||||||
|
map.set(index, {
|
||||||
|
point: LinearElementEditor.pointFromAbsoluteCoords(
|
||||||
|
element,
|
||||||
|
pointFrom<GlobalPoint>(sceneCoords.x, sceneCoords.y),
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, new Map()) ?? new Map();
|
||||||
|
|
||||||
|
bindOrUnbindBindingElement(element, draggedPoints, scene, appState, {
|
||||||
|
newArrow,
|
||||||
|
altKey: event.altKey,
|
||||||
|
});
|
||||||
|
} else if (isLineElement(element)) {
|
||||||
|
if (
|
||||||
|
appState.selectedLinearElement?.isEditing &&
|
||||||
|
!appState.newElement &&
|
||||||
|
!isValidPolygon(element.points)
|
||||||
|
) {
|
||||||
|
scene.mutateElement(element, {
|
||||||
|
polygon: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (linearElementEditor !== appState.selectedLinearElement) {
|
if (linearElementEditor !== appState.selectedLinearElement) {
|
||||||
let newElements = elements;
|
// `handlePointerUp()` updated the linear element instance,
|
||||||
|
// so filter out this element if it is too small,
|
||||||
|
// but do an update to all new elements anyway for undo/redo purposes.
|
||||||
|
|
||||||
if (element && isInvisiblySmallElement(element)) {
|
if (element && isInvisiblySmallElement(element)) {
|
||||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||||
newElements = newElements.map((el) => {
|
newElements = newElements.map((el) => {
|
||||||
@@ -91,39 +134,8 @@ export const actionFinalize = register({
|
|||||||
return el;
|
return el;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
elements: newElements,
|
|
||||||
appState: {
|
|
||||||
selectedLinearElement: {
|
|
||||||
...linearElementEditor,
|
|
||||||
selectedPointsIndices: null,
|
|
||||||
},
|
|
||||||
suggestedBindings: [],
|
|
||||||
},
|
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appState.selectedLinearElement?.isEditing) {
|
const activeToolLocked = appState.activeTool?.locked;
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
|
||||||
appState.selectedLinearElement;
|
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
if (isBindingElement(element)) {
|
|
||||||
bindOrUnbindLinearElement(
|
|
||||||
element,
|
|
||||||
startBindingElement,
|
|
||||||
endBindingElement,
|
|
||||||
scene,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isLineElement(element) && !isValidPolygon(element.points)) {
|
|
||||||
scene.mutateElement(element, {
|
|
||||||
polygon: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements:
|
elements:
|
||||||
@@ -134,23 +146,31 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
})
|
})
|
||||||
: undefined,
|
: newElements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
selectedLinearElement: new LinearElementEditor(
|
selectedLinearElement: activeToolLocked
|
||||||
element,
|
? null
|
||||||
arrayToMap(elementsMap),
|
: {
|
||||||
false, // exit editing mode
|
...linearElementEditor,
|
||||||
),
|
selectedPointsIndices: null,
|
||||||
|
isEditing: false,
|
||||||
|
initialState: {
|
||||||
|
...linearElementEditor.initialState,
|
||||||
|
lastClickedPoint: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selectionElement: null,
|
||||||
|
suggestedBinding: null,
|
||||||
|
newElement: null,
|
||||||
|
multiElement: null,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let newElements = elements;
|
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
focusContainer();
|
focusContainer();
|
||||||
}
|
}
|
||||||
@@ -174,8 +194,14 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
// pen and mouse have hover
|
// pen and mouse have hover
|
||||||
if (appState.multiElement && element.type !== "freedraw") {
|
if (
|
||||||
const { points, lastCommittedPoint } = element;
|
appState.selectedLinearElement &&
|
||||||
|
appState.multiElement &&
|
||||||
|
element.type !== "freedraw" &&
|
||||||
|
appState.lastPointerDownWith !== "touch"
|
||||||
|
) {
|
||||||
|
const { points } = element;
|
||||||
|
const { lastCommittedPoint } = appState.selectedLinearElement;
|
||||||
if (
|
if (
|
||||||
!lastCommittedPoint ||
|
!lastCommittedPoint ||
|
||||||
points[points.length - 1] !== lastCommittedPoint
|
points[points.length - 1] !== lastCommittedPoint
|
||||||
@@ -227,25 +253,6 @@ export const actionFinalize = register({
|
|||||||
polygon: false,
|
polygon: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
isBindingElement(element) &&
|
|
||||||
!isLoop &&
|
|
||||||
element.points.length > 1 &&
|
|
||||||
isBindingEnabled(appState)
|
|
||||||
) {
|
|
||||||
const coords =
|
|
||||||
sceneCoords ??
|
|
||||||
tupleToCoors(
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
element,
|
|
||||||
-1,
|
|
||||||
arrayToMap(elements),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
maybeBindLinearElement(element, appState, coords, scene);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,16 +268,35 @@ export const actionFinalize = register({
|
|||||||
if (appState.activeTool.type === "eraser") {
|
if (appState.activeTool.type === "eraser") {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: app.defaultSelectionTool,
|
type: app.state.preferredSelectionTool.type,
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
type: app.defaultSelectionTool,
|
type: app.state.preferredSelectionTool.type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let selectedLinearElement =
|
||||||
|
element && isLinearElement(element)
|
||||||
|
? new LinearElementEditor(element, arrayToMap(newElements)) // To select the linear element when user has finished mutipoint editing
|
||||||
|
: appState.selectedLinearElement;
|
||||||
|
|
||||||
|
selectedLinearElement = selectedLinearElement
|
||||||
|
? {
|
||||||
|
...selectedLinearElement,
|
||||||
|
isEditing: appState.newElement
|
||||||
|
? false
|
||||||
|
: selectedLinearElement.isEditing,
|
||||||
|
initialState: {
|
||||||
|
...selectedLinearElement.initialState,
|
||||||
|
lastClickedPoint: -1,
|
||||||
|
origin: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: selectedLinearElement;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -288,7 +314,7 @@ export const actionFinalize = register({
|
|||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingTextElement: null,
|
editingTextElement: null,
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBinding: null,
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
element &&
|
element &&
|
||||||
!appState.activeTool.locked &&
|
!appState.activeTool.locked &&
|
||||||
@@ -298,11 +324,8 @@ export const actionFinalize = register({
|
|||||||
[element.id]: true,
|
[element.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
// To select the linear element when user has finished mutipoint editing
|
|
||||||
selectedLinearElement:
|
selectedLinearElement,
|
||||||
element && isLinearElement(element)
|
|
||||||
? new LinearElementEditor(element, arrayToMap(newElements))
|
|
||||||
: appState.selectedLinearElement,
|
|
||||||
},
|
},
|
||||||
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
|
|||||||
@@ -38,15 +38,13 @@ describe("flipping re-centers selection", () => {
|
|||||||
height: 239.9,
|
height: 239.9,
|
||||||
startBinding: {
|
startBinding: {
|
||||||
elementId: "rec1",
|
elementId: "rec1",
|
||||||
focus: 0,
|
|
||||||
gap: 5,
|
|
||||||
fixedPoint: [0.49, -0.05],
|
fixedPoint: [0.49, -0.05],
|
||||||
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
endBinding: {
|
endBinding: {
|
||||||
elementId: "rec2",
|
elementId: "rec2",
|
||||||
focus: 0,
|
|
||||||
gap: 5,
|
|
||||||
fixedPoint: [-0.05, 0.49],
|
fixedPoint: [-0.05, 0.49],
|
||||||
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
startArrowhead: null,
|
startArrowhead: null,
|
||||||
endArrowhead: "arrow",
|
endArrowhead: "arrow",
|
||||||
@@ -74,11 +72,11 @@ describe("flipping re-centers selection", () => {
|
|||||||
|
|
||||||
const rec1 = h.elements.find((el) => el.id === "rec1")!;
|
const rec1 = h.elements.find((el) => el.id === "rec1")!;
|
||||||
expect(rec1.x).toBeCloseTo(100, 0);
|
expect(rec1.x).toBeCloseTo(100, 0);
|
||||||
expect(rec1.y).toBeCloseTo(100, 0);
|
expect(rec1.y).toBeCloseTo(101, 0);
|
||||||
|
|
||||||
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
||||||
expect(rec2.x).toBeCloseTo(220, 0);
|
expect(rec2.x).toBeCloseTo(220, 0);
|
||||||
expect(rec2.y).toBeCloseTo(250, 0);
|
expect(rec2.y).toBeCloseTo(251, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,8 +97,8 @@ describe("flipping arrowheads", () => {
|
|||||||
endArrowhead: null,
|
endArrowhead: null,
|
||||||
endBinding: {
|
endBinding: {
|
||||||
elementId: rect.id,
|
elementId: rect.id,
|
||||||
focus: 0.5,
|
fixedPoint: [0.5, 0.5],
|
||||||
gap: 5,
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,13 +137,13 @@ describe("flipping arrowheads", () => {
|
|||||||
endArrowhead: "circle",
|
endArrowhead: "circle",
|
||||||
startBinding: {
|
startBinding: {
|
||||||
elementId: rect.id,
|
elementId: rect.id,
|
||||||
focus: 0.5,
|
fixedPoint: [0.5, 0.5],
|
||||||
gap: 5,
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
endBinding: {
|
endBinding: {
|
||||||
elementId: rect2.id,
|
elementId: rect2.id,
|
||||||
focus: 0.5,
|
fixedPoint: [0.5, 0.5],
|
||||||
gap: 5,
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,8 +193,8 @@ describe("flipping arrowheads", () => {
|
|||||||
endArrowhead: null,
|
endArrowhead: null,
|
||||||
endBinding: {
|
endBinding: {
|
||||||
elementId: rect.id,
|
elementId: rect.id,
|
||||||
focus: 0.5,
|
fixedPoint: [0.5, 0.5],
|
||||||
gap: 5,
|
mode: "orbit",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import {
|
import { bindOrUnbindBindingElements } from "@excalidraw/element";
|
||||||
bindOrUnbindLinearElements,
|
|
||||||
isBindingEnabled,
|
|
||||||
} from "@excalidraw/element";
|
|
||||||
import { getCommonBoundingBox } from "@excalidraw/element";
|
import { getCommonBoundingBox } from "@excalidraw/element";
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import { deepCopyElement } from "@excalidraw/element";
|
import { deepCopyElement } from "@excalidraw/element";
|
||||||
import { resizeMultipleElements } from "@excalidraw/element";
|
import { resizeMultipleElements } from "@excalidraw/element";
|
||||||
import {
|
import { isArrowElement, isElbowArrow } from "@excalidraw/element";
|
||||||
isArrowElement,
|
|
||||||
isElbowArrow,
|
|
||||||
isLinearElement,
|
|
||||||
} from "@excalidraw/element";
|
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||||
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
@@ -103,7 +96,6 @@ const flipSelectedElements = (
|
|||||||
const updatedElements = flipElements(
|
const updatedElements = flipElements(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState,
|
|
||||||
flipDirection,
|
flipDirection,
|
||||||
app,
|
app,
|
||||||
);
|
);
|
||||||
@@ -118,7 +110,6 @@ const flipSelectedElements = (
|
|||||||
const flipElements = (
|
const flipElements = (
|
||||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
appState: AppState,
|
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
@@ -158,12 +149,10 @@ const flipElements = (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindBindingElements(
|
||||||
selectedElements.filter(isLinearElement),
|
selectedElements.filter(isArrowElement),
|
||||||
isBindingEnabled(appState),
|
|
||||||
[],
|
|
||||||
app.scene,
|
app.scene,
|
||||||
appState.zoom,
|
app.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, randomId, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSelectedGroupIds,
|
getSelectedGroupIds,
|
||||||
@@ -43,6 +43,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
import {
|
||||||
|
isWindows,
|
||||||
|
KEYS,
|
||||||
|
matchKey,
|
||||||
|
arrayToMap,
|
||||||
|
MOBILE_ACTION_BUTTON_BG,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -12,6 +18,8 @@ import { HistoryChangedEvent } from "../history";
|
|||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
import { useStylesPanelMode } from "..";
|
||||||
|
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import type { Action, ActionResult } from "./types";
|
import type { Action, ActionResult } from "./types";
|
||||||
@@ -67,7 +75,7 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ appState, updateData, data, app }) => {
|
||||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(
|
new HistoryChangedEvent(
|
||||||
@@ -75,6 +83,7 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
history.isRedoStackEmpty,
|
history.isRedoStackEmpty,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const isMobile = useStylesPanelMode() === "mobile";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
@@ -85,6 +94,9 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isUndoStackEmpty}
|
disabled={isUndoStackEmpty}
|
||||||
data-testid="button-undo"
|
data-testid="button-undo"
|
||||||
|
style={{
|
||||||
|
...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -103,7 +115,7 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
|
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
|
||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ appState, updateData, data, app }) => {
|
||||||
const { isRedoStackEmpty } = useEmitter(
|
const { isRedoStackEmpty } = useEmitter(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(
|
new HistoryChangedEvent(
|
||||||
@@ -111,6 +123,7 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
history.isRedoStackEmpty,
|
history.isRedoStackEmpty,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const isMobile = useStylesPanelMode() === "mobile";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
@@ -121,6 +134,9 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isRedoStackEmpty}
|
disabled={isRedoStackEmpty}
|
||||||
data-testid="button-redo"
|
data-testid="button-redo"
|
||||||
|
style={{
|
||||||
|
...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ export const actionToggleLinearEditor = register({
|
|||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
})[0] as ExcalidrawLinearElement;
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
if (!selectedElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const label = t(
|
const label = t(
|
||||||
selectedElement.type === "arrow"
|
selectedElement.type === "arrow"
|
||||||
? "labels.lineEditor.editArrow"
|
? "labels.lineEditor.editArrow"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isEmbeddableElement } from "@excalidraw/element";
|
import { isEmbeddableElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
import { KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||||
import { LinkIcon } from "../components/icons";
|
import { LinkIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,11 @@
|
|||||||
import { KEYS } from "@excalidraw/common";
|
import { KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { showSelectedShapeActions } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { HelpIconThin } from "../components/icons";
|
||||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
|
||||||
name: "toggleCanvasMenu",
|
|
||||||
label: "buttons.menu",
|
|
||||||
trackEvent: { category: "menu" },
|
|
||||||
perform: (_, appState) => ({
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
|
||||||
},
|
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
|
||||||
}),
|
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
icon={HamburgerMenuIcon}
|
|
||||||
aria-label={t("buttons.menu")}
|
|
||||||
onClick={updateData}
|
|
||||||
selected={appState.openMenu === "canvas"}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionToggleEditMenu = register({
|
|
||||||
name: "toggleEditMenu",
|
|
||||||
label: "buttons.edit",
|
|
||||||
trackEvent: { category: "menu" },
|
|
||||||
perform: (_elements, appState) => ({
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
|
||||||
},
|
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
|
||||||
}),
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
visible={showSelectedShapeActions(
|
|
||||||
appState,
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
)}
|
|
||||||
type="button"
|
|
||||||
icon={palette}
|
|
||||||
aria-label={t("buttons.edit")}
|
|
||||||
onClick={updateData}
|
|
||||||
selected={appState.openMenu === "shape"}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
label: "welcomeScreen.defaults.helpHint",
|
label: "welcomeScreen.defaults.helpHint",
|
||||||
@@ -79,6 +25,8 @@ export const actionShortcuts = register({
|
|||||||
: {
|
: {
|
||||||
name: "help",
|
name: "help",
|
||||||
},
|
},
|
||||||
|
openMenu: null,
|
||||||
|
openPopup: null,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import clsx from "clsx";
|
|||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { invariant } from "@excalidraw/common";
|
||||||
|
|
||||||
import { getClientColor } from "../clients";
|
import { getClientColor } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import {
|
import {
|
||||||
@@ -16,12 +18,17 @@ import { register } from "./register";
|
|||||||
import type { GoToCollaboratorComponentProps } from "../components/UserList";
|
import type { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||||
import type { Collaborator } from "../types";
|
import type { Collaborator } from "../types";
|
||||||
|
|
||||||
export const actionGoToCollaborator = register({
|
export const actionGoToCollaborator = register<Collaborator>({
|
||||||
name: "goToCollaborator",
|
name: "goToCollaborator",
|
||||||
label: "Go to a collaborator",
|
label: "Go to a collaborator",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "collab" },
|
trackEvent: { category: "collab" },
|
||||||
perform: (_elements, appState, collaborator: Collaborator) => {
|
perform: (_elements, appState, collaborator) => {
|
||||||
|
invariant(
|
||||||
|
collaborator,
|
||||||
|
"actionGoToCollaborator: collaborator should be defined when actionGoToCollaborator is called",
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!collaborator.socketId ||
|
!collaborator.socketId ||
|
||||||
appState.userToFollow?.socketId === collaborator.socketId ||
|
appState.userToFollow?.socketId === collaborator.socketId ||
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,8 +25,11 @@ export const actionToggleZenMode = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.zenModeEnabled,
|
checked: (appState) => appState.zenModeEnabled,
|
||||||
predicate: (elements, appState, appProps) => {
|
predicate: (elements, appState, appProps, app) => {
|
||||||
return typeof appProps.zenModeEnabled === "undefined";
|
return (
|
||||||
|
app.editorInterface.formFactor !== "phone" &&
|
||||||
|
typeof appProps.zenModeEnabled === "undefined"
|
||||||
|
);
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { KEYS, CODES, getShortcutKey, isDarwin } from "@excalidraw/common";
|
import { KEYS, CODES, isDarwin } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
moveOneLeft,
|
moveOneLeft,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SendToBackIcon,
|
SendToBackIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export {
|
|||||||
actionChangeFontFamily,
|
actionChangeFontFamily,
|
||||||
actionChangeTextAlign,
|
actionChangeTextAlign,
|
||||||
actionChangeVerticalAlign,
|
actionChangeVerticalAlign,
|
||||||
|
actionChangeArrowProperties,
|
||||||
|
actionChangeContainerBehavior,
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -43,11 +45,7 @@ export {
|
|||||||
} from "./actionExport";
|
} from "./actionExport";
|
||||||
|
|
||||||
export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
||||||
export {
|
export { actionShortcuts } from "./actionMenu";
|
||||||
actionToggleCanvasMenu,
|
|
||||||
actionToggleEditMenu,
|
|
||||||
actionShortcuts,
|
|
||||||
} from "./actionMenu";
|
|
||||||
|
|
||||||
export { actionGroup, actionUngroup } from "./actionGroup";
|
export { actionGroup, actionUngroup } from "./actionGroup";
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ const trackAction = (
|
|||||||
trackEvent(
|
trackEvent(
|
||||||
action.trackEvent.category,
|
action.trackEvent.category,
|
||||||
action.trackEvent.action || action.name,
|
action.trackEvent.action || action.name,
|
||||||
`${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
|
`${source} (${
|
||||||
|
app.editorInterface.formFactor === "phone" ? "mobile" : "desktop"
|
||||||
|
})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import type { Action } from "./types";
|
|||||||
|
|
||||||
export let actions: readonly Action[] = [];
|
export let actions: readonly Action[] = [];
|
||||||
|
|
||||||
export const register = <T extends Action>(action: T) => {
|
export const register = <
|
||||||
|
TData extends any,
|
||||||
|
T extends Action<TData> = Action<TData>,
|
||||||
|
>(
|
||||||
|
action: T,
|
||||||
|
) => {
|
||||||
actions = actions.concat(action);
|
actions = actions.concat(action);
|
||||||
return action as T & {
|
return action as T & {
|
||||||
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
|
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { isDarwin, getShortcutKey } from "@excalidraw/common";
|
import { isDarwin } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { SubtypeOf } from "@excalidraw/common/utility-types";
|
import type { SubtypeOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import type { ActionName } from "./types";
|
import type { ActionName } from "./types";
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ export type ActionResult =
|
|||||||
}
|
}
|
||||||
| false;
|
| false;
|
||||||
|
|
||||||
type ActionFn = (
|
type ActionFn<TData = any> = (
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
formData: any,
|
formData: TData | undefined,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => ActionResult | Promise<ActionResult>;
|
) => ActionResult | Promise<ActionResult>;
|
||||||
|
|
||||||
@@ -67,12 +67,12 @@ export type ActionName =
|
|||||||
| "changeStrokeShape"
|
| "changeStrokeShape"
|
||||||
| "changeSloppiness"
|
| "changeSloppiness"
|
||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
|
| "changeContainerBehavior"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
| "changeArrowType"
|
| "changeArrowType"
|
||||||
|
| "changeArrowProperties"
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
|
||||||
| "toggleEditMenu"
|
|
||||||
| "undo"
|
| "undo"
|
||||||
| "redo"
|
| "redo"
|
||||||
| "finalize"
|
| "finalize"
|
||||||
@@ -158,7 +158,7 @@ export type PanelComponentProps = {
|
|||||||
) => React.JSX.Element | null;
|
) => React.JSX.Element | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Action {
|
export interface Action<TData = any> {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
label:
|
label:
|
||||||
| string
|
| string
|
||||||
@@ -175,7 +175,7 @@ export interface Action {
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => React.ReactNode);
|
) => React.ReactNode);
|
||||||
PanelComponent?: React.FC<PanelComponentProps>;
|
PanelComponent?: React.FC<PanelComponentProps>;
|
||||||
perform: ActionFn;
|
perform: ActionFn<TData>;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
keyTest?: (
|
keyTest?: (
|
||||||
event: React.KeyboardEvent | KeyboardEvent,
|
event: React.KeyboardEvent | KeyboardEvent,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||||
|
currentItemContainerBehavior: DEFAULT_ELEMENT_PROPS.containerBehavior,
|
||||||
currentHoveredFontFamily: null,
|
currentHoveredFontFamily: null,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
@@ -55,6 +56,10 @@ export const getDefaultAppState = (): Omit<
|
|||||||
fromSelection: false,
|
fromSelection: false,
|
||||||
lastActiveTool: null,
|
lastActiveTool: null,
|
||||||
},
|
},
|
||||||
|
preferredSelectionTool: {
|
||||||
|
type: "selection",
|
||||||
|
initialized: false,
|
||||||
|
},
|
||||||
penMode: false,
|
penMode: false,
|
||||||
penDetected: false,
|
penDetected: false,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -96,7 +101,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
||||||
},
|
},
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBinding: null,
|
||||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||||
frameToHighlight: null,
|
frameToHighlight: null,
|
||||||
editingFrame: null,
|
editingFrame: null,
|
||||||
@@ -123,6 +128,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
searchMatches: null,
|
searchMatches: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
|
bindMode: "orbit",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,6 +174,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||||
|
currentItemContainerBehavior: { browser: true, export: false, server: false },
|
||||||
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
||||||
cursorButton: { browser: true, export: false, server: false },
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
activeEmbeddable: { browser: false, export: false, server: false },
|
activeEmbeddable: { browser: false, export: false, server: false },
|
||||||
@@ -175,6 +182,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
editingTextElement: { browser: false, export: false, server: false },
|
editingTextElement: { browser: false, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
activeTool: { browser: true, export: false, server: false },
|
activeTool: { browser: true, export: false, server: false },
|
||||||
|
preferredSelectionTool: { browser: true, export: false, server: false },
|
||||||
penMode: { browser: true, export: false, server: false },
|
penMode: { browser: true, export: false, server: false },
|
||||||
penDetected: { browser: true, export: false, server: false },
|
penDetected: { browser: true, export: false, server: false },
|
||||||
errorMessage: { browser: false, export: false, server: false },
|
errorMessage: { browser: false, export: false, server: false },
|
||||||
@@ -224,7 +232,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||||
stats: { browser: true, export: false, server: false },
|
stats: { browser: true, export: false, server: false },
|
||||||
startBoundElement: { browser: false, export: false, server: false },
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
suggestedBindings: { browser: false, export: false, server: false },
|
suggestedBinding: { browser: false, export: false, server: false },
|
||||||
frameRendering: { browser: false, export: false, server: false },
|
frameRendering: { browser: false, export: false, server: false },
|
||||||
frameToHighlight: { browser: false, export: false, server: false },
|
frameToHighlight: { browser: false, export: false, server: false },
|
||||||
editingFrame: { browser: false, export: false, server: false },
|
editingFrame: { browser: false, export: false, server: false },
|
||||||
@@ -247,6 +255,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
searchMatches: { browser: false, export: false, server: false },
|
searchMatches: { browser: false, export: false, server: false },
|
||||||
lockedMultiSelections: { browser: true, export: true, server: true },
|
lockedMultiSelections: { browser: true, export: true, server: true },
|
||||||
activeLockedId: { browser: false, export: false, server: false },
|
activeLockedId: { browser: false, export: false, server: false },
|
||||||
|
bindMode: { browser: true, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
randomId,
|
randomId,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -334,6 +335,10 @@ const chartBaseElements = (
|
|||||||
strokeColor: COLOR_PALETTE.black,
|
strokeColor: COLOR_PALETTE.black,
|
||||||
fillStyle: "solid",
|
fillStyle: "solid",
|
||||||
opacity: 6,
|
opacity: 6,
|
||||||
|
containerBehavior: {
|
||||||
|
textFlow: "growing",
|
||||||
|
margin: BOUND_TEXT_PADDING,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -366,6 +371,10 @@ const chartTypeBar = (
|
|||||||
y: y - barHeight - BAR_GAP,
|
y: y - barHeight - BAR_GAP,
|
||||||
width: BAR_WIDTH,
|
width: BAR_WIDTH,
|
||||||
height: barHeight,
|
height: barHeight,
|
||||||
|
containerBehavior: {
|
||||||
|
textFlow: "growing",
|
||||||
|
margin: BOUND_TEXT_PADDING,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -432,6 +441,10 @@ const chartTypeLine = (
|
|||||||
y: y + cy - BAR_GAP * 2,
|
y: y + cy - BAR_GAP * 2,
|
||||||
width: BAR_GAP,
|
width: BAR_GAP,
|
||||||
height: BAR_GAP,
|
height: BAR_GAP,
|
||||||
|
containerBehavior: {
|
||||||
|
textFlow: "growing",
|
||||||
|
margin: BOUND_TEXT_PADDING,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createPasteEvent,
|
createPasteEvent,
|
||||||
parseClipboard,
|
parseClipboard,
|
||||||
|
parseDataTransferEvent,
|
||||||
serializeAsClipboardJSON,
|
serializeAsClipboardJSON,
|
||||||
} from "./clipboard";
|
} from "./clipboard";
|
||||||
import { API } from "./tests/helpers/api";
|
import { API } from "./tests/helpers/api";
|
||||||
@@ -13,7 +14,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "123";
|
text = "123";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@@ -21,7 +24,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "[123]";
|
text = "[123]";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@@ -29,7 +34,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = JSON.stringify({ val: 42 });
|
text = JSON.stringify({ val: 42 });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
});
|
});
|
||||||
@@ -39,11 +46,13 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/plain": json,
|
types: {
|
||||||
},
|
"text/plain": json,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
});
|
});
|
||||||
@@ -56,21 +65,25 @@ describe("parseClipboard()", () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": json,
|
types: {
|
||||||
},
|
"text/html": json,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<div> ${json}</div>`,
|
types: {
|
||||||
},
|
"text/html": `<div> ${json}</div>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<img src="https://example.com/image.png" />`,
|
types: {
|
||||||
},
|
"text/html": `<img src="https://example.com/image.png" />`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
|
|||||||
]);
|
]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
types: {
|
||||||
},
|
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@@ -114,11 +131,13 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
types: {
|
||||||
},
|
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@@ -141,14 +160,16 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/plain": `a b
|
types: {
|
||||||
1 2
|
"text/plain": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@@ -157,14 +178,16 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `a b
|
types: {
|
||||||
1 2
|
"text/html": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@@ -173,19 +196,21 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<html>
|
types: {
|
||||||
<body>
|
"text/html": `<html>
|
||||||
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
<body>
|
||||||
</body>
|
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
||||||
</html>`,
|
</body>
|
||||||
"text/plain": `a b
|
</html>`,
|
||||||
1 2
|
"text/plain": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
isMemberOf,
|
isMemberOf,
|
||||||
isPromiseLike,
|
isPromiseLike,
|
||||||
|
EVENT,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { mutateElement } from "@excalidraw/element";
|
import { mutateElement } from "@excalidraw/element";
|
||||||
@@ -16,15 +17,26 @@ import {
|
|||||||
|
|
||||||
import { getContainingFrame } from "@excalidraw/element";
|
import { getContainingFrame } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ExcalidrawError } from "./errors";
|
import { ExcalidrawError } from "./errors";
|
||||||
import { createFile, isSupportedImageFileType } from "./data/blob";
|
import {
|
||||||
|
createFile,
|
||||||
|
getFileHandle,
|
||||||
|
isSupportedImageFileType,
|
||||||
|
normalizeFile,
|
||||||
|
} from "./data/blob";
|
||||||
|
|
||||||
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
|
|
||||||
|
import type { FileSystemHandle } from "./data/filesystem";
|
||||||
|
|
||||||
import type { Spreadsheet } from "./charts";
|
import type { Spreadsheet } from "./charts";
|
||||||
|
|
||||||
import type { BinaryFiles } from "./types";
|
import type { BinaryFiles } from "./types";
|
||||||
@@ -92,7 +104,7 @@ export const createPasteEvent = ({
|
|||||||
console.warn("createPasteEvent: no types or files provided");
|
console.warn("createPasteEvent: no types or files provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = new ClipboardEvent("paste", {
|
const event = new ClipboardEvent(EVENT.PASTE, {
|
||||||
clipboardData: new DataTransfer(),
|
clipboardData: new DataTransfer(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,10 +113,11 @@ export const createPasteEvent = ({
|
|||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
files = files || [];
|
files = files || [];
|
||||||
files.push(value);
|
files.push(value);
|
||||||
|
event.clipboardData?.items.add(value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
event.clipboardData?.setData(type, value);
|
event.clipboardData?.items.add(value, type);
|
||||||
if (event.clipboardData?.getData(type) !== value) {
|
if (event.clipboardData?.getData(type) !== value) {
|
||||||
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
||||||
}
|
}
|
||||||
@@ -229,14 +242,10 @@ function parseHTMLTree(el: ChildNode) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maybeParseHTMLPaste = (
|
const maybeParseHTMLDataItem = (
|
||||||
event: ClipboardEvent,
|
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
|
||||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||||
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
const html = dataItem.value;
|
||||||
|
|
||||||
if (!html) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
||||||
@@ -332,18 +341,21 @@ export const readSystemClipboard = async () => {
|
|||||||
* Parses "paste" ClipboardEvent.
|
* Parses "paste" ClipboardEvent.
|
||||||
*/
|
*/
|
||||||
const parseClipboardEventTextData = async (
|
const parseClipboardEventTextData = async (
|
||||||
event: ClipboardEvent,
|
dataList: ParsedDataTranferList,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ParsedClipboardEventTextData> => {
|
): Promise<ParsedClipboardEventTextData> => {
|
||||||
try {
|
try {
|
||||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
const htmlItem = dataList.findByType(MIME_TYPES.html);
|
||||||
|
|
||||||
|
const mixedContent =
|
||||||
|
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
|
||||||
|
|
||||||
if (mixedContent) {
|
if (mixedContent) {
|
||||||
if (mixedContent.value.every((item) => item.type === "text")) {
|
if (mixedContent.value.every((item) => item.type === "text")) {
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
value:
|
||||||
event.clipboardData?.getData(MIME_TYPES.text) ||
|
dataList.getData(MIME_TYPES.text) ??
|
||||||
mixedContent.value
|
mixedContent.value
|
||||||
.map((item) => item.value)
|
.map((item) => item.value)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@@ -354,23 +366,156 @@ const parseClipboardEventTextData = async (
|
|||||||
return mixedContent;
|
return mixedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
return {
|
||||||
|
type: "text",
|
||||||
return { type: "text", value: (text || "").trim() };
|
value: (dataList.getData(MIME_TYPES.text) || "").trim(),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { type: "text", value: "" };
|
return { type: "text", value: "" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AllowedParsedDataTransferItem =
|
||||||
|
| {
|
||||||
|
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||||
|
kind: "file";
|
||||||
|
file: File;
|
||||||
|
fileHandle: FileSystemHandle | null;
|
||||||
|
}
|
||||||
|
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
||||||
|
|
||||||
|
type ParsedDataTransferItem =
|
||||||
|
| {
|
||||||
|
type: string;
|
||||||
|
kind: "file";
|
||||||
|
file: File;
|
||||||
|
fileHandle: FileSystemHandle | null;
|
||||||
|
}
|
||||||
|
| { type: string; kind: "string"; value: string };
|
||||||
|
|
||||||
|
type ParsedDataTransferItemType<
|
||||||
|
T extends AllowedParsedDataTransferItem["type"],
|
||||||
|
> = AllowedParsedDataTransferItem & { type: T };
|
||||||
|
|
||||||
|
export type ParsedDataTransferFile = Extract<
|
||||||
|
AllowedParsedDataTransferItem,
|
||||||
|
{ kind: "file" }
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ParsedDataTranferList = ParsedDataTransferItem[] & {
|
||||||
|
/**
|
||||||
|
* Only allows filtering by known `string` data types, since `file`
|
||||||
|
* types can have multiple items of the same type (e.g. multiple image files)
|
||||||
|
* unlike `string` data transfer items.
|
||||||
|
*/
|
||||||
|
findByType: typeof findDataTransferItemType;
|
||||||
|
/**
|
||||||
|
* Only allows filtering by known `string` data types, since `file`
|
||||||
|
* types can have multiple items of the same type (e.g. multiple image files)
|
||||||
|
* unlike `string` data transfer items.
|
||||||
|
*/
|
||||||
|
getData: typeof getDataTransferItemData;
|
||||||
|
getFiles: typeof getDataTransferFiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDataTransferItemType = function <
|
||||||
|
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||||
|
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
|
||||||
|
return (
|
||||||
|
this.find(
|
||||||
|
(item): item is ParsedDataTransferItemType<T> => item.type === type,
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const getDataTransferItemData = function <
|
||||||
|
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||||
|
>(
|
||||||
|
this: ParsedDataTranferList,
|
||||||
|
type: T,
|
||||||
|
):
|
||||||
|
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
|
||||||
|
| null {
|
||||||
|
const item = this.find(
|
||||||
|
(
|
||||||
|
item,
|
||||||
|
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
|
||||||
|
item.type === type,
|
||||||
|
);
|
||||||
|
|
||||||
|
return item?.value ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDataTransferFiles = function (
|
||||||
|
this: ParsedDataTranferList,
|
||||||
|
): ParsedDataTransferFile[] {
|
||||||
|
return this.filter(
|
||||||
|
(item): item is ParsedDataTransferFile => item.kind === "file",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseDataTransferEvent = async (
|
||||||
|
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
|
||||||
|
): Promise<ParsedDataTranferList> => {
|
||||||
|
let items: DataTransferItemList | undefined = undefined;
|
||||||
|
|
||||||
|
if (isClipboardEvent(event)) {
|
||||||
|
items = event.clipboardData?.items;
|
||||||
|
} else {
|
||||||
|
const dragEvent = event;
|
||||||
|
items = dragEvent.dataTransfer?.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataItems = (
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(items || []).map(
|
||||||
|
async (item): Promise<ParsedDataTransferItem | null> => {
|
||||||
|
if (item.kind === "file") {
|
||||||
|
let file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
const fileHandle = await getFileHandle(item);
|
||||||
|
file = await normalizeFile(file);
|
||||||
|
return {
|
||||||
|
type: file.type,
|
||||||
|
kind: "file",
|
||||||
|
file,
|
||||||
|
fileHandle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (item.kind === "string") {
|
||||||
|
const { type } = item;
|
||||||
|
let value: string;
|
||||||
|
if ("clipboardData" in event && event.clipboardData) {
|
||||||
|
value = event.clipboardData?.getData(type);
|
||||||
|
} else {
|
||||||
|
value = await new Promise<string>((resolve) => {
|
||||||
|
item.getAsString((str) => resolve(str));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type, kind: "string", value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).filter((data): data is ParsedDataTransferItem => data != null);
|
||||||
|
|
||||||
|
return Object.assign(dataItems, {
|
||||||
|
findByType: findDataTransferItemType,
|
||||||
|
getData: getDataTransferItemData,
|
||||||
|
getFiles: getDataTransferFiles,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to parse clipboard event.
|
* Attempts to parse clipboard event.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent,
|
dataList: ParsedDataTranferList,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const parsedEventData = await parseClipboardEventTextData(
|
const parsedEventData = await parseClipboardEventTextData(
|
||||||
event,
|
dataList,
|
||||||
isPlainPaste,
|
isPlainPaste,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -519,3 +664,14 @@ const copyTextViaExecCommand = (text: string | null) => {
|
|||||||
|
|
||||||
return success;
|
return success;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isClipboardEvent = (
|
||||||
|
event: React.SyntheticEvent | Event,
|
||||||
|
): event is ClipboardEvent => {
|
||||||
|
/** not using instanceof ClipboardEvent due to tests (jsdom) */
|
||||||
|
return (
|
||||||
|
event.type === EVENT.PASTE ||
|
||||||
|
event.type === EVENT.COPY ||
|
||||||
|
event.type === EVENT.CUT
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -91,3 +91,118 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-shape-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
|
||||||
|
.compact-action-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
--default-button-size: 2rem;
|
||||||
|
|
||||||
|
.compact-action-button {
|
||||||
|
width: var(--mobile-action-button-size);
|
||||||
|
height: var(--mobile-action-button-size);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
background: var(--mobile-action-button-bg);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(
|
||||||
|
--color-surface-primary-container,
|
||||||
|
var(--mobile-action-button-bg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-popover-content {
|
||||||
|
.popover-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon {
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: var(--mobile-action-button-size);
|
||||||
|
height: var(--mobile-action-button-size);
|
||||||
|
|
||||||
|
background: var(--mobile-action-button-bg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-shape-actions-island {
|
||||||
|
width: fit-content;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-shape-actions {
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-actions-theme-scope {
|
||||||
|
--button-border: transparent;
|
||||||
|
--button-bg: var(--color-surface-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.theme--dark .shape-actions-theme-scope {
|
||||||
|
--button-hover-bg: #363541;
|
||||||
|
--button-bg: var(--color-surface-high);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1547
-826
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
import { KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../..//shortcut";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { useDevice } from "../App";
|
import { useEditorInterface } from "../App";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import { eyeDropperIcon } from "../icons";
|
import { eyeDropperIcon } from "../icons";
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ export const ColorInput = ({
|
|||||||
colorPickerType,
|
colorPickerType,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: ColorInputProps) => {
|
}: ColorInputProps) => {
|
||||||
const device = useDevice();
|
const editorInterface = useEditorInterface();
|
||||||
const [innerValue, setInnerValue] = useState(color);
|
const [innerValue, setInnerValue] = useState(color);
|
||||||
const [activeSection, setActiveColorPickerSection] = useAtom(
|
const [activeSection, setActiveColorPickerSection] = useAtom(
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
@@ -98,7 +99,7 @@ export const ColorInput = ({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
{/* TODO reenable on mobile with a better UX */}
|
{/* TODO reenable on mobile with a better UX */}
|
||||||
{!device.editor.isMobile && (
|
{editorInterface.formFactor !== "phone" && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-picker__title {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.color-picker__heading {
|
.color-picker__heading {
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -22,6 +28,12 @@
|
|||||||
@include isMobile {
|
@include isMobile {
|
||||||
max-width: 11rem;
|
max-width: 11rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.color-picker-container--no-top-picks {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
grid-template-columns: unset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker__top-picks {
|
.color-picker__top-picks {
|
||||||
@@ -80,6 +92,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-picker__button-background {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
.color-picker__button-outline {
|
.color-picker__button-outline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -141,6 +163,15 @@
|
|||||||
width: 1.625rem;
|
width: 1.625rem;
|
||||||
height: 1.625rem;
|
height: 1.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.compact-sizing {
|
||||||
|
width: var(--mobile-action-button-size);
|
||||||
|
height: var(--mobile-action-button-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mobile-border {
|
||||||
|
border: 1px solid var(--mobile-color-border);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker__button__hotkey-label {
|
.color-picker__button__hotkey-label {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useRef } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||||
COLOR_PALETTE,
|
COLOR_PALETTE,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
|
isWritableElement,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
|
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
|
||||||
@@ -14,11 +15,16 @@ import type { ExcalidrawElement } from "@excalidraw/element/types";
|
|||||||
|
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { useExcalidrawContainer } from "../App";
|
import { useExcalidrawContainer, useStylesPanelMode } from "../App";
|
||||||
import { ButtonSeparator } from "../ButtonSeparator";
|
import { ButtonSeparator } from "../ButtonSeparator";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import { PropertiesPopover } from "../PropertiesPopover";
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
import { slashIcon } from "../icons";
|
import { slashIcon, strokeIcon } from "../icons";
|
||||||
|
import {
|
||||||
|
saveCaretPosition,
|
||||||
|
restoreCaretPosition,
|
||||||
|
temporarilyDisableTextEditorBlur,
|
||||||
|
} from "../../hooks/useTextEditorFocus";
|
||||||
|
|
||||||
import { ColorInput } from "./ColorInput";
|
import { ColorInput } from "./ColorInput";
|
||||||
import { Picker } from "./Picker";
|
import { Picker } from "./Picker";
|
||||||
@@ -77,6 +83,8 @@ const ColorPickerPopupContent = ({
|
|||||||
elements,
|
elements,
|
||||||
palette = COLOR_PALETTE,
|
palette = COLOR_PALETTE,
|
||||||
updateData,
|
updateData,
|
||||||
|
getOpenPopup,
|
||||||
|
appState,
|
||||||
}: Pick<
|
}: Pick<
|
||||||
ColorPickerProps,
|
ColorPickerProps,
|
||||||
| "type"
|
| "type"
|
||||||
@@ -86,8 +94,14 @@ const ColorPickerPopupContent = ({
|
|||||||
| "elements"
|
| "elements"
|
||||||
| "palette"
|
| "palette"
|
||||||
| "updateData"
|
| "updateData"
|
||||||
>) => {
|
| "appState"
|
||||||
|
> & {
|
||||||
|
getOpenPopup: () => AppState["openPopup"];
|
||||||
|
}) => {
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
|
const stylesPanelMode = useStylesPanelMode();
|
||||||
|
const isCompactMode = stylesPanelMode !== "full";
|
||||||
|
const isMobileMode = stylesPanelMode === "mobile";
|
||||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||||
@@ -117,9 +131,13 @@ const ColorPickerPopupContent = ({
|
|||||||
<PropertiesPopover
|
<PropertiesPopover
|
||||||
container={container}
|
container={container}
|
||||||
style={{ maxWidth: "13rem" }}
|
style={{ maxWidth: "13rem" }}
|
||||||
|
// Improve focus handling for text editing scenarios
|
||||||
|
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||||
onFocusOutside={(event) => {
|
onFocusOutside={(event) => {
|
||||||
// refocus due to eye dropper
|
// refocus due to eye dropper
|
||||||
focusPickerContent();
|
if (!isWritableElement(event.target)) {
|
||||||
|
focusPickerContent();
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}}
|
}}
|
||||||
onPointerDownOutside={(event) => {
|
onPointerDownOutside={(event) => {
|
||||||
@@ -131,8 +149,23 @@ const ColorPickerPopupContent = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
updateData({ openPopup: null });
|
// only clear if we're still the active popup (avoid racing with switch)
|
||||||
|
if (getOpenPopup() === type) {
|
||||||
|
updateData({ openPopup: null });
|
||||||
|
}
|
||||||
setActiveColorPickerSection(null);
|
setActiveColorPickerSection(null);
|
||||||
|
|
||||||
|
// Refocus text editor when popover closes if we were editing text
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor) {
|
||||||
|
textEditor.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{palette ? (
|
{palette ? (
|
||||||
@@ -141,7 +174,17 @@ const ColorPickerPopupContent = ({
|
|||||||
palette={palette}
|
palette={palette}
|
||||||
color={color}
|
color={color}
|
||||||
onChange={(changedColor) => {
|
onChange={(changedColor) => {
|
||||||
|
// Save caret position before color change if editing text
|
||||||
|
const savedSelection = appState.editingTextElement
|
||||||
|
? saveCaretPosition()
|
||||||
|
: null;
|
||||||
|
|
||||||
onChange(changedColor);
|
onChange(changedColor);
|
||||||
|
|
||||||
|
// Restore caret position after color change if editing text
|
||||||
|
if (appState.editingTextElement && savedSelection) {
|
||||||
|
restoreCaretPosition(savedSelection);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onEyeDropperToggle={(force) => {
|
onEyeDropperToggle={(force) => {
|
||||||
setEyeDropperState((state) => {
|
setEyeDropperState((state) => {
|
||||||
@@ -168,12 +211,15 @@ const ColorPickerPopupContent = ({
|
|||||||
if (eyeDropperState) {
|
if (eyeDropperState) {
|
||||||
setEyeDropperState(null);
|
setEyeDropperState(null);
|
||||||
} else {
|
} else {
|
||||||
|
// close explicitly on Escape
|
||||||
updateData({ openPopup: null });
|
updateData({ openPopup: null });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
type={type}
|
type={type}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
showTitle={isCompactMode}
|
||||||
|
showHotKey={!isMobileMode}
|
||||||
>
|
>
|
||||||
{colorInputJSX}
|
{colorInputJSX}
|
||||||
</Picker>
|
</Picker>
|
||||||
@@ -188,11 +234,33 @@ const ColorPickerTrigger = ({
|
|||||||
label,
|
label,
|
||||||
color,
|
color,
|
||||||
type,
|
type,
|
||||||
|
mode = "background",
|
||||||
|
onToggle,
|
||||||
|
editingTextElement,
|
||||||
}: {
|
}: {
|
||||||
color: string | null;
|
color: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
|
mode?: "background" | "stroke";
|
||||||
|
onToggle: () => void;
|
||||||
|
editingTextElement?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const stylesPanelMode = useStylesPanelMode();
|
||||||
|
const isCompactMode = stylesPanelMode !== "full";
|
||||||
|
const isMobileMode = stylesPanelMode === "mobile";
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
// use pointerdown so we run before outside-close logic
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// If editing text, temporarily disable the wysiwyg blur event
|
||||||
|
if (editingTextElement) {
|
||||||
|
temporarilyDisableTextEditorBlur();
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggle();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
type="button"
|
type="button"
|
||||||
@@ -200,6 +268,8 @@ const ColorPickerTrigger = ({
|
|||||||
"is-transparent": !color || color === "transparent",
|
"is-transparent": !color || color === "transparent",
|
||||||
"has-outline":
|
"has-outline":
|
||||||
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
|
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
|
||||||
|
"compact-sizing": isCompactMode,
|
||||||
|
"mobile-border": isMobileMode,
|
||||||
})}
|
})}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
style={color ? { "--swatch-color": color } : undefined}
|
style={color ? { "--swatch-color": color } : undefined}
|
||||||
@@ -208,8 +278,24 @@ const ColorPickerTrigger = ({
|
|||||||
? t("labels.showStroke")
|
? t("labels.showStroke")
|
||||||
: t("labels.showBackground")
|
: t("labels.showBackground")
|
||||||
}
|
}
|
||||||
|
data-openpopup={type}
|
||||||
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
||||||
|
{isCompactMode && color && mode === "stroke" && (
|
||||||
|
<div className="color-picker__button-background">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||||
|
? "#fff"
|
||||||
|
: "#111",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{strokeIcon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -225,24 +311,59 @@ export const ColorPicker = ({
|
|||||||
updateData,
|
updateData,
|
||||||
appState,
|
appState,
|
||||||
}: ColorPickerProps) => {
|
}: ColorPickerProps) => {
|
||||||
|
const openRef = useRef(appState.openPopup);
|
||||||
|
useEffect(() => {
|
||||||
|
openRef.current = appState.openPopup;
|
||||||
|
}, [appState.openPopup]);
|
||||||
|
const stylesPanelMode = useStylesPanelMode();
|
||||||
|
const isCompactMode = stylesPanelMode !== "full";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div role="dialog" aria-modal="true" className="color-picker-container">
|
<div
|
||||||
<TopPicks
|
role="dialog"
|
||||||
activeColor={color}
|
aria-modal="true"
|
||||||
onChange={onChange}
|
className={clsx("color-picker-container", {
|
||||||
type={type}
|
"color-picker-container--no-top-picks": isCompactMode,
|
||||||
topPicks={topPicks}
|
})}
|
||||||
/>
|
>
|
||||||
<ButtonSeparator />
|
{!isCompactMode && (
|
||||||
|
<TopPicks
|
||||||
|
activeColor={color}
|
||||||
|
onChange={onChange}
|
||||||
|
type={type}
|
||||||
|
topPicks={topPicks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isCompactMode && <ButtonSeparator />}
|
||||||
<Popover.Root
|
<Popover.Root
|
||||||
open={appState.openPopup === type}
|
open={appState.openPopup === type}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
updateData({ openPopup: open ? type : null });
|
if (open) {
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* serves as an active color indicator as well */}
|
{/* serves as an active color indicator as well */}
|
||||||
<ColorPickerTrigger color={color} label={label} type={type} />
|
<ColorPickerTrigger
|
||||||
|
color={color}
|
||||||
|
label={label}
|
||||||
|
type={type}
|
||||||
|
mode={type === "elementStroke" ? "stroke" : "background"}
|
||||||
|
editingTextElement={!!appState.editingTextElement}
|
||||||
|
onToggle={() => {
|
||||||
|
// atomic switch: if another popup is open, close it first, then open this one next tick
|
||||||
|
if (appState.openPopup === type) {
|
||||||
|
// toggle off on same trigger
|
||||||
|
updateData({ openPopup: null });
|
||||||
|
} else if (appState.openPopup) {
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
} else {
|
||||||
|
// open this one
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* popup content */}
|
{/* popup content */}
|
||||||
{appState.openPopup === type && (
|
{appState.openPopup === type && (
|
||||||
<ColorPickerPopupContent
|
<ColorPickerPopupContent
|
||||||
@@ -253,6 +374,8 @@ export const ColorPicker = ({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
getOpenPopup={() => openRef.current}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ interface PickerProps {
|
|||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
showTitle?: boolean;
|
||||||
onEyeDropperToggle: (force?: boolean) => void;
|
onEyeDropperToggle: (force?: boolean) => void;
|
||||||
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
||||||
|
showHotKey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Picker = React.forwardRef(
|
export const Picker = React.forwardRef(
|
||||||
@@ -51,11 +53,21 @@ export const Picker = React.forwardRef(
|
|||||||
palette,
|
palette,
|
||||||
updateData,
|
updateData,
|
||||||
children,
|
children,
|
||||||
|
showTitle,
|
||||||
onEyeDropperToggle,
|
onEyeDropperToggle,
|
||||||
onEscape,
|
onEscape,
|
||||||
|
showHotKey = true,
|
||||||
}: PickerProps,
|
}: PickerProps,
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
|
const title = showTitle
|
||||||
|
? type === "elementStroke"
|
||||||
|
? t("labels.stroke")
|
||||||
|
: type === "elementBackground"
|
||||||
|
? t("labels.background")
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
const [customColors] = React.useState(() => {
|
const [customColors] = React.useState(() => {
|
||||||
if (type === "canvasBackground") {
|
if (type === "canvasBackground") {
|
||||||
return [];
|
return [];
|
||||||
@@ -154,6 +166,8 @@ export const Picker = React.forwardRef(
|
|||||||
// to allow focusing by clicking but not by tabbing
|
// to allow focusing by clicking but not by tabbing
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
|
{title && <div className="color-picker__title">{title}</div>}
|
||||||
|
|
||||||
{!!customColors.length && (
|
{!!customColors.length && (
|
||||||
<div>
|
<div>
|
||||||
<PickerHeading>
|
<PickerHeading>
|
||||||
@@ -175,12 +189,18 @@ export const Picker = React.forwardRef(
|
|||||||
palette={palette}
|
palette={palette}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
activeShade={activeShade}
|
activeShade={activeShade}
|
||||||
|
showHotKey={showHotKey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
|
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
|
||||||
<ShadeList color={color} onChange={onChange} palette={palette} />
|
<ShadeList
|
||||||
|
color={color}
|
||||||
|
onChange={onChange}
|
||||||
|
palette={palette}
|
||||||
|
showHotKey={showHotKey}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface PickerColorListProps {
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
activeShade: number;
|
activeShade: number;
|
||||||
|
showHotKey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PickerColorList = ({
|
const PickerColorList = ({
|
||||||
@@ -27,6 +28,7 @@ const PickerColorList = ({
|
|||||||
color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
activeShade,
|
activeShade,
|
||||||
|
showHotKey = true,
|
||||||
}: PickerColorListProps) => {
|
}: PickerColorListProps) => {
|
||||||
const colorObj = getColorNameAndShadeFromColor({
|
const colorObj = getColorNameAndShadeFromColor({
|
||||||
color,
|
color,
|
||||||
@@ -82,7 +84,7 @@ const PickerColorList = ({
|
|||||||
key={key}
|
key={key}
|
||||||
>
|
>
|
||||||
<div className="color-picker__button-outline" />
|
<div className="color-picker__button-outline" />
|
||||||
<HotkeyLabel color={color} keyLabel={keybinding} />
|
{showHotKey && <HotkeyLabel color={color} keyLabel={keybinding} />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -16,9 +16,15 @@ interface ShadeListProps {
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
|
showHotKey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
|
export const ShadeList = ({
|
||||||
|
color,
|
||||||
|
onChange,
|
||||||
|
palette,
|
||||||
|
showHotKey,
|
||||||
|
}: ShadeListProps) => {
|
||||||
const colorObj = getColorNameAndShadeFromColor({
|
const colorObj = getColorNameAndShadeFromColor({
|
||||||
color: color || "transparent",
|
color: color || "transparent",
|
||||||
palette,
|
palette,
|
||||||
@@ -67,7 +73,9 @@ export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="color-picker__button-outline" />
|
<div className="color-picker__button-outline" />
|
||||||
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
|
{showHotKey && (
|
||||||
|
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,6 +100,19 @@ $verticalBreakpoint: 861px;
|
|||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
--icon-size: 1rem;
|
||||||
|
|
||||||
|
&.command-item-large {
|
||||||
|
height: 2.75rem;
|
||||||
|
--icon-size: 1.5rem;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
|
margin-right: 0.625rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background-color: var(--color-surface-low);
|
background-color: var(--color-surface-low);
|
||||||
}
|
}
|
||||||
@@ -130,9 +143,17 @@ $verticalBreakpoint: 861px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
width: 16px;
|
width: var(--icon-size, 1rem);
|
||||||
height: 16px;
|
height: var(--icon-size, 1rem);
|
||||||
margin-right: 6px;
|
margin-right: 0.375rem;
|
||||||
|
|
||||||
|
.library-item-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import fuzzy from "fuzzy";
|
import fuzzy from "fuzzy";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
EVENT,
|
EVENT,
|
||||||
KEYS,
|
KEYS,
|
||||||
capitalizeString,
|
capitalizeString,
|
||||||
getShortcutKey,
|
|
||||||
isWritableElement,
|
isWritableElement,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
|
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
|
||||||
|
|
||||||
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -61,12 +62,21 @@ import { useStable } from "../../hooks/useStable";
|
|||||||
|
|
||||||
import { Ellipsify } from "../Ellipsify";
|
import { Ellipsify } from "../Ellipsify";
|
||||||
|
|
||||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
import {
|
||||||
|
distributeLibraryItemsOnSquareGrid,
|
||||||
|
libraryItemsAtom,
|
||||||
|
} from "../../data/library";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useLibraryCache,
|
||||||
|
useLibraryItemSvg,
|
||||||
|
} from "../../hooks/useLibraryItemSvg";
|
||||||
|
|
||||||
|
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||||
import "./CommandPalette.scss";
|
import "./CommandPalette.scss";
|
||||||
|
|
||||||
import type { CommandPaletteItem } from "./types";
|
import type { CommandPaletteItem } from "./types";
|
||||||
import type { AppProps, AppState, UIAppState } from "../../types";
|
import type { AppProps, AppState, LibraryItem, UIAppState } from "../../types";
|
||||||
import type { ShortcutName } from "../../actions/shortcuts";
|
import type { ShortcutName } from "../../actions/shortcuts";
|
||||||
import type { TranslationKeys } from "../../i18n";
|
import type { TranslationKeys } from "../../i18n";
|
||||||
import type { Action } from "../../actions/types";
|
import type { Action } from "../../actions/types";
|
||||||
@@ -80,6 +90,7 @@ export const DEFAULT_CATEGORIES = {
|
|||||||
editor: "Editor",
|
editor: "Editor",
|
||||||
elements: "Elements",
|
elements: "Elements",
|
||||||
links: "Links",
|
links: "Links",
|
||||||
|
library: "Library",
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryOrder = (category: string) => {
|
const getCategoryOrder = (category: string) => {
|
||||||
@@ -207,6 +218,34 @@ function CommandPaletteInner({
|
|||||||
appProps,
|
appProps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||||
|
const libraryCommands: CommandPaletteItem[] = useMemo(() => {
|
||||||
|
return (
|
||||||
|
libraryItemsData.libraryItems
|
||||||
|
?.filter(
|
||||||
|
(libraryItem): libraryItem is MarkRequired<LibraryItem, "name"> =>
|
||||||
|
!!libraryItem.name,
|
||||||
|
)
|
||||||
|
.map((libraryItem) => ({
|
||||||
|
label: libraryItem.name,
|
||||||
|
icon: (
|
||||||
|
<LibraryItemIcon
|
||||||
|
id={libraryItem.id}
|
||||||
|
elements={libraryItem.elements}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
category: "Library",
|
||||||
|
order: getCategoryOrder("Library"),
|
||||||
|
haystack: deburr(libraryItem.name),
|
||||||
|
perform: () => {
|
||||||
|
app.onInsertElements(
|
||||||
|
distributeLibraryItemsOnSquareGrid([libraryItem]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})) || []
|
||||||
|
);
|
||||||
|
}, [app, libraryItemsData.libraryItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// these props change often and we don't want them to re-run the effect
|
// these props change often and we don't want them to re-run the effect
|
||||||
// which would renew `allCommands`, cascading down and resetting state.
|
// which would renew `allCommands`, cascading down and resetting state.
|
||||||
@@ -438,7 +477,6 @@ function CommandPaletteInner({
|
|||||||
},
|
},
|
||||||
perform: () => {
|
perform: () => {
|
||||||
setAppState((prevState) => ({
|
setAppState((prevState) => ({
|
||||||
openMenu: prevState.openMenu === "shape" ? null : "shape",
|
|
||||||
openPopup: "elementStroke",
|
openPopup: "elementStroke",
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@@ -458,7 +496,6 @@ function CommandPaletteInner({
|
|||||||
},
|
},
|
||||||
perform: () => {
|
perform: () => {
|
||||||
setAppState((prevState) => ({
|
setAppState((prevState) => ({
|
||||||
openMenu: prevState.openMenu === "shape" ? null : "shape",
|
|
||||||
openPopup: "elementBackground",
|
openPopup: "elementBackground",
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@@ -588,8 +625,9 @@ function CommandPaletteInner({
|
|||||||
|
|
||||||
setAllCommands(allCommands);
|
setAllCommands(allCommands);
|
||||||
setLastUsed(
|
setLastUsed(
|
||||||
allCommands.find((command) => command.label === lastUsed?.label) ??
|
[...allCommands, ...libraryCommands].find(
|
||||||
null,
|
(command) => command.label === lastUsed?.label,
|
||||||
|
) ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -600,6 +638,7 @@ function CommandPaletteInner({
|
|||||||
lastUsed?.label,
|
lastUsed?.label,
|
||||||
setLastUsed,
|
setLastUsed,
|
||||||
setAppState,
|
setAppState,
|
||||||
|
libraryCommands,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [commandSearch, setCommandSearch] = useState("");
|
const [commandSearch, setCommandSearch] = useState("");
|
||||||
@@ -796,9 +835,17 @@ function CommandPaletteInner({
|
|||||||
return nextCommandsByCategory;
|
return nextCommandsByCategory;
|
||||||
};
|
};
|
||||||
|
|
||||||
let matchingCommands = allCommands
|
let matchingCommands =
|
||||||
.filter(isCommandAvailable)
|
commandSearch?.length > 1
|
||||||
.sort((a, b) => a.order - b.order);
|
? [
|
||||||
|
...allCommands
|
||||||
|
.filter(isCommandAvailable)
|
||||||
|
.sort((a, b) => a.order - b.order),
|
||||||
|
...libraryCommands,
|
||||||
|
]
|
||||||
|
: allCommands
|
||||||
|
.filter(isCommandAvailable)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
const showLastUsed =
|
const showLastUsed =
|
||||||
!commandSearch && lastUsed && isCommandAvailable(lastUsed);
|
!commandSearch && lastUsed && isCommandAvailable(lastUsed);
|
||||||
@@ -822,14 +869,20 @@ function CommandPaletteInner({
|
|||||||
);
|
);
|
||||||
matchingCommands = fuzzy
|
matchingCommands = fuzzy
|
||||||
.filter(_query, matchingCommands, {
|
.filter(_query, matchingCommands, {
|
||||||
extract: (command) => command.haystack,
|
extract: (command) => command.haystack ?? "",
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.map((item) => item.original);
|
.map((item) => item.original);
|
||||||
|
|
||||||
setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
|
setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
|
||||||
setCurrentCommand(matchingCommands[0] ?? null);
|
setCurrentCommand(matchingCommands[0] ?? null);
|
||||||
}, [commandSearch, allCommands, isCommandAvailable, lastUsed]);
|
}, [
|
||||||
|
commandSearch,
|
||||||
|
allCommands,
|
||||||
|
isCommandAvailable,
|
||||||
|
lastUsed,
|
||||||
|
libraryCommands,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -850,7 +903,7 @@ function CommandPaletteInner({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!app.device.viewport.isMobile && (
|
{app.editorInterface.formFactor !== "phone" && (
|
||||||
<div className="shortcuts-wrapper">
|
<div className="shortcuts-wrapper">
|
||||||
<CommandShortcutHint shortcut="↑↓">
|
<CommandShortcutHint shortcut="↑↓">
|
||||||
{t("commandPalette.shortcuts.select")}
|
{t("commandPalette.shortcuts.select")}
|
||||||
@@ -884,7 +937,7 @@ function CommandPaletteInner({
|
|||||||
onClick={(event) => executeCommand(lastUsed, event)}
|
onClick={(event) => executeCommand(lastUsed, event)}
|
||||||
disabled={!isCommandAvailable(lastUsed)}
|
disabled={!isCommandAvailable(lastUsed)}
|
||||||
onMouseMove={() => setCurrentCommand(lastUsed)}
|
onMouseMove={() => setCurrentCommand(lastUsed)}
|
||||||
showShortcut={!app.device.viewport.isMobile}
|
showShortcut={app.editorInterface.formFactor !== "phone"}
|
||||||
appState={uiAppState}
|
appState={uiAppState}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -902,8 +955,9 @@ function CommandPaletteInner({
|
|||||||
isSelected={command.label === currentCommand?.label}
|
isSelected={command.label === currentCommand?.label}
|
||||||
onClick={(event) => executeCommand(command, event)}
|
onClick={(event) => executeCommand(command, event)}
|
||||||
onMouseMove={() => setCurrentCommand(command)}
|
onMouseMove={() => setCurrentCommand(command)}
|
||||||
showShortcut={!app.device.viewport.isMobile}
|
showShortcut={app.editorInterface.formFactor !== "phone"}
|
||||||
appState={uiAppState}
|
appState={uiAppState}
|
||||||
|
size={category === "Library" ? "large" : "small"}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -919,6 +973,20 @@ function CommandPaletteInner({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const LibraryItemIcon = ({
|
||||||
|
id,
|
||||||
|
elements,
|
||||||
|
}: {
|
||||||
|
id: LibraryItem["id"] | null;
|
||||||
|
elements: LibraryItem["elements"] | undefined;
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const { svgCache } = useLibraryCache();
|
||||||
|
|
||||||
|
useLibraryItemSvg(id, elements, svgCache, ref);
|
||||||
|
|
||||||
|
return <div className="library-item-icon" ref={ref} />;
|
||||||
|
};
|
||||||
|
|
||||||
const CommandItem = ({
|
const CommandItem = ({
|
||||||
command,
|
command,
|
||||||
@@ -928,6 +996,7 @@ const CommandItem = ({
|
|||||||
onClick,
|
onClick,
|
||||||
showShortcut,
|
showShortcut,
|
||||||
appState,
|
appState,
|
||||||
|
size = "small",
|
||||||
}: {
|
}: {
|
||||||
command: CommandPaletteItem;
|
command: CommandPaletteItem;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
@@ -936,6 +1005,7 @@ const CommandItem = ({
|
|||||||
onClick: (event: React.MouseEvent) => void;
|
onClick: (event: React.MouseEvent) => void;
|
||||||
showShortcut: boolean;
|
showShortcut: boolean;
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
|
size?: "small" | "large";
|
||||||
}) => {
|
}) => {
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
@@ -944,6 +1014,7 @@ const CommandItem = ({
|
|||||||
className={clsx("command-item", {
|
className={clsx("command-item", {
|
||||||
"item-selected": isSelected,
|
"item-selected": isSelected,
|
||||||
"item-disabled": disabled,
|
"item-disabled": disabled,
|
||||||
|
"command-item-large": size === "large",
|
||||||
})}
|
})}
|
||||||
ref={(ref) => {
|
ref={(ref) => {
|
||||||
if (isSelected && !disabled) {
|
if (isSelected && !disabled) {
|
||||||
@@ -959,9 +1030,11 @@ const CommandItem = ({
|
|||||||
<div className="name">
|
<div className="name">
|
||||||
{command.icon && (
|
{command.icon && (
|
||||||
<InlineIcon
|
<InlineIcon
|
||||||
|
className="icon"
|
||||||
|
size="var(--icon-size, 1rem)"
|
||||||
icon={
|
icon={
|
||||||
typeof command.icon === "function"
|
typeof command.icon === "function"
|
||||||
? command.icon(appState)
|
? command.icon(appState, [])
|
||||||
: command.icon
|
: command.icon
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ActionManager } from "../../actions/manager";
|
import type { ActionManager } from "../../actions/manager";
|
||||||
import type { Action } from "../../actions/types";
|
import type { Action } from "../../actions/types";
|
||||||
import type { UIAppState } from "../../types";
|
|
||||||
|
|
||||||
export type CommandPaletteItem = {
|
export type CommandPaletteItem = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -12,7 +11,7 @@ export type CommandPaletteItem = {
|
|||||||
* (deburred name + keywords)
|
* (deburred name + keywords)
|
||||||
*/
|
*/
|
||||||
haystack?: string;
|
haystack?: string;
|
||||||
icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode);
|
icon?: Action["icon"];
|
||||||
category: string;
|
category: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
predicate?: boolean | Action["predicate"];
|
predicate?: boolean | Action["predicate"];
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user