Compare commits

..

2 Commits

Author SHA1 Message Date
Ryan Di 118fd7bafa fix: lint 2025-07-03 16:50:57 +10:00
Soham Kulkarni 21f492fb13 Update App.tsx 2025-07-02 23:01:15 +05:30
337 changed files with 12267 additions and 32119 deletions
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 18.x
- name: Set up publish access - name: Set up publish access
run: | run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 18.x
- name: Install and lint - name: Install and lint
run: | run: |
+2 -2
View File
@@ -14,10 +14,10 @@ jobs:
with: with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 18.x
- name: Create report file - name: Create report file
run: | run: |
+2 -2
View File
@@ -10,10 +10,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 18.x
- name: Install and build - name: Install and build
run: | run: |
yarn --frozen-lockfile yarn --frozen-lockfile
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 20.x node-version: 18.x
- name: Install in packages/excalidraw - name: Install in packages/excalidraw
run: yarn run: yarn
working-directory: packages/excalidraw working-directory: packages/excalidraw
+1
View File
@@ -0,0 +1 @@
18
+12 -6
View File
@@ -23,17 +23,23 @@
<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" /></a> <img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
</a>
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw"> <a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" /></a> <img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
</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" /></a> <img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
</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"/></a> <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 href="https://deepwiki.com/excalidraw/excalidraw"> <a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a> <img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
</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"/></a> <img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</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 [`useEditorInterface`](#useEditorInterface) 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 [`useDevice`](#useDevice) 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 editorInterface = useEditorInterface(); const device = useDevice();
if (editorInterface.formFactor === "phone") { if (device.editor.isMobile) {
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(&#123; clientX: number, clientY: number },<br/>&nbsp
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): &#123;x: number, y: number} appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): &#123;x: number, y: number}
</pre> </pre>
### useEditorInterface ### useDevice
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 editorInterface = useEditorInterface(); const device = useDevice();
if (editorInterface.formFactor === "phone") { if (device.editor.isMobile) {
return ( return (
<Footer> <Footer>
<button <button
@@ -336,20 +336,12 @@ 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 |
The `EditorInterface` object has the following properties: | `viewport.isLandscape` | `boolean` | Set to `true` when the viewport is in `landscape` mode |
| `editor.canFitSidebar` | `boolean` | Set to `true` if there's enough space to fit the `sidebar` |
| Name | Type | Description | | `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint |
| --- | --- | --- | --- | --- | --- | | `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
@@ -33,7 +33,6 @@ const ExcalidrawScope = {
initialData, initialData,
useI18n: ExcalidrawComp.useI18n, useI18n: ExcalidrawComp.useI18n,
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements, convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
}; };
export default ExcalidrawScope; export default ExcalidrawScope;
+2
View File
@@ -1,3 +1,5 @@
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 { useEditorInterface, Footer } = excalidrawLib; const { useDevice, Footer } = excalidrawLib;
const editorInterface = useEditorInterface(); const device = useDevice();
if (editorInterface.formFactor === "phone") { if (device.editor.isMobile) {
return ( return (
<Footer> <Footer>
<CustomFooter <CustomFooter
+10 -27
View File
@@ -4,7 +4,6 @@ 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";
@@ -21,6 +20,7 @@ import {
APP_NAME, APP_NAME,
EVENT, EVENT,
THEME, THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT, VERSION_TIMEOUT,
debounce, debounce,
getVersion, getVersion,
@@ -120,7 +120,6 @@ 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";
@@ -138,9 +137,6 @@ 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();
@@ -346,8 +342,6 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAppLangCode(); const [langCode, setLangCode] = useAppLangCode();
const editorInterface = useEditorInterface();
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -505,6 +499,11 @@ const ExcalidrawWrapper = () => {
} }
}; };
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => { const syncData = debounce(() => {
if (isTestEnv()) { if (isTestEnv()) {
return; return;
@@ -595,6 +594,7 @@ const ExcalidrawWrapper = () => {
visibilityChange, visibilityChange,
false, false,
); );
clearTimeout(titleTimeout);
}; };
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]); }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
@@ -669,8 +669,8 @@ const ExcalidrawWrapper = () => {
debugRenderer( debugRenderer(
debugCanvasRef.current, debugCanvasRef.current,
appState, appState,
elements,
window.devicePixelRatio, window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
); );
} }
}; };
@@ -734,8 +734,6 @@ 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],
@@ -854,22 +852,14 @@ const ExcalidrawWrapper = () => {
if (isMobile || !collabAPI || isCollabDisabled) { if (isMobile || !collabAPI || isCollabDisabled) {
return null; return null;
} }
return ( return (
<div className="excalidraw-ui-top-right"> <div className="top-right-ui">
{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>
); );
@@ -918,15 +908,10 @@ const ExcalidrawWrapper = () => {
<TTDDialogTrigger /> <TTDDialogTrigger />
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="alertalert--warning"> <div className="collab-offline-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}
@@ -955,8 +940,6 @@ const ExcalidrawWrapper = () => {
}} }}
/> />
<AppSidebar />
{errorMessage && ( {errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}> <ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage} {errorMessage}
+1 -2
View File
@@ -8,8 +8,7 @@ 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
// should be aligned with MAX_ALLOWED_FILE_BYTES export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
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;
+4 -10
View File
@@ -441,7 +441,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}; };
private decryptPayload = async ( private decryptPayload = async (
iv: Uint8Array<ArrayBuffer>, iv: Uint8Array,
encryptedData: ArrayBuffer, encryptedData: ArrayBuffer,
decryptionKey: string, decryptionKey: string,
): Promise<ValueOf<SocketUpdateDataSource>> => { ): Promise<ValueOf<SocketUpdateDataSource>> => {
@@ -530,10 +530,7 @@ 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" });
@@ -562,7 +559,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// All socket listeners are moving to Portal // All socket listeners are moving to Portal
this.portal.socket.on( this.portal.socket.on(
"client-broadcast", "client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array<ArrayBuffer>) => { async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
if (!this.portal.roomKey) { if (!this.portal.roomKey) {
return; return;
} }
@@ -746,10 +743,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
): ReconciledExcalidrawElement[] => { ): ReconciledExcalidrawElement[] => {
const localElements = this.getSceneElementsIncludingDeleted(); const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState(); const appState = this.excalidrawAPI.getAppState();
const restoredRemoteElements = restoreElements( const restoredRemoteElements = restoreElements(remoteElements, null);
remoteElements,
this.excalidrawAPI.getSceneElementsMapIncludingDeleted(),
);
const reconciledElements = reconcileElements( const reconciledElements = reconcileElements(
localElements, localElements,
restoredRemoteElements as RemoteExcalidrawElement[], restoredRemoteElements as RemoteExcalidrawElement[],
+6 -1
View File
@@ -5,6 +5,7 @@ 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 }) => {
@@ -18,7 +19,11 @@ export const AppFooter = React.memo(
}} }}
> >
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />} {isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{!isExcalidrawPlusSignedUser && <EncryptedIcon />} {isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
</div> </div>
</Footer> </Footer>
); );
-36
View File
@@ -1,36 +0,0 @@
.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;
}
}
-79
View File
@@ -1,79 +0,0 @@
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>
);
};
+38 -213
View File
@@ -8,14 +8,8 @@ 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 { arrayToMap, throttleRAF } from "@excalidraw/common"; import { throttleRAF } from "@excalidraw/common";
import { useCallback } from "react"; import { useCallback, useImperativeHandle, useRef } from "react";
import {
getGlobalFixedPointForBindableElement,
isArrowElement,
isBindableElement,
} from "@excalidraw/element";
import { import {
isLineSegment, isLineSegment,
@@ -24,17 +18,9 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve"; import { isCurve } from "@excalidraw/math/curve";
import React from "react";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/common";
import type { import type { DebugElement } from "@excalidraw/utils/visualdebug";
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
FixedPointBinding,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
@@ -87,176 +73,6 @@ 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,
@@ -289,14 +105,18 @@ 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,
scale, scale,
); );
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({ const context = bootstrapCanvas({
canvas, canvas,
scale, scale,
@@ -313,7 +133,6 @@ 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 &&
@@ -365,10 +184,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, elements, scale); _debugRenderer(canvas, appState, scale, refresh);
}, },
{ trailing: true }, { trailing: true },
); );
@@ -495,29 +314,35 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
interface DebugCanvasProps { interface DebugCanvasProps {
appState: AppState; appState: AppState;
scale: number; scale: number;
ref?: React.Ref<HTMLCanvasElement>;
} }
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>( const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
({ appState, scale }, ref) => { const { width, height } = appState;
const { width, height } = appState;
return ( const canvasRef = useRef<HTMLCanvasElement>(null);
<canvas useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
style={{ ref,
width, () => canvasRef.current,
height, [canvasRef],
position: "absolute", );
zIndex: 2,
pointerEvents: "none", return (
}} <canvas
width={width * scale} style={{
height={height * scale} width,
ref={ref} height,
> position: "absolute",
Debug Canvas zIndex: 2,
</canvas> pointerEvents: "none",
); }}
}, width={width * scale}
); height={height * scale}
ref={canvasRef}
>
Debug Canvas
</canvas>
);
};
export default DebugCanvas; export default DebugCanvas;
@@ -0,0 +1,19 @@
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>
);
};
@@ -1,22 +0,0 @@
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>
);
};
+2 -19
View File
@@ -16,6 +16,7 @@ import {
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
debounce, debounce,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import { import {
createStore, createStore,
entries, entries,
@@ -26,9 +27,6 @@ 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";
@@ -47,8 +45,6 @@ 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) => {
@@ -73,9 +69,6 @@ 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);
@@ -88,29 +81,19 @@ const saveDataStateToLocalStorage = (
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(getNonDeletedElements(elements)), JSON.stringify(clearElementsForLocalStorage(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 {
+3 -5
View File
@@ -105,8 +105,8 @@ const decryptElements = async (
data: FirebaseStoredScene, data: FirebaseStoredScene,
roomKey: string, roomKey: string,
): Promise<readonly ExcalidrawElement[]> => { ): Promise<readonly ExcalidrawElement[]> => {
const ciphertext = data.ciphertext.toUint8Array() as Uint8Array<ArrayBuffer>; const ciphertext = data.ciphertext.toUint8Array();
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>; const iv = data.iv.toUint8Array();
const decrypted = await decryptData(iv, ciphertext, roomKey); const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode( const decodedData = new TextDecoder("utf-8").decode(
@@ -259,9 +259,7 @@ export const loadFromFirebase = async (
} }
const storedScene = docSnap.data() as FirebaseStoredScene; const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements( const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null, { restoreElements(await decryptElements(storedScene, roomKey), null),
deleteInvisibleElements: true,
}),
); );
if (socket) { if (socket) {
+1 -6
View File
@@ -258,16 +258,11 @@ export const loadScene = async (
await importFromBackend(id, privateKey), await importFromBackend(id, privateKey),
localDataState?.appState, localDataState?.appState,
localDataState?.elements, localDataState?.elements,
{ { repairBindings: true, refreshDimensions: false },
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
); );
} else { } else {
data = restore(localDataState || null, null, null, { data = restore(localDataState || null, null, null, {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true,
}); });
} }
+2 -1
View File
@@ -2,6 +2,7 @@ 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";
@@ -49,7 +50,7 @@ export const importFromLocalStorage = () => {
let elements: ExcalidrawElement[] = []; let elements: ExcalidrawElement[] = [];
if (savedElements) { if (savedElements) {
try { try {
elements = JSON.parse(savedElements); elements = clearElementsForLocalStorage(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
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Excalidraw Whiteboard</title> <title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
@@ -14,7 +14,7 @@
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<meta <meta
name="title" name="title"
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw" content="Excalidraw — Collaborative whiteboarding made easy"
/> />
<meta <meta
name="description" name="description"
+14 -27
View File
@@ -1,5 +1,3 @@
@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
@@ -7,6 +5,12 @@
--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;
@@ -54,7 +58,7 @@
} }
} }
.alert { .collab-offline-warning {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
top: 6.5rem; top: 6.5rem;
@@ -65,18 +69,10 @@
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);
}
} }
} }
@@ -86,31 +82,22 @@
} }
} }
.plus-banner { .plus-button {
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.875rem; padding: 0.5rem 0.75rem;
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-family: var(--ui-font); font-size: 0.75rem;
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;
@@ -122,7 +109,7 @@
} }
.theme--dark { .theme--dark {
.plus-banner { .plus-button {
&:hover { &:hover {
color: black !important; color: black !important;
} }
+1 -1
View File
@@ -23,7 +23,7 @@
] ]
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": "18.0.0 - 22.x.x"
}, },
"dependencies": { "dependencies": {
"@excalidraw/random-username": "1.0.0", "@excalidraw/random-username": "1.0.0",
-13
View File
@@ -1,4 +1,3 @@
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";
@@ -34,7 +33,6 @@ Sentry.init({
Sentry.captureConsoleIntegration({ Sentry.captureConsoleIntegration({
levels: ["error"], levels: ["error"],
}), }),
Sentry.featureFlagsIntegration(),
], ],
beforeSend(event) { beforeSend(event) {
if (event.request?.url) { if (event.request?.url) {
@@ -81,14 +79,3 @@ Sentry.init({
return event; return event;
}, },
}); });
const flagsIntegration =
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
"FeatureFlags",
);
if (flagsIntegration) {
flagsIntegration.addFeatureFlag(
"COMPLEX_BINDINGS",
getFeatureFlag("COMPLEX_BINDINGS"),
);
}
+18 -3
View File
@@ -17,15 +17,30 @@ describe("Test MobileMenu", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<ExcalidrawApp />);
h.app.refreshEditorInterface(); // @ts-ignore
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
}); });
afterAll(() => { afterAll(() => {
restoreOriginalGetBoundingClientRect(); restoreOriginalGetBoundingClientRect();
}); });
it("should set editor interface correctly", () => { it("should set device correctly", () => {
expect(h.app.editorInterface.formFactor).toBe("phone"); expect(h.app.device).toMatchInlineSnapshot(`
{
"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 () => {
+2 -2
View File
@@ -34,7 +34,7 @@
"prettier": "2.6.2", "prettier": "2.6.2",
"rewire": "6.0.0", "rewire": "6.0.0",
"rimraf": "^5.0.0", "rimraf": "^5.0.0",
"typescript": "5.9.3", "typescript": "4.9.4",
"vite": "5.0.12", "vite": "5.0.12",
"vite-plugin-checker": "0.7.2", "vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0", "vite-plugin-ejs": "1.7.0",
@@ -44,7 +44,7 @@
"vitest-canvas-mock": "0.3.3" "vitest-canvas-mock": "0.3.3"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": "18.0.0 - 22.x.x"
}, },
"homepage": ".", "homepage": ".",
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
+19 -24
View File
@@ -5,18 +5,17 @@ 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 (nodeScore < this.scoreFunction(parent)) { if (this.scoreFunction(node) < 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) {
@@ -25,39 +24,35 @@ export class BinaryHeap<T> {
const score = this.scoreFunction(node); const score = this.scoreFunction(node);
while (true) { while (true) {
const child1N = ((idx + 1) << 1) - 1; const child2N = (idx + 1) << 1;
const child2N = child1N + 1; const child1N = child2N - 1;
let smallestIdx = idx; let swap = null;
let smallestScore = score; let child1Score = 0;
// Check left child
if (child1N < length) { if (child1N < length) {
const child1Score = this.scoreFunction(this.content[child1N]); const child1 = this.content[child1N];
if (child1Score < smallestScore) { child1Score = this.scoreFunction(child1);
smallestIdx = child1N; if (child1Score < score) {
smallestScore = child1Score; swap = child1N;
} }
} }
// Check right child
if (child2N < length) { if (child2N < length) {
const child2Score = this.scoreFunction(this.content[child2N]); const child2 = this.content[child2N];
if (child2Score < smallestScore) { const child2Score = this.scoreFunction(child2);
smallestIdx = child2N; if (child2Score < (swap === null ? score : child1Score)) {
swap = child2N;
} }
} }
if (smallestIdx === idx) { if (swap !== null) {
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) {
-17
View File
@@ -1,17 +0,0 @@
/**
* x and y position of top left corner, x and y position of bottom right corner
*/
export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
box.length === 4 &&
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
+30 -28
View File
@@ -6,6 +6,25 @@ 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/.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 supportsResizeObserver = export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window; typeof window !== "undefined" && "ResizeObserver" in window;
@@ -17,7 +36,6 @@ export const APP_NAME = "Excalidraw";
// (happens a lot with fast clicks with the text tool) // (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px export const DRAGGING_THRESHOLD = 10; // px
export const MINIMUM_ARROW_SIZE = 20; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1; export const ELEMENT_TRANSLATE_AMOUNT = 1;
@@ -99,22 +117,12 @@ 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 FONT_SIZES = {
sm: 16,
md: 20,
lg: 28,
xl: 36,
} as const;
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
@@ -243,20 +251,13 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif", jfif: "image/jfif",
} as const; } as const;
export const STRING_MIME_TYPES = { export const 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",
@@ -331,6 +332,16 @@ 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];
@@ -503,12 +514,3 @@ export enum UserIdleState {
* the start and end points) * the start and end points)
*/ */
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 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;
-223
View File
@@ -1,223 +0,0 @@
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;
};
-3
View File
@@ -1,5 +1,4 @@
export * from "./binary-heap"; export * from "./binary-heap";
export * from "./bounds";
export * from "./colors"; export * from "./colors";
export * from "./constants"; export * from "./constants";
export * from "./font-metadata"; export * from "./font-metadata";
@@ -11,5 +10,3 @@ 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 -1
View File
@@ -1,4 +1,4 @@
import { isDarwin } from "./editorInterface"; import { isDarwin } from "./constants";
import type { ValueOf } from "./utility-types"; import type { ValueOf } from "./utility-types";
+23 -56
View File
@@ -1,6 +1,10 @@
import { average } from "@excalidraw/math"; import { average } from "@excalidraw/math";
import type { FontFamilyValues, FontString } from "@excalidraw/element/types"; import type {
ExcalidrawBindableElement,
FontFamilyValues,
FontString,
} from "@excalidraw/element/types";
import type { import type {
ActiveTool, ActiveTool,
@@ -16,6 +20,7 @@ import {
ENV, ENV,
FONT_FAMILY, FONT_FAMILY,
getFontFamilyFallbacks, getFontFamilyFallbacks,
isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
@@ -86,8 +91,7 @@ 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,
@@ -115,11 +119,6 @@ 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,
@@ -378,10 +377,6 @@ 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: ((
@@ -423,6 +418,19 @@ 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 },
{ {
@@ -558,6 +566,9 @@ 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
@@ -1267,47 +1278,3 @@ 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);
}
};
+4 -16
View File
@@ -164,14 +164,9 @@ export class Scene {
return this.frames; return this.frames;
} }
constructor( constructor(elements: ElementsMapOrArray | null = null) {
elements: ElementsMapOrArray | null = null,
options?: {
skipValidation?: true;
},
) {
if (elements) { if (elements) {
this.replaceAllElements(elements, options); this.replaceAllElements(elements);
} }
} }
@@ -268,19 +263,12 @@ export class Scene {
return didChange; return didChange;
} }
replaceAllElements( replaceAllElements(nextElements: ElementsMapOrArray) {
nextElements: ElementsMapOrArray,
options?: {
skipValidation?: true;
},
) {
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices // we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
const _nextElements = toArray(nextElements); const _nextElements = toArray(nextElements);
const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
if (!options?.skipValidation) { validateIndicesThrottled(_nextElements);
validateIndicesThrottled(_nextElements);
}
this.elements = syncInvalidIndices(_nextElements); this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear(); this.elementsMap.clear();
+4 -7
View File
@@ -1,8 +1,6 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding"; import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds"; import { getCommonBoundingBox } from "./bounds";
import { getSelectedElementsByGroup } from "./groups"; import { getMaximumGroups } from "./groups";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
@@ -18,12 +16,11 @@ export const alignElements = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
alignment: Alignment, alignment: Alignment,
scene: Scene, scene: Scene,
appState: Readonly<AppState>,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup( const elementsMap = scene.getNonDeletedElementsMap();
const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements, selectedElements,
scene.getNonDeletedElementsMap(), elementsMap,
appState,
); );
const selectionBoundingBox = getCommonBoundingBox(selectedElements); const selectionBoundingBox = getCommonBoundingBox(selectedElements);
File diff suppressed because it is too large Load Diff
+23 -47
View File
@@ -2,7 +2,6 @@ import rough from "roughjs/bin/rough";
import { import {
arrayToMap, arrayToMap,
type Bounds,
invariant, invariant,
rescalePoints, rescalePoints,
sizeOf, sizeOf,
@@ -43,7 +42,6 @@ import {
isBoundToContainer, isBoundToContainer,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isLineElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
@@ -79,6 +77,16 @@ export type RectangleBox = {
type MaybeQuadraticSolution = [number | null, number | null] | false; type MaybeQuadraticSolution = [number | null, number | null] | false;
/**
* x and y position of top left corner, x and y position of bottom right corner
*/
export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];
export type SceneBounds = readonly [ export type SceneBounds = readonly [
sceneX: number, sceneX: number,
sceneY: number, sceneY: number,
@@ -313,42 +321,19 @@ export const getElementLineSegments = (
if (shape.type === "polycurve") { if (shape.type === "polycurve") {
const curves = shape.data; const curves = shape.data;
const pointsOnCurves = curves.map((curve) => const points = curves
pointsOnBezierCurves(curve, 10), .map((curve) => pointsOnBezierCurves(curve, 10))
); .flat();
let i = 0;
const segments: LineSegment<GlobalPoint>[] = []; const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
if ( segments.push(
(isLineElement(element) && !element.polygon) || lineSegment(
isArrowElement(element) pointFrom(points[i][0], points[i][1]),
) { 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;
@@ -1141,9 +1126,7 @@ export interface BoundingBox {
} }
export const getCommonBoundingBox = ( export const getCommonBoundingBox = (
elements: elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
| readonly ExcalidrawElement[]
| readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => { ): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { return {
@@ -1267,13 +1250,6 @@ 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);
+3 -183
View File
@@ -1,4 +1,4 @@
import { invariant, isTransparent, type Bounds } from "@excalidraw/common"; import { isTransparent } from "@excalidraw/common";
import { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
@@ -29,18 +29,15 @@ import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils"; import { isPathALoop } from "./utils";
import { import {
type Bounds,
doBoundsIntersect, doBoundsIntersect,
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,
@@ -61,17 +58,12 @@ 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) => {
@@ -102,7 +94,6 @@ 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 = ({
@@ -111,7 +102,6 @@ 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
@@ -144,9 +134,7 @@ export const hitElementItself = ({
} }
// Do the precise (and relatively costly) hit test // Do the precise (and relatively costly) hit test
const hitElement = ( const hitElement = shouldTestInside(element)
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) ||
@@ -205,116 +193,6 @@ 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
* *
@@ -676,61 +554,3 @@ 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),
);
};
+1 -8
View File
@@ -10,14 +10,7 @@ export const hasBackground = (type: ElementOrToolType) =>
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: ElementOrToolType) => export const hasStrokeColor = (type: ElementOrToolType) =>
type === "rectangle" || type !== "image" && type !== "frame" && type !== "magicframe";
type === "ellipse" ||
type === "diamond" ||
type === "freedraw" ||
type === "arrow" ||
type === "line" ||
type === "text" ||
type === "embeddable";
export const hasStrokeWidth = (type: ElementOrToolType) => export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
+135 -360
View File
@@ -55,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { StoreSnapshot } from "./store";
import { Scene } from "./Scene"; import { Scene } from "./Scene";
import { StoreSnapshot } from "./store";
import type { BindableProp, BindingProp } from "./binding"; import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@@ -150,27 +150,13 @@ export class Delta<T> {
); );
} }
/**
* Merges two deltas into a new one.
*/
public static merge<T>(
delta1: Delta<T>,
delta2: Delta<T>,
delta3: Delta<T> = Delta.empty(),
) {
return Delta.create(
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
);
}
/** /**
* Merges deleted and inserted object partials. * Merges deleted and inserted object partials.
*/ */
public static mergeObjects<T extends { [key: string]: unknown }>( public static mergeObjects<T extends { [key: string]: unknown }>(
prev: T, prev: T,
added: T, added: T,
removed: T = {} as T, removed: T,
) { ) {
const cloned = { ...prev }; const cloned = { ...prev };
@@ -510,11 +496,6 @@ export interface DeltaContainer<T> {
*/ */
applyTo(previous: T, ...options: unknown[]): [T, boolean]; applyTo(previous: T, ...options: unknown[]): [T, boolean];
/**
* Squashes the current delta with the given one.
*/
squash(delta: DeltaContainer<T>): this;
/** /**
* Checks whether all `Delta`s are empty. * Checks whether all `Delta`s are empty.
*/ */
@@ -522,11 +503,7 @@ export interface DeltaContainer<T> {
} }
export class AppStateDelta implements DeltaContainer<AppState> { export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public delta: Delta<ObservedAppState>) {} private constructor(public readonly delta: Delta<ObservedAppState>) {}
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
return new AppStateDelta(delta);
}
public static calculate<T extends ObservedAppState>( public static calculate<T extends ObservedAppState>(
prevAppState: T, prevAppState: T,
@@ -557,124 +534,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return new AppStateDelta(inversedDelta); return new AppStateDelta(inversedDelta);
} }
public squash(delta: AppStateDelta): this {
if (delta.isEmpty()) {
return this;
}
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
this.delta.deleted.selectedElementIds ?? {},
delta.delta.deleted.selectedElementIds ?? {},
);
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
this.delta.inserted.selectedElementIds ?? {},
delta.delta.inserted.selectedElementIds ?? {},
);
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
this.delta.deleted.selectedGroupIds ?? {},
delta.delta.deleted.selectedGroupIds ?? {},
);
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
this.delta.inserted.selectedGroupIds ?? {},
delta.delta.inserted.selectedGroupIds ?? {},
);
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
this.delta.deleted.lockedMultiSelections ?? {},
delta.delta.deleted.lockedMultiSelections ?? {},
);
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
this.delta.inserted.lockedMultiSelections ?? {},
delta.delta.inserted.lockedMultiSelections ?? {},
);
const mergedInserted: Partial<ObservedAppState> = {};
const mergedDeleted: Partial<ObservedAppState> = {};
if (
Object.keys(mergedDeletedSelectedElementIds).length ||
Object.keys(mergedInsertedSelectedElementIds).length
) {
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
}
if (
Object.keys(mergedDeletedSelectedGroupIds).length ||
Object.keys(mergedInsertedSelectedGroupIds).length
) {
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
}
if (
Object.keys(mergedDeletedLockedMultiSelections).length ||
Object.keys(mergedInsertedLockedMultiSelections).length
) {
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
mergedInserted.lockedMultiSelections =
mergedInsertedLockedMultiSelections;
}
this.delta = Delta.merge(
this.delta,
delta.delta,
Delta.create(mergedDeleted, mergedInserted),
);
return this;
}
public applyTo( public applyTo(
appState: AppState, appState: AppState,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
): [AppState, boolean] { ): [AppState, boolean] {
try { try {
const { const {
selectedElementIds: deletedSelectedElementIds = {}, selectedElementIds: removedSelectedElementIds = {},
selectedGroupIds: deletedSelectedGroupIds = {}, selectedGroupIds: removedSelectedGroupIds = {},
lockedMultiSelections: deletedLockedMultiSelections = {},
} = this.delta.deleted; } = this.delta.deleted;
const { const {
selectedElementIds: insertedSelectedElementIds = {}, selectedElementIds: addedSelectedElementIds = {},
selectedGroupIds: insertedSelectedGroupIds = {}, selectedGroupIds: addedSelectedGroupIds = {},
lockedMultiSelections: insertedLockedMultiSelections = {}, selectedLinearElementId,
selectedLinearElement: insertedSelectedLinearElement, editingLinearElementId,
...directlyApplicablePartial ...directlyApplicablePartial
} = this.delta.inserted; } = this.delta.inserted;
const mergedSelectedElementIds = Delta.mergeObjects( const mergedSelectedElementIds = Delta.mergeObjects(
appState.selectedElementIds, appState.selectedElementIds,
insertedSelectedElementIds, addedSelectedElementIds,
deletedSelectedElementIds, removedSelectedElementIds,
); );
const mergedSelectedGroupIds = Delta.mergeObjects( const mergedSelectedGroupIds = Delta.mergeObjects(
appState.selectedGroupIds, appState.selectedGroupIds,
insertedSelectedGroupIds, addedSelectedGroupIds,
deletedSelectedGroupIds, removedSelectedGroupIds,
);
const mergedLockedMultiSelections = Delta.mergeObjects(
appState.lockedMultiSelections,
insertedLockedMultiSelections,
deletedLockedMultiSelections,
); );
const selectedLinearElement = const selectedLinearElement =
insertedSelectedLinearElement && selectedLinearElementId && nextElements.has(selectedLinearElementId)
nextElements.has(insertedSelectedLinearElement.elementId)
? new LinearElementEditor( ? new LinearElementEditor(
nextElements.get( nextElements.get(
insertedSelectedLinearElement.elementId, selectedLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
)
: null;
const editingLinearElement =
editingLinearElementId && nextElements.has(editingLinearElementId)
? new LinearElementEditor(
nextElements.get(
editingLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>, ) as NonDeleted<ExcalidrawLinearElement>,
nextElements, nextElements,
insertedSelectedLinearElement.isEditing,
) )
: null; : null;
@@ -683,11 +589,14 @@ export class AppStateDelta implements DeltaContainer<AppState> {
...directlyApplicablePartial, ...directlyApplicablePartial,
selectedElementIds: mergedSelectedElementIds, selectedElementIds: mergedSelectedElementIds,
selectedGroupIds: mergedSelectedGroupIds, selectedGroupIds: mergedSelectedGroupIds,
lockedMultiSelections: mergedLockedMultiSelections,
selectedLinearElement: selectedLinearElement:
typeof insertedSelectedLinearElement !== "undefined" typeof selectedLinearElementId !== "undefined"
? selectedLinearElement ? selectedLinearElement // element was either inserted or deleted
: appState.selectedLinearElement, : appState.selectedLinearElement, // otherwise assign what we had before
editingLinearElement:
typeof editingLinearElementId !== "undefined"
? editingLinearElement // element was either inserted or deleted
: appState.editingLinearElement, // otherwise assign what we had before
}; };
const constainsVisibleChanges = this.filterInvisibleChanges( const constainsVisibleChanges = this.filterInvisibleChanges(
@@ -816,53 +725,52 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
break; break;
case "selectedLinearElement": case "selectedLinearElementId":
const nextLinearElement = nextAppState[key]; case "editingLinearElementId":
const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
if (!nextLinearElement) { if (!linearElement) {
// previously there was a linear element (assuming visible), now there is none // previously there was a linear element (assuming visible), now there is none
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
const element = nextElements.get(nextLinearElement.elementId); const element = nextElements.get(linearElement.elementId);
if (element && !element.isDeleted) { if (element && !element.isDeleted) {
// previously there wasn't a linear element, now there is one which is visible // previously there wasn't a linear element, now there is one which is visible
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
// there was assigned a linear element now, but it's deleted // there was assigned a linear element now, but it's deleted
nextAppState[key] = null; nextAppState[appStateKey] = null;
} }
} }
break; break;
case "lockedMultiSelections": case "lockedMultiSelections": {
const prevLockedUnits = prevAppState[key] || {}; const prevLockedUnits = prevAppState[key] || {};
const nextLockedUnits = nextAppState[key] || {}; const nextLockedUnits = nextAppState[key] || {};
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) { if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} }
break; break;
case "activeLockedId": }
case "activeLockedId": {
const prevHitLockedId = prevAppState[key] || null; const prevHitLockedId = prevAppState[key] || null;
const nextHitLockedId = nextAppState[key] || null; const nextHitLockedId = nextAppState[key] || null;
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (prevHitLockedId !== nextHitLockedId) { if (prevHitLockedId !== nextHitLockedId) {
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} }
break; break;
default: }
default: {
assertNever( assertNever(
key, key,
`Unknown ObservedElementsAppState's key "${key}"`, `Unknown ObservedElementsAppState's key "${key}"`,
true, true,
); );
}
} }
} }
} }
@@ -870,6 +778,20 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return visibleDifferenceFlag.value; return visibleDifferenceFlag.value;
} }
private static convertToAppStateKey(
key: keyof Pick<
ObservedElementsAppState,
"selectedLinearElementId" | "editingLinearElementId"
>,
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
switch (key) {
case "selectedLinearElementId":
return "selectedLinearElement";
case "editingLinearElementId":
return "editingLinearElement";
}
}
private static filterSelectedElements( private static filterSelectedElements(
selectedElementIds: AppState["selectedElementIds"], selectedElementIds: AppState["selectedElementIds"],
elements: SceneElementsMap, elements: SceneElementsMap,
@@ -934,7 +856,8 @@ export class AppStateDelta implements DeltaContainer<AppState> {
editingGroupId, editingGroupId,
selectedGroupIds, selectedGroupIds,
selectedElementIds, selectedElementIds,
selectedLinearElement, editingLinearElementId,
selectedLinearElementId,
croppingElementId, croppingElementId,
lockedMultiSelections, lockedMultiSelections,
activeLockedId, activeLockedId,
@@ -988,6 +911,12 @@ export class AppStateDelta implements DeltaContainer<AppState> {
"lockedMultiSelections", "lockedMultiSelections",
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>, (prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
); );
Delta.diffObjects(
deleted,
inserted,
"activeLockedId",
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
);
} catch (e) { } catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess appstate change deltas.`); console.error(`Couldn't postprocess appstate change deltas.`);
@@ -1016,13 +945,12 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">; Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
export type ApplyToOptions = { export type ApplyToOptions = {
excludedProperties?: Set<keyof ElementPartial>; excludedProperties: Set<keyof ElementPartial>;
}; };
type ApplyToFlags = { type ApplyToFlags = {
containsVisibleDifference: boolean; containsVisibleDifference: boolean;
containsZindexDifference: boolean; containsZindexDifference: boolean;
applyDirection: "forward" | "backward" | undefined;
}; };
/** /**
@@ -1111,27 +1039,18 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
inserted, inserted,
}: Delta<ElementPartial>) => }: Delta<ElementPartial>) =>
!!( !!(
deleted.version &&
inserted.version &&
// versions are required integers // versions are required integers
( Number.isInteger(deleted.version) &&
Number.isInteger(deleted.version) && Number.isInteger(inserted.version) &&
Number.isInteger(inserted.version) && // versions should be positive, zero included
// versions should be positive, zero included deleted.version >= 0 &&
deleted.version! >= 0 && inserted.version >= 0 &&
inserted.version! >= 0 && // versions should never be the same
// versions should never be the same deleted.version !== inserted.version
deleted.version !== inserted.version
)
); );
private static satisfiesUniqueInvariants = (
elementsDelta: ElementsDelta,
id: string,
) => {
const { added, removed, updated } = elementsDelta;
// it's required that there is only one unique delta type per element
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
};
private static validate( private static validate(
elementsDelta: ElementsDelta, elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated", type: "added" | "removed" | "updated",
@@ -1140,7 +1059,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
for (const [id, delta] of Object.entries(elementsDelta[type])) { for (const [id, delta] of Object.entries(elementsDelta[type])) {
if ( if (
!this.satisfiesCommmonInvariants(delta) || !this.satisfiesCommmonInvariants(delta) ||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
!satifiesSpecialInvariants(delta) !satifiesSpecialInvariants(delta)
) { ) {
console.error( console.error(
@@ -1177,7 +1095,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const nextElement = nextElements.get(prevElement.id); const nextElement = nextElements.get(prevElement.id);
if (!nextElement) { if (!nextElement) {
const deleted = { ...prevElement } as ElementPartial; const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
const inserted = { const inserted = {
isDeleted: true, isDeleted: true,
@@ -1191,11 +1109,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
if (!prevElement.isDeleted) { removed[prevElement.id] = delta;
removed[prevElement.id] = delta;
} else {
updated[prevElement.id] = delta;
}
} }
} }
@@ -1211,6 +1125,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inserted = { const inserted = {
...nextElement, ...nextElement,
isDeleted: false,
} as ElementPartial; } as ElementPartial;
const delta = Delta.create( const delta = Delta.create(
@@ -1219,12 +1134,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
// ignore updates which would "delete" already deleted element added[nextElement.id] = delta;
if (!nextElement.isDeleted) {
added[nextElement.id] = delta;
} else {
updated[nextElement.id] = delta;
}
continue; continue;
} }
@@ -1253,7 +1163,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
updated[nextElement.id] = delta; // making sure there are at least some changes
if (!Delta.isEmpty(delta)) {
updated[nextElement.id] = delta;
}
} }
} }
@@ -1268,8 +1181,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => { const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {}; const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, { inserted, deleted }] of Object.entries(deltas)) { for (const [id, delta] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted }); inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
} }
return inversedDeltas; return inversedDeltas;
@@ -1388,7 +1301,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo( public applyTo(
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements, snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
options?: ApplyToOptions, options: ApplyToOptions = {
excludedProperties: new Set(),
},
): [SceneElementsMap, boolean] { ): [SceneElementsMap, boolean] {
let nextElements = new Map(elements) as SceneElementsMap; let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>; let changedElements: Map<string, OrderedExcalidrawElement>;
@@ -1396,28 +1311,22 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const flags: ApplyToFlags = { const flags: ApplyToFlags = {
containsVisibleDifference: false, containsVisibleDifference: false,
containsZindexDifference: false, containsZindexDifference: false,
applyDirection: undefined,
}; };
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
try { try {
const applyDeltas = ElementsDelta.createApplier( const applyDeltas = ElementsDelta.createApplier(
elements,
nextElements, nextElements,
snapshot, snapshot,
flags,
options, options,
flags,
); );
const addedElements = applyDeltas(this.added); const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas(this.removed); const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas(this.updated); const updatedElements = applyDeltas(this.updated);
const affectedElements = this.resolveConflicts( const affectedElements = this.resolveConflicts(elements, nextElements);
elements,
nextElements,
flags.applyDirection,
);
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues // TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
changedElements = new Map([ changedElements = new Map([
@@ -1441,15 +1350,22 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
} }
try { try {
// the following reorder performs mutations, but only on new instances of changed elements, // the following reorder performs also mutations, but only on new instances of changed elements
// unless something goes really bad and it fallbacks to fixing all invalid indices // (unless something goes really bad and it fallbacks to fixing all invalid indices)
nextElements = ElementsDelta.reorderElements( nextElements = ElementsDelta.reorderElements(
nextElements, nextElements,
changedElements, changedElements,
flags, flags,
); );
ElementsDelta.redrawElements(nextElements, changedElements); // we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements);
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// Need ordered nextElements to avoid z-index binding issues
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) { } catch (e) {
console.error( console.error(
`Couldn't mutate elements after applying elements change`, `Couldn't mutate elements after applying elements change`,
@@ -1464,113 +1380,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
} }
} }
public squash(delta: ElementsDelta): this {
if (delta.isEmpty()) {
return this;
}
const { added, removed, updated } = delta;
const mergeBoundElements = (
prevDelta: Delta<ElementPartial>,
nextDelta: Delta<ElementPartial>,
) => {
const mergedDeletedBoundElements =
Delta.mergeArrays(
prevDelta.deleted.boundElements ?? [],
nextDelta.deleted.boundElements ?? [],
undefined,
(x) => x.id,
) ?? [];
const mergedInsertedBoundElements =
Delta.mergeArrays(
prevDelta.inserted.boundElements ?? [],
nextDelta.inserted.boundElements ?? [],
undefined,
(x) => x.id,
) ?? [];
if (
!mergedDeletedBoundElements.length &&
!mergedInsertedBoundElements.length
) {
return;
}
return Delta.create(
{
boundElements: mergedDeletedBoundElements,
},
{
boundElements: mergedInsertedBoundElements,
},
);
};
for (const [id, nextDelta] of Object.entries(added)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.added[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
delete this.removed[id];
delete this.updated[id];
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
}
}
for (const [id, nextDelta] of Object.entries(removed)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.removed[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
delete this.added[id];
delete this.updated[id];
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
}
}
for (const [id, nextDelta] of Object.entries(updated)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.updated[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
if (prevDelta === this.added[id]) {
this.added[id] = updatedDelta;
} else if (prevDelta === this.removed[id]) {
this.removed[id] = updatedDelta;
} else {
this.updated[id] = updatedDelta;
}
}
}
if (isTestEnv() || isDevEnv()) {
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
}
return this;
}
private static createApplier = private static createApplier =
( (
prevElements: SceneElementsMap,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
snapshot: StoreSnapshot["elements"], snapshot: StoreSnapshot["elements"],
options: ApplyToOptions,
flags: ApplyToFlags, flags: ApplyToFlags,
options?: ApplyToOptions,
) => ) =>
(deltas: Record<string, Delta<ElementPartial>>) => { (deltas: Record<string, Delta<ElementPartial>>) => {
const getElement = ElementsDelta.createGetter( const getElement = ElementsDelta.createGetter(
@@ -1583,26 +1398,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const element = getElement(id, delta.inserted); const element = getElement(id, delta.inserted);
if (element) { if (element) {
const nextElement = ElementsDelta.applyDelta( const newElement = ElementsDelta.applyDelta(
element, element,
delta, delta,
flags,
options, options,
flags,
); );
nextElements.set(nextElement.id, nextElement); nextElements.set(newElement.id, newElement);
acc.set(nextElement.id, nextElement); acc.set(newElement.id, newElement);
if (!flags.applyDirection) {
const prevElement = prevElements.get(id);
if (prevElement) {
flags.applyDirection =
prevElement.version > nextElement.version
? "backward"
: "forward";
}
}
} }
return acc; return acc;
@@ -1647,8 +1451,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static applyDelta( private static applyDelta(
element: OrderedExcalidrawElement, element: OrderedExcalidrawElement,
delta: Delta<ElementPartial>, delta: Delta<ElementPartial>,
options: ApplyToOptions,
flags: ApplyToFlags, flags: ApplyToFlags,
options?: ApplyToOptions,
) { ) {
const directlyApplicablePartial: Mutable<ElementPartial> = {}; const directlyApplicablePartial: Mutable<ElementPartial> = {};
@@ -1662,7 +1466,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
if (options?.excludedProperties?.has(key)) { if (options.excludedProperties.has(key)) {
continue; continue;
} }
@@ -1702,7 +1506,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
delta.deleted.index !== delta.inserted.index; delta.deleted.index !== delta.inserted.index;
} }
return newElementWith(element, directlyApplicablePartial, true); return newElementWith(element, directlyApplicablePartial);
} }
/** /**
@@ -1742,7 +1546,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private resolveConflicts( private resolveConflicts(
prevElements: SceneElementsMap, prevElements: SceneElementsMap,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
applyDirection: "forward" | "backward" = "forward",
) { ) {
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>(); const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
const updater = ( const updater = (
@@ -1754,36 +1557,21 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return; return;
} }
const prevElement = prevElements.get(element.id);
const nextVersion =
applyDirection === "forward"
? nextElement.version + 1
: nextElement.version - 1;
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
let affectedElement: OrderedExcalidrawElement; let affectedElement: OrderedExcalidrawElement;
if (prevElement === nextElement) { if (prevElements.get(element.id) === nextElement) {
// create the new element instance in case we didn't modify the element yet // create the new element instance in case we didn't modify the element yet
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations // so that we won't end up in an incosistent state in case we would fail in the middle of mutations
affectedElement = newElementWith( affectedElement = newElementWith(
nextElement, nextElement,
{ updates as ElementUpdate<OrderedExcalidrawElement>,
...elementUpdates,
version: nextVersion,
},
true,
); );
} else { } else {
affectedElement = mutateElement(nextElement, nextElements, { affectedElement = mutateElement(
...elementUpdates, nextElement,
// don't modify the version further, if it's already different nextElements,
version: updates as ElementUpdate<OrderedExcalidrawElement>,
prevElement?.version !== nextElement.version );
? nextElement.version
: nextVersion,
});
} }
nextAffectedElements.set(affectedElement.id, affectedElement); nextAffectedElements.set(affectedElement.id, affectedElement);
@@ -1821,12 +1609,25 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)), Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
); );
// calculate complete deltas for affected elements, and squash them back to the current deltas // calculate complete deltas for affected elements, and assign them back to all the deltas
this.squash( // technically we could do better here if perf. would become an issue
// technically we could do better here if perf. would become an issue const { added, removed, updated } = ElementsDelta.calculate(
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements), prevAffectedElements,
nextAffectedElements,
); );
for (const [id, delta] of Object.entries(added)) {
this.added[id] = delta;
}
for (const [id, delta] of Object.entries(removed)) {
this.removed[id] = delta;
}
for (const [id, delta] of Object.entries(updated)) {
this.updated[id] = delta;
}
return nextAffectedElements; return nextAffectedElements;
} }
@@ -1888,31 +1689,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
BindableElement.rebindAffected(nextElements, nextElement(), updater); BindableElement.rebindAffected(nextElements, nextElement(), updater);
} }
public static redrawElements(
nextElements: SceneElementsMap,
changedElements: Map<string, OrderedExcalidrawElement>,
) {
try {
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements, { skipValidation: true });
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// needs ordered nextElements to avoid z-index binding issues
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) {
console.error(`Couldn't redraw elements`, e);
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return nextElements;
}
}
private static redrawTextBoundingBoxes( private static redrawTextBoundingBoxes(
scene: Scene, scene: Scene,
changed: Map<string, OrderedExcalidrawElement>, changed: Map<string, OrderedExcalidrawElement>,
@@ -1967,7 +1743,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
) { ) {
for (const element of changed.values()) { for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) { if (!element.isDeleted && isBindableElement(element)) {
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
updateBoundElements(element, scene, { updateBoundElements(element, scene, {
changedElements: changed, changedElements: changed,
}); });
+2 -9
View File
@@ -1,9 +1,7 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { getCommonBoundingBox } from "./bounds"; import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement"; import { newElementWith } from "./mutateElement";
import { getSelectedElementsByGroup } from "./groups"; import { getMaximumGroups } from "./groups";
import type { ElementsMap, ExcalidrawElement } from "./types"; import type { ElementsMap, ExcalidrawElement } from "./types";
@@ -16,7 +14,6 @@ export const distributeElements = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: ElementsMap,
distribution: Distribution, distribution: Distribution,
appState: Readonly<AppState>,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const [start, mid, end, extent] = const [start, mid, end, extent] =
distribution.axis === "x" distribution.axis === "x"
@@ -24,11 +21,7 @@ export const distributeElements = (
: (["minY", "midY", "maxY", "height"] as const); : (["minY", "midY", "maxY", "height"] as const);
const bounds = getCommonBoundingBox(selectedElements); const bounds = getCommonBoundingBox(selectedElements);
const groups = getSelectedElementsByGroup( const groups = getMaximumGroups(selectedElements, elementsMap)
selectedElements,
elementsMap,
appState,
)
.map((group) => [group, getCommonBoundingBox(group)] as const) .map((group) => [group, getCommonBoundingBox(group)] as const)
.sort((a, b) => a[1][mid] - b[1][mid]); .sort((a, b) => a[1][mid] - b[1][mid]);
+3 -48
View File
@@ -1,9 +1,7 @@
import { import {
type Bounds,
TEXT_AUTOWRAP_THRESHOLD, TEXT_AUTOWRAP_THRESHOLD,
getGridPoint, getGridPoint,
getFontString, getFontString,
DRAGGING_THRESHOLD,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -15,7 +13,7 @@ import type {
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { unbindBindingElement, updateBoundElements } from "./binding"; import { 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";
@@ -30,6 +28,7 @@ import {
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
export const dragSelectedElements = ( export const dragSelectedElements = (
@@ -103,26 +102,9 @@ export const dragSelectedElements = (
gridSize, gridSize,
); );
const elementsToUpdateIds = new Set(
Array.from(elementsToUpdate, (el) => el.id),
);
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
const isArrow = !isArrowElement(element); updateElementCoords(pointerDownState, element, scene, adjustedOffset);
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,
@@ -139,33 +121,6 @@ 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);
}
}
} }
}); });
}; };
+36 -54
View File
@@ -14,10 +14,10 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
type Bounds,
BinaryHeap, BinaryHeap,
invariant, invariant,
isAnyTrue, isAnyTrue,
tupleToCoors,
getSizeFromPoints, getSizeFromPoints,
isDevEnv, isDevEnv,
arrayToMap, arrayToMap,
@@ -27,11 +27,10 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import { import {
bindPointToSnapToElementOutline, bindPointToSnapToElementOutline,
FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap, getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
getBindingGap, getHoveredElementForBinding,
maxBindingDistance_simple,
BASE_BINDING_GAP_ELBOW,
} from "./binding"; } from "./binding";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
@@ -52,9 +51,10 @@ import {
type ExcalidrawElbowArrowElement, type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap, type NonDeletedSceneElementsMap,
} from "./types"; } from "./types";
import { aabbForElement, pointInsideBounds } from "./bounds";
import { getHoveredElementForBinding } from "./collision";
import { aabbForElement, pointInsideBounds } from "./bounds";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading"; import type { Heading } from "./heading";
import type { import type {
Arrowhead, Arrowhead,
@@ -63,7 +63,6 @@ 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" };
@@ -360,12 +359,6 @@ const handleSegmentRelease = (
null, null,
); );
if (!restoredPoints || restoredPoints.length < 2) {
throw new Error(
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
);
}
const nextPoints: GlobalPoint[] = []; const nextPoints: GlobalPoint[] = [];
// First part of the arrow are the old points // First part of the arrow are the old points
@@ -713,7 +706,7 @@ const handleEndpointDrag = (
endGlobalPoint: GlobalPoint, endGlobalPoint: GlobalPoint,
hoveredStartElement: ExcalidrawBindableElement | null, hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null,
): ElementUpdate<ExcalidrawElbowArrowElement> => { ) => {
let startIsSpecial = arrow.startIsSpecial ?? null; let startIsSpecial = arrow.startIsSpecial ?? null;
let endIsSpecial = arrow.endIsSpecial ?? null; let endIsSpecial = arrow.endIsSpecial ?? null;
const globalUpdatedPoints = updatedPoints.map((p, i) => const globalUpdatedPoints = updatedPoints.map((p, i) =>
@@ -748,15 +741,8 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point // Calculate the moving second point connection and add the start point
{ {
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1); const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2); const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
if (!secondPoint || !thirdPoint) {
throw new Error(
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
);
}
const startIsHorizontal = headingIsHorizontal(startHeading); const startIsHorizontal = headingIsHorizontal(startHeading);
const secondIsHorizontal = headingIsHorizontal( const secondIsHorizontal = headingIsHorizontal(
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)), vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
@@ -815,19 +801,10 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection // Calculate the moving second to last point connection
{ {
const secondToLastPoint = globalUpdatedPoints.at( const secondToLastPoint =
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2), globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
); const thirdToLastPoint =
const thirdToLastPoint = globalUpdatedPoints.at( globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
);
if (!secondToLastPoint || !thirdToLastPoint) {
throw new Error(
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
);
}
const endIsHorizontal = headingIsHorizontal(endHeading); const endIsHorizontal = headingIsHorizontal(endHeading);
const secondIsHorizontal = headingForPointIsHorizontal( const secondIsHorizontal = headingForPointIsHorizontal(
thirdToLastPoint, thirdToLastPoint,
@@ -1244,7 +1221,6 @@ const getElbowArrowData = (
const startGlobalPoint = getGlobalPoint( const startGlobalPoint = getGlobalPoint(
{ {
...arrow, ...arrow,
angle: 0,
type: "arrow", type: "arrow",
elbowed: true, elbowed: true,
points: nextPoints, points: nextPoints,
@@ -1259,7 +1235,6 @@ const getElbowArrowData = (
const endGlobalPoint = getGlobalPoint( const endGlobalPoint = getGlobalPoint(
{ {
...arrow, ...arrow,
angle: 0,
type: "arrow", type: "arrow",
elbowed: true, elbowed: true,
points: nextPoints, points: nextPoints,
@@ -1277,7 +1252,6 @@ const getElbowArrowData = (
hoveredStartElement, hoveredStartElement,
origStartGlobalPoint, origStartGlobalPoint,
elementsMap, elementsMap,
options?.zoom,
); );
const endHeading = getBindPointHeading( const endHeading = getBindPointHeading(
endGlobalPoint, endGlobalPoint,
@@ -1285,7 +1259,6 @@ const getElbowArrowData = (
hoveredEndElement, hoveredEndElement,
origEndGlobalPoint, origEndGlobalPoint,
elementsMap, elementsMap,
options?.zoom,
); );
const startPointBounds = [ const startPointBounds = [
startGlobalPoint[0] - 2, startGlobalPoint[0] - 2,
@@ -1306,8 +1279,8 @@ const getElbowArrowData = (
offsetFromHeading( offsetFromHeading(
startHeading, startHeading,
arrow.startArrowhead arrow.startArrowhead
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6 ? FIXED_BINDING_DISTANCE * 6
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2, : FIXED_BINDING_DISTANCE * 2,
1, 1,
), ),
) )
@@ -1319,8 +1292,8 @@ const getElbowArrowData = (
offsetFromHeading( offsetFromHeading(
endHeading, endHeading,
arrow.endArrowhead arrow.endArrowhead
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6 ? FIXED_BINDING_DISTANCE * 6
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2, : FIXED_BINDING_DISTANCE * 2,
1, 1,
), ),
) )
@@ -1367,8 +1340,8 @@ const getElbowArrowData = (
? 0 ? 0
: BASE_PADDING - : BASE_PADDING -
(arrow.startArrowhead (arrow.startArrowhead
? BASE_BINDING_GAP_ELBOW * 6 ? FIXED_BINDING_DISTANCE * 6
: BASE_BINDING_GAP_ELBOW * 2), : FIXED_BINDING_DISTANCE * 2),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap boundsOverlap
@@ -1383,8 +1356,8 @@ const getElbowArrowData = (
? 0 ? 0
: BASE_PADDING - : BASE_PADDING -
(arrow.endArrowhead (arrow.endArrowhead
? BASE_BINDING_GAP_ELBOW * 6 ? FIXED_BINDING_DISTANCE * 6
: BASE_BINDING_GAP_ELBOW * 2), : FIXED_BINDING_DISTANCE * 2),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
@@ -2098,7 +2071,16 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null, nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): ElementUpdate<ExcalidrawElbowArrowElement> => { ): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
const offsetX = global[0][0]; const offsetX = global[0][0];
const offsetY = global[0][1]; const offsetY = global[0][1];
let points = global.map((p) => let points = global.map((p) =>
@@ -2244,7 +2226,6 @@ 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,
@@ -2263,20 +2244,21 @@ const getBindPointHeading = (
), ),
origPoint, origPoint,
elementsMap, elementsMap,
zoom,
); );
const getHoveredElement = ( const getHoveredElement = (
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
) => { ) => {
return getHoveredElementForBinding( return getHoveredElementForBinding(
origPoint, tupleToCoors(origPoint),
elements, elements,
elementsMap, elementsMap,
(element) => maxBindingDistance_simple(zoom), zoom,
true,
true,
); );
}; };
+2 -32
View File
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>(); const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
const RE_YOUTUBE = const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/; /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
const RE_VIMEO = const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/; /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
@@ -56,35 +56,6 @@ const RE_REDDIT =
const RE_REDDIT_EMBED = const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i; /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const parseYouTubeTimestamp = (url: string): number => {
let timeParam: string | null | undefined;
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
timeParam =
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
} catch (error) {
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
timeParam = timeMatch?.[1];
}
if (!timeParam) {
return 0;
}
if (/^\d+$/.test(timeParam)) {
return parseInt(timeParam, 10);
}
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (!timeMatch) {
return 0;
}
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
};
const ALLOWED_DOMAINS = new Set([ const ALLOWED_DOMAINS = new Set([
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
@@ -142,8 +113,7 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 }; let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE); const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) { if (ytLink?.[2]) {
const startTime = parseYouTubeTimestamp(originalLink); const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts"); const isPortrait = link.includes("shorts");
type = "video"; type = "video";
switch (ytLink[1]) { switch (ytLink[1]) {
+3 -9
View File
@@ -7,7 +7,7 @@ import type {
PendingExcalidrawElements, PendingExcalidrawElements,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { bindBindingElement } from "./binding"; import { bindLinearElement } from "./binding";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import { import {
HEADING_DOWN, HEADING_DOWN,
@@ -446,14 +446,8 @@ const createBindingArrow = (
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
bindBindingElement( bindLinearElement(bindingArrow, startBindingElement, "start", scene);
bindingArrow, bindLinearElement(bindingArrow, endBindingElement, "end", scene);
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(
-77
View File
@@ -7,8 +7,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { isBoundToContainer } from "./typeChecks";
import { makeNextSelectedElementIds, getSelectedElements } from "./selection"; import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
import type { import type {
@@ -404,78 +402,3 @@ export const getNewGroupIdsForDuplication = (
return copy; return copy;
}; };
// given a list of selected elements, return the element grouped by their immediate group selected state
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
export const getSelectedElementsByGroup = (
selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
appState: Readonly<AppState>,
): ExcalidrawElement[][] => {
const selectedGroupIds = getSelectedGroupIds(appState);
const unboundElements = selectedElements.filter(
(element) => !isBoundToContainer(element),
);
const groups: Map<string, ExcalidrawElement[]> = new Map();
const elements: Map<string, ExcalidrawElement[]> = new Map();
// helper function to add an element to the elements map
const addToElementsMap = (element: ExcalidrawElement) => {
// elements
const currentElementMembers = elements.get(element.id) || [];
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentElementMembers.push(boundTextElement);
}
elements.set(element.id, [...currentElementMembers, element]);
};
// helper function to add an element to the groups map
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
// groups
const currentGroupMembers = groups.get(groupId) || [];
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentGroupMembers.push(boundTextElement);
}
groups.set(groupId, [...currentGroupMembers, element]);
};
// helper function to handle the case where a single group is selected
// and all elements selected are within the group, it will respect group hierarchy in accordance to
// their nested grouping order
const handleSingleSelectedGroupCase = (
element: ExcalidrawElement,
selectedGroupId: GroupId,
) => {
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
const nestedGroupCount = element.groupIds.slice(
0,
indexOfSelectedGroupId,
).length;
return nestedGroupCount > 0
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
: addToElementsMap(element);
};
const isAllInSameGroup = selectedElements.every((element) =>
isSelectedViaGroup(appState, element),
);
unboundElements.forEach((element) => {
const selectedGroupId = getSelectedGroupIdForElement(
element,
appState.selectedGroupIds,
);
if (!selectedGroupId) {
addToElementsMap(element);
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
handleSingleSelectedGroupCase(element, selectedGroupId);
} else {
addToGroupsMap(element, selectedGroupId);
}
});
return Array.from(groups.values()).concat(Array.from(elements.values()));
};
+2 -7
View File
@@ -1,9 +1,4 @@
import { import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
invariant,
isDevEnv,
isTestEnv,
type Bounds,
} from "@excalidraw/common";
import { import {
pointFrom, pointFrom,
@@ -24,7 +19,7 @@ import type {
Vector, Vector,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCenterForBounds } from "./bounds"; import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types"; import type { ExcalidrawBindableElement } from "./types";
+22 -5
View File
@@ -1,6 +1,7 @@
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,
@@ -28,9 +29,6 @@ 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++) {
@@ -54,6 +52,27 @@ 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";
@@ -78,7 +97,6 @@ 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";
@@ -92,7 +110,6 @@ export * from "./store";
export * from "./textElement"; export * from "./textElement";
export * from "./textMeasurements"; export * from "./textMeasurements";
export * from "./textWrapping"; export * from "./textWrapping";
export * from "./transform";
export * from "./transformHandles"; export * from "./transformHandles";
export * from "./typeChecks"; export * from "./typeChecks";
export * from "./utils"; export * from "./utils";
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -46,13 +46,16 @@ 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, fileId } = updates as any; const { points, fixedSegments, startBinding, endBinding, fileId } =
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,
+23 -23
View File
@@ -21,7 +21,7 @@ import {
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
import { newElementWith } from "./mutateElement"; import { newElementWith } from "./mutateElement";
import { getBoundTextMaxWidth, getInitialTextMetrics } from "./textElement"; import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements"; import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
@@ -236,30 +236,27 @@ const getTextElementPositionOffsets = (
}; };
}; };
export type NewTextElementOptions = {
text: string;
originalText?: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"];
autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts;
export const newTextElement = ( export const newTextElement = (
opts: NewTextElementOptions, opts: {
text: string;
originalText?: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"];
autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getLineHeight(fontFamily); const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
const normalizedText = normalizeText(opts.text); const text = normalizeText(opts.text);
const originalText = opts.originalText ?? normalizedText; const metrics = measureText(
const metrics = getInitialTextMetrics( text,
{ ...opts, text: normalizedText }, getFontString({ fontFamily, fontSize }),
fontFamily, lineHeight,
fontSize,
); );
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN; const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN; const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -270,7 +267,7 @@ export const newTextElement = (
const textElementProps: ExcalidrawTextElement = { const textElementProps: ExcalidrawTextElement = {
..._newElementBase<ExcalidrawTextElement>("text", opts), ..._newElementBase<ExcalidrawTextElement>("text", opts),
text: normalizedText, text,
fontSize, fontSize,
fontFamily, fontFamily,
textAlign, textAlign,
@@ -280,7 +277,7 @@ export const newTextElement = (
width: metrics.width, width: metrics.width,
height: metrics.height, height: metrics.height,
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText, originalText: opts.originalText ?? text,
autoResize: opts.autoResize ?? true, autoResize: opts.autoResize ?? true,
lineHeight, lineHeight,
}; };
@@ -455,6 +452,7 @@ export const newFreeDrawElement = (
points: opts.points || [], points: opts.points || [],
pressures: opts.pressures || [], pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure, simulatePressure: opts.simulatePressure,
lastCommittedPoint: null,
}; };
}; };
@@ -468,7 +466,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,
@@ -503,6 +501,7 @@ 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,
@@ -517,6 +516,7 @@ 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,
@@ -1,112 +0,0 @@
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;
};
+20 -86
View File
@@ -1,14 +1,7 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand"; import { getStroke } from "perfect-freehand";
import { import { isRightAngleRads } from "@excalidraw/math";
type GlobalPoint,
isRightAngleRads,
lineSegment,
pointFrom,
pointRotateRads,
type Radians,
} from "@excalidraw/math";
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
@@ -21,7 +14,6 @@ import {
getFontString, getFontString,
isRTL, isRTL,
getVerticalOffset, getVerticalOffset,
invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -40,7 +32,7 @@ import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
} from "@excalidraw/excalidraw/scene/types"; } from "@excalidraw/excalidraw/scene/types";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { getUncroppedImageElement } from "./cropElement"; import { getUncroppedImageElement } from "./cropElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { import {
@@ -98,7 +90,7 @@ const isPendingImageElement = (
const shouldResetImageFilter = ( const shouldResetImageFilter = (
element: ExcalidrawElement, element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
return ( return (
appState.theme === THEME.DARK && appState.theme === THEME.DARK &&
@@ -114,11 +106,6 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
return element.strokeWidth * 12; return element.strokeWidth * 12;
case "text": case "text":
return element.fontSize / 2; return element.fontSize / 2;
case "arrow":
if (element.endArrowhead || element.endArrowhead) {
return 40;
}
return 20;
default: default:
return 20; return 20;
} }
@@ -225,7 +212,7 @@ const generateElementCanvas = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom: Zoom, zoom: Zoom,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState, appState: StaticCanvasAppState,
): ExcalidrawElementWithCanvas | null => { ): ExcalidrawElementWithCanvas | null => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
@@ -277,7 +264,7 @@ const generateElementCanvas = (
context.filter = IMAGE_INVERT_FILTER; context.filter = IMAGE_INVERT_FILTER;
} }
drawElementOnCanvas(element, rc, context, renderConfig); drawElementOnCanvas(element, rc, context, renderConfig, appState);
context.restore(); context.restore();
@@ -412,6 +399,7 @@ 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":
@@ -557,7 +545,7 @@ const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
const zoom: Zoom = renderConfig const zoom: Zoom = renderConfig
? appState.zoom ? appState.zoom
@@ -614,7 +602,7 @@ const drawElementFromCanvas = (
elementWithCanvas: ExcalidrawElementWithCanvas, elementWithCanvas: ExcalidrawElementWithCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState, appState: StaticCanvasAppState,
allElementsMap: NonDeletedSceneElementsMap, allElementsMap: NonDeletedSceneElementsMap,
) => { ) => {
const element = elementWithCanvas.element; const element = elementWithCanvas.element;
@@ -732,7 +720,7 @@ export const renderElement = (
rc: RoughCanvas, rc: RoughCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
const reduceAlphaForSelection = const reduceAlphaForSelection =
appState.openDialog?.name === "elementLinkSelector" && appState.openDialog?.name === "elementLinkSelector" &&
@@ -802,7 +790,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); drawElementOnCanvas(element, rc, context, renderConfig, appState);
context.restore(); context.restore();
} else { } else {
const elementWithCanvas = generateElementWithCanvas( const elementWithCanvas = generateElementWithCanvas(
@@ -895,7 +883,13 @@ export const renderElement = (
tempCanvasContext.translate(-shiftX, -shiftY); tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig); drawElementOnCanvas(
element,
tempRc,
tempCanvasContext,
renderConfig,
appState,
);
tempCanvasContext.translate(shiftX, shiftY); tempCanvasContext.translate(shiftX, shiftY);
@@ -934,7 +928,7 @@ export const renderElement = (
} }
context.translate(-shiftX, -shiftY); context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig); drawElementOnCanvas(element, rc, context, renderConfig, appState);
} }
context.restore(); context.restore();
@@ -1040,66 +1034,6 @@ 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
@@ -1115,10 +1049,10 @@ export function getFreedrawOutlinePoints(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: true, last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
}; };
return getStroke(inputPoints as number[][], options) as [number, number][]; return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
} }
function med(A: number[], B: number[]) { function med(A: number[], B: number[]) {
+12 -103
View File
@@ -20,11 +20,7 @@ import type { PointerDownState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
getArrowLocalFixedPoints,
unbindBindingElement,
updateBoundElements,
} from "./binding";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getCommonBounds, getCommonBounds,
@@ -39,7 +35,6 @@ import {
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement"; } from "./textElement";
import { import {
getMinTextElementWidth, getMinTextElementWidth,
@@ -50,7 +45,6 @@ import {
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { import {
isArrowElement, isArrowElement,
isBindingElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
@@ -79,9 +73,7 @@ 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 = (
@@ -227,40 +219,13 @@ const rotateSingleElement = (
} }
const boundTextElementId = getBoundTextElementId(element); const boundTextElementId = getBoundTextElementId(element);
let update: ElementUpdate<NonDeletedExcalidrawElement> = { scene.mutateElement(element, { angle });
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);
if (textElement && !isArrowElement(element)) { if (textElement && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition( scene.mutateElement(textElement, { angle });
element,
textElement,
scene.getNonDeletedElementsMap(),
);
scene.mutateElement(textElement, {
angle,
x,
y,
});
} }
} }
}; };
@@ -419,11 +384,6 @@ 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);
@@ -454,30 +414,11 @@ 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(
element,
boundText,
elementsMap,
);
scene.mutateElement(boundText, { scene.mutateElement(boundText, {
x, x: boundText.x + (rotatedCX - cx),
y, y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians), angle: normalizeRadians((centerAngle + origAngle) as Radians),
}); });
} }
@@ -878,32 +819,13 @@ export const resizeSingleElement = (
Number.isFinite(newOrigin.x) && Number.isFinite(newOrigin.x) &&
Number.isFinite(newOrigin.y) Number.isFinite(newOrigin.y)
) { ) {
let updates: ElementUpdate<ExcalidrawElement> = { const updates = {
...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,
@@ -921,7 +843,10 @@ 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 },
});
} }
}; };
@@ -1455,36 +1380,20 @@ 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 { angle } = update; const { width, height, 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, {
+12 -15
View File
@@ -5,25 +5,22 @@ import {
type Radians, type Radians,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import { SIDE_RESIZING_THRESHOLD } from "@excalidraw/common";
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, Zoom } from "@excalidraw/excalidraw/types"; import type { AppState, Device, Zoom } from "@excalidraw/excalidraw/types";
import type { Bounds } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { import {
getTransformHandlesFromCoords, getTransformHandlesFromCoords,
getTransformHandles, getTransformHandles,
getOmitSidesForEditorInterface, getOmitSidesForDevice,
canResizeFromSides, canResizeFromSides,
} from "./transformHandles"; } from "./transformHandles";
import { isImageElement, isLinearElement } from "./typeChecks"; import { isImageElement, isLinearElement } from "./typeChecks";
import type { Bounds } from "./bounds";
import type { import type {
TransformHandleType, TransformHandleType,
TransformHandle, TransformHandle,
@@ -54,7 +51,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
y: number, y: number,
zoom: Zoom, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
editorInterface: EditorInterface, device: Device,
): MaybeTransformHandleType => { ): MaybeTransformHandleType => {
if (!appState.selectedElementIds[element.id]) { if (!appState.selectedElementIds[element.id]) {
return false; return false;
@@ -66,7 +63,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
zoom, zoom,
elementsMap, elementsMap,
pointerType, pointerType,
getOmitSidesForEditorInterface(editorInterface), getOmitSidesForDevice(device),
); );
if ( if (
@@ -89,7 +86,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
return filter[0] as TransformHandleType; return filter[0] as TransformHandleType;
} }
if (canResizeFromSides(editorInterface)) { if (canResizeFromSides(device)) {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element, element,
elementsMap, elementsMap,
@@ -135,7 +132,7 @@ export const getElementWithTransformHandleType = (
zoom: Zoom, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
elementsMap: ElementsMap, elementsMap: ElementsMap,
editorInterface: EditorInterface, device: Device,
) => { ) => {
return elements.reduce((result, element) => { return elements.reduce((result, element) => {
if (result) { if (result) {
@@ -149,7 +146,7 @@ export const getElementWithTransformHandleType = (
scenePointerY, scenePointerY,
zoom, zoom,
pointerType, pointerType,
editorInterface, device,
); );
return transformHandleType ? { element, transformHandleType } : null; return transformHandleType ? { element, transformHandleType } : null;
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null); }, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
@@ -163,14 +160,14 @@ export const getTransformHandleTypeFromCoords = <
scenePointerY: number, scenePointerY: number,
zoom: Zoom, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
editorInterface: EditorInterface, device: Device,
): 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,
getOmitSidesForEditorInterface(editorInterface), getOmitSidesForDevice(device),
); );
const found = Object.keys(transformHandles).find((key) => { const found = Object.keys(transformHandles).find((key) => {
@@ -186,7 +183,7 @@ export const getTransformHandleTypeFromCoords = <
return found as MaybeTransformHandleType; return found as MaybeTransformHandleType;
} }
if (canResizeFromSides(editorInterface)) { if (canResizeFromSides(device)) {
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
+15 -38
View File
@@ -27,8 +27,6 @@ import {
isImageElement, isImageElement,
} from "./index"; } from "./index";
import type { ApplyToOptions } from "./delta";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
OrderedExcalidrawElement, OrderedExcalidrawElement,
@@ -76,9 +74,8 @@ type MicroActionsQueue = (() => void)[];
* Store which captures the observed changes and emits them as `StoreIncrement` events. * Store which captures the observed changes and emits them as `StoreIncrement` events.
*/ */
export class Store { export class Store {
// for internal use by history // internally used by history
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>(); public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
// for public use as part of onIncrement API
public readonly onStoreIncrementEmitter = new Emitter< public readonly onStoreIncrementEmitter = new Emitter<
[DurableIncrement | EphemeralIncrement] [DurableIncrement | EphemeralIncrement]
>(); >();
@@ -240,6 +237,7 @@ export class Store {
if (!storeDelta.isEmpty()) { if (!storeDelta.isEmpty()) {
const increment = new DurableIncrement(storeChange, storeDelta); const increment = new DurableIncrement(storeChange, storeDelta);
// Notify listeners with the increment
this.onDurableIncrementEmitter.trigger(increment); this.onDurableIncrementEmitter.trigger(increment);
this.onStoreIncrementEmitter.trigger(increment); this.onStoreIncrementEmitter.trigger(increment);
} }
@@ -552,26 +550,10 @@ export class StoreDelta {
public static load({ public static load({
id, id,
elements: { added, removed, updated }, elements: { added, removed, updated },
appState: { delta: appStateDelta },
}: DTO<StoreDelta>) { }: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated); const elements = ElementsDelta.create(added, removed, updated);
const appState = AppStateDelta.create(appStateDelta);
return new this(id, elements, appState); return new this(id, elements, AppStateDelta.empty());
}
/**
* Squash the passed deltas into the aggregated delta instance.
*/
public static squash(...deltas: StoreDelta[]) {
const aggregatedDelta = StoreDelta.empty();
for (const delta of deltas) {
aggregatedDelta.elements.squash(delta.elements);
aggregatedDelta.appState.squash(delta.appState);
}
return aggregatedDelta;
} }
/** /**
@@ -588,13 +570,9 @@ export class StoreDelta {
delta: StoreDelta, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
options?: ApplyToOptions,
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( const [nextElements, elementsContainVisibleChange] =
elements, delta.elements.applyTo(elements);
StoreSnapshot.empty().elements,
options,
);
const [nextAppState, appStateContainsVisibleChange] = const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements); delta.appState.applyTo(appState, nextElements);
@@ -627,10 +605,6 @@ export class StoreDelta {
); );
} }
public static empty() {
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
}
public isEmpty() { public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty(); return this.elements.isEmpty() && this.appState.isEmpty();
} }
@@ -996,7 +970,8 @@ const getDefaultObservedAppState = (): ObservedAppState => {
viewBackgroundColor: COLOR_PALETTE.white, viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
selectedLinearElement: null, editingLinearElementId: null,
selectedLinearElementId: null,
croppingElementId: null, croppingElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
@@ -1015,12 +990,14 @@ export const getObservedAppState = (
croppingElementId: appState.croppingElementId, croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId, activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections, lockedMultiSelections: appState.lockedMultiSelections,
selectedLinearElement: appState.selectedLinearElement editingLinearElementId:
? { (appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
elementId: appState.selectedLinearElement.elementId, (appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
isEditing: !!appState.selectedLinearElement.isEditing, null,
} selectedLinearElementId:
: null, (appState as AppState).selectedLinearElement?.elementId ??
(appState as ObservedAppState).selectedLinearElementId ??
null,
}; };
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
+2 -48
View File
@@ -8,16 +8,14 @@ import {
getFontString, getFontString,
isProdEnv, isProdEnv,
invariant, invariant,
DEFAULT_FONT_FAMILY,
getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types"; import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { import {
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
@@ -32,8 +30,6 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { NewTextElementOptions } from "./newElement";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles"; import type { MaybeTransformHandleType } from "./transformHandles";
@@ -44,7 +40,6 @@ import type {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontFamilyValues,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
@@ -259,26 +254,6 @@ export const computeBoundTextPosition = (
x = x =
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2); containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
} }
const angle = (container.angle ?? 0) as Radians;
if (angle !== 0) {
const contentCenter = pointFrom(
containerCoords.x + maxContainerWidth / 2,
containerCoords.y + maxContainerHeight / 2,
);
const textCenter = pointFrom(
x + boundTextElement.width / 2,
y + boundTextElement.height / 2,
);
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
return {
x: rx - boundTextElement.width / 2,
y: ry - boundTextElement.height / 2,
};
}
return { x, y }; return { x, y };
}; };
@@ -533,24 +508,3 @@ export const getTextFromElements = (
.join(separator); .join(separator);
return text; return text;
}; };
/** When text is already measured and wrapped, we want to respect those dimensions */
export const getInitialTextMetrics = (
text: NewTextElementOptions,
fontFamily: FontFamilyValues = DEFAULT_FONT_FAMILY,
fontSize: number = DEFAULT_FONT_SIZE,
) => {
const shouldUseProvidedDimensions =
text.autoResize === false && text.width && text.height;
return shouldUseProvidedDimensions
? {
width: text.width,
height: text.height,
}
: measureText(
text.text,
getFontString({ fontFamily, fontSize }),
text.lineHeight ?? getLineHeight(fontFamily),
);
};
+15 -20
View File
@@ -1,6 +1,7 @@
import { import {
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
type EditorInterface, isAndroid,
isIOS,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { pointFrom, pointRotateRads } from "@excalidraw/math";
@@ -8,10 +9,10 @@ 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";
import type { Bounds } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { import {
@@ -21,6 +22,7 @@ import {
isLinearElement, isLinearElement,
} from "./typeChecks"; } from "./typeChecks";
import type { Bounds } from "./bounds";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
@@ -109,21 +111,20 @@ const generateTransformHandle = (
return [xx - width / 2, yy - height / 2, width, height]; return [xx - width / 2, yy - height / 2, width, height];
}; };
export const canResizeFromSides = (editorInterface: EditorInterface) => { export const canResizeFromSides = (device: Device) => {
if ( if (device.viewport.isMobile) {
editorInterface.formFactor === "phone" && return false;
editorInterface.userAgent.isMobileDevice }
) {
if (device.isTouchScreen && (isAndroid || isIOS)) {
return false; return false;
} }
return true; return true;
}; };
export const getOmitSidesForEditorInterface = ( export const getOmitSidesForDevice = (device: Device) => {
editorInterface: EditorInterface, if (canResizeFromSides(device)) {
) => {
if (canResizeFromSides(editorInterface)) {
return DEFAULT_OMIT_SIDES; return DEFAULT_OMIT_SIDES;
} }
@@ -325,15 +326,11 @@ export const getTransformHandles = (
); );
}; };
export const hasBoundingBox = ( export const shouldShowBoundingBox = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
editorInterface: EditorInterface,
) => { ) => {
if ( if (appState.editingLinearElement) {
appState.selectedLinearElement?.isEditing ||
appState.selectedLinearElement?.isDragging
) {
return false; return false;
} }
if (elements.length > 1) { if (elements.length > 1) {
@@ -348,7 +345,5 @@ export const hasBoundingBox = (
return true; return true;
} }
// on mobile/tablet we currently don't show bbox because of resize issues return element.points.length > 2;
// (also prob best for simplicity's sake)
return element.points.length > 2 && !editorInterface.userAgent.isMobileDevice;
}; };
+22 -1
View File
@@ -6,6 +6,7 @@ import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
import type { MarkNonNullable } from "@excalidraw/common/utility-types"; import type { MarkNonNullable } from "@excalidraw/common/utility-types";
import type { Bounds } from "./bounds";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@@ -27,6 +28,8 @@ import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawLineElement, ExcalidrawLineElement,
PointBinding,
FixedPointBinding,
ExcalidrawFlowchartNodeElement, ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType, ExcalidrawLinearElementSubType,
} from "./types"; } from "./types";
@@ -160,7 +163,7 @@ export const isLinearElementType = (
export const isBindingElement = ( export const isBindingElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
includeLocked = true, includeLocked = true,
): element is ExcalidrawArrowElement => { ): element is ExcalidrawLinearElement => {
return ( return (
element != null && element != null &&
(!element.locked || includeLocked === true) && (!element.locked || includeLocked === true) &&
@@ -355,6 +358,24 @@ 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
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
box.length === 4 &&
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
export const getLinearElementSubType = ( export const getLinearElementSubType = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
): ExcalidrawLinearElementSubType => { ): ExcalidrawLinearElementSubType => {
+20 -17
View File
@@ -279,23 +279,24 @@ export type ExcalidrawTextElementWithContainer = {
export type FixedPoint = [number, number]; export type FixedPoint = [number, number];
export type BindMode = "inside" | "orbit" | "skip"; export type PointBinding = {
export type FixedPointBinding = {
elementId: ExcalidrawBindableElement["id"]; elementId: ExcalidrawBindableElement["id"];
focus: number;
// Represents the fixed point binding information in form of a vertical and gap: number;
// 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
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
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;
}; };
export type FixedPointBinding = Merge<
PointBinding,
{
// 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
// 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
// bound element-local point coordinate.
fixedPoint: FixedPoint;
}
>;
type Index = number; type Index = number;
export type PointsPositionUpdates = Map< export type PointsPositionUpdates = Map<
@@ -321,8 +322,9 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "line" | "arrow"; type: "line" | "arrow";
points: readonly LocalPoint[]; points: readonly LocalPoint[];
startBinding: FixedPointBinding | null; lastCommittedPoint: LocalPoint | null;
endBinding: FixedPointBinding | null; startBinding: PointBinding | null;
endBinding: PointBinding | null;
startArrowhead: Arrowhead | null; startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null;
}>; }>;
@@ -349,9 +351,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
@@ -377,6 +379,7 @@ 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" };
+15 -152
View File
@@ -1,7 +1,6 @@
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";
@@ -11,17 +10,10 @@ 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";
@@ -29,17 +21,11 @@ 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 { elementCenterPoint, getDiamondPoints } from "./bounds"; import { 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,
@@ -414,10 +400,20 @@ export function deconstructDiamondElement(
), // TOP ), // TOP
]; ];
const corners = baseCorners.map( const corners =
(corner) => offset > 0
curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!, ? baseCorners.map(
); (corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [ const sides = [
lineSegment<GlobalPoint>( lineSegment<GlobalPoint>(
@@ -485,136 +481,3 @@ 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;
};
+6 -58
View File
@@ -1,25 +1,18 @@
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, isTextElement } from "./typeChecks"; import { isFrameLikeElement } 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 {
ExcalidrawArrowElement, import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
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;
@@ -146,51 +139,6 @@ 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).
-420
View File
@@ -589,424 +589,4 @@ describe("aligning", () => {
expect(API.getSelectedElements()[2].x).toEqual(250); expect(API.getSelectedElements()[2].x).toEqual(250);
expect(API.getSelectedElements()[3].x).toEqual(150); expect(API.getSelectedElements()[3].x).toEqual(150);
}); });
const createGroupAndSelectInEditGroupMode = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
mouse.moveTo(10, 0);
mouse.doubleClick();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
mouse.moveTo(100, 100);
mouse.click();
});
};
it("aligns elements within a group while in group edit mode correctly to the top", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
});
it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(100);
});
it("aligns elements within a group while in group edit mode correctly to the left", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
});
it("aligns elements within a group while in group edit mode correctly to the right", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(100);
});
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(50);
});
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(50);
});
const createNestedGroupAndSelectInEditGroupMode = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
mouse.moveTo(200, 200);
// create third element
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// third element is already selected, select the initial group and group together
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
// double click to enter edit mode
mouse.doubleClick();
// select nested group and other element within the group
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(200, 200);
mouse.click();
});
};
it("aligns element and nested group while in group edit mode correctly to the top", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
});
it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
it("aligns element and nested group while in group edit mode correctly to the left", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
});
it("aligns element and nested group while in group edit mode correctly to the right", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
});
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
});
const createAndSelectSingleGroup = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
};
it("aligns elements within a single-selected group correctly to the top", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
});
it("aligns elements within a single-selected group correctly to the bottom", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(100);
});
it("aligns elements within a single-selected group correctly to the left", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
});
it("aligns elements within a single-selected group correctly to the right", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(100);
});
it("aligns elements within a single-selected group correctly to the vertical center", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(50);
});
it("aligns elements within a single-selected group correctly to the horizontal center", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(50);
});
const createAndSelectSingleGroupWithNestedGroup = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
// Add group to current selection
mouse.restorePosition(10, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
// Create the nested group
API.executeAction(actionGroup);
};
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
});
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
});
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
});
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
});
}); });
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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.9291017525165); expect(x1).toEqual(360.3176068760539);
expect(y1).toEqual(185.24770129343722); expect(y1).toEqual(185.90654264413516);
expect(x2).toEqual(481.4815539037601); expect(x2).toEqual(480.87005902729743);
expect(y2).toEqual(319.8162855827246); expect(y2).toEqual(320.4751269334226);
}); });
}); });
+10 -434
View File
@@ -1,345 +1,13 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { ObservedAppState } from "@excalidraw/excalidraw/types"; import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element"; import type { LinearElementEditor } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta"; import { AppStateDelta } from "../src/delta";
describe("ElementsDelta", () => {
describe("elements delta calculation", () => {
it("should not throw when element gets removed but was already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map([[element.id, element]]);
const nextElements = new Map();
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should not throw when adding element as already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map();
const nextElements = new Map([[element.id, element]]);
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should create updated delta even when there is only version and versionNonce change", () => {
const baseElement = API.createElement({
type: "rectangle",
x: 100,
y: 100,
strokeColor: "#000000",
backgroundColor: "#ffffff",
});
const modifiedElement = {
...baseElement,
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
};
// Create maps for the delta calculation
const prevElements = new Map([[baseElement.id, baseElement]]);
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
// Calculate the delta
const delta = ElementsDelta.calculate(
prevElements as SceneElementsMap,
nextElements as SceneElementsMap,
);
expect(delta).toEqual(
ElementsDelta.create(
{},
{},
{
[baseElement.id]: Delta.create(
{
version: baseElement.version,
versionNonce: baseElement.versionNonce,
},
{
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
},
),
},
),
);
});
});
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{},
{},
{ id1: updatedDelta },
);
const elementsDelta2 = ElementsDelta.empty();
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.updated.id1).toBe(updatedDelta);
});
it("should squash mutually exclusive delta types", () => {
const addedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
);
const removedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
);
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{ id1: addedDelta },
{ id2: removedDelta },
{},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{ id3: updatedDelta },
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toBe(addedDelta);
expect(elementsDelta.removed.id2).toBe(removedDelta);
expect(elementsDelta.updated.id3).toBe(updatedDelta);
});
it("should squash the same delta types", () => {
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id2: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id3: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 200, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
);
expect(elementsDelta.removed.id2).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
);
expect(elementsDelta.updated.id3).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2 },
{ x: 200, y: 200, version: 3, versionNonce: 3 },
),
);
});
it("should squash different delta types ", () => {
// id1: added -> updated => added
// id2: removed -> added => added
// id3: updated -> removed => removed
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 300, version: 1, versionNonce: 1 },
{ x: 301, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id2: Delta.create(
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id3: Delta.create(
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 101, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added).toEqual({
id1: Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
),
id2: Delta.create(
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
});
expect(elementsDelta.removed).toEqual({
id3: Delta.create(
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
});
expect(elementsDelta.updated).toEqual({});
});
it("should squash bound elements", () => {
const elementsDelta1 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 1,
versionNonce: 1,
boundElements: [{ id: "t1", type: "text" }],
},
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "t2", type: "text" }],
},
),
},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "a1", type: "arrow" }],
},
{
version: 3,
versionNonce: 3,
boundElements: [{ id: "a2", type: "arrow" }],
},
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
{ id: "t1", type: "text" },
{ id: "a1", type: "arrow" },
]);
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
{ id: "t2", type: "text" },
{ id: "a2", type: "arrow" },
]);
});
});
});
describe("AppStateDelta", () => { describe("AppStateDelta", () => {
describe("ensure stable delta properties order", () => { describe("ensure stable delta properties order", () => {
it("should maintain stable order for root properties", () => { it("should maintain stable order for root properties", () => {
const name = "untitled scene"; const name = "untitled scene";
const selectedLinearElement = { const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
elementId: "id1" as LinearElementEditor["elementId"],
isEditing: false,
};
const commonAppState = { const commonAppState = {
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
@@ -348,7 +16,6 @@ describe("AppStateDelta", () => {
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
editingLinearElementId: null, editingLinearElementId: null,
selectedLinearElementIsEditing: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
}; };
@@ -356,23 +23,23 @@ describe("AppStateDelta", () => {
const prevAppState1: ObservedAppState = { const prevAppState1: ObservedAppState = {
...commonAppState, ...commonAppState,
name: "", name: "",
selectedLinearElement: null, selectedLinearElementId: null,
}; };
const nextAppState1: ObservedAppState = { const nextAppState1: ObservedAppState = {
...commonAppState, ...commonAppState,
name, name,
selectedLinearElement, selectedLinearElementId,
}; };
const prevAppState2: ObservedAppState = { const prevAppState2: ObservedAppState = {
selectedLinearElement: null, selectedLinearElementId: null,
name: "", name: "",
...commonAppState, ...commonAppState,
}; };
const nextAppState2: ObservedAppState = { const nextAppState2: ObservedAppState = {
selectedLinearElement, selectedLinearElementId,
name, name,
...commonAppState, ...commonAppState,
}; };
@@ -390,7 +57,8 @@ describe("AppStateDelta", () => {
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElement: null, selectedLinearElementId: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -436,7 +104,8 @@ describe("AppStateDelta", () => {
selectedElementIds: {}, selectedElementIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElement: null, selectedLinearElementId: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -477,97 +146,4 @@ describe("AppStateDelta", () => {
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2)); expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
}); });
}); });
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const delta = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const appStateDelta1 = AppStateDelta.create(delta);
const appStateDelta2 = AppStateDelta.empty();
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toBe(delta);
});
it("should squash exclusive properties", () => {
const delta1 = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const delta2 = Delta.create(
{ viewBackgroundColor: "#ffffff" },
{ viewBackgroundColor: "#000000" },
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create(
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
{ name: "titled scene", viewBackgroundColor: "#000000" },
),
);
});
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
const delta1 = Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true },
selectedGroupIds: {},
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
);
const delta2 = Delta.create<Partial<ObservedAppState>>(
{
selectedElementIds: { id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
{
selectedElementIds: { id2: true },
selectedGroupIds: { g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true, id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true, g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
),
);
});
});
}); });
-128
View File
@@ -1,128 +0,0 @@
import {
distributeHorizontally,
distributeVertically,
} from "@excalidraw/excalidraw/actions";
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
unmountComponent,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
const mouse = new Pointer("mouse");
// Scenario: three rectangles that will be distributed with gaps
const createAndSelectThreeRectanglesWithGap = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(300, 300);
mouse.up(100, 100);
mouse.reset();
// Last rectangle is selected by default
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(0, 10);
mouse.click(10, 0);
});
};
// Scenario: three rectangles that will be distributed by their centers
const createAndSelectThreeRectanglesWithoutGap = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(200, 200);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
mouse.reset();
// Last rectangle is selected by default
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(0, 10);
mouse.click(10, 0);
});
};
describe("distributing", () => {
beforeEach(async () => {
unmountComponent();
mouse.reset();
await act(() => {
return setLanguage(defaultLang);
});
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should distribute selected elements horizontally", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(300);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(300);
});
it("should distribute selected elements vertically", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(300);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(300);
});
it("should distribute selected elements horizontally based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(50);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("should distribute selected elements vertically with based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(50);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
});
+15 -8
View File
@@ -144,8 +144,9 @@ 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",
}, },
}); });
@@ -154,8 +155,9 @@ 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" }],
}); });
@@ -274,8 +276,9 @@ 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",
}, },
}); });
@@ -290,13 +293,15 @@ 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",
}, },
}); });
@@ -305,13 +310,15 @@ 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",
}, },
}); });
@@ -814,7 +821,7 @@ describe("duplication z-order", () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: -100, x: -100,
y: 50, y: 50,
width: 115, width: 95,
height: 0, height: 0,
}); });
+39 -39
View File
@@ -1,10 +1,13 @@
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,
@@ -12,11 +15,13 @@ 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 {
@@ -131,11 +136,6 @@ 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,23 +185,25 @@ 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;
API.setElements([rectangle1, rectangle2, arrow]); scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
scene.insertElement(arrow);
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene); bindLinearElement(arrow, rectangle1, "start", scene);
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene); bindLinearElement(arrow, rectangle2, "end", 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.scene.mutateElement(arrow, { h.app.scene.mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)], points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
}); });
expect(arrow.points).toCloselyEqualPoints([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
[39, 0], [45, 0],
[39, 200], [45, 200],
[78, 200], [90, 200],
]); ]);
}); });
}); });
@@ -240,9 +242,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(-53, -99); mouse.moveTo(-43, -99);
mouse.click(); mouse.click();
mouse.moveTo(53, 99); mouse.moveTo(43, 99);
mouse.click(); mouse.click();
const arrow = h.scene.getSelectedElements( const arrow = h.scene.getSelectedElements(
@@ -251,11 +253,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).toCloselyEqualPoints([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
[39, 0], [45, 0],
[39, 200], [45, 200],
[78, 200], [90, 200],
]); ]);
}); });
@@ -277,9 +279,9 @@ describe("elbow arrow ui", () => {
UI.clickOnTestId("elbow-arrow"); UI.clickOnTestId("elbow-arrow");
mouse.reset(); mouse.reset();
mouse.moveTo(-53, -99); mouse.moveTo(-43, -99);
mouse.click(); mouse.click();
mouse.moveTo(53, 99); mouse.moveTo(43, 99);
mouse.click(); mouse.click();
const arrow = h.scene.getSelectedElements( const arrow = h.scene.getSelectedElements(
@@ -295,11 +297,9 @@ 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],
[36, 0], [35, 0],
[36, 90], [35, 165],
[28, 90], [103, 165],
[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(-53, -99); mouse.moveTo(-43, -99);
mouse.click(); mouse.click();
mouse.moveTo(53, 99); mouse.moveTo(43, 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).toCloselyEqualPoints([ expect(duplicatedArrow.points).toEqual([
[0, 0], [0, 0],
[39, 0], [45, 0],
[39, 200], [45, 200],
[78, 200], [90, 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(-53, -99); mouse.moveTo(-43, -99);
mouse.click(); mouse.click();
mouse.moveTo(53, 99); mouse.moveTo(43, 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).toCloselyEqualPoints([ expect(duplicatedArrow.points).toEqual([
[0, 0], [0, 0],
[0, 100], [0, 100],
[78, 100], [90, 100],
[78, 200], [90, 200],
]); ]);
}); });
}); });
-153
View File
@@ -1,153 +0,0 @@
import { getEmbedLink } from "../src/embeddable";
describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
expectedStart: 90,
},
{
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
expectedStart: 120,
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
expectedStart: 150,
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain(`start=${expectedStart}`);
}
});
});
it("should parse YouTube URLs with timestamp in time format", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
expectedStart: 90, // 1*60 + 30
},
{
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
expectedStart: 165, // 2*60 + 45
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
expectedStart: 3723, // 1*3600 + 2*60 + 3
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
expectedStart: 45,
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
expectedStart: 300, // 5*60
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
expectedStart: 7200, // 2*3600
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain(`start=${expectedStart}`);
}
});
});
it("should handle YouTube URLs without timestamps", () => {
const testCases = [
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ",
"https://www.youtube.com/embed/dQw4w9WgXcQ",
];
testCases.forEach((url) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).not.toContain("start=");
}
});
});
it("should handle YouTube shorts URLs with timestamps", () => {
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=30");
}
// Shorts should have portrait aspect ratio
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
});
it("should handle playlist URLs with timestamps", () => {
const url =
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=60");
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
}
});
it("should handle malformed or edge case timestamps", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
expectedStart: 0, // Invalid timestamp should default to 0
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
expectedStart: 0, // Empty timestamp should default to 0
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
expectedStart: 0, // Zero timestamp should be handled
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
if (expectedStart === 0) {
expect(result.link).not.toContain("start=");
} else {
expect(result.link).toContain(`start=${expectedStart}`);
}
}
});
});
it("should preserve other URL parameters", () => {
const url =
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=90");
expect(result.link).toContain("enablejsapi=1");
}
});
});
@@ -136,8 +136,7 @@ describe("Test Linear Elements", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
}); });
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
}; };
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => { const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
@@ -217,7 +216,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(`8`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`);
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(`
@@ -254,82 +253,75 @@ describe("Test Linear Elements", () => {
}); });
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!); fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor via enter (line)", () => { it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]); mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
// ctrl+enter alias (to align with arrows) // ctrl+enter alias (to align with arrows)
it("should enter line editor via ctrl+enter (line)", () => { it("should enter line editor via ctrl+enter (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]); mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
}); });
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor via ctrl+enter (arrow)", () => { it("should enter line editor via ctrl+enter (arrow)", () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]); mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
}); });
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on ctrl+dblclick (simple arrow)", () => { it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.editingLinearElement?.elementId).toBeUndefined();
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick(); mouse.doubleClick();
}); });
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on ctrl+dblclick (line)", () => { it("should enter line editor on ctrl+dblclick (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.editingLinearElement?.elementId).toBeUndefined();
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick(); mouse.doubleClick();
}); });
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on dblclick (line)", () => { it("should enter line editor on dblclick (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick(); mouse.doubleClick();
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should not enter line editor on dblclick (arrow)", async () => { it("should not enter line editor on dblclick (arrow)", async () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick(); mouse.doubleClick();
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.editingLinearElement).toEqual(null);
await getTextEditor(); await getTextEditor();
}); });
@@ -338,12 +330,10 @@ describe("Test Linear Elements", () => {
const arrow = h.elements[0] as ExcalidrawLinearElement; const arrow = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(arrow); enterLineEditingMode(arrow);
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
mouse.doubleClick(); mouse.doubleClick();
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
@@ -357,7 +347,6 @@ 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]);
@@ -378,9 +367,9 @@ 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( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(` expect(line.points).toMatchInlineSnapshot(`
@@ -480,7 +469,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint); drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -548,9 +537,9 @@ describe("Test Linear Elements", () => {
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`, `16`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@@ -599,9 +588,9 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta)); drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@@ -640,9 +629,9 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@@ -688,9 +677,9 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]); deletePoint(points[2]);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`17`, `18`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
const newMidPoints = LinearElementEditor.getEditorMidPoints( const newMidPoints = LinearElementEditor.getEditorMidPoints(
line, line,
@@ -746,9 +735,9 @@ describe("Test Linear Elements", () => {
), ),
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`, `16`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
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)
@@ -844,9 +833,9 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@@ -1304,7 +1293,7 @@ describe("Test Linear Elements", () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: -10, x: -10,
y: 250, y: 250,
width: 410, width: 400,
height: 1, height: 1,
}); });
@@ -1317,7 +1306,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).toBeCloseTo(399); expect(arrow.width).toBe(400);
expect(rect.x).toBe(400); expect(rect.x).toBe(400);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect( expect(
@@ -1336,7 +1325,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(199); expect(arrow.width).toBeCloseTo(200, 0);
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(
+127 -139
View File
@@ -2,7 +2,6 @@ import { pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { import {
type Bounds,
KEYS, KEYS,
getSizeFromPoints, getSizeFromPoints,
reseed, reseed,
@@ -23,6 +22,7 @@ import { resizeSingleElement } from "../src/resizeElements";
import { LinearElementEditor } from "../src/linearElementEditor"; import { LinearElementEditor } from "../src/linearElementEditor";
import { getElementPointsCoords } from "../src/bounds"; import { getElementPointsCoords } from "../src/bounds";
import type { Bounds } from "../src/bounds";
import type { import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
@@ -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.06); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
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.06); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
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.06); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
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.06); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
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,80 +997,68 @@ 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: { const leftArrowBinding = { ...leftBoundArrow.endBinding };
// elementId: string; const rightArrowBinding = { ...rightBoundArrow.endBinding };
// gap?: number; delete rightArrowBinding.gap;
// 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", {
@@ -1350,8 +1338,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(59.7979); expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX);
expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305); expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY);
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 -171
View File
@@ -1,14 +1,13 @@
import { getLineHeight } from "@excalidraw/common"; import { 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 } from "@excalidraw/common";
import { import {
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getContainerCoords, getContainerCoords,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getBoundTextMaxHeight, getBoundTextMaxHeight,
computeBoundTextPosition,
} from "../src/textElement"; } from "../src/textElement";
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements"; import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
@@ -208,172 +207,3 @@ describe("Test getDefaultLineHeight", () => {
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
}); });
}); });
describe("Test computeBoundTextPosition", () => {
const createMockElementsMap = () => new Map();
// Helper function to create rectangle test case with 90-degree rotation
const createRotatedRectangleTestCase = (
textAlign: string,
verticalAlign: string,
) => {
const container = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
angle: (Math.PI / 2) as any, // 90 degrees
});
const boundTextElement = API.createElement({
type: "text",
width: 80,
height: 40,
text: "hello darkness my old friend",
textAlign: textAlign as any,
verticalAlign: verticalAlign as any,
containerId: container.id,
}) as ExcalidrawTextElementWithContainer;
const elementsMap = createMockElementsMap();
return { container, boundTextElement, elementsMap };
};
describe("90-degree rotation with all alignment combinations", () => {
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.MIDDLE,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.BOTTOM,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(185, 1);
});
});
});
+3 -16
View File
@@ -4,14 +4,12 @@ import { isFrameLikeElement } from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { KEYS, arrayToMap } from "@excalidraw/common"; import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element"; import { alignElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElementsByGroup } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element"; import type { Alignment } from "@excalidraw/element";
@@ -30,8 +28,6 @@ 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";
@@ -42,11 +38,7 @@ export const alignActionsPredicate = (
) => { ) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
return ( return (
getSelectedElementsByGroup( selectedElements.length > 1 &&
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState as Readonly<AppState>,
).length > 1 &&
// TODO enable aligning frames when implemented properly // TODO enable aligning frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el)) !selectedElements.some((el) => isFrameLikeElement(el))
); );
@@ -60,12 +52,7 @@ const alignSelectedElements = (
) => { ) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
const updatedElements = alignElements( const updatedElements = alignElements(selectedElements, alignment, app.scene);
selectedElements,
alignment,
app.scene,
appState,
);
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
@@ -8,7 +8,6 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
isBoundToContainer,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -226,9 +225,7 @@ 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( const someTextElements = selectedElements.some((el) => isTextElement(el));
(el) => isTextElement(el) && !isBoundToContainer(el),
);
return selectedElements.length > 0 && someTextElements; return selectedElements.length > 0 && someTextElements;
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@@ -237,7 +234,7 @@ export const actionWrapTextInContainer = register({
const containerIds: Mutable<AppState["selectedElementIds"]> = {}; const containerIds: Mutable<AppState["selectedElementIds"]> = {};
for (const textElement of selectedElements) { for (const textElement of selectedElements) {
if (isTextElement(textElement) && !isBoundToContainer(textElement)) { if (isTextElement(textElement)) {
const container = newElement({ const container = newElement({
type: "rectangle", type: "rectangle",
backgroundColor: appState.currentItemBackgroundColor, backgroundColor: appState.currentItemBackgroundColor,
+9 -16
View File
@@ -7,6 +7,7 @@ import {
MIN_ZOOM, MIN_ZOOM,
THEME, THEME,
ZOOM_STEP, ZOOM_STEP,
getShortcutKey,
updateActiveTool, updateActiveTool,
CODES, CODES,
KEYS, KEYS,
@@ -45,13 +46,12 @@ 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<Partial<AppState>>({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
label: "labels.canvasBackground", label: "labels.canvasBackground",
trackEvent: false, trackEvent: false,
@@ -64,12 +64,12 @@ export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
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, data }) => { PanelComponent: ({ elements, appState, updateData, appProps }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx // FIXME move me to src/components/mainMenu/DefaultItems.tsx
return ( return (
<ColorPicker <ColorPicker
@@ -121,10 +121,7 @@ export const actionClearCanvas = register({
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool: activeTool:
appState.activeTool.type === "image" appState.activeTool.type === "image"
? { ? { ...appState.activeTool, type: "selection" }
...appState.activeTool,
type: app.state.preferredSelectionTool.type,
}
: appState.activeTool, : appState.activeTool,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@@ -466,7 +463,7 @@ export const actionZoomToFit = register({
!event[KEYS.CTRL_OR_CMD], !event[KEYS.CTRL_OR_CMD],
}); });
export const actionToggleTheme = register<AppState["theme"]>({ export const actionToggleTheme = register({
name: "toggleTheme", name: "toggleTheme",
label: (_, appState) => { label: (_, appState) => {
return appState.theme === THEME.DARK return appState.theme === THEME.DARK
@@ -474,8 +471,7 @@ export const actionToggleTheme = register<AppState["theme"]>({
: "buttons.darkMode"; : "buttons.darkMode";
}, },
keywords: ["toggle", "dark", "light", "mode", "theme"], keywords: ["toggle", "dark", "light", "mode", "theme"],
icon: (appState, elements) => icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_, appState, value) => { perform: (_, appState, value) => {
@@ -498,13 +494,13 @@ export const actionToggleEraserTool = register({
name: "toggleEraserTool", name: "toggleEraserTool",
label: "toolBar.eraser", label: "toolBar.eraser",
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => { perform: (elements, appState) => {
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) { if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type, type: "selection",
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
@@ -534,9 +530,6 @@ export const actionToggleLassoTool = register({
label: "toolBar.lasso", label: "toolBar.lasso",
icon: LassoIcon, icon: LassoIcon,
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
predicate: (elements, appState, props, app) => {
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<ClipboardEvent | null>({ export const actionCopy = register({
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, app) => { perform: async (elements, appState, event: ClipboardEvent | null, 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<ClipboardEvent | null>({ export const actionCut = register({
name: "cut", name: "cut",
label: "labels.cut", label: "labels.cut",
icon: cutIcon, icon: cutIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, event, app) => { perform: (elements, appState, event: ClipboardEvent | null, 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,8 +1,4 @@
import { import { KEYS, updateActiveTool } from "@excalidraw/common";
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";
@@ -30,8 +26,6 @@ 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";
@@ -211,15 +205,16 @@ export const actionDeleteSelected = register({
icon: TrashIcon, icon: TrashIcon,
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.editingLinearElement) {
const { elementId, selectedPointsIndices } = const {
appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const linearElement = LinearElementEditor.getElement(
elementId, elementId,
elementsMap, selectedPointsIndices,
); startBindingElement,
if (!linearElement) { endBindingElement,
} = appState.editingLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return false; return false;
} }
// case: no point selected → do nothing, as deleting the whole element // case: no point selected → do nothing, as deleting the whole element
@@ -230,10 +225,10 @@ export const actionDeleteSelected = register({
return false; return false;
} }
// case: deleting all points // case: deleting last remaining point
if (selectedPointsIndices.length >= linearElement.points.length) { if (element.points.length < 2) {
const nextElements = elements.map((el) => { const nextElements = elements.map((el) => {
if (el.id === linearElement.id) { if (el.id === element.id) {
return newElementWith(el, { isDeleted: true }); return newElementWith(el, { isDeleted: true });
} }
return el; return el;
@@ -244,24 +239,34 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
selectedLinearElement: null, editingLinearElement: null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
} }
LinearElementEditor.deletePoints( // We cannot do this inside `movePoint` because it is also called
linearElement, // when deleting the uncommitted point (which hasn't caused any binding)
app, const binding = {
selectedPointsIndices, startBindingElement: selectedPointsIndices?.includes(0)
); ? null
: startBindingElement,
endBindingElement: selectedPointsIndices?.includes(
element.points.length - 1,
)
? null
: endBindingElement,
};
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
return { return {
elements, elements,
appState: { appState: {
...appState, ...appState,
selectedLinearElement: { editingLinearElement: {
...appState.selectedLinearElement, ...appState.editingLinearElement,
...binding,
selectedPointsIndices: selectedPointsIndices:
selectedPointsIndices?.[0] > 0 selectedPointsIndices?.[0] > 0
? [selectedPointsIndices[0] - 1] ? [selectedPointsIndices[0] - 1]
@@ -286,11 +291,8 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
activeTool: updateActiveTool(appState, { activeTool: updateActiveTool(appState, { type: "selection" }),
type: app.state.preferredSelectionTool.type,
}),
multiElement: null, multiElement: null,
newElement: null,
activeEmbeddable: null, activeEmbeddable: null,
selectedLinearElement: null, selectedLinearElement: null,
}, },
@@ -305,25 +307,14 @@ 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, app }) => { PanelComponent: ({ elements, appState, updateData }) => (
const isMobile = useStylesPanelMode() === "mobile"; <ToolButton
type="button"
return ( icon={TrashIcon}
<ToolButton title={t("labels.delete")}
type="button" aria-label={t("labels.delete")}
icon={TrashIcon} onClick={() => updateData(null)}
title={t("labels.delete")} visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
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 } from "@excalidraw/common"; import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
@@ -10,8 +10,6 @@ import { distributeElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElementsByGroup } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Distribution } from "@excalidraw/element"; import type { Distribution } from "@excalidraw/element";
@@ -26,8 +24,6 @@ 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";
@@ -35,11 +31,7 @@ import type { AppClassProperties, AppState } from "../types";
const enableActionGroup = (appState: AppState, app: AppClassProperties) => { const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
return ( return (
getSelectedElementsByGroup( selectedElements.length > 1 &&
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState as Readonly<AppState>,
).length > 2 &&
// TODO enable distributing frames when implemented properly // TODO enable distributing frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el)) !selectedElements.some((el) => isFrameLikeElement(el))
); );
@@ -57,7 +49,6 @@ const distributeSelectedElements = (
selectedElements, selectedElements,
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
distribution, distribution,
appState,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
@@ -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,9 +25,6 @@ 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";
@@ -42,7 +39,7 @@ export const actionDuplicateSelection = register({
} }
// duplicate selected point(s) if editing a line // duplicate selected point(s) if editing a line
if (appState.selectedLinearElement?.isEditing) { if (appState.editingLinearElement) {
// TODO: Invariants should be checked here instead of duplicateSelectedPoints() // TODO: Invariants should be checked here instead of duplicateSelectedPoints()
try { try {
const newAppState = LinearElementEditor.duplicateSelectedPoints( const newAppState = LinearElementEditor.duplicateSelectedPoints(
@@ -109,27 +106,16 @@ 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, app }) => { PanelComponent: ({ elements, appState, updateData }) => (
const isMobile = useStylesPanelMode() === "mobile"; <ToolButton
type="button"
return ( icon={DuplicateIcon}
<ToolButton title={`${t("labels.duplicateSelection")}${getShortcutKey(
type="button" "CtrlOrCmd+D",
icon={DuplicateIcon} )}`}
title={`${t("labels.duplicateSelection")}${getShortcutKey( aria-label={t("labels.duplicateSelection")}
"CtrlOrCmd+D", onClick={() => updateData(null)}
)}`} 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
: {}),
}}
/>
);
},
}); });
+7 -15
View File
@@ -11,7 +11,7 @@ import { CaptureUpdateAction } from "@excalidraw/element";
import type { Theme } from "@excalidraw/element/types"; import type { Theme } from "@excalidraw/element/types";
import { useEditorInterface } from "../components/App"; import { useDevice } 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,9 +31,7 @@ import "../components/ToolIcon.scss";
import { register } from "./register"; import { register } from "./register";
import type { AppState } from "../types"; export const actionChangeProjectName = register({
export const actionChangeProjectName = register<AppState["name"]>({
name: "changeProjectName", name: "changeProjectName",
label: "labels.fileTitle", label: "labels.fileTitle",
trackEvent: false, trackEvent: false,
@@ -53,7 +51,7 @@ export const actionChangeProjectName = register<AppState["name"]>({
), ),
}); });
export const actionChangeExportScale = register<AppState["exportScale"]>({ export const actionChangeExportScale = register({
name: "changeExportScale", name: "changeExportScale",
label: "imageExportDialog.scale", label: "imageExportDialog.scale",
trackEvent: { category: "export", action: "scale" }, trackEvent: { category: "export", action: "scale" },
@@ -103,9 +101,7 @@ export const actionChangeExportScale = register<AppState["exportScale"]>({
}, },
}); });
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" },
@@ -125,9 +121,7 @@ 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" },
@@ -248,7 +242,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={useEditorInterface().formFactor === "phone"} showAriaLabel={useDevice().editor.isMobile}
hidden={!nativeFileSystemSupported} hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)} onClick={() => updateData(null)}
data-testid="save-as-button" data-testid="save-as-button"
@@ -294,9 +288,7 @@ 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" },
+95 -149
View File
@@ -1,11 +1,11 @@
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { bindOrUnbindBindingElement } from "@excalidraw/element/binding";
import { import {
isValidPolygon, maybeBindLinearElement,
LinearElementEditor, bindOrUnbindLinearElement,
newElementWith, isBindingEnabled,
} from "@excalidraw/element"; } from "@excalidraw/element/binding";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
import { import {
isBindingElement, isBindingElement,
@@ -17,8 +17,7 @@ import {
import { import {
KEYS, KEYS,
arrayToMap, arrayToMap,
invariant, tupleToCoors,
shouldRotateWithDiscreteAngle,
updateActiveTool, updateActiveTool,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element"; import { isPathALoop } from "@excalidraw/element";
@@ -27,12 +26,11 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math"; import type { 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";
@@ -44,37 +42,20 @@ import { register } from "./register";
import type { AppState } from "../types"; import type { AppState } from "../types";
type FormData = { export const actionFinalize = register({
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 (data && appState.selectedLinearElement) { if (event && 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,
@@ -82,105 +63,74 @@ export const actionFinalize = register<FormData>({
app.scene, app.scene,
); );
const { startBindingElement, endBindingElement } = linearElementEditor;
const element = app.scene.getElement(linearElementEditor.elementId);
if (isBindingElement(element)) { if (isBindingElement(element)) {
const newArrow = !!appState.newElement; bindOrUnbindLinearElement(
const selectedPointsIndices =
newArrow || !appState.selectedLinearElement.selectedPointsIndices
? [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, element,
draggedPoints, startBindingElement,
sceneCoords.x, endBindingElement,
sceneCoords.y, app.scene,
scene,
appState,
{
newArrow,
altKey: event.altKey,
angleLocked: shouldRotateWithDiscreteAngle(event),
},
); );
} 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) {
// `handlePointerUp()` updated the linear element instance, let newElements = elements;
// 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.filter((el) => el.id !== element!.id);
if (el.id === element.id) {
return newElementWith(el, {
isDeleted: true,
});
}
return el;
});
} }
const activeToolLocked = appState.activeTool?.locked;
return { return {
elements: elements: newElements,
element.points.length < 2 || isInvisiblySmallElement(element)
? elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
})
: newElements,
appState: { appState: {
...appState, selectedLinearElement: {
cursorButton: "up", ...linearElementEditor,
selectedLinearElement: activeToolLocked selectedPointsIndices: null,
? null },
: { suggestedBindings: [],
...linearElementEditor,
selectedPointsIndices: null,
isEditing: false,
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
},
selectionElement: null,
suggestedBinding: null,
newElement: null,
multiElement: null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
} }
} }
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
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 {
elements:
element.points.length < 2 || isInvisiblySmallElement(element)
? elements.filter((el) => el.id !== element.id)
: undefined,
appState: {
...appState,
cursorButton: "up",
editingLinearElement: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
}
let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
focusContainer(); focusContainer();
} }
@@ -205,13 +155,11 @@ export const actionFinalize = register<FormData>({
if (element) { if (element) {
// pen and mouse have hover // pen and mouse have hover
if ( if (
appState.selectedLinearElement &&
appState.multiElement && appState.multiElement &&
element.type !== "freedraw" && element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch" appState.lastPointerDownWith !== "touch"
) { ) {
const { points } = element; const { points, lastCommittedPoint } = element;
const { lastCommittedPoint } = appState.selectedLinearElement;
if ( if (
!lastCommittedPoint || !lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint points[points.length - 1] !== lastCommittedPoint
@@ -224,12 +172,7 @@ export const actionFinalize = register<FormData>({
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.filter((el) => el.id !== element!.id);
if (el.id === element?.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
} }
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
@@ -263,6 +206,25 @@ export const actionFinalize = register<FormData>({
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);
}
} }
} }
@@ -278,35 +240,16 @@ export const actionFinalize = register<FormData>({
if (appState.activeTool.type === "eraser") { if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type, type: "selection",
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
} else { } else {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type, type: "selection",
}); });
} }
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: {
@@ -324,7 +267,7 @@ export const actionFinalize = register<FormData>({
multiElement: null, multiElement: null,
editingTextElement: null, editingTextElement: null,
startBoundElement: null, startBoundElement: null,
suggestedBinding: null, suggestedBindings: [],
selectedElementIds: selectedElementIds:
element && element &&
!appState.activeTool.locked && !appState.activeTool.locked &&
@@ -334,8 +277,11 @@ export const actionFinalize = register<FormData>({
[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,
@@ -343,7 +289,7 @@ export const actionFinalize = register<FormData>({
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && (event.key === KEYS.ESCAPE &&
(appState.selectedLinearElement?.isEditing || (appState.editingLinearElement !== null ||
(!appState.newElement && appState.multiElement === null))) || (!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
+14 -12
View File
@@ -38,13 +38,15 @@ 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",
@@ -72,11 +74,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(101, 0); expect(rec1.y).toBeCloseTo(100, 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(251, 0); expect(rec2.y).toBeCloseTo(250, 0);
}); });
}); });
@@ -97,8 +99,8 @@ describe("flipping arrowheads", () => {
endArrowhead: null, endArrowhead: null,
endBinding: { endBinding: {
elementId: rect.id, elementId: rect.id,
fixedPoint: [0.5, 0.5], focus: 0.5,
mode: "orbit", gap: 5,
}, },
}); });
@@ -137,13 +139,13 @@ describe("flipping arrowheads", () => {
endArrowhead: "circle", endArrowhead: "circle",
startBinding: { startBinding: {
elementId: rect.id, elementId: rect.id,
fixedPoint: [0.5, 0.5], focus: 0.5,
mode: "orbit", gap: 5,
}, },
endBinding: { endBinding: {
elementId: rect2.id, elementId: rect2.id,
fixedPoint: [0.5, 0.5], focus: 0.5,
mode: "orbit", gap: 5,
}, },
}); });
@@ -193,8 +195,8 @@ describe("flipping arrowheads", () => {
endArrowhead: null, endArrowhead: null,
endBinding: { endBinding: {
elementId: rect.id, elementId: rect.id,
fixedPoint: [0.5, 0.5], focus: 0.5,
mode: "orbit", gap: 5,
}, },
}); });
+16 -5
View File
@@ -1,10 +1,17 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { bindOrUnbindBindingElements } from "@excalidraw/element"; import {
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 { isArrowElement, isElbowArrow } from "@excalidraw/element"; import {
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";
@@ -96,6 +103,7 @@ const flipSelectedElements = (
const updatedElements = flipElements( const updatedElements = flipElements(
selectedElements, selectedElements,
elementsMap, elementsMap,
appState,
flipDirection, flipDirection,
app, app,
); );
@@ -110,6 +118,7 @@ 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[] => {
@@ -149,10 +158,12 @@ const flipElements = (
}, },
); );
bindOrUnbindBindingElements( bindOrUnbindLinearElements(
selectedElements.filter(isArrowElement), selectedElements.filter(isLinearElement),
isBindingEnabled(appState),
[],
app.scene, app.scene,
app.state, appState.zoom,
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1 -3
View File
@@ -14,7 +14,7 @@ import {
replaceAllElementsInFrame, replaceAllElementsInFrame,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, randomId, arrayToMap } from "@excalidraw/common"; import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { import {
getSelectedGroupIds, getSelectedGroupIds,
@@ -43,8 +43,6 @@ 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";
+3 -19
View File
@@ -1,10 +1,4 @@
import { import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
isWindows,
KEYS,
matchKey,
arrayToMap,
MOBILE_ACTION_BUTTON_BG,
} from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
@@ -18,8 +12,6 @@ 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";
@@ -75,7 +67,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: ({ appState, updateData, data, app }) => { PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>( const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -83,7 +75,6 @@ export const createUndoAction: ActionCreator = (history) => ({
history.isRedoStackEmpty, history.isRedoStackEmpty,
), ),
); );
const isMobile = useStylesPanelMode() === "mobile";
return ( return (
<ToolButton <ToolButton
@@ -94,9 +85,6 @@ 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 : {}),
}}
/> />
); );
}, },
@@ -115,7 +103,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: ({ appState, updateData, data, app }) => { PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -123,7 +111,6 @@ export const createRedoAction: ActionCreator = (history) => ({
history.isRedoStackEmpty, history.isRedoStackEmpty,
), ),
); );
const isMobile = useStylesPanelMode() === "mobile";
return ( return (
<ToolButton <ToolButton
@@ -134,9 +121,6 @@ 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 : {}),
}}
/> />
); );
}, },
@@ -1,9 +1,10 @@
import { LinearElementEditor } from "@excalidraw/element";
import { import {
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { arrayToMap, invariant } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { import {
toggleLinePolygonState, toggleLinePolygonState,
@@ -45,7 +46,7 @@ export const actionToggleLinearEditor = register({
predicate: (elements, appState, _, app) => { predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
if ( if (
!appState.selectedLinearElement?.isEditing && !appState.editingLinearElement &&
selectedElements.length === 1 && selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) && isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0]) !isElbowArrow(selectedElements[0])
@@ -60,25 +61,14 @@ export const actionToggleLinearEditor = register({
includeBoundTextElement: true, includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement; })[0] as ExcalidrawLinearElement;
invariant(selectedElement, "No selected element found"); const editingLinearElement =
invariant( appState.editingLinearElement?.elementId === selectedElement.id
appState.selectedLinearElement, ? null
"No selected linear element found", : new LinearElementEditor(selectedElement, arrayToMap(elements));
);
invariant(
selectedElement.id === appState.selectedLinearElement.elementId,
"Selected element ID and linear editor elementId does not match",
);
const selectedLinearElement = {
...appState.selectedLinearElement,
isEditing: !appState.selectedLinearElement.isEditing,
};
return { return {
appState: { appState: {
...appState, ...appState,
selectedLinearElement, editingLinearElement,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -88,10 +78,6 @@ 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"
+2 -2
View File
@@ -1,6 +1,6 @@
import { isEmbeddableElement } from "@excalidraw/element"; import { isEmbeddableElement } from "@excalidraw/element";
import { KEYS } from "@excalidraw/common"; import { KEYS, getShortcutKey } 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";
+55 -3
View File
@@ -1,11 +1,65 @@
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 { HelpIconThin } from "../components/icons"; import { ToolButton } from "../components/ToolButton";
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",
@@ -25,8 +79,6 @@ export const actionShortcuts = register({
: { : {
name: "help", name: "help",
}, },
openMenu: null,
openPopup: null,
}, },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
@@ -2,8 +2,6 @@ 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 {
@@ -18,17 +16,12 @@ 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<Collaborator>({ export const actionGoToCollaborator = register({
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) => { perform: (_elements, appState, collaborator: 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 ||
+174 -335
View File
@@ -1,5 +1,4 @@
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
@@ -18,17 +17,16 @@ import {
randomInteger, randomInteger,
arrayToMap, arrayToMap,
getFontFamilyString, getFontFamilyString,
getShortcutKey,
getLineHeight, getLineHeight,
isTransparent, isTransparent,
reduceToCommonValue, reduceToCommonValue,
invariant,
FONT_SIZES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
import { import {
bindBindingElement, bindLinearElement,
calculateFixedPointForElbowArrowBinding, calculateFixedPointForElbowArrowBinding,
updateBoundElements, updateBoundElements,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -60,9 +58,7 @@ import {
toggleLinePolygonState, toggleLinePolygonState,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { deriveStylesPanelMode } from "@excalidraw/common"; import type { LocalPoint } from "@excalidraw/math";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type { import type {
Arrowhead, Arrowhead,
@@ -85,6 +81,9 @@ import { RadioSelection } from "../components/RadioSelection";
import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { FontPicker } from "../components/FontPicker/FontPicker"; import { FontPicker } from "../components/FontPicker/FontPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons
import { Range } from "../components/Range"; import { Range } from "../components/Range";
import { import {
ArrowheadArrowIcon, ArrowheadArrowIcon,
@@ -138,28 +137,12 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import {
withCaretPositionPreservation,
restoreCaretPosition,
} from "../hooks/useTextEditorFocus";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState, Primitive } from "../types"; import type { AppClassProperties, AppState, Primitive } from "../types";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
const getStylesPanelInfo = (app: AppClassProperties) => {
const stylesPanelMode = deriveStylesPanelMode(app.editorInterface);
return {
stylesPanelMode,
isCompact: stylesPanelMode !== "full",
isMobile: stylesPanelMode === "mobile",
} as const;
};
export const changeProperty = ( export const changeProperty = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
@@ -309,15 +292,13 @@ const changeFontSize = (
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const actionChangeStrokeColor = register< export const actionChangeStrokeColor = register({
Pick<AppState, "currentItemStrokeColor">
>({
name: "changeStrokeColor", name: "changeStrokeColor",
label: "labels.stroke", label: "labels.stroke",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value?.currentItemStrokeColor && { ...(value.currentItemStrokeColor && {
elements: changeProperty( elements: changeProperty(
elements, elements,
appState, appState,
@@ -335,50 +316,42 @@ export const actionChangeStrokeColor = register<
...appState, ...appState,
...value, ...value,
}, },
captureUpdate: !!value?.currentItemStrokeColor captureUpdate: !!value.currentItemStrokeColor
? CaptureUpdateAction.IMMEDIATELY ? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app }) => (
const { stylesPanelMode } = getStylesPanelInfo(app); <>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
return ( <ColorPicker
<> topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
{stylesPanelMode === "full" && ( palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
<h3 aria-hidden="true">{t("labels.stroke")}</h3> type="elementStroke"
label={t("labels.stroke")}
color={getFormValue(
elements,
app,
(element) => element.strokeColor,
true,
(hasSelection) =>
!hasSelection ? appState.currentItemStrokeColor : null,
)} )}
<ColorPicker onChange={(color) => updateData({ currentItemStrokeColor: color })}
topPicks={DEFAULT_ELEMENT_STROKE_PICKS} elements={elements}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE} appState={appState}
type="elementStroke" updateData={updateData}
label={t("labels.stroke")} />
color={getFormValue( </>
elements, ),
app,
(element) => element.strokeColor,
true,
(hasSelection) =>
!hasSelection ? appState.currentItemStrokeColor : null,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
elements={elements}
appState={appState}
updateData={updateData}
/>
</>
);
},
}); });
export const actionChangeBackgroundColor = register< export const actionChangeBackgroundColor = register({
Pick<AppState, "currentItemBackgroundColor" | "viewBackgroundColor">
>({
name: "changeBackgroundColor", name: "changeBackgroundColor",
label: "labels.changeBackground", label: "labels.changeBackground",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
if (!value?.currentItemBackgroundColor) { if (!value.currentItemBackgroundColor) {
return { return {
appState: { appState: {
...appState, ...appState,
@@ -425,40 +398,32 @@ export const actionChangeBackgroundColor = register<
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app }) => (
const { stylesPanelMode } = getStylesPanelInfo(app); <>
<h3 aria-hidden="true">{t("labels.background")}</h3>
return ( <ColorPicker
<> topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
{stylesPanelMode === "full" && ( palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
<h3 aria-hidden="true">{t("labels.background")}</h3> type="elementBackground"
label={t("labels.background")}
color={getFormValue(
elements,
app,
(element) => element.backgroundColor,
true,
(hasSelection) =>
!hasSelection ? appState.currentItemBackgroundColor : null,
)} )}
<ColorPicker onChange={(color) => updateData({ currentItemBackgroundColor: color })}
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS} elements={elements}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE} appState={appState}
type="elementBackground" updateData={updateData}
label={t("labels.background")} />
color={getFormValue( </>
elements, ),
app,
(element) => element.backgroundColor,
true,
(hasSelection) =>
!hasSelection ? appState.currentItemBackgroundColor : null,
)}
onChange={(color) =>
updateData({ currentItemBackgroundColor: color })
}
elements={elements}
appState={appState}
updateData={updateData}
/>
</>
);
},
}); });
export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({ export const actionChangeFillStyle = register({
name: "changeFillStyle", name: "changeFillStyle",
label: "labels.fill", label: "labels.fill",
trackEvent: false, trackEvent: false,
@@ -466,9 +431,7 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
trackEvent( trackEvent(
"element", "element",
"changeFillStyle", "changeFillStyle",
`${value} (${ `${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
app.editorInterface.formFactor === "phone" ? "mobile" : "desktop"
})`,
); );
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -540,9 +503,7 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
}, },
}); });
export const actionChangeStrokeWidth = register< export const actionChangeStrokeWidth = register({
ExcalidrawElement["strokeWidth"]
>({
name: "changeStrokeWidth", name: "changeStrokeWidth",
label: "labels.strokeWidth", label: "labels.strokeWidth",
trackEvent: false, trackEvent: false,
@@ -557,7 +518,7 @@ export const actionChangeStrokeWidth = register<
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeWidth")}</legend> <legend>{t("labels.strokeWidth")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -598,7 +559,7 @@ export const actionChangeStrokeWidth = register<
), ),
}); });
export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({ export const actionChangeSloppiness = register({
name: "changeSloppiness", name: "changeSloppiness",
label: "labels.sloppiness", label: "labels.sloppiness",
trackEvent: false, trackEvent: false,
@@ -614,7 +575,7 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.sloppiness")}</legend> <legend>{t("labels.sloppiness")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -652,9 +613,7 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
), ),
}); });
export const actionChangeStrokeStyle = register< export const actionChangeStrokeStyle = register({
ExcalidrawElement["strokeStyle"]
>({
name: "changeStrokeStyle", name: "changeStrokeStyle",
label: "labels.strokeStyle", label: "labels.strokeStyle",
trackEvent: false, trackEvent: false,
@@ -669,7 +628,7 @@ export const actionChangeStrokeStyle = register<
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeStyle")}</legend> <legend>{t("labels.strokeStyle")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -707,7 +666,7 @@ export const actionChangeStrokeStyle = register<
), ),
}); });
export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({ export const actionChangeOpacity = register({
name: "changeOpacity", name: "changeOpacity",
label: "labels.opacity", label: "labels.opacity",
trackEvent: false, trackEvent: false,
@@ -731,100 +690,78 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
), ),
}); });
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>( export const actionChangeFontSize = register({
{ name: "changeFontSize",
name: "changeFontSize", label: "labels.fontSize",
label: "labels.fontSize", trackEvent: false,
trackEvent: false, perform: (elements, appState, value, app) => {
perform: (elements, appState, value, app) => { return changeFontSize(elements, appState, app, () => value, value);
return changeFontSize(
elements,
appState,
app,
() => {
invariant(value, "actionChangeFontSize: Expected a font size value");
return value;
},
value,
);
},
PanelComponent: ({ elements, appState, updateData, app, data }) => {
const { isCompact } = getStylesPanelInfo(app);
return (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<div className="buttonList">
<RadioSelection
group="font-size"
options={[
{
value: FONT_SIZES.sm,
text: t("labels.small"),
icon: FontSizeSmallIcon,
testId: "fontSize-small",
},
{
value: FONT_SIZES.md,
text: t("labels.medium"),
icon: FontSizeMediumIcon,
testId: "fontSize-medium",
},
{
value: FONT_SIZES.lg,
text: t("labels.large"),
icon: FontSizeLargeIcon,
testId: "fontSize-large",
},
{
value: FONT_SIZES.xl,
text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
isCompact,
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/>
</div>
</fieldset>
);
},
}, },
); PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<div className="buttonList">
<RadioSelection
group="font-size"
options={[
{
value: 16,
text: t("labels.small"),
icon: FontSizeSmallIcon,
testId: "fontSize-small",
},
{
value: 20,
text: t("labels.medium"),
icon: FontSizeMediumIcon,
testId: "fontSize-medium",
},
{
value: 28,
text: t("labels.large"),
icon: FontSizeLargeIcon,
testId: "fontSize-large",
},
{
value: 36,
text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
});
export const actionDecreaseFontSize = register({ export const actionDecreaseFontSize = register({
name: "decreaseFontSize", name: "decreaseFontSize",
@@ -884,10 +821,7 @@ type ChangeFontFamilyData = Partial<
resetContainers?: true; resetContainers?: true;
}; };
export const actionChangeFontFamily = register<{ export const actionChangeFontFamily = register({
currentItemFontFamily: any;
currentHoveredFontFamily: any;
}>({
name: "changeFontFamily", name: "changeFontFamily",
label: "labels.fontFamily", label: "labels.fontFamily",
trackEvent: false, trackEvent: false,
@@ -924,8 +858,6 @@ export const actionChangeFontFamily = register<{
}; };
} }
invariant(value, "actionChangeFontFamily: value must be defined");
const { currentItemFontFamily, currentHoveredFontFamily } = value; const { currentItemFontFamily, currentHoveredFontFamily } = value;
let nextCaptureUpdateAction: CaptureUpdateActionType = let nextCaptureUpdateAction: CaptureUpdateActionType =
@@ -1090,7 +1022,6 @@ export const actionChangeFontFamily = register<{
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({}); const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
const isUnmounted = useRef(true); const isUnmounted = useRef(true);
const { stylesPanelMode, isCompact } = getStylesPanelInfo(app);
const selectedFontFamily = useMemo(() => { const selectedFontFamily = useMemo(() => {
const getFontFamily = ( const getFontFamily = (
@@ -1162,29 +1093,21 @@ export const actionChangeFontFamily = register<{
}, []); }, []);
return ( return (
<> <fieldset>
{stylesPanelMode === "full" && ( <legend>{t("labels.fontFamily")}</legend>
<legend>{t("labels.fontFamily")}</legend>
)}
<FontPicker <FontPicker
isOpened={appState.openPopup === "fontFamily"} isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily} hoveredFontFamily={appState.currentHoveredFontFamily}
compactMode={stylesPanelMode !== "full"}
onSelect={(fontFamily) => { onSelect={(fontFamily) => {
withCaretPositionPreservation( setBatchedData({
() => { openPopup: null,
setBatchedData({ currentHoveredFontFamily: null,
openPopup: null, currentItemFontFamily: fontFamily,
currentHoveredFontFamily: null, });
currentItemFontFamily: fontFamily,
}); // defensive clear so immediate close won't abuse the cached elements
// defensive clear so immediate close won't abuse the cached elements cachedElementsRef.current.clear();
cachedElementsRef.current.clear();
},
isCompact,
!!appState.editingTextElement,
);
}} }}
onHover={(fontFamily) => { onHover={(fontFamily) => {
setBatchedData({ setBatchedData({
@@ -1241,34 +1164,34 @@ export const actionChangeFontFamily = register<{
} }
setBatchedData({ setBatchedData({
...batchedData,
openPopup: "fontFamily", openPopup: "fontFamily",
}); });
} else { } else {
const fontFamilyData = { // close, use the cache and clear it afterwards
const data = {
openPopup: null,
currentHoveredFontFamily: null, currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current), cachedElements: new Map(cachedElementsRef.current),
resetAll: true, resetAll: true,
} as ChangeFontFamilyData; } as ChangeFontFamilyData;
setBatchedData({ if (isUnmounted.current) {
...fontFamilyData, // in case the component was unmounted by the parent, trigger the update directly
}); updateData({ ...batchedData, ...data });
cachedElementsRef.current.clear(); } else {
setBatchedData(data);
// Refocus text editor when font picker closes if we were editing text
if (isCompact && appState.editingTextElement) {
restoreCaretPosition(null); // Just refocus without saved position
} }
cachedElementsRef.current.clear();
} }
}} }}
/> />
</> </fieldset>
); );
}, },
}); });
export const actionChangeTextAlign = register<TextAlign>({ export const actionChangeTextAlign = register({
name: "changeTextAlign", name: "changeTextAlign",
label: "Change text alignment", label: "Change text alignment",
trackEvent: false, trackEvent: false,
@@ -1302,10 +1225,8 @@ export const actionChangeTextAlign = register<TextAlign>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const { isCompact } = getStylesPanelInfo(app);
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.textAlign")}</legend> <legend>{t("labels.textAlign")}</legend>
@@ -1354,14 +1275,7 @@ export const actionChangeTextAlign = register<TextAlign>({
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign, hasSelection ? null : appState.currentItemTextAlign,
)} )}
onChange={(value) => { onChange={(value) => updateData(value)}
withCaretPositionPreservation(
() => updateData(value),
isCompact,
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1369,7 +1283,7 @@ export const actionChangeTextAlign = register<TextAlign>({
}, },
}); });
export const actionChangeVerticalAlign = register<VerticalAlign>({ export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign", name: "changeVerticalAlign",
label: "Change vertical alignment", label: "Change vertical alignment",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
@@ -1403,8 +1317,7 @@ export const actionChangeVerticalAlign = register<VerticalAlign>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const { isCompact } = getStylesPanelInfo(app);
return ( return (
<fieldset> <fieldset>
<div className="buttonList"> <div className="buttonList">
@@ -1454,14 +1367,7 @@ export const actionChangeVerticalAlign = register<VerticalAlign>({
) !== null, ) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)} )}
onChange={(value) => { onChange={(value) => updateData(value)}
withCaretPositionPreservation(
() => updateData(value),
isCompact,
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1469,7 +1375,7 @@ export const actionChangeVerticalAlign = register<VerticalAlign>({
}, },
}); });
export const actionChangeRoundness = register<"sharp" | "round">({ export const actionChangeRoundness = register({
name: "changeRoundness", name: "changeRoundness",
label: "Change edge roundness", label: "Change edge roundness",
trackEvent: false, trackEvent: false,
@@ -1626,16 +1532,15 @@ const getArrowheadOptions = (flip: boolean) => {
] as const; ] as const;
}; };
export const actionChangeArrowhead = register<{ export const actionChangeArrowhead = register({
position: "start" | "end";
type: Arrowhead;
}>({
name: "changeArrowhead", name: "changeArrowhead",
label: "Change arrowheads", label: "Change arrowheads",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (
invariant(value, "actionChangeArrowhead: value must be defined"); elements,
appState,
value: { position: "start" | "end"; type: Arrowhead },
) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) => {
if (isLinearElement(el)) { if (isLinearElement(el)) {
@@ -1711,26 +1616,7 @@ export const actionChangeArrowhead = register<{
}, },
}); });
export const actionChangeArrowProperties = register({ export const actionChangeArrowType = register({
name: "changeArrowProperties",
label: "Change arrow properties",
trackEvent: false,
perform: (elements, appState, value, app) => {
// This action doesn't perform any changes directly
// It's just a container for the arrow type and arrowhead actions
return false;
},
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
return (
<div className="selected-shape-actions">
{renderAction("changeArrowhead")}
{renderAction("changeArrowType")}
</div>
);
},
});
export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
name: "changeArrowType", name: "changeArrowType",
label: "Change arrow types", label: "Change arrow types",
trackEvent: false, trackEvent: false,
@@ -1739,20 +1625,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
if (!isArrowElement(el)) { if (!isArrowElement(el)) {
return el; return el;
} }
const elementsMap = app.scene.getNonDeletedElementsMap();
const startPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
el,
0,
elementsMap,
);
const endPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
el,
-1,
elementsMap,
);
let newElement = newElementWith(el, { let newElement = newElementWith(el, {
x: value === ARROW_TYPE.elbow ? startPoint[0] : el.x,
y: value === ARROW_TYPE.elbow ? startPoint[1] : el.y,
roundness: roundness:
value === ARROW_TYPE.round value === ARROW_TYPE.round
? { ? {
@@ -1760,31 +1633,9 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
} }
: null, : null,
elbowed: value === ARROW_TYPE.elbow, elbowed: value === ARROW_TYPE.elbow,
angle: value === ARROW_TYPE.elbow ? (0 as Radians) : el.angle,
points: points:
value === ARROW_TYPE.elbow || el.elbowed value === ARROW_TYPE.elbow || el.elbowed
? [ ? [el.points[0], el.points[el.points.length - 1]]
LinearElementEditor.pointFromAbsoluteCoords(
{
...el,
x: startPoint[0],
y: startPoint[1],
angle: 0 as Radians,
},
startPoint,
elementsMap,
),
LinearElementEditor.pointFromAbsoluteCoords(
{
...el,
x: startPoint[0],
y: startPoint[1],
angle: 0 as Radians,
},
endPoint,
elementsMap,
),
]
: el.points, : el.points,
}); });
@@ -1866,13 +1717,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
newElement.startBinding.elementId, newElement.startBinding.elementId,
) as ExcalidrawBindableElement; ) as ExcalidrawBindableElement;
if (startElement) { if (startElement) {
bindBindingElement( bindLinearElement(newElement, startElement, "start", app.scene);
newElement,
startElement,
appState.bindMode === "inside" ? "inside" : "orbit",
"start",
app.scene,
);
} }
} }
if (newElement.endBinding) { if (newElement.endBinding) {
@@ -1880,13 +1725,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
newElement.endBinding.elementId, newElement.endBinding.elementId,
) as ExcalidrawBindableElement; ) as ExcalidrawBindableElement;
if (endElement) { if (endElement) {
bindBindingElement( bindLinearElement(newElement, endElement, "end", app.scene);
newElement,
endElement,
appState.bindMode === "inside" ? "inside" : "orbit",
"end",
app.scene,
);
} }
} }
} }
@@ -21,7 +21,7 @@ export const actionSelectAll = register({
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
viewMode: false, viewMode: false,
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
if (appState.selectedLinearElement?.isEditing) { if (appState.editingLinearElement) {
return false; return false;
} }
@@ -25,11 +25,8 @@ export const actionToggleZenMode = register({
}; };
}, },
checked: (appState) => appState.zenModeEnabled, checked: (appState) => appState.zenModeEnabled,
predicate: (elements, appState, appProps, app) => { predicate: (elements, appState, appProps) => {
return ( return typeof appProps.zenModeEnabled === "undefined";
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 -2
View File
@@ -1,4 +1,4 @@
import { KEYS, CODES, isDarwin } from "@excalidraw/common"; import { KEYS, CODES, getShortcutKey, isDarwin } from "@excalidraw/common";
import { import {
moveOneLeft, moveOneLeft,
@@ -16,7 +16,6 @@ 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";
+5 -2
View File
@@ -18,7 +18,6 @@ export {
actionChangeFontFamily, actionChangeFontFamily,
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign, actionChangeVerticalAlign,
actionChangeArrowProperties,
} from "./actionProperties"; } from "./actionProperties";
export { export {
@@ -44,7 +43,11 @@ export {
} from "./actionExport"; } from "./actionExport";
export { actionCopyStyles, actionPasteStyles } from "./actionStyles"; export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
export { actionShortcuts } from "./actionMenu"; export {
actionToggleCanvasMenu,
actionToggleEditMenu,
actionShortcuts,
} from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup"; export { actionGroup, actionUngroup } from "./actionGroup";

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