Compare commits

..

36 Commits

Author SHA1 Message Date
Mark Tolmacs 385416d231 fix: Lint
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-10-29 18:30:27 +01:00
Mark Tolmacs 4d04e3e9a1 chore: Additional master merge
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-10-27 16:25:31 +01:00
Mark Tolmacs e08ebdec28 Merge branch 'master' into ryan-di/freedraw-width
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-10-27 16:19:33 +01:00
Ryan Di 86605829c6 demo: a temp freehand solution to replace laser 2025-07-21 21:25:02 +10:00
dwelle c398af6c92 DISABLE DEBUG 2025-07-15 15:37:18 +02:00
dwelle 973f2a464d tweak icons 2025-07-15 13:09:37 +02:00
dwelle 02cef5ea92 Merge branch 'master' into ryan-di/freedraw-width
# Conflicts:
#	packages/excalidraw/package.json
2025-07-15 13:06:50 +02:00
dwelle d615c2cea1 rename drawingConfigs to freedrawOptions 2025-07-14 13:15:31 +02:00
dwelle 446f871536 Revert "differentiate between constant/variable stroke type"
This reverts commit 0199c82e98.
2025-07-08 23:44:54 +02:00
dwelle 34bff557e3 tweak icons 2025-07-08 23:42:42 +02:00
dwelle a0e54e3768 tweak fixed freedraw stroke width 2025-07-08 23:42:35 +02:00
dwelle d6ec1dc7e6 support extraBold for all element types 2025-07-08 23:42:08 +02:00
dwelle 62e20aa247 improve debug 2025-06-27 15:43:13 +02:00
dwelle 0199c82e98 differentiate between constant/variable stroke type 2025-06-27 14:18:48 +02:00
dwelle 3c07ff358a differentiate freedraw config based on input type 2025-06-27 14:07:12 +02:00
dwelle d9c85ff18f bump extraBold width to 8 2025-06-27 13:56:47 +02:00
dwelle 6d84fa21c5 chore: bump @excalidraw/laser-pointer@1.3.2 2025-06-27 13:39:47 +02:00
Ryan Di 5666fd8199 update snap 2025-06-27 20:51:50 +10:00
Ryan Di abdacf8239 code cleanup 2025-06-27 20:36:37 +10:00
Ryan Di 1068153b25 merge 2025-06-27 20:26:27 +10:00
Ryan Di 09876aba6d change to fixedStrokeWidth 2025-06-27 20:19:32 +10:00
dwelle 8ceb55dd02 Revert "remove debug and provide value for stylus"
This reverts commit c72c47f0cd.

# Conflicts:
#	packages/element/src/freedraw.ts
#	packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
#	packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
2025-06-26 22:21:47 +02:00
Ryan Di b1f3cc50ee tweak stroke widths 2025-06-16 22:16:28 +10:00
Ryan Di c72c47f0cd remove debug and provide value for stylus 2025-06-16 17:19:55 +10:00
Ryan Di 37b75263f8 put streamline & simplify into ele obj too 2025-06-13 18:12:56 +10:00
Ryan Di c08840358b fix: funky shape corners for freedraw 2025-06-11 18:05:46 +10:00
Ryan Di e99baaa6bb fix simulate pressure 2025-06-09 21:08:57 +10:00
Ryan Di a8857f2849 debug sliders 2025-06-09 17:53:14 +10:00
Ryan Di df1f9281b4 change slider to radio 2025-06-06 00:31:35 +10:00
Ryan Di c210b7b092 improve params and real pressure 2025-06-05 23:00:40 +10:00
Ryan Di 660d21fe46 improve freedraw rendering 2025-06-05 16:53:22 +10:00
Ryan Di c7780cb9cb snapshots 2025-06-02 17:33:44 +10:00
Ryan Di 4e265629c3 tweak stroke rendering 2025-06-02 17:00:19 +10:00
Ryan Di 1c611d6c4f add stroke sensivity action 2025-06-02 16:44:30 +10:00
Ryan Di ab6af41d33 add current item stroke sensivity 2025-06-02 16:43:12 +10:00
Ryan Di 15dfe0cc7c add stroke/pressure sensitivity to freedraw 2025-06-02 16:39:42 +10:00
246 changed files with 11143 additions and 23164 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
@@ -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
+3 -18
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";
@@ -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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -669,8 +663,8 @@ const ExcalidrawWrapper = () => {
debugRenderer( debugRenderer(
debugCanvasRef.current, debugCanvasRef.current,
appState, appState,
elements,
window.devicePixelRatio, window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
); );
} }
}; };
@@ -854,22 +848,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>
); );
@@ -955,8 +941,6 @@ const ExcalidrawWrapper = () => {
}} }}
/> />
<AppSidebar />
{errorMessage && ( {errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}> <ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage} {errorMessage}
@@ -1159,6 +1143,7 @@ const ExcalidrawWrapper = () => {
ref={debugCanvasRef} ref={debugCanvasRef}
/> />
)} )}
{/* <FreedrawDebugSliders /> */}
</Excalidraw> </Excalidraw>
</div> </div>
); );
+3 -6
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>> => {
@@ -562,7 +562,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 +746,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>
);
};
+6 -189
View File
@@ -8,15 +8,9 @@ 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 } from "react";
import {
getGlobalFixedPointForBindableElement,
isArrowElement,
isBindableElement,
} from "@excalidraw/element";
import { import {
isLineSegment, isLineSegment,
type GlobalPoint, type GlobalPoint,
@@ -27,14 +21,8 @@ import { isCurve } from "@excalidraw/math/curve";
import React from "react"; import React from "react";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/common";
import type { 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 +75,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,8 +107,8 @@ const render = (
const _debugRenderer = ( const _debugRenderer = (
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
appState: AppState, appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number, scale: number,
refresh: () => void,
) => { ) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas, canvas,
@@ -313,7 +131,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 +182,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 },
); );
@@ -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>
);
};
@@ -0,0 +1,150 @@
import { STROKE_OPTIONS, isFreeDrawElement } from "@excalidraw/element";
import { useState, useEffect } from "react";
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
import { useExcalidrawElements } from "@excalidraw/excalidraw/components/App";
import { round } from "../../packages/math/src";
export const FreedrawDebugSliders = () => {
const [streamline, setStreamline] = useState<number>(
STROKE_OPTIONS.default.streamline,
);
const [simplify, setSimplify] = useState<number>(
STROKE_OPTIONS.default.simplify,
);
useEffect(() => {
if (!window.h) {
window.h = {} as any;
}
if (!window.h.debugFreedraw) {
window.h.debugFreedraw = {
enabled: true,
...STROKE_OPTIONS.default,
};
}
setStreamline(window.h.debugFreedraw.streamline);
setSimplify(window.h.debugFreedraw.simplify);
}, []);
const handleStreamlineChange = (value: number) => {
setStreamline(value);
if (window.h && window.h.debugFreedraw) {
window.h.debugFreedraw.streamline = value;
}
};
const handleSimplifyChange = (value: number) => {
setSimplify(value);
if (window.h && window.h.debugFreedraw) {
window.h.debugFreedraw.simplify = value;
}
};
const [enabled, setEnabled] = useState<boolean>(
window.h?.debugFreedraw?.enabled ?? true,
);
// counter incrasing each 50ms
const [, setCounter] = useState<number>(0);
useEffect(() => {
const interval = setInterval(() => {
setCounter((prev) => prev + 1);
}, 50);
return () => clearInterval(interval);
}, []);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const elements = useExcalidrawElements();
const appState = useUIAppState();
const newFreedrawElement =
appState.newElement && isFreeDrawElement(appState.newElement)
? appState.newElement
: null;
return (
<div
style={{
position: "absolute",
bottom: "70px",
left: "50%",
transform: "translateX(-50%)",
zIndex: 9999,
padding: "10px",
borderRadius: "8px",
border: "1px solid #ccc",
display: "flex",
flexDirection: "column",
gap: "8px",
fontSize: "12px",
fontFamily: "monospace",
}}
>
{newFreedrawElement && (
<div>
pressures:{" "}
{newFreedrawElement.simulatePressure
? "simulated"
: JSON.stringify(
newFreedrawElement.pressures
.slice(-4)
.map((x) => round(x, 2))
.join(" ") || [],
)}{" "}
({round(window.__lastPressure__ || 0, 2) || "?"})
</div>
)}
<div>
<label>
{" "}
enabled
<br />
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
if (window.h.debugFreedraw) {
window.h.debugFreedraw.enabled = e.target.checked;
setEnabled(e.target.checked);
}
}}
/>
</label>
</div>
<div>
<label>
Streamline: {streamline.toFixed(2)}
<br />
<input
type="range"
min="0"
max="1"
step="0.01"
value={streamline}
onChange={(e) => handleStreamlineChange(parseFloat(e.target.value))}
style={{ width: "150px" }}
/>
</label>
</div>
<div>
<label>
Simplify: {simplify.toFixed(2)}
<br />
<input
type="range"
min="0"
max="1"
step="0.01"
value={simplify}
onChange={(e) => handleSimplifyChange(parseFloat(e.target.value))}
style={{ width: "150px" }}
/>
</label>
</div>
</div>
);
};
+2 -2
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,
@@ -27,7 +28,6 @@ import {
} from "idb-keyval"; } from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai"; 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";
@@ -88,7 +88,7 @@ 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,
+2 -2
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(
+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
+11 -16
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;
@@ -86,31 +90,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 +117,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"),
);
}
@@ -1,34 +0,0 @@
import { defaultLang } from "@excalidraw/excalidraw/i18n";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
screen,
fireEvent,
waitFor,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
describe("Test LanguageList", () => {
it("rerenders UI on language change", async () => {
await render(<ExcalidrawApp />);
// select rectangle tool to show properties menu
UI.clickTool("rectangle");
// english lang should display `thin` label
expect(screen.queryByTitle(/thin/i)).not.toBeNull();
fireEvent.click(document.querySelector(".dropdown-menu-button")!);
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
target: { value: "de-DE" },
});
// switching to german, `thin` label should no longer exist
await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
// reset language
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
target: { value: defaultLang.code },
});
// switching back to English
await waitFor(() => expect(screen.queryByTitle(/thin/i)).not.toBeNull());
});
});
+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": true,
"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",
-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";
+50 -13
View File
@@ -6,6 +6,32 @@ import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
export const isFirefox =
typeof window !== "undefined" &&
"netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1;
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS =
/iPad|iPhone/i.test(navigator.platform) ||
// iPadOS 13+
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
// keeping function so it can be mocked in test
export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave";
export const isMobile =
isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent,
) ||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
export const supportsResizeObserver = export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window; typeof window !== "undefined" && "ResizeObserver" in window;
@@ -105,16 +131,8 @@ export const CLASSES = {
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", 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";
@@ -331,6 +349,26 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
}, },
}; };
// 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;
// -----------------------------------------------------------------------------
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];
@@ -404,8 +442,9 @@ export const ROUGHNESS = {
export const STROKE_WIDTH = { export const STROKE_WIDTH = {
thin: 1, thin: 1,
bold: 2, medium: 2,
extraBold: 4, bold: 4,
extraBold: 8,
} as const; } as const;
export const DEFAULT_ELEMENT_PROPS: { export const DEFAULT_ELEMENT_PROPS: {
@@ -421,7 +460,7 @@ export const DEFAULT_ELEMENT_PROPS: {
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent, backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid", fillStyle: "solid",
strokeWidth: 2, strokeWidth: STROKE_WIDTH.medium,
strokeStyle: "solid", strokeStyle: "solid",
roughness: ROUGHNESS.artist, roughness: ROUGHNESS.artist,
opacity: 100, opacity: 100,
@@ -506,8 +545,6 @@ export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35; export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
export const BIND_MODE_TIMEOUT = 700; // ms
// glass background for mobile action buttons // glass background for mobile action buttons
export const MOBILE_ACTION_BUTTON_BG = { export const MOBILE_ACTION_BUTTON_BG = {
background: "var(--mobile-action-button-bg)", background: "var(--mobile-action-button-bg)",
-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";
+60 -43
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,8 @@ import {
ENV, ENV,
FONT_FAMILY, FONT_FAMILY,
getFontFamilyFallbacks, getFontFamilyFallbacks,
isAndroid,
isIOS,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
@@ -378,10 +384,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: ((
@@ -558,6 +560,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
@@ -1268,46 +1273,58 @@ export const reduceToCommonValue = <T, R = T>(
return commonValue; return commonValue;
}; };
type FEATURE_FLAGS = { export const isMobileOrTablet = (): boolean => {
COMPLEX_BINDINGS: boolean; const ua = navigator.userAgent || "";
}; const platform = navigator.platform || "";
const uaData = (navigator as any).userAgentData as
| { mobile?: boolean; platform?: string }
| undefined;
const FEATURE_FLAGS_STORAGE_KEY = "excalidraw-feature-flags"; // --- 1) chromium: prefer ua client hints -------------------------------
const DEFAULT_FEATURE_FLAGS: FEATURE_FLAGS = { if (uaData) {
COMPLEX_BINDINGS: false, const plat = (uaData.platform || "").toLowerCase();
}; const isDesktopOS =
let featureFlags: FEATURE_FLAGS | null = null; plat === "windows" ||
plat === "macos" ||
export const getFeatureFlag = <F extends keyof FEATURE_FLAGS>( plat === "linux" ||
flag: F, plat === "chrome os";
): FEATURE_FLAGS[F] => { if (uaData.mobile === true) {
if (!featureFlags) { return true;
try { }
const serializedFlags = localStorage.getItem(FEATURE_FLAGS_STORAGE_KEY); if (uaData.mobile === false && plat === "android") {
if (serializedFlags) { const looksTouchTablet =
const flags = JSON.parse(serializedFlags); matchMedia?.("(hover: none)").matches &&
featureFlags = flags ?? DEFAULT_FEATURE_FLAGS; matchMedia?.("(pointer: coarse)").matches;
} return looksTouchTablet;
} catch {} }
if (isDesktopOS) {
return false;
}
} }
return (featureFlags || DEFAULT_FEATURE_FLAGS)[flag]; // --- 2) ios (includes ipad) --------------------------------------------
}; if (isIOS) {
return true;
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);
} }
// --- 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;
}; };
File diff suppressed because it is too large Load Diff
+22 -13
View File
@@ -2,10 +2,10 @@ import rough from "roughjs/bin/rough";
import { import {
arrayToMap, arrayToMap,
type Bounds,
invariant, invariant,
rescalePoints, rescalePoints,
sizeOf, sizeOf,
STROKE_WIDTH,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -79,6 +79,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,
@@ -823,9 +833,15 @@ export const getArrowheadPoints = (
// This value is selected by minimizing a minimum size with the last segment of the arrowhead // This value is selected by minimizing a minimum size with the last segment of the arrowhead
const lengthMultiplier = const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5; arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier); // make arrowheads bigger for thick strokes
const xs = x2 - nx * minSize; const strokeWidthMultiplier =
const ys = y2 - ny * minSize; element.strokeWidth >= STROKE_WIDTH.extraBold ? 1.5 : 1;
const adjustedSize =
Math.min(size, length * lengthMultiplier) * strokeWidthMultiplier;
const xs = x2 - nx * adjustedSize;
const ys = y2 - ny * adjustedSize;
if ( if (
arrowhead === "dot" || arrowhead === "dot" ||
@@ -874,7 +890,7 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 + minSize * 2, y2), pointFrom(x2 + adjustedSize * 2, y2),
pointFrom(x2, y2), pointFrom(x2, y2),
Math.atan2(py - y2, px - x2) as Radians, Math.atan2(py - y2, px - x2) as Radians,
); );
@@ -885,7 +901,7 @@ export const getArrowheadPoints = (
: [0, 0]; : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 - minSize * 2, y2), pointFrom(x2 - adjustedSize * 2, y2),
pointFrom(x2, y2), pointFrom(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians, Math.atan2(y2 - py, x2 - px) as Radians,
); );
@@ -1267,13 +1283,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 -2
View File
@@ -16,8 +16,7 @@ export const hasStrokeColor = (type: ElementOrToolType) =>
type === "freedraw" || type === "freedraw" ||
type === "arrow" || type === "arrow" ||
type === "line" || type === "line" ||
type === "text" || type === "text";
type === "embeddable";
export const hasStrokeWidth = (type: ElementOrToolType) => export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
+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);
}
}
} }
}); });
}; };
+19 -24
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" };
@@ -1244,7 +1243,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 +1257,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 +1274,6 @@ const getElbowArrowData = (
hoveredStartElement, hoveredStartElement,
origStartGlobalPoint, origStartGlobalPoint,
elementsMap, elementsMap,
options?.zoom,
); );
const endHeading = getBindPointHeading( const endHeading = getBindPointHeading(
endGlobalPoint, endGlobalPoint,
@@ -1285,7 +1281,6 @@ const getElbowArrowData = (
hoveredEndElement, hoveredEndElement,
origEndGlobalPoint, origEndGlobalPoint,
elementsMap, elementsMap,
options?.zoom,
); );
const startPointBounds = [ const startPointBounds = [
startGlobalPoint[0] - 2, startGlobalPoint[0] - 2,
@@ -1306,8 +1301,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 +1314,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 +1362,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 +1378,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,
@@ -2244,7 +2239,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 +2257,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,
); );
}; };
+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(
+373
View File
@@ -0,0 +1,373 @@
import { LaserPointer, type Point } from "@excalidraw/laser-pointer";
import {
clamp,
lineSegment,
pointFrom,
pointRotateRads,
round,
type LocalPoint,
} from "@excalidraw/math";
import getStroke from "perfect-freehand";
import { invariant } from "@excalidraw/common";
import type { GlobalPoint, Radians } from "@excalidraw/math";
import { getElementBounds } from "./bounds";
import type { StrokeOptions } from "perfect-freehand";
import type {
ElementsMap,
ExcalidrawFreeDrawElement,
PointerType,
} from "./types";
export const STROKE_OPTIONS: Record<
PointerType | "default",
{ streamline: number; simplify: number }
> = {
default: {
streamline: 0.35,
simplify: 0.1,
},
mouse: {
streamline: 0.6,
simplify: 0.1,
},
pen: {
// for optimal performance, we use a lower streamline and simplify
streamline: 0.2,
simplify: 0.1,
},
touch: {
streamline: 0.65,
simplify: 0.1,
},
} as const;
export const getFreedrawConfig = (eventType: string | null | undefined) => {
return (
STROKE_OPTIONS[(eventType as PointerType | null) || "default"] ||
STROKE_OPTIONS.default
);
};
/**
* Calculates simulated pressure based on velocity between consecutive points.
* Fast movement (large distances) -> lower pressure
* Slow movement (small distances) -> higher pressure
*/
const calculateVelocityBasedPressure = (
points: readonly LocalPoint[],
index: number,
fixedStrokeWidth: boolean | undefined,
maxDistance = 8, // Maximum expected distance for normalization
): number => {
if (fixedStrokeWidth) {
return 1;
}
// First point gets highest pressure
// This avoid "a dot followed by a line" effect, •== when first stroke is "slow"
if (index === 0) {
return 1;
}
const [x1, y1] = points[index - 1];
const [x2, y2] = points[index];
// Calculate distance between consecutive points
const distance = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
// Normalize distance and invert for pressure (0 = fast/low pressure, 1 = slow/high pressure)
const normalizedDistance = Math.min(distance / maxDistance, 1);
const basePressure = Math.max(0.1, 1 - normalizedDistance * 0.7); // Range: 0.1 to 1.0
const constantPressure = 0.5;
const pressure = constantPressure + (basePressure - constantPressure);
return Math.max(0.1, Math.min(1.0, pressure));
};
export const getFreedrawStroke = (element: ExcalidrawFreeDrawElement) => {
// Compose points as [x, y, pressure]
let points: [number, number, number][];
if (element.freedrawOptions?.fixedStrokeWidth) {
points = element.points.map(
([x, y]: LocalPoint): [number, number, number] => [x, y, 1],
);
} else if (element.simulatePressure) {
// Simulate pressure based on velocity between consecutive points
points = element.points.map(([x, y]: LocalPoint, i) => [
x,
y,
calculateVelocityBasedPressure(
element.points,
i,
element.freedrawOptions?.fixedStrokeWidth,
),
]);
} else {
points = element.points.map(([x, y]: LocalPoint, i) => {
const rawPressure = element.pressures?.[i] ?? 0.5;
const amplifiedPressure = Math.pow(rawPressure, 0.6);
const adjustedPressure = amplifiedPressure;
return [x, y, clamp(adjustedPressure, 0.1, 1.0)];
});
}
const streamline =
element.freedrawOptions?.streamline ?? STROKE_OPTIONS.default.streamline;
const simplify =
element.freedrawOptions?.simplify ?? STROKE_OPTIONS.default.simplify;
const laser = new LaserPointer({
size: element.strokeWidth,
streamline,
simplify,
sizeMapping: ({ pressure: t }) => {
if (element.freedrawOptions?.fixedStrokeWidth) {
return 0.6;
}
if (element.simulatePressure) {
return 0.2 + t * 0.6;
}
return 0.2 + t * 0.8;
},
});
for (const pt of points) {
laser.addPoint(pt);
}
laser.close();
return laser.getStrokeOutline();
};
/**
* Generates an SVG path for a freedraw element using LaserPointer logic.
* Uses actual pressure data if available, otherwise simulates pressure based on velocity.
* No streamline, smoothing, or simulation is performed.
*/
export const getFreeDrawSvgPath = (
element: ExcalidrawFreeDrawElement,
): string => {
// legacy, for backwards compatibility
if (element.freedrawOptions === null) {
return _legacy_getFreeDrawSvgPath(element);
}
return _transition_getFreeDrawSvgPath(element);
// return getSvgPathFromStroke(getFreedrawStroke(element));
};
const roundPoint = (A: Point): string => {
return `${round(A[0], 4, "round")},${round(A[1], 4, "round")} `;
};
const average = (A: Point, B: Point): string => {
return `${round((A[0] + B[0]) / 2, 4, "round")},${round(
(A[1] + B[1]) / 2,
4,
"round",
)} `;
};
export const getSvgPathFromStroke = (points: Point[]): string => {
const len = points.length;
if (len < 2) {
return "";
}
let a = points[0];
let b = points[1];
if (len === 2) {
return `M${roundPoint(a)}L${roundPoint(b)}`;
}
let result = "";
for (let i = 2, max = len - 1; i < max; i++) {
a = points[i];
b = points[i + 1];
result += average(a, b);
}
return `M${roundPoint(points[0])}Q${roundPoint(points[1])}${average(
points[1],
points[2],
)}${points.length > 3 ? "T" : ""}${result}L${roundPoint(points[len - 1])}`;
};
function _transition_getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => {
if (element.freedrawOptions?.fixedStrokeWidth) {
return 0.5;
}
return Math.sin((t * Math.PI) / 2) * 0.65;
}, // https://easings.net/#easeOutSine
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return _legacy_getSvgPathFromStroke(
getStroke(inputPoints as number[][], options),
);
}
function _legacy_getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return _legacy_getSvgPathFromStroke(
getStroke(inputPoints as number[][], options),
);
}
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
const _legacy_getSvgPathFromStroke = (points: number[][]): string => {
if (!points.length) {
return "";
}
const max = points.length - 1;
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
["M", points[0], "Q"],
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
};
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
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return getStroke(inputPoints as number[][], options) as [number, number][];
}
+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";
+23 -1
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,
@@ -54,6 +55,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";
@@ -72,6 +94,7 @@ export * from "./embeddable";
export * from "./flowchart"; export * from "./flowchart";
export * from "./fractionalIndex"; export * from "./fractionalIndex";
export * from "./frame"; export * from "./frame";
export * from "./freedraw";
export * from "./groups"; export * from "./groups";
export * from "./heading"; export * from "./heading";
export * from "./image"; export * from "./image";
@@ -92,7 +115,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,
+29 -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,
}; };
@@ -448,6 +445,7 @@ export const newFreeDrawElement = (
points?: ExcalidrawFreeDrawElement["points"]; points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean; simulatePressure: boolean;
pressures?: ExcalidrawFreeDrawElement["pressures"]; pressures?: ExcalidrawFreeDrawElement["pressures"];
strokeOptions?: ExcalidrawFreeDrawElement["freedrawOptions"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => { ): NonDeleted<ExcalidrawFreeDrawElement> => {
return { return {
@@ -455,6 +453,12 @@ export const newFreeDrawElement = (
points: opts.points || [], points: opts.points || [],
pressures: opts.pressures || [], pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure, simulatePressure: opts.simulatePressure,
lastCommittedPoint: null,
freedrawOptions: opts.strokeOptions || {
fixedStrokeWidth: true,
streamline: 0.25,
simplify: 0.1,
},
}; };
}; };
@@ -468,7 +472,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 +507,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 +522,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,
+20 -135
View File
@@ -1,14 +1,6 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
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 +13,6 @@ import {
getFontString, getFontString,
isRTL, isRTL,
getVerticalOffset, getVerticalOffset,
invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -40,7 +31,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 {
@@ -66,6 +57,8 @@ import { getCornerRadius } from "./utils";
import { ShapeCache } from "./shape"; import { ShapeCache } from "./shape";
import { getFreeDrawSvgPath } from "./freedraw";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@@ -78,7 +71,6 @@ import type {
ElementsMap, ElementsMap,
} from "./types"; } from "./types";
import type { StrokeOptions } from "perfect-freehand";
import type { RoughCanvas } from "roughjs/bin/canvas"; import type { RoughCanvas } from "roughjs/bin/canvas";
// using a stronger invert (100% vs our regular 93%) and saturate // using a stronger invert (100% vs our regular 93%) and saturate
@@ -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 &&
@@ -225,7 +217,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 +269,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 +404,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 +550,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 +607,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 +725,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 +795,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 +888,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 +933,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();
@@ -1038,117 +1037,3 @@ export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) { export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
return pathsCache.get(element); return pathsCache.get(element);
} }
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
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
};
return getStroke(inputPoints as number[][], options) as [number, number][];
}
function med(A: number[], B: number[]) {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
}
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
function getSvgPathFromStroke(points: number[][]): string {
if (!points.length) {
return "";
}
const max = points.length - 1;
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
["M", points[0], "Q"],
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
}
+9 -84
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,
@@ -50,7 +46,6 @@ import {
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { import {
isArrowElement, isArrowElement,
isBindingElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
@@ -79,9 +74,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,25 +220,7 @@ 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);
@@ -419,11 +394,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,19 +424,6 @@ const rotateMultipleElements = (
simultaneouslyUpdated: elements, simultaneouslyUpdated: elements,
}); });
if (isBindingElement(element)) {
if (element.startBinding) {
if (!rotatedElementsMap.has(element.startBinding.elementId)) {
unbindBindingElement(element, "start", scene);
}
}
if (element.endBinding) {
if (!rotatedElementsMap.has(element.endBinding.elementId)) {
unbindBindingElement(element, "end", scene);
}
}
}
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) { if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition( const { x, y } = computeBoundTextPosition(
@@ -878,32 +835,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 +859,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 +1396,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;
+17 -10
View File
@@ -21,6 +21,7 @@ import {
assertNever, assertNever,
COLOR_PALETTE, COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE, LINE_POLYGON_POINT_MERGE_DISTANCE,
STROKE_WIDTH,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator"; import { RoughGenerator } from "roughjs/bin/generator";
@@ -202,7 +203,7 @@ export const generateRoughOptions = (
// hachureGap because if not specified, roughjs uses strokeWidth to // hachureGap because if not specified, roughjs uses strokeWidth to
// calculate them (and we don't want the fills to be modified) // calculate them (and we don't want the fills to be modified)
fillWeight: element.strokeWidth / 2, fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4, hachureGap: Math.min(element.strokeWidth, STROKE_WIDTH.bold) * 4,
roughness: adjustRoughness(element), roughness: adjustRoughness(element),
stroke: element.strokeColor, stroke: element.strokeColor,
preserveVertices: preserveVertices:
@@ -806,15 +807,21 @@ const generateElementShape = (
generateFreeDrawShape(element); generateFreeDrawShape(element);
if (isPathALoop(element.points)) { if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape const points =
const simplifiedPoints = simplify( element.freedrawOptions === null
element.points as Mutable<LocalPoint[]>, ? simplify(element.points as LocalPoint[], 0.75)
0.75, : simplify(element.points as LocalPoint[], 1.5);
);
shape = generator.curve(simplifiedPoints as [number, number][], { shape =
...generateRoughOptions(element), element.freedrawOptions === null
stroke: "none", ? generator.curve(points, {
}); ...generateRoughOptions(element),
stroke: "none",
})
: generator.polygon(points, {
...generateRoughOptions(element),
stroke: "none",
});
} else { } else {
shape = null; shape = null;
} }
-26
View File
@@ -8,8 +8,6 @@ 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 { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
@@ -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";
@@ -533,24 +528,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 -17
View File
@@ -1,6 +1,8 @@
import { import {
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
type EditorInterface, isAndroid,
isIOS,
isMobileOrTablet,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { pointFrom, pointRotateRads } from "@excalidraw/math";
@@ -8,10 +10,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 +23,7 @@ import {
isLinearElement, isLinearElement,
} from "./typeChecks"; } from "./typeChecks";
import type { Bounds } from "./bounds";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
@@ -109,21 +112,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;
} }
@@ -328,12 +330,8 @@ export const getTransformHandles = (
export const hasBoundingBox = ( export const hasBoundingBox = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
editorInterface: EditorInterface,
) => { ) => {
if ( if (appState.selectedLinearElement?.isEditing) {
appState.selectedLinearElement?.isEditing ||
appState.selectedLinearElement?.isDragging
) {
return false; return false;
} }
if (elements.length > 1) { if (elements.length > 1) {
@@ -350,5 +348,5 @@ export const hasBoundingBox = (
// on mobile/tablet we currently don't show bbox because of resize issues // on mobile/tablet we currently don't show bbox because of resize issues
// (also prob best for simplicity's sake) // (also prob best for simplicity's sake)
return element.points.length > 2 && !editorInterface.userAgent.isMobileDevice; return element.points.length > 2 && !isMobileOrTablet();
}; };
+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 => {
+25 -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,12 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
points: readonly LocalPoint[]; points: readonly LocalPoint[];
pressures: readonly number[]; pressures: readonly number[];
simulatePressure: boolean; simulatePressure: boolean;
lastCommittedPoint: LocalPoint | null;
freedrawOptions: {
streamline?: number;
simplify?: number;
fixedStrokeWidth?: boolean;
} | 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).
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);
}); });
}); });
+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],
]); ]);
}); });
}); });
@@ -217,7 +217,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`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(`
@@ -329,7 +329,7 @@ describe("Test Linear Elements", () => {
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.doubleClick(); mouse.doubleClick();
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement).toBe(null);
await getTextEditor(); await getTextEditor();
}); });
@@ -357,7 +357,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]);
@@ -380,7 +379,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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(`
@@ -550,7 +549,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@@ -601,7 +600,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@@ -642,7 +641,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@@ -690,7 +689,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`17`, `17`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
const newMidPoints = LinearElementEditor.getEditorMidPoints( const newMidPoints = LinearElementEditor.getEditorMidPoints(
line, line,
@@ -748,7 +747,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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)
@@ -846,7 +845,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@@ -1304,7 +1303,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 +1316,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 +1335,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,
+5 -5
View File
@@ -51,7 +51,7 @@ 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,7 +64,7 @@ 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,
}; };
@@ -83,6 +83,7 @@ export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/> />
); );
}, },
@@ -466,7 +467,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 +475,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) => {
@@ -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);
}, },
@@ -30,8 +30,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";
@@ -212,8 +210,12 @@ export const actionDeleteSelected = register({
trackEvent: { category: "element", action: "delete" }, trackEvent: { category: "element", action: "delete" },
perform: (elements, appState, formData, app) => { perform: (elements, appState, formData, app) => {
if (appState.selectedLinearElement?.isEditing) { if (appState.selectedLinearElement?.isEditing) {
const { elementId, selectedPointsIndices } = const {
appState.selectedLinearElement; elementId,
selectedPointsIndices,
startBindingElement,
endBindingElement,
} = appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const linearElement = LinearElementEditor.getElement( const linearElement = LinearElementEditor.getElement(
elementId, elementId,
@@ -250,6 +252,19 @@ export const actionDeleteSelected = register({
}; };
} }
// We cannot do this inside `movePoint` because it is also called
// when deleting the uncommitted point (which hasn't caused any binding)
const binding = {
startBindingElement: selectedPointsIndices?.includes(0)
? null
: startBindingElement,
endBindingElement: selectedPointsIndices?.includes(
linearElement.points.length - 1,
)
? null
: endBindingElement,
};
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(
linearElement, linearElement,
app, app,
@@ -262,6 +277,7 @@ export const actionDeleteSelected = register({
...appState, ...appState,
selectedLinearElement: { selectedLinearElement: {
...appState.selectedLinearElement, ...appState.selectedLinearElement,
...binding,
selectedPointsIndices: selectedPointsIndices:
selectedPointsIndices?.[0] > 0 selectedPointsIndices?.[0] > 0
? [selectedPointsIndices[0] - 1] ? [selectedPointsIndices[0] - 1]
@@ -290,7 +306,6 @@ export const actionDeleteSelected = register({
type: app.state.preferredSelectionTool.type, type: app.state.preferredSelectionTool.type,
}), }),
multiElement: null, multiElement: null,
newElement: null,
activeEmbeddable: null, activeEmbeddable: null,
selectedLinearElement: null, selectedLinearElement: null,
}, },
@@ -305,25 +320,22 @@ 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")} disabled={
aria-label={t("labels.delete")} !isSomeElementSelected(getNonDeletedElements(elements), appState)
onClick={() => updateData(null)} }
disabled={ style={{
!isSomeElementSelected(getNonDeletedElements(elements), appState) ...(appState.stylesPanelMode === "mobile" &&
} appState.openPopup !== "compactOtherProperties"
style={{ ? MOBILE_ACTION_BUTTON_BG
...(isMobile && appState.openPopup !== "compactOtherProperties" : {}),
? MOBILE_ACTION_BUTTON_BG }}
: {}), />
}} ),
/>
);
},
}); });
@@ -27,8 +27,6 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut"; import { getShortcutKey } from "../shortcut";
import { useStylesPanelMode } from "..";
import { register } from "./register"; import { register } from "./register";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
@@ -109,27 +107,24 @@ 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)}
)}`} disabled={
aria-label={t("labels.duplicateSelection")} !isSomeElementSelected(getNonDeletedElements(elements), appState)
onClick={() => updateData(null)} }
disabled={ style={{
!isSomeElementSelected(getNonDeletedElements(elements), appState) ...(appState.stylesPanelMode === "mobile" &&
} appState.openPopup !== "compactOtherProperties"
style={{ ? MOBILE_ACTION_BUTTON_BG
...(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" },
+88 -121
View File
@@ -1,6 +1,10 @@
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { bindOrUnbindBindingElement } from "@excalidraw/element/binding"; import {
maybeBindLinearElement,
bindOrUnbindLinearElement,
isBindingEnabled,
} from "@excalidraw/element/binding";
import { import {
isValidPolygon, isValidPolygon,
LinearElementEditor, LinearElementEditor,
@@ -17,8 +21,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 +30,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 +46,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,57 +67,19 @@ 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.map((el) => {
@@ -144,8 +91,39 @@ export const actionFinalize = register<FormData>({
return el; return el;
}); });
} }
return {
elements: newElements,
appState: {
selectedLinearElement: {
...linearElementEditor,
selectedPointsIndices: null,
},
suggestedBindings: [],
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
}
const activeToolLocked = appState.activeTool?.locked; if (appState.selectedLinearElement?.isEditing) {
const { elementId, startBindingElement, endBindingElement } =
appState.selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (element) {
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
scene,
);
}
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
});
}
return { return {
elements: elements:
@@ -156,31 +134,23 @@ export const actionFinalize = register<FormData>({
} }
return el; return el;
}) })
: newElements, : undefined,
appState: { appState: {
...appState, ...appState,
cursorButton: "up", cursorButton: "up",
selectedLinearElement: activeToolLocked selectedLinearElement: new LinearElementEditor(
? null element,
: { arrayToMap(elementsMap),
...linearElementEditor, false, // exit editing mode
selectedPointsIndices: null, ),
isEditing: false,
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
},
selectionElement: null,
suggestedBinding: null,
newElement: null,
multiElement: null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
} }
} }
let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
focusContainer(); focusContainer();
} }
@@ -204,14 +174,8 @@ export const actionFinalize = register<FormData>({
if (element) { if (element) {
// pen and mouse have hover // pen and mouse have hover
if ( if (appState.multiElement && element.type !== "freedraw") {
appState.selectedLinearElement && const { points, lastCommittedPoint } = element;
appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points } = element;
const { lastCommittedPoint } = appState.selectedLinearElement;
if ( if (
!lastCommittedPoint || !lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint points[points.length - 1] !== lastCommittedPoint
@@ -263,6 +227,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);
}
} }
} }
@@ -288,25 +271,6 @@ export const actionFinalize = register<FormData>({
}); });
} }
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 +288,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 +298,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,
+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,
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -18,8 +18,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 +73,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: ({ appState, updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>( const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -83,7 +81,6 @@ export const createUndoAction: ActionCreator = (history) => ({
history.isRedoStackEmpty, history.isRedoStackEmpty,
), ),
); );
const isMobile = useStylesPanelMode() === "mobile";
return ( return (
<ToolButton <ToolButton
@@ -95,7 +92,9 @@ export const createUndoAction: ActionCreator = (history) => ({
disabled={isUndoStackEmpty} disabled={isUndoStackEmpty}
data-testid="button-undo" data-testid="button-undo"
style={{ style={{
...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}), ...(appState.stylesPanelMode === "mobile"
? MOBILE_ACTION_BUTTON_BG
: {}),
}} }}
/> />
); );
@@ -115,7 +114,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: ({ appState, updateData, data }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -123,7 +122,6 @@ export const createRedoAction: ActionCreator = (history) => ({
history.isRedoStackEmpty, history.isRedoStackEmpty,
), ),
); );
const isMobile = useStylesPanelMode() === "mobile";
return ( return (
<ToolButton <ToolButton
@@ -135,7 +133,9 @@ export const createRedoAction: ActionCreator = (history) => ({
disabled={isRedoStackEmpty} disabled={isRedoStackEmpty}
data-testid="button-redo" data-testid="button-redo"
style={{ style={{
...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}), ...(appState.stylesPanelMode === "mobile"
? MOBILE_ACTION_BUTTON_BG
: {}),
}} }}
/> />
); );
@@ -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 ||
@@ -145,26 +145,27 @@ describe("element locking", () => {
queryByTestId(document.body, `strokeWidth-thin`), queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked(); ).not.toBeChecked();
expect( expect(
queryByTestId(document.body, `strokeWidth-bold`), queryByTestId(document.body, `strokeWidth-medium`),
).not.toBeChecked(); ).not.toBeChecked();
expect( expect(
queryByTestId(document.body, `strokeWidth-extraBold`), queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked(); ).not.toBeChecked();
}); });
it("should show properties of different element types when selected", () => { it("should show properties of different element types when selected", () => {
const rect = API.createElement({ const rect = API.createElement({
type: "rectangle", type: "rectangle",
strokeWidth: STROKE_WIDTH.bold, strokeWidth: STROKE_WIDTH.medium,
}); });
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
fontFamily: FONT_FAMILY["Comic Shanns"], fontFamily: FONT_FAMILY["Comic Shanns"],
strokeWidth: undefined,
}); });
API.setElements([rect, text]); API.setElements([rect, text]);
API.setSelectedElements([rect, text]); API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked(); expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass( expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active", "active",
); );
+274 -284
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 {
@@ -21,14 +20,12 @@ import {
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";
@@ -46,6 +43,7 @@ import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFreeDrawElement,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
isTextElement, isTextElement,
@@ -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,
@@ -127,6 +126,9 @@ import {
ArrowheadCrowfootIcon, ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon, ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon, ArrowheadCrowfootOneOrManyIcon,
strokeWidthFixedIcon,
strokeWidthVariableIcon,
StrokeWidthMediumIcon,
} from "../components/icons"; } from "../components/icons";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
@@ -151,15 +153,6 @@ 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 +302,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 +326,48 @@ 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, data }) => (
const { stylesPanelMode } = getStylesPanelInfo(app); <>
{appState.stylesPanelMode === "full" && (
return ( <h3 aria-hidden="true">{t("labels.stroke")}</h3>
<> )}
{stylesPanelMode === "full" && ( <ColorPicker
<h3 aria-hidden="true">{t("labels.stroke")}</h3> topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
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")} compactMode={
color={getFormValue( appState.stylesPanelMode === "compact" ||
elements, appState.stylesPanelMode === "mobile"
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 +414,38 @@ export const actionChangeBackgroundColor = register<
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app, data }) => (
const { stylesPanelMode } = getStylesPanelInfo(app); <>
{appState.stylesPanelMode === "full" && (
return ( <h3 aria-hidden="true">{t("labels.background")}</h3>
<> )}
{stylesPanelMode === "full" && ( <ColorPicker
<h3 aria-hidden="true">{t("labels.background")}</h3> topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
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")} compactMode={
color={getFormValue( appState.stylesPanelMode === "compact" ||
elements, appState.stylesPanelMode === "mobile"
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 +453,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 +525,34 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
}, },
}); });
export const actionChangeStrokeWidth = register< const WIDTHS = [
ExcalidrawElement["strokeWidth"] {
>({ value: STROKE_WIDTH.thin,
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: STROKE_WIDTH.medium,
text: t("labels.medium"),
icon: StrokeWidthMediumIcon,
testId: "strokeWidth-medium",
},
{
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
},
{
value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
},
];
export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth", name: "changeStrokeWidth",
label: "labels.strokeWidth", label: "labels.strokeWidth",
trackEvent: false, trackEvent: false,
@@ -563,26 +573,7 @@ export const actionChangeStrokeWidth = register<
<div className="buttonList"> <div className="buttonList">
<RadioSelection <RadioSelection
group="stroke-width" group="stroke-width"
options={[ options={WIDTHS}
{
value: STROKE_WIDTH.thin,
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
},
{
value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
},
]}
value={getFormValue( value={getFormValue(
elements, elements,
app, app,
@@ -598,7 +589,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,
@@ -652,9 +643,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,
@@ -707,7 +696,71 @@ export const actionChangeStrokeStyle = register<
), ),
}); });
export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({ export const actionChangePressureSensitivity = register({
name: "changeStrokeType",
label: "labels.strokeType",
trackEvent: false,
perform: (elements, appState, value) => {
const updatedElements = changeProperty(elements, appState, (el) => {
if (isFreeDrawElement(el)) {
return newElementWith(el, {
freedrawOptions: {
...el.freedrawOptions,
fixedStrokeWidth: value,
},
});
}
return el;
});
return {
elements: updatedElements,
appState: { ...appState, currentItemFixedStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ app, appState, updateData }) => {
const selectedElements = app.scene.getSelectedElements(app.state);
const freedraws = selectedElements.filter(isFreeDrawElement);
const currentValue =
freedraws.length > 0
? reduceToCommonValue(
freedraws,
(element) => element.freedrawOptions?.fixedStrokeWidth,
) ?? null
: appState.currentItemFixedStrokeWidth;
return (
<fieldset>
<legend>{t("labels.strokeType")}</legend>
<div className="buttonList">
<RadioSelection
group="pressure-sensitivity"
options={[
{
value: true,
text: t("labels.strokeWidthFixed"),
icon: strokeWidthFixedIcon,
testId: "pressure-fixed",
},
{
value: false,
text: t("labels.strokeWidthVariable"),
icon: strokeWidthVariableIcon,
testId: "pressure-variable",
},
]}
value={currentValue}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},
});
export const actionChangeOpacity = register({
name: "changeOpacity", name: "changeOpacity",
label: "labels.opacity", label: "labels.opacity",
trackEvent: false, trackEvent: false,
@@ -731,100 +784,86 @@ 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, data }) => (
<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) => {
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/>
</div>
</fieldset>
),
});
export const actionDecreaseFontSize = register({ export const actionDecreaseFontSize = register({
name: "decreaseFontSize", name: "decreaseFontSize",
@@ -884,10 +923,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 +960,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 +1124,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 = (
@@ -1163,14 +1196,14 @@ export const actionChangeFontFamily = register<{
return ( return (
<> <>
{stylesPanelMode === "full" && ( {appState.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"} compactMode={appState.stylesPanelMode !== "full"}
onSelect={(fontFamily) => { onSelect={(fontFamily) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => { () => {
@@ -1182,7 +1215,8 @@ export const actionChangeFontFamily = register<{
// 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.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement, !!appState.editingTextElement,
); );
}} }}
@@ -1257,7 +1291,11 @@ export const actionChangeFontFamily = register<{
cachedElementsRef.current.clear(); cachedElementsRef.current.clear();
// Refocus text editor when font picker closes if we were editing text // Refocus text editor when font picker closes if we were editing text
if (isCompact && appState.editingTextElement) { if (
(appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile") &&
appState.editingTextElement
) {
restoreCaretPosition(null); // Just refocus without saved position restoreCaretPosition(null); // Just refocus without saved position
} }
} }
@@ -1268,7 +1306,7 @@ export const actionChangeFontFamily = register<{
}, },
}); });
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,
@@ -1304,7 +1342,6 @@ export const actionChangeTextAlign = register<TextAlign>({
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app, data }) => {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const { isCompact } = getStylesPanelInfo(app);
return ( return (
<fieldset> <fieldset>
@@ -1357,7 +1394,8 @@ export const actionChangeTextAlign = register<TextAlign>({
onChange={(value) => { onChange={(value) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => updateData(value), () => updateData(value),
isCompact, appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement, !!appState.editingTextElement,
data?.onPreventClose, data?.onPreventClose,
); );
@@ -1369,7 +1407,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" },
@@ -1404,7 +1442,6 @@ export const actionChangeVerticalAlign = register<VerticalAlign>({
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app, data }) => {
const { isCompact } = getStylesPanelInfo(app);
return ( return (
<fieldset> <fieldset>
<div className="buttonList"> <div className="buttonList">
@@ -1457,7 +1494,8 @@ export const actionChangeVerticalAlign = register<VerticalAlign>({
onChange={(value) => { onChange={(value) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => updateData(value), () => updateData(value),
isCompact, appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement, !!appState.editingTextElement,
data?.onPreventClose, data?.onPreventClose,
); );
@@ -1469,7 +1507,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 +1664,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)) {
@@ -1730,7 +1767,7 @@ export const actionChangeArrowProperties = register({
}, },
}); });
export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({ export const actionChangeArrowType = register({
name: "changeArrowType", name: "changeArrowType",
label: "Change arrow types", label: "Change arrow types",
trackEvent: false, trackEvent: false,
@@ -1739,20 +1776,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 +1784,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 +1868,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 +1876,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,
);
} }
} }
} }
@@ -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
View File
@@ -13,6 +13,7 @@ export {
actionChangeStrokeWidth, actionChangeStrokeWidth,
actionChangeFillStyle, actionChangeFillStyle,
actionChangeSloppiness, actionChangeSloppiness,
actionChangePressureSensitivity,
actionChangeOpacity, actionChangeOpacity,
actionChangeFontSize, actionChangeFontSize,
actionChangeFontFamily, actionChangeFontFamily,
+1 -3
View File
@@ -37,9 +37,7 @@ const trackAction = (
trackEvent( trackEvent(
action.trackEvent.category, action.trackEvent.category,
action.trackEvent.action || action.name, action.trackEvent.action || action.name,
`${source} (${ `${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
app.editorInterface.formFactor === "phone" ? "mobile" : "desktop"
})`,
); );
} }
} }
+1 -6
View File
@@ -2,12 +2,7 @@ import type { Action } from "./types";
export let actions: readonly Action[] = []; export let actions: readonly Action[] = [];
export const register = < export const register = <T extends Action>(action: T) => {
TData extends any,
T extends Action<TData> = Action<TData>,
>(
action: T,
) => {
actions = actions.concat(action); actions = actions.concat(action);
return action as T & { return action as T & {
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"]; keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
+5 -4
View File
@@ -32,10 +32,10 @@ export type ActionResult =
} }
| false; | false;
type ActionFn<TData = any> = ( type ActionFn = (
elements: readonly OrderedExcalidrawElement[], elements: readonly OrderedExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
formData: TData | undefined, formData: any,
app: AppClassProperties, app: AppClassProperties,
) => ActionResult | Promise<ActionResult>; ) => ActionResult | Promise<ActionResult>;
@@ -69,6 +69,7 @@ export type ActionName =
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType" | "changeArrowType"
| "changeStrokeType"
| "changeArrowProperties" | "changeArrowProperties"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
@@ -157,7 +158,7 @@ export type PanelComponentProps = {
) => React.JSX.Element | null; ) => React.JSX.Element | null;
}; };
export interface Action<TData = any> { export interface Action {
name: ActionName; name: ActionName;
label: label:
| string | string
@@ -174,7 +175,7 @@ export interface Action<TData = any> {
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => React.ReactNode); ) => React.ReactNode);
PanelComponent?: React.FC<PanelComponentProps>; PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn<TData>; perform: ActionFn;
keyPriority?: number; keyPriority?: number;
keyTest?: ( keyTest?: (
event: React.KeyboardEvent | KeyboardEvent, event: React.KeyboardEvent | KeyboardEvent,
+10 -4
View File
@@ -34,6 +34,7 @@ export const getDefaultAppState = (): Omit<
currentItemFontFamily: DEFAULT_FONT_FAMILY, currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE, currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity, currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
currentItemFixedStrokeWidth: true,
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness, currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
@@ -100,7 +101,7 @@ export const getDefaultAppState = (): Omit<
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties, panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
}, },
startBoundElement: null, startBoundElement: null,
suggestedBinding: null, suggestedBindings: [],
frameRendering: { enabled: true, clip: true, name: true, outline: true }, frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null, frameToHighlight: null,
editingFrame: null, editingFrame: null,
@@ -127,7 +128,7 @@ export const getDefaultAppState = (): Omit<
searchMatches: null, searchMatches: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
bindMode: "orbit", stylesPanelMode: "full",
}; };
}; };
@@ -167,6 +168,11 @@ const APP_STATE_STORAGE_CONF = (<
server: false, server: false,
}, },
currentItemOpacity: { browser: true, export: false, server: false }, currentItemOpacity: { browser: true, export: false, server: false },
currentItemFixedStrokeWidth: {
browser: true,
export: false,
server: false,
},
currentItemRoughness: { browser: true, export: false, server: false }, currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false }, currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false }, currentItemStrokeColor: { browser: true, export: false, server: false },
@@ -230,7 +236,7 @@ const APP_STATE_STORAGE_CONF = (<
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false }, stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false },
suggestedBinding: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false }, frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false }, frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false }, editingFrame: { browser: false, export: false, server: false },
@@ -253,7 +259,7 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true }, lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false }, activeLockedId: { browser: false, export: false, server: false },
bindMode: { browser: true, export: false, server: false }, stylesPanelMode: { browser: false, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <
+1 -2
View File
@@ -9,7 +9,6 @@ import {
VERTICAL_ALIGN, VERTICAL_ALIGN,
randomId, randomId,
isDevEnv, isDevEnv,
FONT_SIZES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -214,7 +213,7 @@ const chartXLabels = (
y: y + BAR_GAP / 2, y: y + BAR_GAP / 2,
width: BAR_WIDTH, width: BAR_WIDTH,
angle: 5.87 as Radians, angle: 5.87 as Radians,
fontSize: FONT_SIZES.sm, fontSize: 16,
textAlign: "center", textAlign: "center",
verticalAlign: "top", verticalAlign: "top",
}); });
+1
View File
@@ -113,6 +113,7 @@ export const createPasteEvent = ({
if (typeof value !== "string") { if (typeof value !== "string") {
files = files || []; files = files || [];
files.push(value); files.push(value);
event.clipboardData?.items.add(value);
continue; continue;
} }
try { try {
+14 -36
View File
@@ -49,17 +49,11 @@ import { getFormValue } from "../actions/actionProperties";
import { useTextEditorFocus } from "../hooks/useTextEditorFocus"; import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import { getToolbarTools } from "./shapes"; import { getToolbarTools } from "./shapes";
import "./Actions.scss"; import "./Actions.scss";
import { import { useDevice, useExcalidrawContainer } from "./App";
useEditorInterface,
useStylesPanelMode,
useExcalidrawContainer,
} from "./App";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { ToolPopover } from "./ToolPopover"; import { ToolPopover } from "./ToolPopover";
@@ -81,7 +75,6 @@ import {
adjustmentsIcon, adjustmentsIcon,
DotsHorizontalIcon, DotsHorizontalIcon,
SelectionIcon, SelectionIcon,
pencilIcon,
} from "./icons"; } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
@@ -158,7 +151,7 @@ export const SelectedShapeActions = ({
const isEditingTextOrNewElement = Boolean( const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement, appState.editingTextElement || appState.newElement,
); );
const editorInterface = useEditorInterface(); const device = useDevice();
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons = const showFillIcons =
@@ -202,8 +195,12 @@ export const SelectedShapeActions = ({
renderAction("changeStrokeWidth")} renderAction("changeStrokeWidth")}
{(appState.activeTool.type === "freedraw" || {(appState.activeTool.type === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) && targetElements.some((element) => element.type === "freedraw")) && (
renderAction("changeStrokeShape")} <>
{renderAction("changeStrokeShape")}
{renderAction("changeStrokeType")}
</>
)}
{(hasStrokeStyle(appState.activeTool.type) || {(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && ( targetElements.some((element) => hasStrokeStyle(element.type))) && (
@@ -299,10 +296,8 @@ export const SelectedShapeActions = ({
<fieldset> <fieldset>
<legend>{t("labels.actions")}</legend> <legend>{t("labels.actions")}</legend>
<div className="buttonList"> <div className="buttonList">
{editorInterface.formFactor !== "phone" && {!device.editor.isMobile && renderAction("duplicateSelection")}
renderAction("duplicateSelection")} {!device.editor.isMobile && renderAction("deleteSelectedElements")}
{editorInterface.formFactor !== "phone" &&
renderAction("deleteSelectedElements")}
{renderAction("group")} {renderAction("group")}
{renderAction("ungroup")} {renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")} {showLinkIcon && renderAction("hyperlink")}
@@ -1050,9 +1045,6 @@ export const ShapesSwitcher = ({
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
}) => { }) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const stylesPanelMode = useStylesPanelMode();
const isFullStylesPanel = stylesPanelMode === "full";
const isCompactStylesPanel = stylesPanelMode === "compact";
const SELECTION_TOOLS = [ const SELECTION_TOOLS = [
{ {
@@ -1070,7 +1062,7 @@ export const ShapesSwitcher = ({
const frameToolSelected = activeTool.type === "frame"; const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser"; const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected = const lassoToolSelected =
isFullStylesPanel && app.state.stylesPanelMode === "full" &&
activeTool.type === "lasso" && activeTool.type === "lasso" &&
app.state.preferredSelectionTool.type !== "lasso"; app.state.preferredSelectionTool.type !== "lasso";
@@ -1103,7 +1095,7 @@ export const ShapesSwitcher = ({
// use a ToolPopover for selection/lasso toggle as well // use a ToolPopover for selection/lasso toggle as well
if ( if (
(value === "selection" || value === "lasso") && (value === "selection" || value === "lasso") &&
isCompactStylesPanel app.state.stylesPanelMode === "compact"
) { ) {
return ( return (
<ToolPopover <ToolPopover
@@ -1237,7 +1229,7 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.laser")} {t("toolBar.laser")}
</DropdownMenu.Item> </DropdownMenu.Item>
{isFullStylesPanel && ( {app.state.stylesPanelMode === "full" && (
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })} onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon} icon={LassoIcon}
@@ -1307,7 +1299,7 @@ export const UndoRedoActions = ({
</div> </div>
); );
export const ExitZenModeButton = ({ export const ExitZenModeAction = ({
actionManager, actionManager,
showExitZenModeBtn, showExitZenModeBtn,
}: { }: {
@@ -1324,17 +1316,3 @@ export const ExitZenModeButton = ({
{t("buttons.exitZenMode")} {t("buttons.exitZenMode")}
</button> </button>
); );
export const ExitViewModeButton = ({
actionManager,
}: {
actionManager: ActionManager;
}) => (
<button
type="button"
className="disable-view-mode"
onClick={() => actionManager.executeAction(actionToggleViewMode)}
>
{pencilIcon}
</button>
);
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@ import { KEYS } from "@excalidraw/common";
import { getShortcutKey } from "../..//shortcut"; import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai"; import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useEditorInterface } from "../App"; import { useDevice } from "../App";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { eyeDropperIcon } from "../icons"; import { eyeDropperIcon } from "../icons";
@@ -30,7 +30,7 @@ export const ColorInput = ({
colorPickerType, colorPickerType,
placeholder, placeholder,
}: ColorInputProps) => { }: ColorInputProps) => {
const editorInterface = useEditorInterface(); const device = useDevice();
const [innerValue, setInnerValue] = useState(color); const [innerValue, setInnerValue] = useState(color);
const [activeSection, setActiveColorPickerSection] = useAtom( const [activeSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom, activeColorPickerSectionAtom,
@@ -99,7 +99,7 @@ export const ColorInput = ({
placeholder={placeholder} placeholder={placeholder}
/> />
{/* TODO reenable on mobile with a better UX */} {/* TODO reenable on mobile with a better UX */}
{editorInterface.formFactor !== "phone" && ( {!device.editor.isMobile && (
<> <>
<div <div
style={{ style={{
@@ -15,7 +15,7 @@ import type { ExcalidrawElement } from "@excalidraw/element/types";
import { useAtom } from "../../editor-jotai"; import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useExcalidrawContainer, useStylesPanelMode } from "../App"; import { useExcalidrawContainer } from "../App";
import { ButtonSeparator } from "../ButtonSeparator"; import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
@@ -73,6 +73,7 @@ interface ColorPickerProps {
palette?: ColorPaletteCustom | null; palette?: ColorPaletteCustom | null;
topPicks?: ColorTuple; topPicks?: ColorTuple;
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
compactMode?: boolean;
} }
const ColorPickerPopupContent = ({ const ColorPickerPopupContent = ({
@@ -99,9 +100,6 @@ const ColorPickerPopupContent = ({
getOpenPopup: () => AppState["openPopup"]; getOpenPopup: () => AppState["openPopup"];
}) => { }) => {
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const stylesPanelMode = useStylesPanelMode();
const isCompactMode = stylesPanelMode !== "full";
const isMobileMode = stylesPanelMode === "mobile";
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
@@ -218,8 +216,11 @@ const ColorPickerPopupContent = ({
type={type} type={type}
elements={elements} elements={elements}
updateData={updateData} updateData={updateData}
showTitle={isCompactMode} showTitle={
showHotKey={!isMobileMode} appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
showHotKey={appState.stylesPanelMode !== "mobile"}
> >
{colorInputJSX} {colorInputJSX}
</Picker> </Picker>
@@ -234,6 +235,7 @@ const ColorPickerTrigger = ({
label, label,
color, color,
type, type,
stylesPanelMode,
mode = "background", mode = "background",
onToggle, onToggle,
editingTextElement, editingTextElement,
@@ -241,13 +243,11 @@ const ColorPickerTrigger = ({
color: string | null; color: string | null;
label: string; label: string;
type: ColorPickerType; type: ColorPickerType;
stylesPanelMode?: AppState["stylesPanelMode"];
mode?: "background" | "stroke"; mode?: "background" | "stroke";
onToggle: () => void; onToggle: () => void;
editingTextElement?: boolean; editingTextElement?: boolean;
}) => { }) => {
const stylesPanelMode = useStylesPanelMode();
const isCompactMode = stylesPanelMode !== "full";
const isMobileMode = stylesPanelMode === "mobile";
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
// use pointerdown so we run before outside-close logic // use pointerdown so we run before outside-close logic
e.preventDefault(); e.preventDefault();
@@ -268,8 +268,9 @@ const ColorPickerTrigger = ({
"is-transparent": !color || color === "transparent", "is-transparent": !color || color === "transparent",
"has-outline": "has-outline":
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD), !color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
"compact-sizing": isCompactMode, "compact-sizing":
"mobile-border": isMobileMode, stylesPanelMode === "compact" || stylesPanelMode === "mobile",
"mobile-border": stylesPanelMode === "mobile",
})} })}
aria-label={label} aria-label={label}
style={color ? { "--swatch-color": color } : undefined} style={color ? { "--swatch-color": color } : undefined}
@@ -282,20 +283,22 @@ const ColorPickerTrigger = ({
onClick={handleClick} onClick={handleClick}
> >
<div className="color-picker__button-outline">{!color && slashIcon}</div> <div className="color-picker__button-outline">{!color && slashIcon}</div>
{isCompactMode && color && mode === "stroke" && ( {(stylesPanelMode === "compact" || stylesPanelMode === "mobile") &&
<div className="color-picker__button-background"> color &&
<span mode === "stroke" && (
style={{ <div className="color-picker__button-background">
color: <span
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD) style={{
? "#fff" color:
: "#111", color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
}} ? "#fff"
> : "#111",
{strokeIcon} }}
</span> >
</div> {strokeIcon}
)} </span>
</div>
)}
</Popover.Trigger> </Popover.Trigger>
); );
}; };
@@ -315,8 +318,10 @@ export const ColorPicker = ({
useEffect(() => { useEffect(() => {
openRef.current = appState.openPopup; openRef.current = appState.openPopup;
}, [appState.openPopup]); }, [appState.openPopup]);
const stylesPanelMode = useStylesPanelMode(); const compactMode =
const isCompactMode = stylesPanelMode !== "full"; type !== "canvasBackground" &&
(appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile");
return ( return (
<div> <div>
@@ -324,10 +329,10 @@ export const ColorPicker = ({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className={clsx("color-picker-container", { className={clsx("color-picker-container", {
"color-picker-container--no-top-picks": isCompactMode, "color-picker-container--no-top-picks": compactMode,
})} })}
> >
{!isCompactMode && ( {!compactMode && (
<TopPicks <TopPicks
activeColor={color} activeColor={color}
onChange={onChange} onChange={onChange}
@@ -335,7 +340,7 @@ export const ColorPicker = ({
topPicks={topPicks} topPicks={topPicks}
/> />
)} )}
{!isCompactMode && <ButtonSeparator />} {!compactMode && <ButtonSeparator />}
<Popover.Root <Popover.Root
open={appState.openPopup === type} open={appState.openPopup === type}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -349,6 +354,7 @@ export const ColorPicker = ({
color={color} color={color}
label={label} label={label}
type={type} type={type}
stylesPanelMode={appState.stylesPanelMode}
mode={type === "elementStroke" ? "stroke" : "background"} mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement} editingTextElement={!!appState.editingTextElement}
onToggle={() => { onToggle={() => {
@@ -903,7 +903,7 @@ function CommandPaletteInner({
ref={inputRef} ref={inputRef}
/> />
{app.editorInterface.formFactor !== "phone" && ( {!app.device.viewport.isMobile && (
<div className="shortcuts-wrapper"> <div className="shortcuts-wrapper">
<CommandShortcutHint shortcut="↑↓"> <CommandShortcutHint shortcut="↑↓">
{t("commandPalette.shortcuts.select")} {t("commandPalette.shortcuts.select")}
@@ -937,7 +937,7 @@ function CommandPaletteInner({
onClick={(event) => executeCommand(lastUsed, event)} onClick={(event) => executeCommand(lastUsed, event)}
disabled={!isCommandAvailable(lastUsed)} disabled={!isCommandAvailable(lastUsed)}
onMouseMove={() => setCurrentCommand(lastUsed)} onMouseMove={() => setCurrentCommand(lastUsed)}
showShortcut={app.editorInterface.formFactor !== "phone"} showShortcut={!app.device.viewport.isMobile}
appState={uiAppState} appState={uiAppState}
/> />
</div> </div>
@@ -955,7 +955,7 @@ function CommandPaletteInner({
isSelected={command.label === currentCommand?.label} isSelected={command.label === currentCommand?.label}
onClick={(event) => executeCommand(command, event)} onClick={(event) => executeCommand(command, event)}
onMouseMove={() => setCurrentCommand(command)} onMouseMove={() => setCurrentCommand(command)}
showShortcut={app.editorInterface.formFactor !== "phone"} showShortcut={!app.device.viewport.isMobile}
appState={uiAppState} appState={uiAppState}
size={category === "Library" ? "large" : "small"} size={category === "Library" ? "large" : "small"}
/> />
@@ -1034,7 +1034,7 @@ const CommandItem = ({
size="var(--icon-size, 1rem)" size="var(--icon-size, 1rem)"
icon={ icon={
typeof command.icon === "function" typeof command.icon === "function"
? command.icon(appState, []) ? command.icon(appState)
: command.icon : command.icon
} }
/> />
@@ -1,5 +1,6 @@
import type { ActionManager } from "../../actions/manager"; import type { ActionManager } from "../../actions/manager";
import type { Action } from "../../actions/types"; import type { Action } from "../../actions/types";
import type { UIAppState } from "../../types";
export type CommandPaletteItem = { export type CommandPaletteItem = {
label: string; label: string;
@@ -11,7 +12,7 @@ export type CommandPaletteItem = {
* (deburred name + keywords) * (deburred name + keywords)
*/ */
haystack?: string; haystack?: string;
icon?: Action["icon"]; icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode);
category: string; category: string;
order?: number; order?: number;
predicate?: boolean | Action["predicate"]; predicate?: boolean | Action["predicate"];
@@ -844,7 +844,7 @@ const convertElementType = <
}), }),
) as typeof element; ) as typeof element;
updateBindings(nextElement, app.scene, app.state); updateBindings(nextElement, app.scene);
return nextElement; return nextElement;
} }
+2 -2
View File
@@ -9,7 +9,7 @@ import { t } from "../i18n";
import { import {
useExcalidrawContainer, useExcalidrawContainer,
useEditorInterface, useDevice,
useExcalidrawSetAppState, useExcalidrawSetAppState,
} from "./App"; } from "./App";
import { Island } from "./Island"; import { Island } from "./Island";
@@ -51,7 +51,7 @@ export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>(); const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement); const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer(); const { id } = useExcalidrawContainer();
const isFullscreen = useEditorInterface().formFactor === "phone"; const isFullscreen = useDevice().viewport.isMobile;
useEffect(() => { useEffect(() => {
if (!islandNode) { if (!islandNode) {
@@ -20,7 +20,7 @@ export type ButtonColor =
export type ButtonSize = "medium" | "large"; export type ButtonSize = "medium" | "large";
export type FilledButtonProps = { export type FilledButtonProps = {
label?: string; label: string;
children?: React.ReactNode; children?: React.ReactNode;
onClick?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void;
@@ -20,12 +20,7 @@ import type { ValueOf } from "@excalidraw/common/utility-types";
import { Fonts } from "../../fonts"; import { Fonts } from "../../fonts";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { import { useApp, useAppProps, useExcalidrawContainer } from "../App";
useApp,
useAppProps,
useExcalidrawContainer,
useStylesPanelMode,
} from "../App";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch"; import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList"; import { ScrollableList } from "../ScrollableList";
@@ -98,7 +93,6 @@ export const FontPickerList = React.memo(
const app = useApp(); const app = useApp();
const { fonts } = app; const { fonts } = app;
const { showDeprecatedFonts } = useAppProps(); const { showDeprecatedFonts } = useAppProps();
const stylesPanelMode = useStylesPanelMode();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -344,7 +338,7 @@ export const FontPickerList = React.memo(
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement} preventAutoFocusOnTouch={!!app.state.editingTextElement}
> >
{stylesPanelMode === "full" && ( {app.state.stylesPanelMode === "full" && (
<QuickSearch <QuickSearch
ref={inputRef} ref={inputRef}
placeholder={t("quickSearch.placeholder")} placeholder={t("quickSearch.placeholder")}
+8 -22
View File
@@ -11,8 +11,6 @@ import {
import { isNodeInFlowchart } from "@excalidraw/element"; import { isNodeInFlowchart } from "@excalidraw/element";
import type { EditorInterface } from "@excalidraw/common";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut"; import { getShortcutKey } from "../shortcut";
import { isEraserActive } from "../appState"; import { isEraserActive } from "../appState";
@@ -20,12 +18,12 @@ import { isGridModeEnabled } from "../snapping";
import "./HintViewer.scss"; import "./HintViewer.scss";
import type { AppClassProperties, UIAppState } from "../types"; import type { AppClassProperties, Device, UIAppState } from "../types";
interface HintViewerProps { interface HintViewerProps {
appState: UIAppState; appState: UIAppState;
isMobile: boolean; isMobile: boolean;
editorInterface: EditorInterface; device: Device;
app: AppClassProperties; app: AppClassProperties;
} }
@@ -37,7 +35,7 @@ const getTaggedShortcutKey = (key: string | string[]) =>
const getHints = ({ const getHints = ({
appState, appState,
isMobile, isMobile,
editorInterface, device,
app, app,
}: HintViewerProps): null | string | string[] => { }: HintViewerProps): null | string | string[] => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
@@ -53,7 +51,7 @@ const getHints = ({
}); });
} }
if (appState.openSidebar && !editorInterface.canFitSidebar) { if (appState.openSidebar && !device.editor.canFitSidebar) {
return null; return null;
} }
@@ -62,20 +60,6 @@ const getHints = ({
shortcut: getTaggedShortcutKey("Alt"), shortcut: getTaggedShortcutKey("Alt"),
}); });
} }
const selectedElements = app.scene.getSelectedElements(appState);
// creating or dragging arrow point
if (
appState.selectedLinearElement?.isDragging &&
selectedElements[0]?.type === "arrow"
) {
return t("hints.arrowBindModifiers", {
shortcut_1: getTaggedShortcutKey("Ctrl"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
}
if (activeTool.type === "arrow" || activeTool.type === "line") { if (activeTool.type === "arrow" || activeTool.type === "line") {
if (multiMode) { if (multiMode) {
return t("hints.linearElementMulti", { return t("hints.linearElementMulti", {
@@ -103,6 +87,8 @@ const getHints = ({
return t("hints.embeddable"); return t("hints.embeddable");
} }
const selectedElements = app.scene.getSelectedElements(appState);
if ( if (
isResizing && isResizing &&
lastPointerDownWith === "mouse" && lastPointerDownWith === "mouse" &&
@@ -239,13 +225,13 @@ const getHints = ({
export const HintViewer = ({ export const HintViewer = ({
appState, appState,
isMobile, isMobile,
editorInterface, device,
app, app,
}: HintViewerProps) => { }: HintViewerProps) => {
const hints = getHints({ const hints = getHints({
appState, appState,
isMobile, isMobile,
editorInterface, device,
app, app,
}); });
@@ -8,7 +8,7 @@ import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible"; import Collapsible from "./Stats/Collapsible";
import { useEditorInterface, useExcalidrawContainer } from "./App"; import { useDevice, useExcalidrawContainer } from "./App";
import "./IconPicker.scss"; import "./IconPicker.scss";
@@ -38,7 +38,7 @@ function Picker<T>({
onClose: () => void; onClose: () => void;
numberOfOptionsToAlwaysShow?: number; numberOfOptionsToAlwaysShow?: number;
}) { }) {
const editorInterface = useEditorInterface(); const device = useDevice();
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
@@ -153,7 +153,7 @@ function Picker<T>({
); );
}; };
const isMobile = editorInterface.formFactor === "phone"; const isMobile = device.editor.isMobile;
return ( return (
<Popover.Content <Popover.Content
@@ -120,53 +120,4 @@
margin-bottom: auto; margin-bottom: auto;
} }
} }
.disable-view-mode {
display: flex;
justify-content: center;
cursor: pointer;
align-items: center;
border: 1px solid var(--color-primary);
padding: 0.5rem;
border-radius: var(--border-radius-lg);
background-color: var(--island-bg-color);
text-decoration: none !important;
font-family: var(--ui-font);
font-size: 0.8333rem;
box-sizing: border-box;
width: var(--mobile-action-button-size, var(--default-button-size));
height: var(--mobile-action-button-size, var(--default-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 {
background-color: var(--color-primary);
color: white !important;
}
&:active {
background-color: var(--color-primary-darker);
}
svg {
width: 20px;
height: 20px;
}
}
.theme--dark {
.plus-banner {
&:hover {
color: black !important;
}
}
}
} }
+51 -46
View File
@@ -4,6 +4,7 @@ import React from "react";
import { import {
CLASSES, CLASSES,
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
MQ_MIN_WIDTH_DESKTOP,
TOOL_TYPE, TOOL_TYPE,
arrayToMap, arrayToMap,
capitalizeString, capitalizeString,
@@ -45,9 +46,9 @@ import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import MainMenu from "./main-menu/MainMenu"; import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { useEditorInterface, useStylesPanelMode } from "./App"; import { useDevice } from "./App";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm"; import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
import { sidebarRightIcon } from "./icons"; import { LibraryIcon } from "./icons";
import { DefaultSidebar } from "./DefaultSidebar"; import { DefaultSidebar } from "./DefaultSidebar";
import { TTDDialog } from "./TTDDialog/TTDDialog"; import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
@@ -160,28 +161,27 @@ const LayerUI = ({
isCollaborating, isCollaborating,
generateLinkForSelection, generateLinkForSelection,
}: LayerUIProps) => { }: LayerUIProps) => {
const editorInterface = useEditorInterface(); const device = useDevice();
const stylesPanelMode = useStylesPanelMode();
const isCompactStylesPanel = stylesPanelMode === "compact";
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
const spacing = isCompactStylesPanel const spacing =
? { appState.stylesPanelMode === "compact"
menuTopGap: 4, ? {
toolbarColGap: 4, menuTopGap: 4,
toolbarRowGap: 1, toolbarColGap: 4,
toolbarInnerRowGap: 0.5, toolbarRowGap: 1,
islandPadding: 1, toolbarInnerRowGap: 0.5,
collabMarginLeft: 8, islandPadding: 1,
} collabMarginLeft: 8,
: { }
menuTopGap: 6, : {
toolbarColGap: 4, menuTopGap: 6,
toolbarRowGap: 1, toolbarColGap: 4,
toolbarInnerRowGap: 1, toolbarRowGap: 1,
islandPadding: 1, toolbarInnerRowGap: 1,
collabMarginLeft: 8, islandPadding: 1,
}; collabMarginLeft: 8,
};
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider; const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
@@ -236,7 +236,7 @@ const LayerUI = ({
); );
const renderSelectedShapeActions = () => { const renderSelectedShapeActions = () => {
const isCompactMode = isCompactStylesPanel; const isCompactMode = appState.stylesPanelMode === "compact";
return ( return (
<Section <Section
@@ -308,7 +308,7 @@ const LayerUI = ({
<div <div
className={clsx("selected-shape-actions-container", { className={clsx("selected-shape-actions-container", {
"selected-shape-actions-container--compact": "selected-shape-actions-container--compact":
isCompactStylesPanel, appState.stylesPanelMode === "compact",
})} })}
> >
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
@@ -333,13 +333,14 @@ const LayerUI = ({
padding={spacing.islandPadding} padding={spacing.islandPadding}
className={clsx("App-toolbar", { className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
"App-toolbar--compact": isCompactStylesPanel, "App-toolbar--compact":
appState.stylesPanelMode === "compact",
})} })}
> >
<HintViewer <HintViewer
appState={appState} appState={appState}
isMobile={editorInterface.formFactor === "phone"} isMobile={device.editor.isMobile}
editorInterface={editorInterface} device={device}
app={app} app={app}
/> />
{heading} {heading}
@@ -405,7 +406,8 @@ const LayerUI = ({
"layer-ui__wrapper__top-right zen-mode-transition", "layer-ui__wrapper__top-right zen-mode-transition",
{ {
"transition-right": appState.zenModeEnabled, "transition-right": appState.zenModeEnabled,
"layer-ui__wrapper__top-right--compact": isCompactStylesPanel, "layer-ui__wrapper__top-right--compact":
appState.stylesPanelMode === "compact",
}, },
)} )}
> >
@@ -415,10 +417,7 @@ const LayerUI = ({
userToFollow={appState.userToFollow?.socketId || null} userToFollow={appState.userToFollow?.socketId || null}
/> />
)} )}
{renderTopRightUI?.( {renderTopRightUI?.(device.editor.isMobile, appState)}
editorInterface.formFactor === "phone",
appState,
)}
{!appState.viewModeEnabled && {!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && appState.openDialog?.name !== "elementLinkSelector" &&
// hide button when sidebar docked // hide button when sidebar docked
@@ -449,9 +448,7 @@ const LayerUI = ({
trackEvent( trackEvent(
"sidebar", "sidebar",
`toggleDock (${docked ? "dock" : "undock"})`, `toggleDock (${docked ? "dock" : "undock"})`,
`(${ `(${device.editor.isMobile ? "mobile" : "desktop"})`,
editorInterface.formFactor === "phone" ? "mobile" : "desktop"
})`,
); );
}} }}
/> />
@@ -472,21 +469,23 @@ const LayerUI = ({
<DefaultMainMenu UIOptions={UIOptions} /> <DefaultMainMenu UIOptions={UIOptions} />
<DefaultSidebar.Trigger <DefaultSidebar.Trigger
__fallback __fallback
icon={sidebarRightIcon} icon={LibraryIcon}
title={capitalizeString(t("toolBar.library"))} title={capitalizeString(t("toolBar.library"))}
onToggle={(open) => { onToggle={(open) => {
if (open) { if (open) {
trackEvent( trackEvent(
"sidebar", "sidebar",
`${DEFAULT_SIDEBAR.name} (open)`, `${DEFAULT_SIDEBAR.name} (open)`,
`button (${ `button (${device.editor.isMobile ? "mobile" : "desktop"})`,
editorInterface.formFactor === "phone" ? "mobile" : "desktop"
})`,
); );
} }
}} }}
tab={DEFAULT_SIDEBAR.defaultTab} tab={DEFAULT_SIDEBAR.defaultTab}
/> >
{appState.stylesPanelMode === "full" &&
appState.width >= MQ_MIN_WIDTH_DESKTOP &&
t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog /> <DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />} {appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
@@ -497,7 +496,7 @@ const LayerUI = ({
{appState.errorMessage} {appState.errorMessage}
</ErrorDialog> </ErrorDialog>
)} )}
{eyeDropperState && editorInterface.formFactor !== "phone" && ( {eyeDropperState && !device.editor.isMobile && (
<EyeDropper <EyeDropper
colorPickerType={eyeDropperState.colorPickerType} colorPickerType={eyeDropperState.colorPickerType}
onCancel={() => { onCancel={() => {
@@ -576,7 +575,7 @@ const LayerUI = ({
} }
/> />
)} )}
{editorInterface.formFactor === "phone" && ( {device.editor.isMobile && (
<MobileMenu <MobileMenu
app={app} app={app}
appState={appState} appState={appState}
@@ -594,14 +593,14 @@ const LayerUI = ({
UIOptions={UIOptions} UIOptions={UIOptions}
/> />
)} )}
{editorInterface.formFactor !== "phone" && ( {!device.editor.isMobile && (
<> <>
<div <div
className="layer-ui__wrapper" className="layer-ui__wrapper"
style={ style={
appState.openSidebar && appState.openSidebar &&
isSidebarDocked && isSidebarDocked &&
editorInterface.canFitSidebar device.editor.canFitSidebar
? { width: `calc(100% - var(--right-sidebar-width))` } ? { width: `calc(100% - var(--right-sidebar-width))` }
: {} : {}
} }
@@ -646,8 +645,14 @@ const LayerUI = ({
}; };
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => { const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
const { startBoundElement, cursorButton, scrollX, scrollY, ...ret } = const {
appState; suggestedBindings,
startBoundElement,
cursorButton,
scrollX,
scrollY,
...ret
} = appState;
return ret; return ret;
}; };
@@ -32,7 +32,7 @@ import "./LibraryMenuItems.scss";
import { TextField } from "./TextField"; import { TextField } from "./TextField";
import { useEditorInterface } from "./App"; import { useDevice } from "./App";
import { Button } from "./Button"; import { Button } from "./Button";
@@ -75,7 +75,7 @@ export default function LibraryMenuItems({
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
}) { }) {
const editorInterface = useEditorInterface(); const device = useDevice();
const libraryContainerRef = useRef<HTMLDivElement>(null); const libraryContainerRef = useRef<HTMLDivElement>(null);
const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef); const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
@@ -392,7 +392,7 @@ export default function LibraryMenuItems({
ref={searchInputRef} ref={searchInputRef}
type="search" type="search"
className={clsx("library-menu-items-container__search", { className={clsx("library-menu-items-container__search", {
hideCancelButton: editorInterface.formFactor !== "phone", hideCancelButton: !device.editor.isMobile,
})} })}
placeholder={t("library.search.inputPlaceholder")} placeholder={t("library.search.inputPlaceholder")}
value={searchInputValue} value={searchInputValue}
@@ -3,7 +3,7 @@ import { memo, useRef, useState } from "react";
import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
import { useEditorInterface } from "./App"; import { useDevice } from "./App";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
import { PlusIcon } from "./icons"; import { PlusIcon } from "./icons";
@@ -36,7 +36,7 @@ export const LibraryUnit = memo(
const svg = useLibraryItemSvg(id, elements, svgCache, ref); const svg = useLibraryItemSvg(id, elements, svgCache, ref);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useEditorInterface().formFactor === "phone"; const isMobile = useDevice().editor.isMobile;
const adder = isPending && ( const adder = isPending && (
<div className="library-unit__adder">{PlusIcon}</div> <div className="library-unit__adder">{PlusIcon}</div>
); );
@@ -1,15 +0,0 @@
import { FilledButton } from "./FilledButton";
export const LinkButton = ({
children,
href,
}: {
href: string;
children: React.ReactNode;
}) => {
return (
<a href={href} target="_blank" rel="noopener" className="link-button">
<FilledButton>{children}</FilledButton>
</a>
);
};

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