Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 385416d231 | |||
| 4d04e3e9a1 | |||
| e08ebdec28 | |||
| 8d8f696628 | |||
| 19b3dc658a | |||
| 4e0441eeb4 | |||
| 8013eb5e16 | |||
| 725412ebd3 | |||
| 7da176ff7d | |||
| 5fffc4743f | |||
| 8608d7b2e0 | |||
| 19b03b4ca9 | |||
| 416e8b3e42 | |||
| 98e0cd9078 | |||
| f3c16a600d | |||
| 835eb8d2fd | |||
| fde796a7a0 | |||
| 7c41944856 | |||
| f1b097ad06 | |||
| 9fcbbe0d27 | |||
| ec070911b8 | |||
| dcdeb2be57 | |||
| a8acc8212d | |||
| a89a03c66c | |||
| e32836f799 | |||
| 06c40006db | |||
| 91c7748c3d | |||
| f738b74791 | |||
| 00ae455873 | |||
| 06c5ea94d3 | |||
| f55ecb96cc | |||
| a6a32b9b29 | |||
| ac0d3059dc | |||
| 1161f1b8ba | |||
| 204e06b77b | |||
| 414182f599 | |||
| b9d27d308e | |||
| 3bdaafe4b5 | |||
| ae89608985 | |||
| 3085f4af81 | |||
| 531f3e5524 | |||
| 90ec2739ae | |||
| f29e9df72d | |||
| b5ad7ae4e3 | |||
| c78e4aab7f | |||
| b4903a7eab | |||
| c6f8ef9ad2 | |||
| 2535d73054 | |||
| dda3affcb0 | |||
| 54c148f390 | |||
| cc8e490c75 | |||
| 9036812b6d | |||
| df25de7e68 | |||
| a3763648fe | |||
| 178eca5828 | |||
| cb33de25f4 | |||
| 37ad85cbaf | |||
| d6a934ed19 | |||
| 416da62138 | |||
| f38f381989 | |||
| e5e07260c6 | |||
| 86605829c6 | |||
| 8492b144b0 | |||
| e46f038132 | |||
| c398af6c92 | |||
| 973f2a464d | |||
| 02cef5ea92 | |||
| 678dff25ed | |||
| 0cfa53b764 | |||
| d615c2cea1 | |||
| cde46793f8 | |||
| 446f871536 | |||
| 34bff557e3 | |||
| a0e54e3768 | |||
| d6ec1dc7e6 | |||
| 2d127f8c22 | |||
| 4eadb891f8 | |||
| 258605d1d5 | |||
| 62e20aa247 | |||
| 0199c82e98 | |||
| 3c07ff358a | |||
| d9c85ff18f | |||
| 6d84fa21c5 | |||
| 5666fd8199 | |||
| abdacf8239 | |||
| 1068153b25 | |||
| 09876aba6d | |||
| 8ceb55dd02 | |||
| c141500400 | |||
| b1f3cc50ee | |||
| 8e27de2cdc | |||
| 0a19c93509 | |||
| 958597dfaa | |||
| c72c47f0cd | |||
| 058918f8e5 | |||
| 3f194918e6 | |||
| 93c92d13e9 | |||
| 84e96e9393 | |||
| 320af405e9 | |||
| 60512f13d5 | |||
| 37b75263f8 | |||
| f0458cc216 | |||
| 9f3fdf5505 | |||
| f42e1ab64e | |||
| 18808481fd | |||
| c08840358b | |||
| a7b64f02b3 | |||
| 0d4abd1ddc | |||
| 9e77373c81 | |||
| e99baaa6bb | |||
| d108053351 | |||
| a8857f2849 | |||
| d4e85a9480 | |||
| 08cd4c4f9a | |||
| 469caadb87 | |||
| ca1a4f25e7 | |||
| df1f9281b4 | |||
| c210b7b092 | |||
| 660d21fe46 | |||
| 56c05b3099 | |||
| c7780cb9cb | |||
| 4e265629c3 | |||
| 1c611d6c4f | |||
| ab6af41d33 | |||
| 15dfe0cc7c | |||
| 6c0ff7fc5c |
@@ -24,4 +24,4 @@ jobs:
|
|||||||
- name: Auto release
|
- name: Auto release
|
||||||
run: |
|
run: |
|
||||||
yarn add @actions/core -W
|
yarn add @actions/core -W
|
||||||
yarn autorelease
|
yarn release --tag=next --non-interactive
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
name: Auto release excalidraw preview
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created, edited]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
Auto-release-excalidraw-preview:
|
|
||||||
name: Auto release preview
|
|
||||||
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: React to release comment
|
|
||||||
uses: peter-evans/create-or-update-comment@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
|
||||||
comment-id: ${{ github.event.comment.id }}
|
|
||||||
reactions: "+1"
|
|
||||||
- name: Get PR SHA
|
|
||||||
id: sha
|
|
||||||
uses: actions/github-script@v4
|
|
||||||
with:
|
|
||||||
result-encoding: string
|
|
||||||
script: |
|
|
||||||
const { owner, repo, number } = context.issue;
|
|
||||||
const pr = await github.pulls.get({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pull_number: number,
|
|
||||||
});
|
|
||||||
return pr.data.head.sha
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
ref: ${{ steps.sha.outputs.result }}
|
|
||||||
fetch-depth: 2
|
|
||||||
- name: Setup Node.js 18.x
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: 18.x
|
|
||||||
- name: Set up publish access
|
|
||||||
run: |
|
|
||||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
- name: Auto release preview
|
|
||||||
id: "autorelease"
|
|
||||||
run: |
|
|
||||||
yarn add @actions/core -W
|
|
||||||
yarn autorelease preview ${{ github.event.issue.number }}
|
|
||||||
- name: Post comment post release
|
|
||||||
if: always()
|
|
||||||
uses: peter-evans/create-or-update-comment@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
|
||||||
issue-number: ${{ github.event.issue.number }}
|
|
||||||
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
|
|
||||||
@@ -17,9 +17,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: excalidraw/excalidraw:latest
|
tags: excalidraw/excalidraw:latest
|
||||||
|
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18 AS build
|
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||||
|
|
||||||
WORKDIR /opt/node_app
|
WORKDIR /opt/node_app
|
||||||
|
|
||||||
@@ -6,13 +6,14 @@ COPY . .
|
|||||||
|
|
||||||
# do not ignore optional dependencies:
|
# do not ignore optional dependencies:
|
||||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||||
RUN yarn --network-timeout 600000
|
RUN --mount=type=cache,target=/root/.cache/yarn \
|
||||||
|
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
||||||
|
|
||||||
ARG NODE_ENV=production
|
ARG NODE_ENV=production
|
||||||
|
|
||||||
RUN yarn build:app:docker
|
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@@ -363,13 +363,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
|||||||
```ts
|
```ts
|
||||||
(
|
(
|
||||||
tool: (
|
tool: (
|
||||||
| (
|
| { type: ToolType }
|
||||||
| { type: Exclude<ToolType, "image"> }
|
|
||||||
| {
|
|
||||||
type: Extract<ToolType, "image">;
|
|
||||||
insertOnCanvasDirectly?: boolean;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
| { type: "custom"; customType: string }
|
| { type: "custom"; customType: string }
|
||||||
) & { locked?: boolean },
|
) & { locked?: boolean },
|
||||||
) => {};
|
) => {};
|
||||||
@@ -377,7 +371,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
|||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
|
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool |
|
||||||
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
|
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
|
||||||
|
|
||||||
## setCursor
|
## setCursor
|
||||||
|
|||||||
@@ -28,32 +28,12 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
|
|||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
### Create a test release
|
|
||||||
|
|
||||||
You can create a test release by posting the below comment in your pull request:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
@excalibot trigger release
|
|
||||||
```
|
|
||||||
|
|
||||||
Once the version is released `@excalibot` will post a comment with the release version.
|
|
||||||
|
|
||||||
### Creating a production release
|
### Creating a production release
|
||||||
|
|
||||||
To release the next stable version follow the below steps:
|
To release the next stable version follow the below steps:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn prerelease:excalidraw
|
yarn release --tag=latest --version=0.19.0
|
||||||
```
|
```
|
||||||
|
|
||||||
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
|
You will need to pass the `latest` tag with `version` for which you want to create the release. This will make the changes needed before publishing the packages into NPM, like updating dependencies of all `@excalidraw/*` packages, generating new entries in `CHANGELOG.md` and more.
|
||||||
|
|
||||||
The next step is to run the `release` script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn release:excalidraw
|
|
||||||
```
|
|
||||||
|
|
||||||
This will publish the package.
|
|
||||||
|
|
||||||
Right now there are two steps to create a production release but once this works fine these scripts will be combined and more automation will be done.
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ If you want to only import `Excalidraw` component you can do :point_down:
|
|||||||
|
|
||||||
```jsx showLineNumbers
|
```jsx showLineNumbers
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
|
||||||
const Excalidraw = dynamic(
|
const Excalidraw = dynamic(
|
||||||
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const ExcalidrawScope = {
|
|||||||
initialData,
|
initialData,
|
||||||
useI18n: ExcalidrawComp.useI18n,
|
useI18n: ExcalidrawComp.useI18n,
|
||||||
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
||||||
|
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ExcalidrawScope;
|
export default ExcalidrawScope;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
excalidraw:
|
excalidraw:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
"build:packages": "yarn --cwd ../../ build:packages",
|
||||||
|
"build:workspace": "yarn build:packages && yarn copy:assets",
|
||||||
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
|
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
|
||||||
"dev": "yarn build:workspace && next dev -p 3005",
|
"dev": "yarn build:workspace && next dev -p 3005",
|
||||||
"build": "yarn build:workspace && next build",
|
"build": "yarn build:workspace && next build",
|
||||||
|
|||||||
@@ -17,6 +17,6 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 5002",
|
"preview": "vite preview --port 5002",
|
||||||
"build:preview": "yarn build && yarn preview",
|
"build:preview": "yarn build && yarn preview",
|
||||||
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
|
"build:packages": "yarn --cwd ../../ build:packages"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"outputDirectory": "dist",
|
"outputDirectory": "dist",
|
||||||
"installCommand": "yarn install",
|
"installCommand": "yarn install",
|
||||||
"buildCommand": "yarn build:package && yarn build"
|
"buildCommand": "yarn build:packages && yarn build"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-8
@@ -20,7 +20,6 @@ import {
|
|||||||
APP_NAME,
|
APP_NAME,
|
||||||
EVENT,
|
EVENT,
|
||||||
THEME,
|
THEME,
|
||||||
TITLE_TIMEOUT,
|
|
||||||
VERSION_TIMEOUT,
|
VERSION_TIMEOUT,
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
@@ -120,6 +119,7 @@ import {
|
|||||||
LibraryIndexedDBAdapter,
|
LibraryIndexedDBAdapter,
|
||||||
LibraryLocalStorageMigrationAdapter,
|
LibraryLocalStorageMigrationAdapter,
|
||||||
LocalData,
|
LocalData,
|
||||||
|
localStorageQuotaExceededAtom,
|
||||||
} from "./data/LocalData";
|
} from "./data/LocalData";
|
||||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||||
@@ -499,11 +499,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const titleTimeout = setTimeout(
|
|
||||||
() => (document.title = APP_NAME),
|
|
||||||
TITLE_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const syncData = debounce(() => {
|
const syncData = debounce(() => {
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
return;
|
return;
|
||||||
@@ -594,7 +589,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
visibilityChange,
|
visibilityChange,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
clearTimeout(titleTimeout);
|
|
||||||
};
|
};
|
||||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||||
|
|
||||||
@@ -734,6 +728,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const isOffline = useAtomValue(isOfflineAtom);
|
const isOffline = useAtomValue(isOfflineAtom);
|
||||||
|
|
||||||
|
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
|
||||||
|
|
||||||
const onCollabDialogOpen = useCallback(
|
const onCollabDialogOpen = useCallback(
|
||||||
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||||
[setShareDialogState],
|
[setShareDialogState],
|
||||||
@@ -908,10 +904,15 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
<TTDDialogTrigger />
|
<TTDDialogTrigger />
|
||||||
{isCollaborating && isOffline && (
|
{isCollaborating && isOffline && (
|
||||||
<div className="collab-offline-warning">
|
<div className="alertalert--warning">
|
||||||
{t("alerts.collabOfflineWarning")}
|
{t("alerts.collabOfflineWarning")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{localStorageQuotaExceeded && (
|
||||||
|
<div className="alert alert--danger">
|
||||||
|
{t("alerts.localStorageQuotaExceeded")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{latestShareableLink && (
|
{latestShareableLink && (
|
||||||
<ShareableLinkDialog
|
<ShareableLinkDialog
|
||||||
link={latestShareableLink}
|
link={latestShareableLink}
|
||||||
@@ -1142,6 +1143,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
ref={debugCanvasRef}
|
ref={debugCanvasRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* <FreedrawDebugSliders /> */}
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
|||||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||||
|
|
||||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
// should be aligned with MAX_ALLOWED_FILE_BYTES
|
||||||
|
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
|
||||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||||
|
|
||||||
|
|||||||
@@ -530,7 +530,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existingRoomLinkData) {
|
if (existingRoomLinkData) {
|
||||||
|
// when joining existing room, don't merge it with current scene data
|
||||||
|
this.excalidrawAPI.resetScene();
|
||||||
|
} else {
|
||||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||||
if (isImageElement(element) && element.status === "saved") {
|
if (isImageElement(element) && element.status === "saved") {
|
||||||
return newElementWith(element, { status: "pending" });
|
return newElementWith(element, { status: "pending" });
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@excalidraw/excalidraw/renderer/helpers";
|
} from "@excalidraw/excalidraw/renderer/helpers";
|
||||||
import { type AppState } from "@excalidraw/excalidraw/types";
|
import { type AppState } from "@excalidraw/excalidraw/types";
|
||||||
import { throttleRAF } from "@excalidraw/common";
|
import { throttleRAF } from "@excalidraw/common";
|
||||||
import { useCallback, useImperativeHandle, useRef } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isLineSegment,
|
isLineSegment,
|
||||||
@@ -18,10 +18,12 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { isCurve } from "@excalidraw/math/curve";
|
import { isCurve } from "@excalidraw/math/curve";
|
||||||
|
|
||||||
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
|
import React from "react";
|
||||||
|
|
||||||
import type { Curve } from "@excalidraw/math";
|
import type { Curve } from "@excalidraw/math";
|
||||||
|
|
||||||
|
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
||||||
|
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
|
|
||||||
const renderLine = (
|
const renderLine = (
|
||||||
@@ -113,10 +115,6 @@ const _debugRenderer = (
|
|||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = bootstrapCanvas({
|
const context = bootstrapCanvas({
|
||||||
canvas,
|
canvas,
|
||||||
scale,
|
scale,
|
||||||
@@ -314,35 +312,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
|||||||
interface DebugCanvasProps {
|
interface DebugCanvasProps {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
scale: number;
|
scale: number;
|
||||||
ref?: React.Ref<HTMLCanvasElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||||
const { width, height } = appState;
|
({ appState, scale }, ref) => {
|
||||||
|
const { width, height } = appState;
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
return (
|
||||||
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
<canvas
|
||||||
ref,
|
style={{
|
||||||
() => canvasRef.current,
|
width,
|
||||||
[canvasRef],
|
height,
|
||||||
);
|
position: "absolute",
|
||||||
|
zIndex: 2,
|
||||||
return (
|
pointerEvents: "none",
|
||||||
<canvas
|
}}
|
||||||
style={{
|
width={width * scale}
|
||||||
width,
|
height={height * scale}
|
||||||
height,
|
ref={ref}
|
||||||
position: "absolute",
|
>
|
||||||
zIndex: 2,
|
Debug Canvas
|
||||||
pointerEvents: "none",
|
</canvas>
|
||||||
}}
|
);
|
||||||
width={width * scale}
|
},
|
||||||
height={height * scale}
|
);
|
||||||
ref={canvasRef}
|
|
||||||
>
|
|
||||||
Debug Canvas
|
|
||||||
</canvas>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DebugCanvas;
|
export default DebugCanvas;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
get,
|
get,
|
||||||
} from "idb-keyval";
|
} from "idb-keyval";
|
||||||
|
|
||||||
|
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
|
||||||
|
|
||||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||||
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
||||||
@@ -45,6 +47,8 @@ import { updateBrowserStateVersion } from "./tabSync";
|
|||||||
|
|
||||||
const filesStore = createStore("files-db", "files-store");
|
const filesStore = createStore("files-db", "files-store");
|
||||||
|
|
||||||
|
export const localStorageQuotaExceededAtom = atom(false);
|
||||||
|
|
||||||
class LocalFileManager extends FileManager {
|
class LocalFileManager extends FileManager {
|
||||||
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
||||||
await entries(filesStore).then((entries) => {
|
await entries(filesStore).then((entries) => {
|
||||||
@@ -69,6 +73,9 @@ const saveDataStateToLocalStorage = (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
|
const localStorageQuotaExceeded = appJotaiStore.get(
|
||||||
|
localStorageQuotaExceededAtom,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const _appState = clearAppStateForLocalStorage(appState);
|
const _appState = clearAppStateForLocalStorage(appState);
|
||||||
|
|
||||||
@@ -88,12 +95,22 @@ const saveDataStateToLocalStorage = (
|
|||||||
JSON.stringify(_appState),
|
JSON.stringify(_appState),
|
||||||
);
|
);
|
||||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||||
|
if (localStorageQuotaExceeded) {
|
||||||
|
appJotaiStore.set(localStorageQuotaExceededAtom, false);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Unable to access window.localStorage
|
// Unable to access window.localStorage
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
|
||||||
|
appJotaiStore.set(localStorageQuotaExceededAtom, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isQuotaExceededError = (error: any) => {
|
||||||
|
return error instanceof DOMException && error.name === "QuotaExceededError";
|
||||||
|
};
|
||||||
|
|
||||||
type SavingLockTypes = "collaboration";
|
type SavingLockTypes = "collaboration";
|
||||||
|
|
||||||
export class LocalData {
|
export class LocalData {
|
||||||
|
|||||||
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
|
|||||||
}
|
}
|
||||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||||
|
deleteInvisibleElements: true,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
|
|||||||
@@ -258,11 +258,16 @@ export const loadScene = async (
|
|||||||
await importFromBackend(id, privateKey),
|
await importFromBackend(id, privateKey),
|
||||||
localDataState?.appState,
|
localDataState?.appState,
|
||||||
localDataState?.elements,
|
localDataState?.elements,
|
||||||
{ repairBindings: true, refreshDimensions: false },
|
{
|
||||||
|
repairBindings: true,
|
||||||
|
refreshDimensions: false,
|
||||||
|
deleteInvisibleElements: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
data = restore(localDataState || null, null, null, {
|
data = restore(localDataState || null, null, null, {
|
||||||
repairBindings: true,
|
repairBindings: true,
|
||||||
|
deleteInvisibleElements: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
<title>Excalidraw Whiteboard</title>
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<meta
|
<meta
|
||||||
name="title"
|
name="title"
|
||||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.collab-offline-warning {
|
.alert {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6.5rem;
|
top: 6.5rem;
|
||||||
@@ -69,10 +69,18 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
background-color: var(--color-warning);
|
|
||||||
color: var(--color-text-warning);
|
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
color: var(--color-text-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background-color: var(--color-danger-dark);
|
||||||
|
color: var(--color-danger-text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
|
|||||||
},
|
},
|
||||||
"isTouchScreen": false,
|
"isTouchScreen": false,
|
||||||
"viewport": {
|
"viewport": {
|
||||||
"isLandscape": false,
|
"isLandscape": true,
|
||||||
"isMobile": true,
|
"isMobile": true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ describe("collaboration", () => {
|
|||||||
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
expect(API.getSnapshot()).toEqual([
|
expect(API.getSnapshot()).toEqual([
|
||||||
expect.objectContaining(rect1Props),
|
expect.objectContaining(rect1Props),
|
||||||
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
||||||
@@ -247,79 +248,5 @@ describe("collaboration", () => {
|
|||||||
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => h.app.actionManager.executeAction(undoAction));
|
|
||||||
|
|
||||||
// simulate local update
|
|
||||||
API.updateScene({
|
|
||||||
elements: syncInvalidIndices([
|
|
||||||
h.elements[0],
|
|
||||||
newElementWith(h.elements[1], { x: 100 }),
|
|
||||||
]),
|
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
|
||||||
expect(API.getSnapshot()).toEqual([
|
|
||||||
expect.objectContaining(rect1Props),
|
|
||||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
|
||||||
]);
|
|
||||||
expect(h.elements).toEqual([
|
|
||||||
expect.objectContaining(rect1Props),
|
|
||||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => h.app.actionManager.executeAction(undoAction));
|
|
||||||
|
|
||||||
// we expect to iterate the stack to the first visible change
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
|
||||||
expect(API.getSnapshot()).toEqual([
|
|
||||||
expect.objectContaining(rect1Props),
|
|
||||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
|
||||||
]);
|
|
||||||
expect(h.elements).toEqual([
|
|
||||||
expect.objectContaining(rect1Props),
|
|
||||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// simulate force deleting the element remotely
|
|
||||||
API.updateScene({
|
|
||||||
elements: syncInvalidIndices([rect1]),
|
|
||||||
captureUpdate: CaptureUpdateAction.NEVER,
|
|
||||||
});
|
|
||||||
|
|
||||||
// snapshot was correctly updated and marked the element as deleted
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
|
||||||
expect(API.getSnapshot()).toEqual([
|
|
||||||
expect.objectContaining(rect1Props),
|
|
||||||
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
|
|
||||||
]);
|
|
||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => h.app.actionManager.executeAction(redoAction));
|
|
||||||
|
|
||||||
// with explicit redo (as update) we again restored the element from the snapshot!
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
|
||||||
expect(API.getSnapshot()).toEqual([
|
|
||||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
|
||||||
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
|
||||||
]);
|
|
||||||
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
|
||||||
expect(h.elements).toEqual([
|
|
||||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
|
||||||
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-5
@@ -52,13 +52,17 @@
|
|||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||||
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
|
"build:common": "yarn --cwd ./packages/common build:esm",
|
||||||
|
"build:element": "yarn --cwd ./packages/element build:esm",
|
||||||
|
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
|
||||||
|
"build:math": "yarn --cwd ./packages/math build:esm",
|
||||||
|
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
|
||||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||||
"build": "yarn --cwd ./excalidraw-app build",
|
"build": "yarn --cwd ./excalidraw-app build",
|
||||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||||
"start": "yarn --cwd ./excalidraw-app start",
|
"start": "yarn --cwd ./excalidraw-app start",
|
||||||
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||||
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
|
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
|
||||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||||
"test:app": "vitest",
|
"test:app": "vitest",
|
||||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||||
@@ -76,9 +80,10 @@
|
|||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"autorelease": "node scripts/autorelease.js",
|
"release": "node scripts/release.js",
|
||||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
"release:test": "node scripts/release.js --tag=test",
|
||||||
"release:excalidraw": "node scripts/release.js",
|
"release:next": "node scripts/release.js --tag=next",
|
||||||
|
"release:latest": "node scripts/release.js --tag=latest",
|
||||||
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
|
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
|
||||||
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
||||||
"clean-install": "yarn rm:node_modules && yarn install"
|
"clean-install": "yarn rm:node_modules && yarn install"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@excalidraw/common",
|
"name": "@excalidraw/common",
|
||||||
"version": "0.1.0",
|
"version": "0.18.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "./dist/types/common/src/index.d.ts",
|
"types": "./dist/types/common/src/index.d.ts",
|
||||||
"main": "./dist/prod/index.js",
|
"main": "./dist/prod/index.js",
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
"default": "./dist/prod/index.js"
|
"default": "./dist/prod/index.js"
|
||||||
},
|
},
|
||||||
"./*": {
|
"./*": {
|
||||||
"types": "./dist/types/common/src/*.d.ts"
|
"types": "./dist/types/common/src/*.d.ts",
|
||||||
|
"development": "./dist/dev/index.js",
|
||||||
|
"production": "./dist/prod/index.js",
|
||||||
|
"default": "./dist/prod/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
|
|||||||
|
|
||||||
sinkDown(idx: number) {
|
sinkDown(idx: number) {
|
||||||
const node = this.content[idx];
|
const node = this.content[idx];
|
||||||
|
const nodeScore = this.scoreFunction(node);
|
||||||
while (idx > 0) {
|
while (idx > 0) {
|
||||||
const parentN = ((idx + 1) >> 1) - 1;
|
const parentN = ((idx + 1) >> 1) - 1;
|
||||||
const parent = this.content[parentN];
|
const parent = this.content[parentN];
|
||||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
if (nodeScore < this.scoreFunction(parent)) {
|
||||||
this.content[parentN] = node;
|
|
||||||
this.content[idx] = parent;
|
this.content[idx] = parent;
|
||||||
idx = parentN; // TODO: Optimize
|
idx = parentN; // TODO: Optimize
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.content[idx] = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
bubbleUp(idx: number) {
|
bubbleUp(idx: number) {
|
||||||
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
|
|||||||
const score = this.scoreFunction(node);
|
const score = this.scoreFunction(node);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const child2N = (idx + 1) << 1;
|
const child1N = ((idx + 1) << 1) - 1;
|
||||||
const child1N = child2N - 1;
|
const child2N = child1N + 1;
|
||||||
let swap = null;
|
let smallestIdx = idx;
|
||||||
let child1Score = 0;
|
let smallestScore = score;
|
||||||
|
|
||||||
|
// Check left child
|
||||||
if (child1N < length) {
|
if (child1N < length) {
|
||||||
const child1 = this.content[child1N];
|
const child1Score = this.scoreFunction(this.content[child1N]);
|
||||||
child1Score = this.scoreFunction(child1);
|
if (child1Score < smallestScore) {
|
||||||
if (child1Score < score) {
|
smallestIdx = child1N;
|
||||||
swap = child1N;
|
smallestScore = child1Score;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check right child
|
||||||
if (child2N < length) {
|
if (child2N < length) {
|
||||||
const child2 = this.content[child2N];
|
const child2Score = this.scoreFunction(this.content[child2N]);
|
||||||
const child2Score = this.scoreFunction(child2);
|
if (child2Score < smallestScore) {
|
||||||
if (child2Score < (swap === null ? score : child1Score)) {
|
smallestIdx = child2N;
|
||||||
swap = child2N;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (swap !== null) {
|
if (smallestIdx === idx) {
|
||||||
this.content[idx] = this.content[swap];
|
|
||||||
this.content[swap] = node;
|
|
||||||
idx = swap; // TODO: Optimize
|
|
||||||
} else {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move the smaller child up, continue finding position for node
|
||||||
|
this.content[idx] = this.content[smallestIdx];
|
||||||
|
idx = smallestIdx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Place node in its final position
|
||||||
|
this.content[idx] = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
push(node: T) {
|
push(node: T) {
|
||||||
|
|||||||
@@ -18,13 +18,20 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
|||||||
export const isSafari =
|
export const isSafari =
|
||||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||||
export const isIOS =
|
export const isIOS =
|
||||||
/iPad|iPhone/.test(navigator.platform) ||
|
/iPad|iPhone/i.test(navigator.platform) ||
|
||||||
// iPadOS 13+
|
// iPadOS 13+
|
||||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||||
// keeping function so it can be mocked in test
|
// keeping function so it can be mocked in test
|
||||||
export const isBrave = () =>
|
export const isBrave = () =>
|
||||||
(navigator as any).brave?.isBrave?.name === "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;
|
||||||
|
|
||||||
@@ -36,6 +43,7 @@ export const APP_NAME = "Excalidraw";
|
|||||||
// (happens a lot with fast clicks with the text tool)
|
// (happens a lot with fast clicks with the text tool)
|
||||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||||
export const DRAGGING_THRESHOLD = 10; // px
|
export const DRAGGING_THRESHOLD = 10; // px
|
||||||
|
export const MINIMUM_ARROW_SIZE = 20; // px
|
||||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||||
@@ -117,10 +125,12 @@ export const ENV = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CLASSES = {
|
export const CLASSES = {
|
||||||
|
SIDEBAR: "sidebar",
|
||||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||||
ZOOM_ACTIONS: "zoom-actions",
|
ZOOM_ACTIONS: "zoom-actions",
|
||||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||||
|
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||||
@@ -147,19 +157,49 @@ export const FONT_FAMILY = {
|
|||||||
Assistant: 10,
|
Assistant: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
|
||||||
|
// so we need to have generic font fallback before it
|
||||||
|
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
|
||||||
|
export const MONOSPACE_GENERIC_FONT = "monospace";
|
||||||
|
|
||||||
|
export const FONT_FAMILY_GENERIC_FALLBACKS = {
|
||||||
|
[SANS_SERIF_GENERIC_FONT]: 998,
|
||||||
|
[MONOSPACE_GENERIC_FONT]: 999,
|
||||||
|
};
|
||||||
|
|
||||||
export const FONT_FAMILY_FALLBACKS = {
|
export const FONT_FAMILY_FALLBACKS = {
|
||||||
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
|
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
|
||||||
|
...FONT_FAMILY_GENERIC_FALLBACKS,
|
||||||
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
|
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getGenericFontFamilyFallback(
|
||||||
|
fontFamily: number,
|
||||||
|
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
|
||||||
|
switch (fontFamily) {
|
||||||
|
case FONT_FAMILY.Cascadia:
|
||||||
|
case FONT_FAMILY["Comic Shanns"]:
|
||||||
|
return MONOSPACE_GENERIC_FONT;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return SANS_SERIF_GENERIC_FONT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const getFontFamilyFallbacks = (
|
export const getFontFamilyFallbacks = (
|
||||||
fontFamily: number,
|
fontFamily: number,
|
||||||
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
|
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
|
||||||
|
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
|
||||||
|
|
||||||
switch (fontFamily) {
|
switch (fontFamily) {
|
||||||
case FONT_FAMILY.Excalifont:
|
case FONT_FAMILY.Excalifont:
|
||||||
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
|
return [
|
||||||
|
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||||
|
genericFallbackFont,
|
||||||
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
|
];
|
||||||
default:
|
default:
|
||||||
return [WINDOWS_EMOJI_FALLBACK_FONT];
|
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,13 +261,20 @@ export const IMAGE_MIME_TYPES = {
|
|||||||
jfif: "image/jfif",
|
jfif: "image/jfif",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const MIME_TYPES = {
|
export const STRING_MIME_TYPES = {
|
||||||
text: "text/plain",
|
text: "text/plain",
|
||||||
html: "text/html",
|
html: "text/html",
|
||||||
json: "application/json",
|
json: "application/json",
|
||||||
// excalidraw data
|
// excalidraw data
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
|
// LEGACY: fully-qualified library JSON data
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
|
// list of excalidraw library item ids
|
||||||
|
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const MIME_TYPES = {
|
||||||
|
...STRING_MIME_TYPES,
|
||||||
// image-encoded excalidraw data
|
// image-encoded excalidraw data
|
||||||
"excalidraw.svg": "image/svg+xml",
|
"excalidraw.svg": "image/svg+xml",
|
||||||
"excalidraw.png": "image/png",
|
"excalidraw.png": "image/png",
|
||||||
@@ -304,10 +351,20 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
|
|
||||||
// breakpoints
|
// breakpoints
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// md screen
|
|
||||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
// mobile: up to 699px
|
||||||
|
export const MQ_MAX_MOBILE = 599;
|
||||||
|
|
||||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
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
|
// sidebar
|
||||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -385,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: {
|
||||||
@@ -402,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,
|
||||||
@@ -484,3 +542,10 @@ export enum UserIdleState {
|
|||||||
* the start and end points)
|
* the start and end points)
|
||||||
*/
|
*/
|
||||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||||
|
|
||||||
|
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
||||||
|
|
||||||
|
// glass background for mobile action buttons
|
||||||
|
export const MOBILE_ACTION_BUTTON_BG = {
|
||||||
|
background: "var(--mobile-action-button-bg)",
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
import { average } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
FontString,
|
FontString,
|
||||||
ExcalidrawElement,
|
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -21,7 +20,8 @@ import {
|
|||||||
ENV,
|
ENV,
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
getFontFamilyFallbacks,
|
getFontFamilyFallbacks,
|
||||||
isDarwin,
|
isAndroid,
|
||||||
|
isIOS,
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
|
||||||
@@ -92,7 +92,8 @@ export const isWritableElement = (
|
|||||||
(target instanceof HTMLInputElement &&
|
(target instanceof HTMLInputElement &&
|
||||||
(target.type === "text" ||
|
(target.type === "text" ||
|
||||||
target.type === "number" ||
|
target.type === "number" ||
|
||||||
target.type === "password"));
|
target.type === "password" ||
|
||||||
|
target.type === "search"));
|
||||||
|
|
||||||
export const getFontFamilyString = ({
|
export const getFontFamilyString = ({
|
||||||
fontFamily,
|
fontFamily,
|
||||||
@@ -101,7 +102,6 @@ export const getFontFamilyString = ({
|
|||||||
}) => {
|
}) => {
|
||||||
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
|
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
|
||||||
if (id === fontFamily) {
|
if (id === fontFamily) {
|
||||||
// TODO: we should fallback first to generic family names first
|
|
||||||
return `${fontFamilyString}${getFontFamilyFallbacks(id)
|
return `${fontFamilyString}${getFontFamilyFallbacks(id)
|
||||||
.map((x) => `, ${x}`)
|
.map((x) => `, ${x}`)
|
||||||
.join("")}`;
|
.join("")}`;
|
||||||
@@ -121,6 +121,11 @@ export const getFontString = ({
|
|||||||
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** executes callback in the frame that's after the current one */
|
||||||
|
export const nextAnimationFrame = async (cb: () => any) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(cb));
|
||||||
|
};
|
||||||
|
|
||||||
export const debounce = <T extends any[]>(
|
export const debounce = <T extends any[]>(
|
||||||
fn: (...args: T) => void,
|
fn: (...args: T) => void,
|
||||||
timeout: number,
|
timeout: number,
|
||||||
@@ -420,19 +425,6 @@ export const allowFullScreen = () =>
|
|||||||
|
|
||||||
export const exitFullScreen = () => document.exitFullscreen();
|
export const exitFullScreen = () => document.exitFullscreen();
|
||||||
|
|
||||||
export const getShortcutKey = (shortcut: string): string => {
|
|
||||||
shortcut = shortcut
|
|
||||||
.replace(/\bAlt\b/i, "Alt")
|
|
||||||
.replace(/\bShift\b/i, "Shift")
|
|
||||||
.replace(/\b(Enter|Return)\b/i, "Enter");
|
|
||||||
if (isDarwin) {
|
|
||||||
return shortcut
|
|
||||||
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
|
|
||||||
.replace(/\bAlt\b/i, "Option");
|
|
||||||
}
|
|
||||||
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const viewportCoordsToSceneCoords = (
|
export const viewportCoordsToSceneCoords = (
|
||||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||||
{
|
{
|
||||||
@@ -712,8 +704,8 @@ export const arrayToObject = <T>(
|
|||||||
array: readonly T[],
|
array: readonly T[],
|
||||||
groupBy?: (value: T) => string | number,
|
groupBy?: (value: T) => string | number,
|
||||||
) =>
|
) =>
|
||||||
array.reduce((acc, value) => {
|
array.reduce((acc, value, idx) => {
|
||||||
acc[groupBy ? groupBy(value) : String(value)] = value;
|
acc[groupBy ? groupBy(value) : idx] = value;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as { [key: string]: T });
|
}, {} as { [key: string]: T });
|
||||||
|
|
||||||
@@ -1238,20 +1230,6 @@ export const escapeDoubleQuotes = (str: string) => {
|
|||||||
export const castArray = <T>(value: T | T[]): T[] =>
|
export const castArray = <T>(value: T | T[]): T[] =>
|
||||||
Array.isArray(value) ? value : [value];
|
Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
export const elementCenterPoint = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
xOffset: number = 0,
|
|
||||||
yOffset: number = 0,
|
|
||||||
) => {
|
|
||||||
const { x, y, width, height } = element;
|
|
||||||
|
|
||||||
const centerXPoint = x + width / 2 + xOffset;
|
|
||||||
|
|
||||||
const centerYPoint = y + height / 2 + yOffset;
|
|
||||||
|
|
||||||
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** hack for Array.isArray type guard not working with readonly value[] */
|
/** hack for Array.isArray type guard not working with readonly value[] */
|
||||||
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||||
return Array.isArray(value);
|
return Array.isArray(value);
|
||||||
@@ -1294,3 +1272,59 @@ export const reduceToCommonValue = <T, R = T>(
|
|||||||
|
|
||||||
return commonValue;
|
return commonValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@excalidraw/element",
|
"name": "@excalidraw/element",
|
||||||
"version": "0.1.0",
|
"version": "0.18.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "./dist/types/element/src/index.d.ts",
|
"types": "./dist/types/element/src/index.d.ts",
|
||||||
"main": "./dist/prod/index.js",
|
"main": "./dist/prod/index.js",
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
"default": "./dist/prod/index.js"
|
"default": "./dist/prod/index.js"
|
||||||
},
|
},
|
||||||
"./*": {
|
"./*": {
|
||||||
"types": "./dist/types/element/src/*.d.ts"
|
"types": "./dist/types/element/src/*.d.ts",
|
||||||
|
"development": "./dist/dev/index.js",
|
||||||
|
"production": "./dist/prod/index.js",
|
||||||
|
"default": "./dist/prod/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -52,5 +55,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rimraf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@excalidraw/common": "0.18.0",
|
||||||
|
"@excalidraw/math": "0.18.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,9 +164,14 @@ export class Scene {
|
|||||||
return this.frames;
|
return this.frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(elements: ElementsMapOrArray | null = null) {
|
constructor(
|
||||||
|
elements: ElementsMapOrArray | null = null,
|
||||||
|
options?: {
|
||||||
|
skipValidation?: true;
|
||||||
|
},
|
||||||
|
) {
|
||||||
if (elements) {
|
if (elements) {
|
||||||
this.replaceAllElements(elements);
|
this.replaceAllElements(elements, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,12 +268,19 @@ export class Scene {
|
|||||||
return didChange;
|
return didChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
replaceAllElements(
|
||||||
|
nextElements: ElementsMapOrArray,
|
||||||
|
options?: {
|
||||||
|
skipValidation?: true;
|
||||||
|
},
|
||||||
|
) {
|
||||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||||
const _nextElements = toArray(nextElements);
|
const _nextElements = toArray(nextElements);
|
||||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||||
|
|
||||||
validateIndicesThrottled(_nextElements);
|
if (!options?.skipValidation) {
|
||||||
|
validateIndicesThrottled(_nextElements);
|
||||||
|
}
|
||||||
|
|
||||||
this.elements = syncInvalidIndices(_nextElements);
|
this.elements = syncInvalidIndices(_nextElements);
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
|
||||||
|
|
||||||
import { COLOR_PALETTE } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AppState,
|
|
||||||
EmbedsValidationStatus,
|
|
||||||
} from "@excalidraw/excalidraw/types";
|
|
||||||
import type {
|
|
||||||
ElementShape,
|
|
||||||
ElementShapes,
|
|
||||||
} from "@excalidraw/excalidraw/scene/types";
|
|
||||||
|
|
||||||
import { _generateElementShape } from "./Shape";
|
|
||||||
|
|
||||||
import { elementWithCanvasCache } from "./renderElement";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
|
|
||||||
|
|
||||||
import type { Drawable } from "roughjs/bin/core";
|
|
||||||
|
|
||||||
export class ShapeCache {
|
|
||||||
private static rg = new RoughGenerator();
|
|
||||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves shape from cache if available. Use this only if shape
|
|
||||||
* is optional and you have a fallback in case it's not cached.
|
|
||||||
*/
|
|
||||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
|
||||||
return ShapeCache.cache.get(
|
|
||||||
element,
|
|
||||||
) as T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]] | undefined
|
|
||||||
: ElementShape | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
public static set = <T extends ExcalidrawElement>(
|
|
||||||
element: T,
|
|
||||||
shape: T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]]
|
|
||||||
: Drawable,
|
|
||||||
) => ShapeCache.cache.set(element, shape);
|
|
||||||
|
|
||||||
public static delete = (element: ExcalidrawElement) =>
|
|
||||||
ShapeCache.cache.delete(element);
|
|
||||||
|
|
||||||
public static destroy = () => {
|
|
||||||
ShapeCache.cache = new WeakMap();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates & caches shape for element if not already cached, otherwise
|
|
||||||
* returns cached shape.
|
|
||||||
*/
|
|
||||||
public static generateElementShape = <
|
|
||||||
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
|
||||||
>(
|
|
||||||
element: T,
|
|
||||||
renderConfig: {
|
|
||||||
isExporting: boolean;
|
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
|
||||||
embedsValidationStatus: EmbedsValidationStatus;
|
|
||||||
} | null,
|
|
||||||
) => {
|
|
||||||
// when exporting, always regenerated to guarantee the latest shape
|
|
||||||
const cachedShape = renderConfig?.isExporting
|
|
||||||
? undefined
|
|
||||||
: ShapeCache.get(element);
|
|
||||||
|
|
||||||
// `null` indicates no rc shape applicable for this element type,
|
|
||||||
// but it's considered a valid cache value (= do not regenerate)
|
|
||||||
if (cachedShape !== undefined) {
|
|
||||||
return cachedShape;
|
|
||||||
}
|
|
||||||
|
|
||||||
elementWithCanvasCache.delete(element);
|
|
||||||
|
|
||||||
const shape = _generateElementShape(
|
|
||||||
element,
|
|
||||||
ShapeCache.rg,
|
|
||||||
renderConfig || {
|
|
||||||
isExporting: false,
|
|
||||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
|
||||||
embedsValidationStatus: null,
|
|
||||||
},
|
|
||||||
) as T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]]
|
|
||||||
: Drawable | null;
|
|
||||||
|
|
||||||
ShapeCache.cache.set(element, shape);
|
|
||||||
|
|
||||||
return shape;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { updateBoundElements } from "./binding";
|
import { updateBoundElements } from "./binding";
|
||||||
import { getCommonBoundingBox } from "./bounds";
|
import { getCommonBoundingBox } from "./bounds";
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getSelectedElementsByGroup } from "./groups";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
@@ -16,11 +18,12 @@ export const alignElements = (
|
|||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
|
appState: Readonly<AppState>,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
||||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
|
||||||
selectedElements,
|
selectedElements,
|
||||||
elementsMap,
|
scene.getNonDeletedElementsMap(),
|
||||||
|
appState,
|
||||||
);
|
);
|
||||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
|
|||||||
+204
-125
@@ -6,7 +6,6 @@ import {
|
|||||||
invariant,
|
invariant,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
elementCenterPoint,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -27,8 +26,6 @@ import {
|
|||||||
PRECISION,
|
PRECISION,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { isPointOnShape } from "@excalidraw/utils/collision";
|
|
||||||
|
|
||||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
@@ -36,12 +33,12 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
|||||||
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
doBoundsIntersect,
|
||||||
getCenterForBounds,
|
getCenterForBounds,
|
||||||
getElementBounds,
|
getElementBounds,
|
||||||
doBoundsIntersect,
|
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import { intersectElementWithLineSegment } from "./collision";
|
import { intersectElementWithLineSegment } from "./collision";
|
||||||
import { distanceToBindableElement } from "./distance";
|
import { distanceToElement } from "./distance";
|
||||||
import {
|
import {
|
||||||
headingForPointFromElement,
|
headingForPointFromElement,
|
||||||
headingIsHorizontal,
|
headingIsHorizontal,
|
||||||
@@ -63,7 +60,7 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
|
import { aabbForElement, elementCenterPoint } from "./bounds";
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
@@ -109,7 +106,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
|
|||||||
|
|
||||||
export const FIXED_BINDING_DISTANCE = 5;
|
export const FIXED_BINDING_DISTANCE = 5;
|
||||||
export const BINDING_HIGHLIGHT_THICKNESS = 10;
|
export const BINDING_HIGHLIGHT_THICKNESS = 10;
|
||||||
export const BINDING_HIGHLIGHT_OFFSET = 4;
|
|
||||||
|
|
||||||
const getNonDeletedElements = (
|
const getNonDeletedElements = (
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
@@ -131,6 +127,7 @@ export const bindOrUnbindLinearElement = (
|
|||||||
endBindingElement: ExcalidrawBindableElement | null | "keep",
|
endBindingElement: ExcalidrawBindableElement | null | "keep",
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
): void => {
|
): void => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
||||||
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
||||||
bindOrUnbindLinearElementEdge(
|
bindOrUnbindLinearElementEdge(
|
||||||
@@ -141,6 +138,7 @@ export const bindOrUnbindLinearElement = (
|
|||||||
boundToElementIds,
|
boundToElementIds,
|
||||||
unboundFromElementIds,
|
unboundFromElementIds,
|
||||||
scene,
|
scene,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
bindOrUnbindLinearElementEdge(
|
bindOrUnbindLinearElementEdge(
|
||||||
linearElement,
|
linearElement,
|
||||||
@@ -150,6 +148,7 @@ export const bindOrUnbindLinearElement = (
|
|||||||
boundToElementIds,
|
boundToElementIds,
|
||||||
unboundFromElementIds,
|
unboundFromElementIds,
|
||||||
scene,
|
scene,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
||||||
@@ -176,6 +175,7 @@ const bindOrUnbindLinearElementEdge = (
|
|||||||
// Is mutated
|
// Is mutated
|
||||||
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
): void => {
|
): void => {
|
||||||
// "keep" is for method chaining convenience, a "no-op", so just bail out
|
// "keep" is for method chaining convenience, a "no-op", so just bail out
|
||||||
if (bindableElement === "keep") {
|
if (bindableElement === "keep") {
|
||||||
@@ -216,43 +216,29 @@ const bindOrUnbindLinearElementEdge = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
edge: "start" | "end",
|
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
zoom?: AppState["zoom"],
|
|
||||||
): NonDeleted<ExcalidrawElement> | null => {
|
|
||||||
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
|
||||||
const elementId =
|
|
||||||
edge === "start"
|
|
||||||
? linearElement.startBinding?.elementId
|
|
||||||
: linearElement.endBinding?.elementId;
|
|
||||||
if (elementId) {
|
|
||||||
const element = elementsMap.get(elementId);
|
|
||||||
if (
|
|
||||||
isBindableElement(element) &&
|
|
||||||
bindingBorderTest(element, coors, elementsMap, zoom)
|
|
||||||
) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
||||||
["start", "end"].map((edge) =>
|
(["start", "end"] as const).map((edge) => {
|
||||||
getOriginalBindingIfStillCloseOfLinearElementEdge(
|
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
||||||
linearElement,
|
const elementId =
|
||||||
edge as "start" | "end",
|
edge === "start"
|
||||||
elementsMap,
|
? linearElement.startBinding?.elementId
|
||||||
zoom,
|
: linearElement.endBinding?.elementId;
|
||||||
),
|
if (elementId) {
|
||||||
);
|
const element = elementsMap.get(elementId);
|
||||||
|
if (
|
||||||
|
isBindableElement(element) &&
|
||||||
|
bindingBorderTest(element, coors, elementsMap, zoom)
|
||||||
|
) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const getBindingStrategyForDraggingArrowEndpoints = (
|
const getBindingStrategyForDraggingArrowEndpoints = (
|
||||||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
@@ -268,7 +254,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
|
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
|
||||||
const start = startDragged
|
const start = startDragged
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(
|
? getEligibleElementForBindingElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
"start",
|
"start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -279,7 +265,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
: "keep";
|
: "keep";
|
||||||
const end = endDragged
|
const end = endDragged
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(
|
? getEligibleElementForBindingElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
"end",
|
"end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -311,7 +297,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||||||
);
|
);
|
||||||
const start = startIsClose
|
const start = startIsClose
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(
|
? getEligibleElementForBindingElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
"start",
|
"start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -322,7 +308,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||||||
: null;
|
: null;
|
||||||
const end = endIsClose
|
const end = endIsClose
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(
|
? getEligibleElementForBindingElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
"end",
|
"end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -398,6 +384,48 @@ export const getSuggestedBindingsForArrows = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const maybeSuggestBindingsForLinearElementAtCoords = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
/** scene coords */
|
||||||
|
pointerCoords: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[],
|
||||||
|
scene: Scene,
|
||||||
|
zoom: AppState["zoom"],
|
||||||
|
// During line creation the start binding hasn't been written yet
|
||||||
|
// into `linearElement`
|
||||||
|
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
|
||||||
|
): ExcalidrawBindableElement[] =>
|
||||||
|
Array.from(
|
||||||
|
pointerCoords.reduce(
|
||||||
|
(acc: Set<NonDeleted<ExcalidrawBindableElement>>, coords) => {
|
||||||
|
const hoveredBindableElement = getHoveredElementForBinding(
|
||||||
|
coords,
|
||||||
|
scene.getNonDeletedElements(),
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
zoom,
|
||||||
|
isElbowArrow(linearElement),
|
||||||
|
isElbowArrow(linearElement),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
hoveredBindableElement != null &&
|
||||||
|
!isLinearElementSimpleAndAlreadyBound(
|
||||||
|
linearElement,
|
||||||
|
oppositeBindingBoundElement?.id,
|
||||||
|
hoveredBindableElement,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
acc.add(hoveredBindableElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
new Set() as Set<NonDeleted<ExcalidrawBindableElement>>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const maybeBindLinearElement = (
|
export const maybeBindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@@ -441,22 +469,13 @@ export const maybeBindLinearElement = (
|
|||||||
const normalizePointBinding = (
|
const normalizePointBinding = (
|
||||||
binding: { focus: number; gap: number },
|
binding: { focus: number; gap: number },
|
||||||
hoveredElement: ExcalidrawBindableElement,
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
) => {
|
) => ({
|
||||||
let gap = binding.gap;
|
...binding,
|
||||||
const maxGap = maxBindingGap(
|
gap: Math.min(
|
||||||
hoveredElement,
|
binding.gap,
|
||||||
hoveredElement.width,
|
maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
|
||||||
hoveredElement.height,
|
),
|
||||||
);
|
});
|
||||||
|
|
||||||
if (gap > maxGap) {
|
|
||||||
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...binding,
|
|
||||||
gap,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bindLinearElement = (
|
export const bindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
@@ -488,6 +507,7 @@ export const bindLinearElement = (
|
|||||||
linearElement,
|
linearElement,
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
startOrEnd,
|
startOrEnd,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -535,7 +555,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
|
|||||||
|
|
||||||
const isLinearElementSimple = (
|
const isLinearElementSimple = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
): boolean => linearElement.points.length < 3;
|
): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement);
|
||||||
|
|
||||||
const unbindLinearElement = (
|
const unbindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
@@ -703,8 +723,13 @@ const calculateFocusAndGap = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
|
focus: determineFocusDistance(
|
||||||
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
|
hoveredElement,
|
||||||
|
elementsMap,
|
||||||
|
adjacentPoint,
|
||||||
|
edgePoint,
|
||||||
|
),
|
||||||
|
gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -874,6 +899,7 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
bindableElement: ExcalidrawBindableElement | undefined | null,
|
bindableElement: ExcalidrawBindableElement | undefined | null,
|
||||||
aabb: Bounds | undefined | null,
|
aabb: Bounds | undefined | null,
|
||||||
origPoint: GlobalPoint,
|
origPoint: GlobalPoint,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
): Heading => {
|
): Heading => {
|
||||||
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
|
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
|
||||||
@@ -882,11 +908,16 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
return otherPointHeading;
|
return otherPointHeading;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
|
const distance = getDistanceForBinding(
|
||||||
|
origPoint,
|
||||||
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
);
|
||||||
|
|
||||||
if (!distance) {
|
if (!distance) {
|
||||||
return vectorToHeading(
|
return vectorToHeading(
|
||||||
vectorFromPoint(p, elementCenterPoint(bindableElement)),
|
vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,9 +927,10 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
const getDistanceForBinding = (
|
const getDistanceForBinding = (
|
||||||
point: Readonly<GlobalPoint>,
|
point: Readonly<GlobalPoint>,
|
||||||
bindableElement: ExcalidrawBindableElement,
|
bindableElement: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
) => {
|
) => {
|
||||||
const distance = distanceToBindableElement(bindableElement, point);
|
const distance = distanceToElement(bindableElement, elementsMap, point);
|
||||||
const bindDistance = maxBindingGap(
|
const bindDistance = maxBindingGap(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
bindableElement.width,
|
bindableElement.width,
|
||||||
@@ -913,12 +945,13 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
bindableElement: ExcalidrawBindableElement,
|
bindableElement: ExcalidrawBindableElement,
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
|
elementsMap: ElementsMap,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
if (isDevEnv() || isTestEnv()) {
|
if (isDevEnv() || isTestEnv()) {
|
||||||
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
|
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
|
||||||
}
|
}
|
||||||
|
|
||||||
const aabb = aabbForElement(bindableElement);
|
const aabb = aabbForElement(bindableElement, elementsMap);
|
||||||
const localP =
|
const localP =
|
||||||
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
|
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
|
||||||
const globalP = pointFrom<GlobalPoint>(
|
const globalP = pointFrom<GlobalPoint>(
|
||||||
@@ -926,7 +959,7 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
arrow.y + localP[1],
|
arrow.y + localP[1],
|
||||||
);
|
);
|
||||||
const edgePoint = isRectanguloidElement(bindableElement)
|
const edgePoint = isRectanguloidElement(bindableElement)
|
||||||
? avoidRectangularCorner(bindableElement, globalP)
|
? avoidRectangularCorner(bindableElement, elementsMap, globalP)
|
||||||
: globalP;
|
: globalP;
|
||||||
const elbowed = isElbowArrow(arrow);
|
const elbowed = isElbowArrow(arrow);
|
||||||
const center = getCenterForBounds(aabb);
|
const center = getCenterForBounds(aabb);
|
||||||
@@ -945,26 +978,54 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
const isHorizontal = headingIsHorizontal(
|
const isHorizontal = headingIsHorizontal(
|
||||||
headingForPointFromElement(bindableElement, aabb, globalP),
|
headingForPointFromElement(bindableElement, aabb, globalP),
|
||||||
);
|
);
|
||||||
|
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
|
||||||
const otherPoint = pointFrom<GlobalPoint>(
|
const otherPoint = pointFrom<GlobalPoint>(
|
||||||
isHorizontal ? center[0] : edgePoint[0],
|
isHorizontal ? center[0] : snapPoint[0],
|
||||||
!isHorizontal ? center[1] : edgePoint[1],
|
!isHorizontal ? center[1] : snapPoint[1],
|
||||||
|
);
|
||||||
|
const intersector = lineSegment(
|
||||||
|
otherPoint,
|
||||||
|
pointFromVector(
|
||||||
|
vectorScale(
|
||||||
|
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
|
||||||
|
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||||
|
),
|
||||||
|
otherPoint,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
intersection = intersectElementWithLineSegment(
|
intersection = intersectElementWithLineSegment(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
lineSegment(
|
elementsMap,
|
||||||
otherPoint,
|
intersector,
|
||||||
|
FIXED_BINDING_DISTANCE,
|
||||||
|
).sort(pointDistanceSq)[0];
|
||||||
|
|
||||||
|
if (!intersection) {
|
||||||
|
const anotherPoint = pointFrom<GlobalPoint>(
|
||||||
|
!isHorizontal ? center[0] : snapPoint[0],
|
||||||
|
isHorizontal ? center[1] : snapPoint[1],
|
||||||
|
);
|
||||||
|
const anotherIntersector = lineSegment(
|
||||||
|
anotherPoint,
|
||||||
pointFromVector(
|
pointFromVector(
|
||||||
vectorScale(
|
vectorScale(
|
||||||
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
|
vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)),
|
||||||
Math.max(bindableElement.width, bindableElement.height) * 2,
|
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||||
),
|
),
|
||||||
otherPoint,
|
anotherPoint,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
)[0];
|
intersection = intersectElementWithLineSegment(
|
||||||
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
|
anotherIntersector,
|
||||||
|
FIXED_BINDING_DISTANCE,
|
||||||
|
).sort(pointDistanceSq)[0];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
intersection = intersectElementWithLineSegment(
|
intersection = intersectElementWithLineSegment(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
lineSegment(
|
lineSegment(
|
||||||
adjacentPoint,
|
adjacentPoint,
|
||||||
pointFromVector(
|
pointFromVector(
|
||||||
@@ -991,31 +1052,15 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
return edgePoint;
|
return edgePoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elbowed) {
|
return elbowed ? intersection : edgePoint;
|
||||||
const scalar =
|
|
||||||
pointDistanceSq(edgePoint, center) -
|
|
||||||
pointDistanceSq(intersection, center) >
|
|
||||||
0
|
|
||||||
? FIXED_BINDING_DISTANCE
|
|
||||||
: -FIXED_BINDING_DISTANCE;
|
|
||||||
|
|
||||||
return pointFromVector(
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
|
|
||||||
scalar,
|
|
||||||
),
|
|
||||||
intersection,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return edgePoint;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const avoidRectangularCorner = (
|
export const avoidRectangularCorner = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||||
|
|
||||||
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
||||||
@@ -1108,35 +1153,34 @@ export const avoidRectangularCorner = (
|
|||||||
|
|
||||||
export const snapToMid = (
|
export const snapToMid = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
tolerance: number = 0.05,
|
tolerance: number = 0.05,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const { x, y, width, height, angle } = element;
|
const { x, y, width, height, angle } = element;
|
||||||
|
const center = elementCenterPoint(element, elementsMap, -0.1, -0.1);
|
||||||
const center = elementCenterPoint(element, -0.1, -0.1);
|
|
||||||
|
|
||||||
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
||||||
|
|
||||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||||
// above and below certain px distance
|
// above and below certain px distance
|
||||||
const verticalThrehsold = clamp(tolerance * height, 5, 80);
|
const verticalThreshold = clamp(tolerance * height, 5, 80);
|
||||||
const horizontalThrehsold = clamp(tolerance * width, 5, 80);
|
const horizontalThreshold = clamp(tolerance * width, 5, 80);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
nonRotated[0] <= x + width / 2 &&
|
nonRotated[0] <= x + width / 2 &&
|
||||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
nonRotated[1] > center[1] - verticalThreshold &&
|
||||||
nonRotated[1] < center[1] + verticalThrehsold
|
nonRotated[1] < center[1] + verticalThreshold
|
||||||
) {
|
) {
|
||||||
// LEFT
|
// LEFT
|
||||||
return pointRotateRads(
|
return pointRotateRads<GlobalPoint>(
|
||||||
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
|
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||||
center,
|
center,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
nonRotated[1] <= y + height / 2 &&
|
nonRotated[1] <= y + height / 2 &&
|
||||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||||
nonRotated[0] < center[0] + horizontalThrehsold
|
nonRotated[0] < center[0] + horizontalThreshold
|
||||||
) {
|
) {
|
||||||
// TOP
|
// TOP
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
@@ -1146,8 +1190,8 @@ export const snapToMid = (
|
|||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
nonRotated[0] >= x + width / 2 &&
|
nonRotated[0] >= x + width / 2 &&
|
||||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
nonRotated[1] > center[1] - verticalThreshold &&
|
||||||
nonRotated[1] < center[1] + verticalThrehsold
|
nonRotated[1] < center[1] + verticalThreshold
|
||||||
) {
|
) {
|
||||||
// RIGHT
|
// RIGHT
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
@@ -1157,8 +1201,8 @@ export const snapToMid = (
|
|||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
nonRotated[1] >= y + height / 2 &&
|
nonRotated[1] >= y + height / 2 &&
|
||||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||||
nonRotated[0] < center[0] + horizontalThrehsold
|
nonRotated[0] < center[0] + horizontalThreshold
|
||||||
) {
|
) {
|
||||||
// DOWN
|
// DOWN
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
@@ -1167,7 +1211,7 @@ export const snapToMid = (
|
|||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
} else if (element.type === "diamond") {
|
} else if (element.type === "diamond") {
|
||||||
const distance = FIXED_BINDING_DISTANCE - 1;
|
const distance = FIXED_BINDING_DISTANCE;
|
||||||
const topLeft = pointFrom<GlobalPoint>(
|
const topLeft = pointFrom<GlobalPoint>(
|
||||||
x + width / 4 - distance,
|
x + width / 4 - distance,
|
||||||
y + height / 4 - distance,
|
y + height / 4 - distance,
|
||||||
@@ -1184,27 +1228,28 @@ export const snapToMid = (
|
|||||||
x + (3 * width) / 4 + distance,
|
x + (3 * width) / 4 + distance,
|
||||||
y + (3 * height) / 4 + distance,
|
y + (3 * height) / 4 + distance,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
pointDistance(topLeft, nonRotated) <
|
pointDistance(topLeft, nonRotated) <
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
Math.max(horizontalThreshold, verticalThreshold)
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(topLeft, center, angle);
|
return pointRotateRads(topLeft, center, angle);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
pointDistance(topRight, nonRotated) <
|
pointDistance(topRight, nonRotated) <
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
Math.max(horizontalThreshold, verticalThreshold)
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(topRight, center, angle);
|
return pointRotateRads(topRight, center, angle);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
pointDistance(bottomLeft, nonRotated) <
|
pointDistance(bottomLeft, nonRotated) <
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
Math.max(horizontalThreshold, verticalThreshold)
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(bottomLeft, center, angle);
|
return pointRotateRads(bottomLeft, center, angle);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
pointDistance(bottomRight, nonRotated) <
|
pointDistance(bottomRight, nonRotated) <
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
Math.max(horizontalThreshold, verticalThreshold)
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(bottomRight, center, angle);
|
return pointRotateRads(bottomRight, center, angle);
|
||||||
}
|
}
|
||||||
@@ -1239,8 +1284,9 @@ const updateBoundPoint = (
|
|||||||
linearElement,
|
linearElement,
|
||||||
bindableElement,
|
bindableElement,
|
||||||
startOrEnd === "startBinding" ? "start" : "end",
|
startOrEnd === "startBinding" ? "start" : "end",
|
||||||
|
elementsMap,
|
||||||
).fixedPoint;
|
).fixedPoint;
|
||||||
const globalMidPoint = elementCenterPoint(bindableElement);
|
const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
|
||||||
const global = pointFrom<GlobalPoint>(
|
const global = pointFrom<GlobalPoint>(
|
||||||
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
||||||
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
||||||
@@ -1266,6 +1312,7 @@ const updateBoundPoint = (
|
|||||||
);
|
);
|
||||||
const focusPointAbsolute = determineFocusPoint(
|
const focusPointAbsolute = determineFocusPoint(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
binding.focus,
|
binding.focus,
|
||||||
adjacentPoint,
|
adjacentPoint,
|
||||||
);
|
);
|
||||||
@@ -1284,7 +1331,7 @@ const updateBoundPoint = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const center = elementCenterPoint(bindableElement);
|
const center = elementCenterPoint(bindableElement, elementsMap);
|
||||||
const interceptorLength =
|
const interceptorLength =
|
||||||
pointDistance(adjacentPoint, edgePointAbsolute) +
|
pointDistance(adjacentPoint, edgePointAbsolute) +
|
||||||
pointDistance(adjacentPoint, center) +
|
pointDistance(adjacentPoint, center) +
|
||||||
@@ -1292,6 +1339,7 @@ const updateBoundPoint = (
|
|||||||
const intersections = [
|
const intersections = [
|
||||||
...intersectElementWithLineSegment(
|
...intersectElementWithLineSegment(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
lineSegment<GlobalPoint>(
|
lineSegment<GlobalPoint>(
|
||||||
adjacentPoint,
|
adjacentPoint,
|
||||||
pointFromVector(
|
pointFromVector(
|
||||||
@@ -1342,6 +1390,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
|||||||
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
|
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
|
||||||
hoveredElement: ExcalidrawBindableElement,
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
|
elementsMap: ElementsMap,
|
||||||
): { fixedPoint: FixedPoint } => {
|
): { fixedPoint: FixedPoint } => {
|
||||||
const bounds = [
|
const bounds = [
|
||||||
hoveredElement.x,
|
hoveredElement.x,
|
||||||
@@ -1353,6 +1402,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
|||||||
linearElement,
|
linearElement,
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
startOrEnd,
|
startOrEnd,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
const globalMidPoint = pointFrom(
|
const globalMidPoint = pointFrom(
|
||||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||||
@@ -1396,7 +1446,7 @@ const maybeCalculateNewGapWhenScaling = (
|
|||||||
return { ...currentBinding, gap: newGap };
|
return { ...currentBinding, gap: newGap };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElligibleElementForBindingElement = (
|
const getEligibleElementForBindingElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
@@ -1548,14 +1598,38 @@ export const bindingBorderTest = (
|
|||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
fullShape?: boolean,
|
fullShape?: boolean,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
|
const p = pointFrom<GlobalPoint>(x, y);
|
||||||
const threshold = maxBindingGap(element, element.width, element.height, zoom);
|
const threshold = maxBindingGap(element, element.width, element.height, zoom);
|
||||||
|
const shouldTestInside =
|
||||||
|
// disable fullshape snapping for frame elements so we
|
||||||
|
// can bind to frame children
|
||||||
|
(fullShape || !isBindingFallthroughEnabled(element)) &&
|
||||||
|
!isFrameLikeElement(element);
|
||||||
|
|
||||||
const shape = getElementShape(element, elementsMap);
|
// PERF: Run a cheap test to see if the binding element
|
||||||
return (
|
// is even close to the element
|
||||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
const bounds = [
|
||||||
(fullShape === true &&
|
x - threshold,
|
||||||
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
|
y - threshold,
|
||||||
|
x + threshold,
|
||||||
|
y + threshold,
|
||||||
|
] as Bounds;
|
||||||
|
const elementBounds = getElementBounds(element, elementsMap);
|
||||||
|
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||||
|
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 <= threshold
|
||||||
|
: intersections.length > 0 && distance <= threshold;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const maxBindingGap = (
|
export const maxBindingGap = (
|
||||||
@@ -1575,7 +1649,7 @@ export const maxBindingGap = (
|
|||||||
// bigger bindable boundary for bigger elements
|
// bigger bindable boundary for bigger elements
|
||||||
Math.min(0.25 * smallerDimension, 32),
|
Math.min(0.25 * smallerDimension, 32),
|
||||||
// keep in sync with the zoomed highlight
|
// keep in sync with the zoomed highlight
|
||||||
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
|
BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1586,12 +1660,13 @@ export const maxBindingGap = (
|
|||||||
// of the element.
|
// of the element.
|
||||||
const determineFocusDistance = (
|
const determineFocusDistance = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
// Point on the line, in absolute coordinates
|
// Point on the line, in absolute coordinates
|
||||||
a: GlobalPoint,
|
a: GlobalPoint,
|
||||||
// Another point on the line, in absolute coordinates (closer to element)
|
// Another point on the line, in absolute coordinates (closer to element)
|
||||||
b: GlobalPoint,
|
b: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
if (pointsEqual(a, b)) {
|
if (pointsEqual(a, b)) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -1716,12 +1791,13 @@ const determineFocusDistance = (
|
|||||||
|
|
||||||
const determineFocusPoint = (
|
const determineFocusPoint = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
// The oriented, relative distance from the center of `element` of the
|
// The oriented, relative distance from the center of `element` of the
|
||||||
// returned focusPoint
|
// returned focusPoint
|
||||||
focus: number,
|
focus: number,
|
||||||
adjacentPoint: GlobalPoint,
|
adjacentPoint: GlobalPoint,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
if (focus === 0) {
|
if (focus === 0) {
|
||||||
return center;
|
return center;
|
||||||
@@ -2144,6 +2220,7 @@ export class BindableElement {
|
|||||||
export const getGlobalFixedPointForBindableElement = (
|
export const getGlobalFixedPointForBindableElement = (
|
||||||
fixedPointRatio: [number, number],
|
fixedPointRatio: [number, number],
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||||
|
|
||||||
@@ -2152,7 +2229,7 @@ export const getGlobalFixedPointForBindableElement = (
|
|||||||
element.x + element.width * fixedX,
|
element.x + element.width * fixedX,
|
||||||
element.y + element.height * fixedY,
|
element.y + element.height * fixedY,
|
||||||
),
|
),
|
||||||
elementCenterPoint(element),
|
elementCenterPoint(element, elementsMap),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2176,6 +2253,7 @@ export const getGlobalFixedPoints = (
|
|||||||
? getGlobalFixedPointForBindableElement(
|
? getGlobalFixedPointForBindableElement(
|
||||||
arrow.startBinding.fixedPoint,
|
arrow.startBinding.fixedPoint,
|
||||||
startElement as ExcalidrawBindableElement,
|
startElement as ExcalidrawBindableElement,
|
||||||
|
elementsMap,
|
||||||
)
|
)
|
||||||
: pointFrom<GlobalPoint>(
|
: pointFrom<GlobalPoint>(
|
||||||
arrow.x + arrow.points[0][0],
|
arrow.x + arrow.points[0][0],
|
||||||
@@ -2186,6 +2264,7 @@ export const getGlobalFixedPoints = (
|
|||||||
? getGlobalFixedPointForBindableElement(
|
? getGlobalFixedPointForBindableElement(
|
||||||
arrow.endBinding.fixedPoint,
|
arrow.endBinding.fixedPoint,
|
||||||
endElement as ExcalidrawBindableElement,
|
endElement as ExcalidrawBindableElement,
|
||||||
|
elementsMap,
|
||||||
)
|
)
|
||||||
: pointFrom<GlobalPoint>(
|
: pointFrom<GlobalPoint>(
|
||||||
arrow.x + arrow.points[arrow.points.length - 1][0],
|
arrow.x + arrow.points[arrow.points.length - 1][0],
|
||||||
|
|||||||
+166
-25
@@ -5,6 +5,7 @@ import {
|
|||||||
invariant,
|
invariant,
|
||||||
rescalePoints,
|
rescalePoints,
|
||||||
sizeOf,
|
sizeOf,
|
||||||
|
STROKE_WIDTH,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -33,8 +34,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { generateRoughOptions } from "./Shape";
|
import { generateRoughOptions } from "./shape";
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./shape";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||||
import {
|
import {
|
||||||
@@ -42,10 +43,11 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { getElementShape } from "./shapes";
|
import { getElementShape } from "./shape";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
@@ -102,9 +104,23 @@ export class ElementBounds {
|
|||||||
version: ExcalidrawElement["version"];
|
version: ExcalidrawElement["version"];
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
private static nonRotatedBoundsCache = new WeakMap<
|
||||||
|
ExcalidrawElement,
|
||||||
|
{
|
||||||
|
bounds: Bounds;
|
||||||
|
version: ExcalidrawElement["version"];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
|
static getBounds(
|
||||||
const cachedBounds = ElementBounds.boundsCache.get(element);
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
nonRotated: boolean = false,
|
||||||
|
) {
|
||||||
|
const cachedBounds =
|
||||||
|
nonRotated && element.angle !== 0
|
||||||
|
? ElementBounds.nonRotatedBoundsCache.get(element)
|
||||||
|
: ElementBounds.boundsCache.get(element);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cachedBounds?.version &&
|
cachedBounds?.version &&
|
||||||
@@ -115,6 +131,23 @@ export class ElementBounds {
|
|||||||
) {
|
) {
|
||||||
return cachedBounds.bounds;
|
return cachedBounds.bounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nonRotated && element.angle !== 0) {
|
||||||
|
const nonRotatedBounds = ElementBounds.calculateBounds(
|
||||||
|
{
|
||||||
|
...element,
|
||||||
|
angle: 0 as Radians,
|
||||||
|
},
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
ElementBounds.nonRotatedBoundsCache.set(element, {
|
||||||
|
version: element.version,
|
||||||
|
bounds: nonRotatedBounds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nonRotatedBounds;
|
||||||
|
}
|
||||||
|
|
||||||
const bounds = ElementBounds.calculateBounds(element, elementsMap);
|
const bounds = ElementBounds.calculateBounds(element, elementsMap);
|
||||||
|
|
||||||
ElementBounds.boundsCache.set(element, {
|
ElementBounds.boundsCache.set(element, {
|
||||||
@@ -290,19 +323,42 @@ export const getElementLineSegments = (
|
|||||||
|
|
||||||
if (shape.type === "polycurve") {
|
if (shape.type === "polycurve") {
|
||||||
const curves = shape.data;
|
const curves = shape.data;
|
||||||
const points = curves
|
const pointsOnCurves = curves.map((curve) =>
|
||||||
.map((curve) => pointsOnBezierCurves(curve, 10))
|
pointsOnBezierCurves(curve, 10),
|
||||||
.flat();
|
);
|
||||||
let i = 0;
|
|
||||||
const segments: LineSegment<GlobalPoint>[] = [];
|
const segments: LineSegment<GlobalPoint>[] = [];
|
||||||
while (i < points.length - 1) {
|
|
||||||
segments.push(
|
if (
|
||||||
lineSegment(
|
(isLineElement(element) && !element.polygon) ||
|
||||||
pointFrom(points[i][0], points[i][1]),
|
isArrowElement(element)
|
||||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
) {
|
||||||
),
|
for (const points of pointsOnCurves) {
|
||||||
);
|
let i = 0;
|
||||||
i++;
|
|
||||||
|
while (i < points.length - 1) {
|
||||||
|
segments.push(
|
||||||
|
lineSegment(
|
||||||
|
pointFrom(points[i][0], points[i][1]),
|
||||||
|
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const points = pointsOnCurves.flat();
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < points.length - 1) {
|
||||||
|
segments.push(
|
||||||
|
lineSegment(
|
||||||
|
pointFrom(points[i][0], points[i][1]),
|
||||||
|
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
@@ -553,7 +609,7 @@ const solveQuadratic = (
|
|||||||
return [s1, s2];
|
return [s1, s2];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCubicBezierCurveBound = (
|
export const getCubicBezierCurveBound = (
|
||||||
p0: GlobalPoint,
|
p0: GlobalPoint,
|
||||||
p1: GlobalPoint,
|
p1: GlobalPoint,
|
||||||
p2: GlobalPoint,
|
p2: GlobalPoint,
|
||||||
@@ -777,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" ||
|
||||||
@@ -828,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,
|
||||||
);
|
);
|
||||||
@@ -839,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,
|
||||||
);
|
);
|
||||||
@@ -939,8 +1001,9 @@ const getLinearElementRotatedBounds = (
|
|||||||
export const getElementBounds = (
|
export const getElementBounds = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
nonRotated: boolean = false,
|
||||||
): Bounds => {
|
): Bounds => {
|
||||||
return ElementBounds.getBounds(element, elementsMap);
|
return ElementBounds.getBounds(element, elementsMap, nonRotated);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCommonBounds = (
|
export const getCommonBounds = (
|
||||||
@@ -1094,7 +1157,9 @@ export interface BoundingBox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getCommonBoundingBox = (
|
export const getCommonBoundingBox = (
|
||||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
elements:
|
||||||
|
| readonly ExcalidrawElement[]
|
||||||
|
| readonly NonDeleted<ExcalidrawElement>[],
|
||||||
): BoundingBox => {
|
): BoundingBox => {
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
return {
|
return {
|
||||||
@@ -1133,6 +1198,71 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
|||||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the axis-aligned bounding box for a given element
|
||||||
|
*/
|
||||||
|
export const aabbForElement = (
|
||||||
|
element: Readonly<ExcalidrawElement>,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
offset?: [number, number, number, number],
|
||||||
|
) => {
|
||||||
|
const bbox = {
|
||||||
|
minX: element.x,
|
||||||
|
minY: element.y,
|
||||||
|
maxX: element.x + element.width,
|
||||||
|
maxY: element.y + element.height,
|
||||||
|
midX: element.x + element.width / 2,
|
||||||
|
midY: element.y + element.height / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.minX, bbox.minY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [topRightX, topRightY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.maxX, bbox.minY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.maxX, bbox.maxY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.minX, bbox.maxY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bounds = [
|
||||||
|
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||||
|
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||||
|
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||||
|
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
if (offset) {
|
||||||
|
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
||||||
|
return [
|
||||||
|
bounds[0] - leftOffset,
|
||||||
|
bounds[1] - topOffset,
|
||||||
|
bounds[2] + rightOffset,
|
||||||
|
bounds[3] + downOffset,
|
||||||
|
] as Bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bounds;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||||
|
p: P,
|
||||||
|
bounds: Bounds,
|
||||||
|
): boolean =>
|
||||||
|
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||||
|
|
||||||
export const doBoundsIntersect = (
|
export const doBoundsIntersect = (
|
||||||
bounds1: Bounds | null,
|
bounds1: Bounds | null,
|
||||||
bounds2: Bounds | null,
|
bounds2: Bounds | null,
|
||||||
@@ -1146,3 +1276,14 @@ export const doBoundsIntersect = (
|
|||||||
|
|
||||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const elementCenterPoint = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
xOffset: number = 0,
|
||||||
|
yOffset: number = 0,
|
||||||
|
) => {
|
||||||
|
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||||
|
|
||||||
|
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||||
|
};
|
||||||
|
|||||||
+377
-129
@@ -1,52 +1,68 @@
|
|||||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
import { isTransparent } from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
curveIntersectLineSegment,
|
curveIntersectLineSegment,
|
||||||
isPointWithinBounds,
|
isPointWithinBounds,
|
||||||
line,
|
|
||||||
lineSegment,
|
lineSegment,
|
||||||
lineSegmentIntersectionPoints,
|
lineSegmentIntersectionPoints,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
|
pointFromVector,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
pointsEqual,
|
pointsEqual,
|
||||||
|
vectorFromPoint,
|
||||||
|
vectorNormalize,
|
||||||
|
vectorScale,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ellipse,
|
ellipse,
|
||||||
ellipseLineIntersectionPoints,
|
ellipseSegmentInterceptPoints,
|
||||||
} from "@excalidraw/math/ellipse";
|
} from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
|
||||||
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
Curve,
|
||||||
GlobalPoint,
|
GlobalPoint,
|
||||||
LineSegment,
|
LineSegment,
|
||||||
LocalPoint,
|
|
||||||
Polygon,
|
|
||||||
Radians,
|
Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
import { isPathALoop } from "./utils";
|
||||||
import { getElementBounds } from "./bounds";
|
import {
|
||||||
|
type Bounds,
|
||||||
|
doBoundsIntersect,
|
||||||
|
elementCenterPoint,
|
||||||
|
getCenterForBounds,
|
||||||
|
getCubicBezierCurveBound,
|
||||||
|
getElementBounds,
|
||||||
|
} from "./bounds";
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
|
isFreeDrawElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isImageElement,
|
isImageElement,
|
||||||
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
|
deconstructLinearOrFreeDrawElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
|
import { getBoundTextElement } from "./textElement";
|
||||||
|
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
|
||||||
|
import { distanceToElement } from "./distance";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawEllipseElement,
|
ExcalidrawEllipseElement,
|
||||||
ExcalidrawRectangleElement,
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -72,45 +88,64 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
|||||||
return isDraggableFromInside || isImageElement(element);
|
return isDraggableFromInside || isImageElement(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
export type HitTestArgs = {
|
||||||
x: number;
|
point: GlobalPoint;
|
||||||
y: number;
|
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
shape: GeometricShape<Point>;
|
threshold: number;
|
||||||
threshold?: number;
|
elementsMap: ElementsMap;
|
||||||
frameNameBound?: FrameNameBounds | null;
|
frameNameBound?: FrameNameBounds | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
export const hitElementItself = ({
|
||||||
x,
|
point,
|
||||||
y,
|
|
||||||
element,
|
element,
|
||||||
shape,
|
threshold,
|
||||||
threshold = 10,
|
elementsMap,
|
||||||
frameNameBound = null,
|
frameNameBound = null,
|
||||||
}: HitTestArgs<Point>) => {
|
}: HitTestArgs) => {
|
||||||
let hit = shouldTestInside(element)
|
// Hit test against a frame's name
|
||||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
const hitFrameName = frameNameBound
|
||||||
// we would need `onShape` as well to include the "borders"
|
? isPointWithinBounds(
|
||||||
isPointInShape(pointFrom(x, y), shape) ||
|
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
|
||||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
point,
|
||||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
pointFrom(
|
||||||
|
frameNameBound.x + frameNameBound.width + threshold,
|
||||||
|
frameNameBound.y + frameNameBound.height + threshold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
// hit test against a frame's name
|
// Hit test against the extended, rotated bounding box of the element first
|
||||||
if (!hit && frameNameBound) {
|
const bounds = getElementBounds(element, elementsMap, true);
|
||||||
hit = isPointInShape(pointFrom(x, y), {
|
const hitBounds = isPointWithinBounds(
|
||||||
type: "polygon",
|
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
pointRotateRads(
|
||||||
.data as Polygon<Point>,
|
point,
|
||||||
});
|
getCenterForBounds(bounds),
|
||||||
|
-element.angle as Radians,
|
||||||
|
),
|
||||||
|
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
|
||||||
|
);
|
||||||
|
|
||||||
|
// PERF: Bail out early if the point is not even in the
|
||||||
|
// rotated bounding box or not hitting the frame name (saves 99%)
|
||||||
|
if (!hitBounds && !hitFrameName) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return hit;
|
// Do the precise (and relatively costly) hit test
|
||||||
|
const hitElement = shouldTestInside(element)
|
||||||
|
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||||
|
// we would need `onShape` as well to include the "borders"
|
||||||
|
isPointInElement(point, element, elementsMap) ||
|
||||||
|
isPointOnElementOutline(point, element, elementsMap, threshold)
|
||||||
|
: isPointOnElementOutline(point, element, elementsMap, threshold);
|
||||||
|
|
||||||
|
return hitElement || hitFrameName;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementBoundingBox = (
|
export const hitElementBoundingBox = (
|
||||||
x: number,
|
point: GlobalPoint,
|
||||||
y: number,
|
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
tolerance = 0,
|
tolerance = 0,
|
||||||
@@ -120,37 +155,42 @@ export const hitElementBoundingBox = (
|
|||||||
y1 -= tolerance;
|
y1 -= tolerance;
|
||||||
x2 += tolerance;
|
x2 += tolerance;
|
||||||
y2 += tolerance;
|
y2 += tolerance;
|
||||||
return isPointWithinBounds(
|
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||||
pointFrom(x1, y1),
|
|
||||||
pointFrom(x, y),
|
|
||||||
pointFrom(x2, y2),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementBoundingBoxOnly = <
|
export const hitElementBoundingBoxOnly = (
|
||||||
Point extends GlobalPoint | LocalPoint,
|
hitArgs: HitTestArgs,
|
||||||
>(
|
|
||||||
hitArgs: HitTestArgs<Point>,
|
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) =>
|
||||||
return (
|
!hitElementItself(hitArgs) &&
|
||||||
!hitElementItself(hitArgs) &&
|
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
|
||||||
!hitElementBoundText(
|
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
|
||||||
hitArgs.x,
|
|
||||||
hitArgs.y,
|
|
||||||
getBoundTextShape(hitArgs.element, elementsMap),
|
|
||||||
) &&
|
|
||||||
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
export const hitElementBoundText = (
|
||||||
x: number,
|
point: GlobalPoint,
|
||||||
y: number,
|
element: ExcalidrawElement,
|
||||||
textShape: GeometricShape<Point> | null,
|
elementsMap: ElementsMap,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
|
if (!boundTextElementCandidate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const boundTextElement = isLinearElement(element)
|
||||||
|
? {
|
||||||
|
...boundTextElementCandidate,
|
||||||
|
// arrow's bound text accurate position is not stored in the element's property
|
||||||
|
// but rather calculated and returned from the following static method
|
||||||
|
...LinearElementEditor.getBoundTextElementPosition(
|
||||||
|
element,
|
||||||
|
boundTextElementCandidate,
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: boundTextElementCandidate;
|
||||||
|
|
||||||
|
return isPointInElement(point, boundTextElement, elementsMap);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,9 +203,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
*/
|
*/
|
||||||
export const intersectElementWithLineSegment = (
|
export const intersectElementWithLineSegment = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
line: LineSegment<GlobalPoint>,
|
line: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
|
// First check if the line intersects the element's axis-aligned bounding box
|
||||||
|
// as it is much faster than checking intersection against the element's shape
|
||||||
|
const intersectorBounds = [
|
||||||
|
Math.min(line[0][0] - offset, line[1][0] - offset),
|
||||||
|
Math.min(line[0][1] - offset, line[1][1] - offset),
|
||||||
|
Math.max(line[0][0] + offset, line[1][0] + offset),
|
||||||
|
Math.max(line[0][1] + offset, line[1][1] + offset),
|
||||||
|
] as Bounds;
|
||||||
|
const elementBounds = getElementBounds(element, elementsMap);
|
||||||
|
|
||||||
|
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the actual intersection test against the element's shape
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
@@ -173,67 +230,196 @@ export const intersectElementWithLineSegment = (
|
|||||||
case "iframe":
|
case "iframe":
|
||||||
case "embeddable":
|
case "embeddable":
|
||||||
case "frame":
|
case "frame":
|
||||||
|
case "selection":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
return intersectRectanguloidWithLineSegment(element, line, offset);
|
return intersectRectanguloidWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
line,
|
||||||
|
offset,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return intersectDiamondWithLineSegment(element, line, offset);
|
return intersectDiamondWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
line,
|
||||||
|
offset,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
return intersectEllipseWithLineSegment(element, line, offset);
|
return intersectEllipseWithLineSegment(
|
||||||
default:
|
element,
|
||||||
throw new Error(`Unimplemented element type '${element.type}'`);
|
elementsMap,
|
||||||
|
line,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
case "line":
|
||||||
|
case "freedraw":
|
||||||
|
case "arrow":
|
||||||
|
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const curveIntersections = (
|
||||||
|
curves: Curve<GlobalPoint>[],
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
|
intersections: GlobalPoint[],
|
||||||
|
center: GlobalPoint,
|
||||||
|
angle: Radians,
|
||||||
|
onlyFirst = false,
|
||||||
|
) => {
|
||||||
|
for (const c of curves) {
|
||||||
|
// Optimize by doing a cheap bounding box check first
|
||||||
|
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||||
|
const b2 = [
|
||||||
|
Math.min(segment[0][0], segment[1][0]),
|
||||||
|
Math.min(segment[0][1], segment[1][1]),
|
||||||
|
Math.max(segment[0][0], segment[1][0]),
|
||||||
|
Math.max(segment[0][1], segment[1][1]),
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
if (!doBoundsIntersect(b1, b2)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = curveIntersectLineSegment(c, segment);
|
||||||
|
|
||||||
|
if (hits.length > 0) {
|
||||||
|
for (const j of hits) {
|
||||||
|
intersections.push(pointRotateRads(j, center, angle));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyFirst) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersections;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineIntersections = (
|
||||||
|
lines: LineSegment<GlobalPoint>[],
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
|
intersections: GlobalPoint[],
|
||||||
|
center: GlobalPoint,
|
||||||
|
angle: Radians,
|
||||||
|
onlyFirst = false,
|
||||||
|
) => {
|
||||||
|
for (const l of lines) {
|
||||||
|
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||||
|
if (intersection) {
|
||||||
|
intersections.push(pointRotateRads(intersection, center, angle));
|
||||||
|
|
||||||
|
if (onlyFirst) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersections;
|
||||||
|
};
|
||||||
|
|
||||||
|
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
|
onlyFirst = false,
|
||||||
|
): GlobalPoint[] => {
|
||||||
|
// NOTE: This is the only one which return the decomposed elements
|
||||||
|
// rotated! This is due to taking advantage of roughjs definitions.
|
||||||
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
const intersections: GlobalPoint[] = [];
|
||||||
|
|
||||||
|
for (const l of lines) {
|
||||||
|
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||||
|
if (intersection) {
|
||||||
|
intersections.push(intersection);
|
||||||
|
|
||||||
|
if (onlyFirst) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of curves) {
|
||||||
|
// Optimize by doing a cheap bounding box check first
|
||||||
|
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||||
|
const b2 = [
|
||||||
|
Math.min(segment[0][0], segment[1][0]),
|
||||||
|
Math.min(segment[0][1], segment[1][1]),
|
||||||
|
Math.max(segment[0][0], segment[1][0]),
|
||||||
|
Math.max(segment[0][1], segment[1][1]),
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
if (!doBoundsIntersect(b1, b2)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = curveIntersectLineSegment(c, segment);
|
||||||
|
|
||||||
|
if (hits.length > 0) {
|
||||||
|
intersections.push(...hits);
|
||||||
|
|
||||||
|
if (onlyFirst) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersections;
|
||||||
|
};
|
||||||
|
|
||||||
const intersectRectanguloidWithLineSegment = (
|
const intersectRectanguloidWithLineSegment = (
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
l: LineSegment<GlobalPoint>,
|
elementsMap: ElementsMap,
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||||
l[0],
|
segment[0],
|
||||||
center,
|
center,
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||||
l[1],
|
segment[1],
|
||||||
center,
|
center,
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||||
|
|
||||||
// Get the element's building components we can test against
|
// Get the element's building components we can test against
|
||||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||||
|
|
||||||
return (
|
const intersections: GlobalPoint[] = [];
|
||||||
// Test intersection against the sides, keep only the valid
|
|
||||||
// intersection points and rotate them back to scene space
|
lineIntersections(
|
||||||
sides
|
sides,
|
||||||
.map((s) =>
|
rotatedIntersector,
|
||||||
lineSegmentIntersectionPoints(
|
intersections,
|
||||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
center,
|
||||||
s,
|
element.angle,
|
||||||
),
|
onlyFirst,
|
||||||
)
|
|
||||||
.filter((x) => x != null)
|
|
||||||
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle))
|
|
||||||
// Test intersection against the corners which are cubic bezier curves,
|
|
||||||
// keep only the valid intersection points and rotate them back to scene
|
|
||||||
// space
|
|
||||||
.concat(
|
|
||||||
corners
|
|
||||||
.flatMap((t) =>
|
|
||||||
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
|
|
||||||
)
|
|
||||||
.filter((i) => i != null)
|
|
||||||
.map((j) => pointRotateRads(j, center, element.angle)),
|
|
||||||
)
|
|
||||||
// Remove duplicates
|
|
||||||
.filter(
|
|
||||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onlyFirst && intersections.length > 0) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
|
||||||
|
curveIntersections(
|
||||||
|
corners,
|
||||||
|
rotatedIntersector,
|
||||||
|
intersections,
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
|
|
||||||
|
return intersections;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,43 +431,45 @@ const intersectRectanguloidWithLineSegment = (
|
|||||||
*/
|
*/
|
||||||
const intersectDiamondWithLineSegment = (
|
const intersectDiamondWithLineSegment = (
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||||
|
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||||
|
|
||||||
const [sides, curves] = deconstructDiamondElement(element, offset);
|
const [sides, corners] = deconstructDiamondElement(element, offset);
|
||||||
|
const intersections: GlobalPoint[] = [];
|
||||||
|
|
||||||
return (
|
lineIntersections(
|
||||||
sides
|
sides,
|
||||||
.map((s) =>
|
rotatedIntersector,
|
||||||
lineSegmentIntersectionPoints(
|
intersections,
|
||||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
center,
|
||||||
s,
|
element.angle,
|
||||||
),
|
onlyFirst,
|
||||||
)
|
|
||||||
.filter((p): p is GlobalPoint => p != null)
|
|
||||||
// Rotate back intersection points
|
|
||||||
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle))
|
|
||||||
.concat(
|
|
||||||
curves
|
|
||||||
.flatMap((p) =>
|
|
||||||
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
|
|
||||||
)
|
|
||||||
.filter((p) => p != null)
|
|
||||||
// Rotate back intersection points
|
|
||||||
.map((p) => pointRotateRads(p, center, element.angle)),
|
|
||||||
)
|
|
||||||
// Remove duplicates
|
|
||||||
.filter(
|
|
||||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onlyFirst && intersections.length > 0) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
|
||||||
|
curveIntersections(
|
||||||
|
corners,
|
||||||
|
rotatedIntersector,
|
||||||
|
intersections,
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
|
|
||||||
|
return intersections;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -293,16 +481,76 @@ const intersectDiamondWithLineSegment = (
|
|||||||
*/
|
*/
|
||||||
const intersectEllipseWithLineSegment = (
|
const intersectEllipseWithLineSegment = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||||
|
|
||||||
return ellipseLineIntersectionPoints(
|
return ellipseSegmentInterceptPoints(
|
||||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||||
line(rotatedA, rotatedB),
|
lineSegment(rotatedA, rotatedB),
|
||||||
).map((p) => pointRotateRads(p, center, element.angle));
|
).map((p) => pointRotateRads(p, center, element.angle));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given point is considered on the given shape's border
|
||||||
|
*
|
||||||
|
* @param point
|
||||||
|
* @param element
|
||||||
|
* @param tolerance
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
const isPointOnElementOutline = (
|
||||||
|
point: GlobalPoint,
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
tolerance = 1,
|
||||||
|
) => distanceToElement(element, elementsMap, point) <= tolerance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given point is considered inside the element's border
|
||||||
|
*
|
||||||
|
* @param point
|
||||||
|
* @param element
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const isPointInElement = (
|
||||||
|
point: GlobalPoint,
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
(isLinearElement(element) || isFreeDrawElement(element)) &&
|
||||||
|
!isPathALoop(element.points)
|
||||||
|
) {
|
||||||
|
// There isn't any "inside" for a non-looping path
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||||
|
|
||||||
|
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||||
|
const otherPoint = pointFromVector(
|
||||||
|
vectorScale(
|
||||||
|
vectorNormalize(vectorFromPoint(point, center, 0.1)),
|
||||||
|
Math.max(element.width, element.height) * 2,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
);
|
||||||
|
const intersector = lineSegment(point, otherPoint);
|
||||||
|
const intersections = intersectElementWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
intersector,
|
||||||
|
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
|
||||||
|
|
||||||
|
return intersections.length % 2 === 1;
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export const hasBackground = (type: ElementOrToolType) =>
|
|||||||
type === "freedraw";
|
type === "freedraw";
|
||||||
|
|
||||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||||
type !== "image" && type !== "frame" && type !== "magicframe";
|
type === "rectangle" ||
|
||||||
|
type === "ellipse" ||
|
||||||
|
type === "diamond" ||
|
||||||
|
type === "freedraw" ||
|
||||||
|
type === "arrow" ||
|
||||||
|
type === "line" ||
|
||||||
|
type === "text";
|
||||||
|
|
||||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||||
type === "rectangle" ||
|
type === "rectangle" ||
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { type Point } from "points-on-curve";
|
import { type Point } from "points-on-curve";
|
||||||
|
|
||||||
import { elementCenterPoint } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
elementCenterPoint,
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
@@ -34,6 +33,7 @@ export const MINIMAL_CROP_SIZE = 10;
|
|||||||
|
|
||||||
export const cropElement = (
|
export const cropElement = (
|
||||||
element: ExcalidrawImageElement,
|
element: ExcalidrawImageElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
transformHandle: TransformHandleType,
|
transformHandle: TransformHandleType,
|
||||||
naturalWidth: number,
|
naturalWidth: number,
|
||||||
naturalHeight: number,
|
naturalHeight: number,
|
||||||
@@ -63,7 +63,7 @@ export const cropElement = (
|
|||||||
|
|
||||||
const rotatedPointer = pointRotateRads(
|
const rotatedPointer = pointRotateRads(
|
||||||
pointFrom(pointerX, pointerY),
|
pointFrom(pointerX, pointerY),
|
||||||
elementCenterPoint(element),
|
elementCenterPoint(element, elementsMap),
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+615
-221
File diff suppressed because it is too large
Load Diff
@@ -6,27 +6,33 @@ import {
|
|||||||
|
|
||||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
import { elementCenterPoint } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
|
deconstructLinearOrFreeDrawElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
|
import { elementCenterPoint } from "./bounds";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ElementsMap,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
|
ExcalidrawElement,
|
||||||
ExcalidrawEllipseElement,
|
ExcalidrawEllipseElement,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const distanceToBindableElement = (
|
export const distanceToElement = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
|
case "selection":
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
@@ -34,11 +40,15 @@ export const distanceToBindableElement = (
|
|||||||
case "embeddable":
|
case "embeddable":
|
||||||
case "frame":
|
case "frame":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
return distanceToRectanguloidElement(element, p);
|
return distanceToRectanguloidElement(element, elementsMap, p);
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return distanceToDiamondElement(element, p);
|
return distanceToDiamondElement(element, elementsMap, p);
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
return distanceToEllipseElement(element, p);
|
return distanceToEllipseElement(element, elementsMap, p);
|
||||||
|
case "line":
|
||||||
|
case "arrow":
|
||||||
|
case "freedraw":
|
||||||
|
return distanceToLinearOrFreeDraElement(element, p);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,9 +62,10 @@ export const distanceToBindableElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToRectanguloidElement = (
|
const distanceToRectanguloidElement = (
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
) => {
|
) => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||||
@@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToDiamondElement = (
|
const distanceToDiamondElement = (
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
@@ -108,12 +120,24 @@ const distanceToDiamondElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToEllipseElement = (
|
const distanceToEllipseElement = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
return ellipseDistanceFromPoint(
|
return ellipseDistanceFromPoint(
|
||||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||||
pointRotateRads(p, center, -element.angle as Radians),
|
pointRotateRads(p, center, -element.angle as Radians),
|
||||||
ellipse(center, element.width / 2, element.height / 2),
|
ellipse(center, element.width / 2, element.height / 2),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const distanceToLinearOrFreeDraElement = (
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
p: GlobalPoint,
|
||||||
|
) => {
|
||||||
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
return Math.min(
|
||||||
|
...lines.map((s) => distanceToLineSegment(p, s)),
|
||||||
|
...curves.map((a) => curvePointDistance(a, p)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getCommonBoundingBox } from "./bounds";
|
import { getCommonBoundingBox } from "./bounds";
|
||||||
import { newElementWith } from "./mutateElement";
|
import { newElementWith } from "./mutateElement";
|
||||||
|
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getSelectedElementsByGroup } from "./groups";
|
||||||
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ export const distributeElements = (
|
|||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
distribution: Distribution,
|
distribution: Distribution,
|
||||||
|
appState: Readonly<AppState>,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const [start, mid, end, extent] =
|
const [start, mid, end, extent] =
|
||||||
distribution.axis === "x"
|
distribution.axis === "x"
|
||||||
@@ -21,7 +24,11 @@ export const distributeElements = (
|
|||||||
: (["minY", "midY", "maxY", "height"] as const);
|
: (["minY", "midY", "maxY", "height"] as const);
|
||||||
|
|
||||||
const bounds = getCommonBoundingBox(selectedElements);
|
const bounds = getCommonBoundingBox(selectedElements);
|
||||||
const groups = getMaximumGroups(selectedElements, elementsMap)
|
const groups = getSelectedElementsByGroup(
|
||||||
|
selectedElements,
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
)
|
||||||
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
||||||
.sort((a, b) => a[1][mid] - b[1][mid]);
|
.sort((a, b) => a[1][mid] - b[1][mid]);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
getSizeFromPoints,
|
getSizeFromPoints,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
|
arrayToMap,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
@@ -29,10 +30,9 @@ import {
|
|||||||
FIXED_BINDING_DISTANCE,
|
FIXED_BINDING_DISTANCE,
|
||||||
getHeadingForElbowArrowSnap,
|
getHeadingForElbowArrowSnap,
|
||||||
getGlobalFixedPointForBindableElement,
|
getGlobalFixedPointForBindableElement,
|
||||||
snapToMid,
|
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { distanceToBindableElement } from "./distance";
|
import { distanceToElement } from "./distance";
|
||||||
import {
|
import {
|
||||||
compareHeading,
|
compareHeading,
|
||||||
flipHeading,
|
flipHeading,
|
||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
type NonDeletedSceneElementsMap,
|
type NonDeletedSceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { aabbForElement, pointInsideBounds } from "./shapes";
|
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type { Heading } from "./heading";
|
import type { Heading } from "./heading";
|
||||||
@@ -359,6 +359,12 @@ const handleSegmentRelease = (
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!restoredPoints || restoredPoints.length < 2) {
|
||||||
|
throw new Error(
|
||||||
|
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const nextPoints: GlobalPoint[] = [];
|
const nextPoints: GlobalPoint[] = [];
|
||||||
|
|
||||||
// First part of the arrow are the old points
|
// First part of the arrow are the old points
|
||||||
@@ -706,7 +712,7 @@ const handleEndpointDrag = (
|
|||||||
endGlobalPoint: GlobalPoint,
|
endGlobalPoint: GlobalPoint,
|
||||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||||
) => {
|
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||||
let startIsSpecial = arrow.startIsSpecial ?? null;
|
let startIsSpecial = arrow.startIsSpecial ?? null;
|
||||||
let endIsSpecial = arrow.endIsSpecial ?? null;
|
let endIsSpecial = arrow.endIsSpecial ?? null;
|
||||||
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
||||||
@@ -741,8 +747,15 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second point connection and add the start point
|
// Calculate the moving second point connection and add the start point
|
||||||
{
|
{
|
||||||
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
|
||||||
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
|
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
|
||||||
|
|
||||||
|
if (!secondPoint || !thirdPoint) {
|
||||||
|
throw new Error(
|
||||||
|
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const startIsHorizontal = headingIsHorizontal(startHeading);
|
const startIsHorizontal = headingIsHorizontal(startHeading);
|
||||||
const secondIsHorizontal = headingIsHorizontal(
|
const secondIsHorizontal = headingIsHorizontal(
|
||||||
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
||||||
@@ -801,10 +814,19 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second to last point connection
|
// Calculate the moving second to last point connection
|
||||||
{
|
{
|
||||||
const secondToLastPoint =
|
const secondToLastPoint = globalUpdatedPoints.at(
|
||||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
|
||||||
const thirdToLastPoint =
|
);
|
||||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
|
const thirdToLastPoint = globalUpdatedPoints.at(
|
||||||
|
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!secondToLastPoint || !thirdToLastPoint) {
|
||||||
|
throw new Error(
|
||||||
|
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const endIsHorizontal = headingIsHorizontal(endHeading);
|
const endIsHorizontal = headingIsHorizontal(endHeading);
|
||||||
const secondIsHorizontal = headingForPointIsHorizontal(
|
const secondIsHorizontal = headingForPointIsHorizontal(
|
||||||
thirdToLastPoint,
|
thirdToLastPoint,
|
||||||
@@ -898,50 +920,6 @@ export const updateElbowArrowPoints = (
|
|||||||
return { points: updates.points ?? arrow.points };
|
return { points: updates.points ?? arrow.points };
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
|
|
||||||
// arrow size is valid. This check will be removed once the issue is identified
|
|
||||||
if (
|
|
||||||
arrow.x < -MAX_POS ||
|
|
||||||
arrow.x > MAX_POS ||
|
|
||||||
arrow.y < -MAX_POS ||
|
|
||||||
arrow.y > MAX_POS ||
|
|
||||||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
|
|
||||||
{
|
|
||||||
arrow,
|
|
||||||
updates,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// @ts-ignore See above note
|
|
||||||
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
|
|
||||||
// @ts-ignore See above note
|
|
||||||
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
|
|
||||||
if (updates.points) {
|
|
||||||
updates.points = updates.points.map(([x, y]) =>
|
|
||||||
pointFrom<LocalPoint>(
|
|
||||||
clamp(x, -MAX_POS, MAX_POS),
|
|
||||||
clamp(y, -MAX_POS, MAX_POS),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!import.meta.env.PROD) {
|
if (!import.meta.env.PROD) {
|
||||||
invariant(
|
invariant(
|
||||||
!updates.points || updates.points.length >= 2,
|
!updates.points || updates.points.length >= 2,
|
||||||
@@ -1273,6 +1251,7 @@ const getElbowArrowData = (
|
|||||||
arrow.startBinding?.fixedPoint,
|
arrow.startBinding?.fixedPoint,
|
||||||
origStartGlobalPoint,
|
origStartGlobalPoint,
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
|
elementsMap,
|
||||||
options?.isDragging,
|
options?.isDragging,
|
||||||
);
|
);
|
||||||
const endGlobalPoint = getGlobalPoint(
|
const endGlobalPoint = getGlobalPoint(
|
||||||
@@ -1286,6 +1265,7 @@ const getElbowArrowData = (
|
|||||||
arrow.endBinding?.fixedPoint,
|
arrow.endBinding?.fixedPoint,
|
||||||
origEndGlobalPoint,
|
origEndGlobalPoint,
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
|
elementsMap,
|
||||||
options?.isDragging,
|
options?.isDragging,
|
||||||
);
|
);
|
||||||
const startHeading = getBindPointHeading(
|
const startHeading = getBindPointHeading(
|
||||||
@@ -1293,12 +1273,14 @@ const getElbowArrowData = (
|
|||||||
endGlobalPoint,
|
endGlobalPoint,
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
origStartGlobalPoint,
|
origStartGlobalPoint,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
const endHeading = getBindPointHeading(
|
const endHeading = getBindPointHeading(
|
||||||
endGlobalPoint,
|
endGlobalPoint,
|
||||||
startGlobalPoint,
|
startGlobalPoint,
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
origEndGlobalPoint,
|
origEndGlobalPoint,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
const startPointBounds = [
|
const startPointBounds = [
|
||||||
startGlobalPoint[0] - 2,
|
startGlobalPoint[0] - 2,
|
||||||
@@ -1315,6 +1297,7 @@ const getElbowArrowData = (
|
|||||||
const startElementBounds = hoveredStartElement
|
const startElementBounds = hoveredStartElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(
|
offsetFromHeading(
|
||||||
startHeading,
|
startHeading,
|
||||||
arrow.startArrowhead
|
arrow.startArrowhead
|
||||||
@@ -1327,6 +1310,7 @@ const getElbowArrowData = (
|
|||||||
const endElementBounds = hoveredEndElement
|
const endElementBounds = hoveredEndElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(
|
offsetFromHeading(
|
||||||
endHeading,
|
endHeading,
|
||||||
arrow.endArrowhead
|
arrow.endArrowhead
|
||||||
@@ -1342,6 +1326,7 @@ const getElbowArrowData = (
|
|||||||
hoveredEndElement
|
hoveredEndElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
|
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
|
||||||
)
|
)
|
||||||
: endPointBounds,
|
: endPointBounds,
|
||||||
@@ -1351,6 +1336,7 @@ const getElbowArrowData = (
|
|||||||
hoveredStartElement
|
hoveredStartElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
|
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
|
||||||
)
|
)
|
||||||
: startPointBounds,
|
: startPointBounds,
|
||||||
@@ -1397,8 +1383,8 @@ const getElbowArrowData = (
|
|||||||
BASE_PADDING,
|
BASE_PADDING,
|
||||||
),
|
),
|
||||||
boundsOverlap,
|
boundsOverlap,
|
||||||
hoveredStartElement && aabbForElement(hoveredStartElement),
|
hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
|
||||||
hoveredEndElement && aabbForElement(hoveredEndElement),
|
hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
|
||||||
);
|
);
|
||||||
const startDonglePosition = getDonglePosition(
|
const startDonglePosition = getDonglePosition(
|
||||||
dynamicAABBs[0],
|
dynamicAABBs[0],
|
||||||
@@ -2107,16 +2093,7 @@ const normalizeArrowElementUpdate = (
|
|||||||
nextFixedSegments: readonly FixedSegment[] | null,
|
nextFixedSegments: readonly FixedSegment[] | null,
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
): {
|
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||||
points: LocalPoint[];
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
fixedSegments: readonly FixedSegment[] | null;
|
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
|
||||||
} => {
|
|
||||||
const offsetX = global[0][0];
|
const offsetX = global[0][0];
|
||||||
const offsetY = global[0][1];
|
const offsetY = global[0][1];
|
||||||
let points = global.map((p) =>
|
let points = global.map((p) =>
|
||||||
@@ -2229,35 +2206,28 @@ const getGlobalPoint = (
|
|||||||
fixedPointRatio: [number, number] | undefined | null,
|
fixedPointRatio: [number, number] | undefined | null,
|
||||||
initialPoint: GlobalPoint,
|
initialPoint: GlobalPoint,
|
||||||
element?: ExcalidrawBindableElement | null,
|
element?: ExcalidrawBindableElement | null,
|
||||||
|
elementsMap?: ElementsMap,
|
||||||
isDragging?: boolean,
|
isDragging?: boolean,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
if (element) {
|
if (element && elementsMap) {
|
||||||
const snapPoint = bindPointToSnapToElementOutline(
|
return bindPointToSnapToElementOutline(
|
||||||
arrow,
|
arrow,
|
||||||
element,
|
element,
|
||||||
startOrEnd,
|
startOrEnd,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
return snapToMid(element, snapPoint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialPoint;
|
return initialPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
return getGlobalFixedPointForBindableElement(
|
||||||
fixedPointRatio || [0, 0],
|
fixedPointRatio || [0, 0],
|
||||||
element,
|
element,
|
||||||
|
elementsMap ?? arrayToMap([element]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOTE: Resize scales the binding position point too, so we need to update it
|
|
||||||
return Math.abs(
|
|
||||||
distanceToBindableElement(element, fixedGlobalPoint) -
|
|
||||||
FIXED_BINDING_DISTANCE,
|
|
||||||
) > 0.01
|
|
||||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
|
|
||||||
: fixedGlobalPoint;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialPoint;
|
return initialPoint;
|
||||||
@@ -2268,6 +2238,7 @@ const getBindPointHeading = (
|
|||||||
otherPoint: GlobalPoint,
|
otherPoint: GlobalPoint,
|
||||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||||
origPoint: GlobalPoint,
|
origPoint: GlobalPoint,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
): Heading =>
|
): Heading =>
|
||||||
getHeadingForElbowArrowSnap(
|
getHeadingForElbowArrowSnap(
|
||||||
p,
|
p,
|
||||||
@@ -2276,7 +2247,8 @@ const getBindPointHeading = (
|
|||||||
hoveredElement &&
|
hoveredElement &&
|
||||||
aabbForElement(
|
aabbForElement(
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
|
elementsMap,
|
||||||
|
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
@@ -2284,6 +2256,7 @@ const getBindPointHeading = (
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
origPoint,
|
origPoint,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const getHoveredElement = (
|
const getHoveredElement = (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
|||||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||||
|
|
||||||
const RE_YOUTUBE =
|
const RE_YOUTUBE =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
||||||
|
|
||||||
const RE_VIMEO =
|
const RE_VIMEO =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||||
@@ -56,6 +56,35 @@ const RE_REDDIT =
|
|||||||
const RE_REDDIT_EMBED =
|
const RE_REDDIT_EMBED =
|
||||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||||
|
|
||||||
|
const parseYouTubeTimestamp = (url: string): number => {
|
||||||
|
let timeParam: string | null | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
|
||||||
|
timeParam =
|
||||||
|
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
|
||||||
|
} catch (error) {
|
||||||
|
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
|
||||||
|
timeParam = timeMatch?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timeParam) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(timeParam)) {
|
||||||
|
return parseInt(timeParam, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||||
|
if (!timeMatch) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
|
||||||
|
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
const ALLOWED_DOMAINS = new Set([
|
const ALLOWED_DOMAINS = new Set([
|
||||||
"youtube.com",
|
"youtube.com",
|
||||||
"youtu.be",
|
"youtu.be",
|
||||||
@@ -113,7 +142,8 @@ export const getEmbedLink = (
|
|||||||
let aspectRatio = { w: 560, h: 840 };
|
let aspectRatio = { w: 560, h: 840 };
|
||||||
const ytLink = link.match(RE_YOUTUBE);
|
const ytLink = link.match(RE_YOUTUBE);
|
||||||
if (ytLink?.[2]) {
|
if (ytLink?.[2]) {
|
||||||
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
|
const startTime = parseYouTubeTimestamp(originalLink);
|
||||||
|
const time = startTime > 0 ? `&start=${startTime}` : ``;
|
||||||
const isPortrait = link.includes("shorts");
|
const isPortrait = link.includes("shorts");
|
||||||
type = "video";
|
type = "video";
|
||||||
switch (ytLink[1]) {
|
switch (ytLink[1]) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { newArrowElement, newElement } from "./newElement";
|
import { newArrowElement, newElement } from "./newElement";
|
||||||
import { aabbForElement } from "./shapes";
|
import { aabbForElement } from "./bounds";
|
||||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||||
import {
|
import {
|
||||||
isBindableElement,
|
isBindableElement,
|
||||||
@@ -95,10 +95,11 @@ const getNodeRelatives = (
|
|||||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||||
) as Readonly<LocalPoint>;
|
) as Readonly<LocalPoint>;
|
||||||
|
|
||||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
const heading = headingForPointFromElement(
|
||||||
edgePoint[0] + el.x,
|
node,
|
||||||
edgePoint[1] + el.y,
|
aabbForElement(node, elementsMap),
|
||||||
] as Readonly<GlobalPoint>);
|
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
|
||||||
|
);
|
||||||
|
|
||||||
acc.push({
|
acc.push({
|
||||||
relative,
|
relative,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
|
|||||||
|
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement, newElementWith } from "./mutateElement";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
import { hasBoundTextElement } from "./typeChecks";
|
import { hasBoundTextElement } from "./typeChecks";
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FractionalIndex,
|
FractionalIndex,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export class InvalidFractionalIndexError extends Error {
|
export class InvalidFractionalIndexError extends Error {
|
||||||
@@ -161,9 +162,15 @@ export const syncMovedIndices = (
|
|||||||
|
|
||||||
// try generatating indices, throws on invalid movedElements
|
// try generatating indices, throws on invalid movedElements
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
const elementsCandidates = elements.map((x) =>
|
const elementsCandidates = elements.map((x) => {
|
||||||
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
|
const elementUpdates = elementsUpdates.get(x);
|
||||||
);
|
|
||||||
|
if (elementUpdates) {
|
||||||
|
return { ...x, index: elementUpdates.index };
|
||||||
|
}
|
||||||
|
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
|
||||||
// ensure next indices are valid before mutation, throws on invalid ones
|
// ensure next indices are valid before mutation, throws on invalid ones
|
||||||
validateFractionalIndices(
|
validateFractionalIndices(
|
||||||
@@ -177,8 +184,8 @@ export const syncMovedIndices = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// split mutation so we don't end up in an incosistent state
|
// split mutation so we don't end up in an incosistent state
|
||||||
for (const [element, update] of elementsUpdates) {
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
mutateElement(element, elementsMap, update);
|
mutateElement(element, elementsMap, { index });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// fallback to default sync
|
// fallback to default sync
|
||||||
@@ -189,7 +196,7 @@ export const syncMovedIndices = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
|
* Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
|
||||||
*
|
*
|
||||||
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||||
*/
|
*/
|
||||||
@@ -200,13 +207,32 @@ export const syncInvalidIndices = (
|
|||||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
for (const [element, update] of elementsUpdates) {
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
mutateElement(element, elementsMap, update);
|
mutateElement(element, elementsMap, { index });
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements as OrderedExcalidrawElement[];
|
return elements as OrderedExcalidrawElement[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes all invalid fractional indices within the array order by creating new instances of elements with corrected indices.
|
||||||
|
*
|
||||||
|
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||||
|
*/
|
||||||
|
export const syncInvalidIndicesImmutable = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): SceneElementsMap | undefined => {
|
||||||
|
const syncedElements = arrayToMap(elements);
|
||||||
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
|
syncedElements.set(element.id, newElementWith(element, { index }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncedElements as SceneElementsMap;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get contiguous groups of indices of passed moved elements.
|
* Get contiguous groups of indices of passed moved elements.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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][];
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
|||||||
|
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
|
|
||||||
|
import { isBoundToContainer } from "./typeChecks";
|
||||||
|
|
||||||
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = (
|
|||||||
|
|
||||||
return copy;
|
return copy;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// given a list of selected elements, return the element grouped by their immediate group selected state
|
||||||
|
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
|
||||||
|
export const getSelectedElementsByGroup = (
|
||||||
|
selectedElements: ExcalidrawElement[],
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
): ExcalidrawElement[][] => {
|
||||||
|
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||||
|
const unboundElements = selectedElements.filter(
|
||||||
|
(element) => !isBoundToContainer(element),
|
||||||
|
);
|
||||||
|
const groups: Map<string, ExcalidrawElement[]> = new Map();
|
||||||
|
const elements: Map<string, ExcalidrawElement[]> = new Map();
|
||||||
|
|
||||||
|
// helper function to add an element to the elements map
|
||||||
|
const addToElementsMap = (element: ExcalidrawElement) => {
|
||||||
|
// elements
|
||||||
|
const currentElementMembers = elements.get(element.id) || [];
|
||||||
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
|
if (boundTextElement) {
|
||||||
|
currentElementMembers.push(boundTextElement);
|
||||||
|
}
|
||||||
|
elements.set(element.id, [...currentElementMembers, element]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// helper function to add an element to the groups map
|
||||||
|
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
|
||||||
|
// groups
|
||||||
|
const currentGroupMembers = groups.get(groupId) || [];
|
||||||
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
|
if (boundTextElement) {
|
||||||
|
currentGroupMembers.push(boundTextElement);
|
||||||
|
}
|
||||||
|
groups.set(groupId, [...currentGroupMembers, element]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// helper function to handle the case where a single group is selected
|
||||||
|
// and all elements selected are within the group, it will respect group hierarchy in accordance to
|
||||||
|
// their nested grouping order
|
||||||
|
const handleSingleSelectedGroupCase = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
selectedGroupId: GroupId,
|
||||||
|
) => {
|
||||||
|
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
|
||||||
|
const nestedGroupCount = element.groupIds.slice(
|
||||||
|
0,
|
||||||
|
indexOfSelectedGroupId,
|
||||||
|
).length;
|
||||||
|
return nestedGroupCount > 0
|
||||||
|
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
|
||||||
|
: addToElementsMap(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllInSameGroup = selectedElements.every((element) =>
|
||||||
|
isSelectedViaGroup(appState, element),
|
||||||
|
);
|
||||||
|
|
||||||
|
unboundElements.forEach((element) => {
|
||||||
|
const selectedGroupId = getSelectedGroupIdForElement(
|
||||||
|
element,
|
||||||
|
appState.selectedGroupIds,
|
||||||
|
);
|
||||||
|
if (!selectedGroupId) {
|
||||||
|
addToElementsMap(element);
|
||||||
|
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
|
||||||
|
handleSingleSelectedGroupCase(element, selectedGroupId);
|
||||||
|
} else {
|
||||||
|
addToGroupsMap(element, selectedGroupId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(groups.values()).concat(Array.from(elements.values()));
|
||||||
|
};
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
|||||||
|
|
||||||
// string hash function (using djb2). Not cryptographically secure, use only
|
// string hash function (using djb2). Not cryptographically secure, use only
|
||||||
// for versioning and such.
|
// for versioning and such.
|
||||||
|
// note: hashes individual code units (not code points),
|
||||||
|
// but for hashing purposes this is fine as it iterates through every code unit
|
||||||
|
// (as such, no need to encode to byte string first)
|
||||||
export const hashString = (s: string): number => {
|
export const hashString = (s: string): number => {
|
||||||
let hash: number = 5381;
|
let hash: number = 5381;
|
||||||
for (let i = 0; i < s.length; i++) {
|
for (let i = 0; i < s.length; i++) {
|
||||||
@@ -91,20 +94,20 @@ 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";
|
||||||
export * from "./linearElementEditor";
|
export * from "./linearElementEditor";
|
||||||
export * from "./mutateElement";
|
export * from "./mutateElement";
|
||||||
export * from "./newElement";
|
export * from "./newElement";
|
||||||
|
export * from "./positionElementsOnGrid";
|
||||||
export * from "./renderElement";
|
export * from "./renderElement";
|
||||||
export * from "./resizeElements";
|
export * from "./resizeElements";
|
||||||
export * from "./resizeTest";
|
export * from "./resizeTest";
|
||||||
export * from "./Scene";
|
export * from "./Scene";
|
||||||
export * from "./selection";
|
export * from "./selection";
|
||||||
export * from "./Shape";
|
export * from "./shape";
|
||||||
export * from "./ShapeCache";
|
|
||||||
export * from "./shapes";
|
|
||||||
export * from "./showSelectedShapeActions";
|
export * from "./showSelectedShapeActions";
|
||||||
export * from "./sizeHelpers";
|
export * from "./sizeHelpers";
|
||||||
export * from "./sortElements";
|
export * from "./sortElements";
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
pointDistance,
|
pointDistance,
|
||||||
vectorFromPoint,
|
vectorFromPoint,
|
||||||
|
curveLength,
|
||||||
|
curvePointAtLength,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||||
@@ -18,9 +20,14 @@ import {
|
|||||||
getGridPoint,
|
getGridPoint,
|
||||||
invariant,
|
invariant,
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
|
viewportCoordsToSceneCoords,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { Store } from "@excalidraw/element";
|
import {
|
||||||
|
deconstructLinearOrFreeDrawElement,
|
||||||
|
isPathALoop,
|
||||||
|
type Store,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
@@ -39,6 +46,7 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
|
maybeSuggestBindingsForLinearElementAtCoords,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import {
|
import {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
@@ -55,16 +63,7 @@ import {
|
|||||||
isFixedPointBinding,
|
isFixedPointBinding,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache, toggleLinePolygonState } from "./shape";
|
||||||
|
|
||||||
import {
|
|
||||||
isPathALoop,
|
|
||||||
getBezierCurveLength,
|
|
||||||
getControlPointsForBezierCurve,
|
|
||||||
mapIntervalToBezierT,
|
|
||||||
getBezierXY,
|
|
||||||
toggleLinePolygonState,
|
|
||||||
} from "./shapes";
|
|
||||||
|
|
||||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||||
|
|
||||||
@@ -150,10 +149,12 @@ export class LinearElementEditor {
|
|||||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||||
public readonly elbowed: boolean;
|
public readonly elbowed: boolean;
|
||||||
public readonly customLineAngle: number | null;
|
public readonly customLineAngle: number | null;
|
||||||
|
public readonly isEditing: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
isEditing: boolean = false,
|
||||||
) {
|
) {
|
||||||
this.elementId = element.id as string & {
|
this.elementId = element.id as string & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
@@ -188,6 +189,7 @@ export class LinearElementEditor {
|
|||||||
this.segmentMidPointHoveredCoords = null;
|
this.segmentMidPointHoveredCoords = null;
|
||||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||||
this.customLineAngle = null;
|
this.customLineAngle = null;
|
||||||
|
this.isEditing = isEditing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -195,6 +197,7 @@ export class LinearElementEditor {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static POINT_HANDLE_SIZE = 10;
|
static POINT_HANDLE_SIZE = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param id the `elementId` from the instance of this class (so that we can
|
* @param id the `elementId` from the instance of this class (so that we can
|
||||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||||
@@ -216,11 +219,14 @@ export class LinearElementEditor {
|
|||||||
setState: React.Component<any, AppState>["setState"],
|
setState: React.Component<any, AppState>["setState"],
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
) {
|
) {
|
||||||
if (!appState.editingLinearElement || !appState.selectionElement) {
|
if (
|
||||||
|
!appState.selectedLinearElement?.isEditing ||
|
||||||
|
!appState.selectionElement
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { editingLinearElement } = appState;
|
const { selectedLinearElement } = appState;
|
||||||
const { selectedPointsIndices, elementId } = editingLinearElement;
|
const { selectedPointsIndices, elementId } = selectedLinearElement;
|
||||||
|
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -261,8 +267,8 @@ export class LinearElementEditor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
editingLinearElement: {
|
selectedLinearElement: {
|
||||||
...editingLinearElement,
|
...selectedLinearElement,
|
||||||
selectedPointsIndices: nextSelectedPoints.length
|
selectedPointsIndices: nextSelectedPoints.length
|
||||||
? nextSelectedPoints
|
? nextSelectedPoints
|
||||||
: null,
|
: null,
|
||||||
@@ -278,18 +284,13 @@ export class LinearElementEditor {
|
|||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
maybeSuggestBinding: (
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
pointSceneCoords: { x: number; y: number }[],
|
|
||||||
) => void,
|
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
scene: Scene,
|
): Pick<AppState, keyof AppState> | null {
|
||||||
): LinearElementEditor | null {
|
|
||||||
if (!linearElementEditor) {
|
if (!linearElementEditor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { elementId } = linearElementEditor;
|
const { elementId } = linearElementEditor;
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
let customLineAngle = linearElementEditor.customLineAngle;
|
let customLineAngle = linearElementEditor.customLineAngle;
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -350,7 +351,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
app.scene,
|
||||||
new Map([
|
new Map([
|
||||||
[
|
[
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
@@ -378,7 +379,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
app.scene,
|
||||||
new Map(
|
new Map(
|
||||||
selectedPointsIndices.map((pointIndex) => {
|
selectedPointsIndices.map((pointIndex) => {
|
||||||
const newPointPosition: LocalPoint =
|
const newPointPosition: LocalPoint =
|
||||||
@@ -410,46 +411,59 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
handleBindTextResize(element, scene, false);
|
handleBindTextResize(element, app.scene, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// suggest bindings for first and last point if selected
|
// suggest bindings for first and last point if selected
|
||||||
|
let suggestedBindings: ExcalidrawBindableElement[] = [];
|
||||||
if (isBindingElement(element, false)) {
|
if (isBindingElement(element, false)) {
|
||||||
|
const firstSelectedIndex = selectedPointsIndices[0] === 0;
|
||||||
|
const lastSelectedIndex =
|
||||||
|
selectedPointsIndices[selectedPointsIndices.length - 1] ===
|
||||||
|
element.points.length - 1;
|
||||||
const coords: { x: number; y: number }[] = [];
|
const coords: { x: number; y: number }[] = [];
|
||||||
|
|
||||||
const firstSelectedIndex = selectedPointsIndices[0];
|
if (!firstSelectedIndex !== !lastSelectedIndex) {
|
||||||
if (firstSelectedIndex === 0) {
|
coords.push({ x: scenePointerX, y: scenePointerY });
|
||||||
coords.push(
|
} else {
|
||||||
tupleToCoors(
|
if (firstSelectedIndex) {
|
||||||
LinearElementEditor.getPointGlobalCoordinates(
|
coords.push(
|
||||||
element,
|
tupleToCoors(
|
||||||
element.points[0],
|
LinearElementEditor.getPointGlobalCoordinates(
|
||||||
elementsMap,
|
element,
|
||||||
|
element.points[0],
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const lastSelectedIndex =
|
if (lastSelectedIndex) {
|
||||||
selectedPointsIndices[selectedPointsIndices.length - 1];
|
coords.push(
|
||||||
if (lastSelectedIndex === element.points.length - 1) {
|
tupleToCoors(
|
||||||
coords.push(
|
LinearElementEditor.getPointGlobalCoordinates(
|
||||||
tupleToCoors(
|
element,
|
||||||
LinearElementEditor.getPointGlobalCoordinates(
|
element.points[
|
||||||
element,
|
selectedPointsIndices[selectedPointsIndices.length - 1]
|
||||||
element.points[lastSelectedIndex],
|
],
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coords.length) {
|
if (coords.length) {
|
||||||
maybeSuggestBinding(element, coords);
|
suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords(
|
||||||
|
element,
|
||||||
|
coords,
|
||||||
|
app.scene,
|
||||||
|
app.state.zoom,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const newLinearElementEditor = {
|
||||||
...linearElementEditor,
|
...linearElementEditor,
|
||||||
selectedPointsIndices,
|
selectedPointsIndices,
|
||||||
segmentMidPointHoveredCoords:
|
segmentMidPointHoveredCoords:
|
||||||
@@ -469,6 +483,12 @@ export class LinearElementEditor {
|
|||||||
isDragging: true,
|
isDragging: true,
|
||||||
customLineAngle,
|
customLineAngle,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...app.state,
|
||||||
|
selectedLinearElement: newLinearElementEditor,
|
||||||
|
suggestedBindings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -482,6 +502,7 @@ export class LinearElementEditor {
|
|||||||
): LinearElementEditor {
|
): LinearElementEditor {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const elements = scene.getNonDeletedElements();
|
const elements = scene.getNonDeletedElements();
|
||||||
|
const pointerCoords = viewportCoordsToSceneCoords(event, appState);
|
||||||
|
|
||||||
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||||
editingLinearElement;
|
editingLinearElement;
|
||||||
@@ -537,13 +558,15 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
const bindingElement = isBindingEnabled(appState)
|
const bindingElement = isBindingEnabled(appState)
|
||||||
? getHoveredElementForBinding(
|
? getHoveredElementForBinding(
|
||||||
tupleToCoors(
|
(selectedPointsIndices?.length ?? 0) > 1
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
? tupleToCoors(
|
||||||
element,
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
selectedPoint!,
|
element,
|
||||||
elementsMap,
|
selectedPoint!,
|
||||||
),
|
elementsMap,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: pointerCoords,
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
@@ -599,7 +622,7 @@ export class LinearElementEditor {
|
|||||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||||
if (
|
if (
|
||||||
!isElbowArrow(element) &&
|
!isElbowArrow(element) &&
|
||||||
!appState.editingLinearElement &&
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
element.points.length > 2 &&
|
element.points.length > 2 &&
|
||||||
!boundText
|
!boundText
|
||||||
) {
|
) {
|
||||||
@@ -629,10 +652,7 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
element,
|
element,
|
||||||
points[index],
|
|
||||||
points[index + 1],
|
|
||||||
index + 1,
|
index + 1,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
midpoints.push(segmentMidPoint);
|
midpoints.push(segmentMidPoint);
|
||||||
index++;
|
index++;
|
||||||
@@ -668,7 +688,7 @@ export class LinearElementEditor {
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
points.length >= 3 &&
|
points.length >= 3 &&
|
||||||
!appState.editingLinearElement &&
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
!isElbowArrow(element)
|
!isElbowArrow(element)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
@@ -734,7 +754,18 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
let distance = pointDistance(startPoint, endPoint);
|
let distance = pointDistance(startPoint, endPoint);
|
||||||
if (element.points.length > 2 && element.roundness) {
|
if (element.points.length > 2 && element.roundness) {
|
||||||
distance = getBezierCurveLength(element, endPoint);
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
lines.length === 0 && curves.length > 0,
|
||||||
|
"Only linears built out of curves are supported",
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
lines.length + curves.length >= index,
|
||||||
|
"Invalid segment index while calculating mid point",
|
||||||
|
);
|
||||||
|
|
||||||
|
distance = curveLength<GlobalPoint>(curves[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
||||||
@@ -742,39 +773,42 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static getSegmentMidPoint(
|
static getSegmentMidPoint(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startPoint: GlobalPoint,
|
index: number,
|
||||||
endPoint: GlobalPoint,
|
|
||||||
endPointIndex: number,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): GlobalPoint {
|
): GlobalPoint {
|
||||||
let segmentMidPoint = pointCenter(startPoint, endPoint);
|
if (isElbowArrow(element)) {
|
||||||
if (element.points.length > 2 && element.roundness) {
|
invariant(
|
||||||
const controlPoints = getControlPointsForBezierCurve(
|
element.points.length >= index,
|
||||||
element,
|
"Invalid segment index while calculating elbow arrow mid point",
|
||||||
element.points[endPointIndex],
|
|
||||||
);
|
);
|
||||||
if (controlPoints) {
|
|
||||||
const t = mapIntervalToBezierT(
|
|
||||||
element,
|
|
||||||
element.points[endPointIndex],
|
|
||||||
0.5,
|
|
||||||
);
|
|
||||||
|
|
||||||
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
const p = pointCenter(element.points[index - 1], element.points[index]);
|
||||||
element,
|
|
||||||
getBezierXY(
|
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
|
||||||
controlPoints[0],
|
|
||||||
controlPoints[1],
|
|
||||||
controlPoints[2],
|
|
||||||
controlPoints[3],
|
|
||||||
t,
|
|
||||||
),
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return segmentMidPoint;
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
(lines.length === 0 && curves.length > 0) ||
|
||||||
|
(lines.length > 0 && curves.length === 0),
|
||||||
|
"Only linears built out of either segments or curves are supported",
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
lines.length + curves.length >= index,
|
||||||
|
"Invalid segment index while calculating mid point",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lines.length) {
|
||||||
|
const segment = lines[index - 1];
|
||||||
|
return pointCenter(segment[0], segment[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curves.length) {
|
||||||
|
const segment = curves[index - 1];
|
||||||
|
return curvePointAtLength(segment, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
invariant(false, "Invalid segment type while calculating mid point");
|
||||||
}
|
}
|
||||||
|
|
||||||
static getSegmentMidPointIndex(
|
static getSegmentMidPointIndex(
|
||||||
@@ -851,7 +885,7 @@ export class LinearElementEditor {
|
|||||||
segmentMidpoint,
|
segmentMidpoint,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
} else if (event.altKey && appState.editingLinearElement) {
|
} else if (event.altKey && appState.selectedLinearElement?.isEditing) {
|
||||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||||
scene.mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
points: [
|
points: [
|
||||||
@@ -993,14 +1027,14 @@ export class LinearElementEditor {
|
|||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
): LinearElementEditor | null {
|
): LinearElementEditor | null {
|
||||||
const appState = app.state;
|
const appState = app.state;
|
||||||
if (!appState.editingLinearElement) {
|
if (!appState.selectedLinearElement?.isEditing) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return appState.editingLinearElement;
|
return appState.selectedLinearElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
@@ -1010,10 +1044,12 @@ export class LinearElementEditor {
|
|||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
||||||
}
|
}
|
||||||
return {
|
return appState.selectedLinearElement?.lastUncommittedPoint
|
||||||
...appState.editingLinearElement,
|
? {
|
||||||
lastUncommittedPoint: null,
|
...appState.selectedLinearElement,
|
||||||
};
|
lastUncommittedPoint: null,
|
||||||
|
}
|
||||||
|
: appState.selectedLinearElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newPoint: LocalPoint;
|
let newPoint: LocalPoint;
|
||||||
@@ -1037,8 +1073,8 @@ export class LinearElementEditor {
|
|||||||
newPoint = LinearElementEditor.createPointAt(
|
newPoint = LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
scenePointerX - appState.editingLinearElement.pointerOffset.x,
|
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
|
||||||
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
|
||||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||||
? null
|
? null
|
||||||
: app.getEffectiveGridSize(),
|
: app.getEffectiveGridSize(),
|
||||||
@@ -1062,7 +1098,7 @@ export class LinearElementEditor {
|
|||||||
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.editingLinearElement,
|
...appState.selectedLinearElement,
|
||||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1221,12 +1257,12 @@ export class LinearElementEditor {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
||||||
invariant(
|
invariant(
|
||||||
appState.editingLinearElement,
|
appState.selectedLinearElement?.isEditing,
|
||||||
"Not currently editing a linear element",
|
"Not currently editing a linear element",
|
||||||
);
|
);
|
||||||
|
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
invariant(
|
invariant(
|
||||||
@@ -1288,8 +1324,8 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...appState,
|
...appState,
|
||||||
editingLinearElement: {
|
selectedLinearElement: {
|
||||||
...appState.editingLinearElement,
|
...appState.selectedLinearElement,
|
||||||
selectedPointsIndices: nextSelectedIndices,
|
selectedPointsIndices: nextSelectedIndices,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1301,8 +1337,9 @@ export class LinearElementEditor {
|
|||||||
pointIndices: readonly number[],
|
pointIndices: readonly number[],
|
||||||
) {
|
) {
|
||||||
const isUncommittedPoint =
|
const isUncommittedPoint =
|
||||||
app.state.editingLinearElement?.lastUncommittedPoint ===
|
app.state.selectedLinearElement?.isEditing &&
|
||||||
element.points[element.points.length - 1];
|
app.state.selectedLinearElement?.lastUncommittedPoint ===
|
||||||
|
element.points[element.points.length - 1];
|
||||||
|
|
||||||
const nextPoints = element.points.filter((_, idx) => {
|
const nextPoints = element.points.filter((_, idx) => {
|
||||||
return !pointIndices.includes(idx);
|
return !pointIndices.includes(idx);
|
||||||
@@ -1475,7 +1512,7 @@ export class LinearElementEditor {
|
|||||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!appState.editingLinearElement &&
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
dist < DRAGGING_THRESHOLD / appState.zoom.value
|
dist < DRAGGING_THRESHOLD / appState.zoom.value
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1670,10 +1707,7 @@ export class LinearElementEditor {
|
|||||||
const index = element.points.length / 2 - 1;
|
const index = element.points.length / 2 - 1;
|
||||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
element,
|
element,
|
||||||
points[index],
|
|
||||||
points[index + 1],
|
|
||||||
index + 1,
|
index + 1,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./shape";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ import type {
|
|||||||
|
|
||||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||||
Partial<TElement>,
|
Partial<TElement>,
|
||||||
"id" | "version" | "versionNonce" | "updated"
|
"id" | "updated"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
ShapeCache.delete(element);
|
ShapeCache.delete(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
element.version++;
|
element.version = updates.version ?? element.version + 1;
|
||||||
element.versionNonce = randomInteger();
|
element.versionNonce = updates.versionNonce ?? randomInteger();
|
||||||
element.updated = getUpdatedTimestamp();
|
element.updated = getUpdatedTimestamp();
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
@@ -172,9 +172,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
|||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
...updates,
|
...updates,
|
||||||
|
version: updates.version ?? element.version + 1,
|
||||||
|
versionNonce: updates.versionNonce ?? randomInteger(),
|
||||||
updated: getUpdatedTimestamp(),
|
updated: getUpdatedTimestamp(),
|
||||||
version: element.version + 1,
|
|
||||||
versionNonce: randomInteger(),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -445,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 {
|
||||||
@@ -453,6 +454,11 @@ export const newFreeDrawElement = (
|
|||||||
pressures: opts.pressures || [],
|
pressures: opts.pressures || [],
|
||||||
simulatePressure: opts.simulatePressure,
|
simulatePressure: opts.simulatePressure,
|
||||||
lastCommittedPoint: null,
|
lastCommittedPoint: null,
|
||||||
|
freedrawOptions: opts.strokeOptions || {
|
||||||
|
fixedStrokeWidth: true,
|
||||||
|
streamline: 0.25,
|
||||||
|
simplify: 0.1,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { getCommonBounds } from "./bounds";
|
||||||
|
import { type ElementUpdate, newElementWith } from "./mutateElement";
|
||||||
|
|
||||||
|
import type { ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
|
// TODO rewrite (mostly vibe-coded)
|
||||||
|
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
|
||||||
|
elements: TElement[] | TElement[][],
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
padding = 50,
|
||||||
|
): TElement[] => {
|
||||||
|
// Ensure there are elements to position
|
||||||
|
if (!elements || elements.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const res: TElement[] = [];
|
||||||
|
// Normalize input to work with atomic units (groups of elements)
|
||||||
|
// If elements is a flat array, treat each element as its own atomic unit
|
||||||
|
const atomicUnits: TElement[][] = Array.isArray(elements[0])
|
||||||
|
? (elements as TElement[][])
|
||||||
|
: (elements as TElement[]).map((element) => [element]);
|
||||||
|
|
||||||
|
// Determine the number of columns for atomic units
|
||||||
|
// A common approach for a "grid-like" layout without specific column constraints
|
||||||
|
// is to aim for a roughly square arrangement.
|
||||||
|
const numUnits = atomicUnits.length;
|
||||||
|
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
|
||||||
|
|
||||||
|
// Group atomic units into rows based on the calculated number of columns
|
||||||
|
const rows: TElement[][][] = [];
|
||||||
|
for (let i = 0; i < numUnits; i += numColumns) {
|
||||||
|
rows.push(atomicUnits.slice(i, i + numColumns));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate properties for each row (total width, max height)
|
||||||
|
// and the total actual height of all row content.
|
||||||
|
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
|
||||||
|
const rowProperties = rows.map((rowUnits) => {
|
||||||
|
let rowWidth = 0;
|
||||||
|
let maxUnitHeightInRow = 0;
|
||||||
|
|
||||||
|
const unitBounds = rowUnits.map((unit) => {
|
||||||
|
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
|
||||||
|
return {
|
||||||
|
elements: unit,
|
||||||
|
bounds: [minX, minY, maxX, maxY] as const,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
unitBounds.forEach((unitBound, index) => {
|
||||||
|
rowWidth += unitBound.width;
|
||||||
|
// Add padding between units in the same row, but not after the last one
|
||||||
|
if (index < unitBounds.length - 1) {
|
||||||
|
rowWidth += padding;
|
||||||
|
}
|
||||||
|
if (unitBound.height > maxUnitHeightInRow) {
|
||||||
|
maxUnitHeightInRow = unitBound.height;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
totalGridActualHeight += maxUnitHeightInRow;
|
||||||
|
return {
|
||||||
|
unitBounds,
|
||||||
|
width: rowWidth,
|
||||||
|
maxHeight: maxUnitHeightInRow,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate the total height of the grid including padding between rows
|
||||||
|
const totalGridHeightWithPadding =
|
||||||
|
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
|
||||||
|
|
||||||
|
// Calculate the starting Y position to center the entire grid vertically around centerY
|
||||||
|
let currentY = centerY - totalGridHeightWithPadding / 2;
|
||||||
|
|
||||||
|
// Position atomic units row by row
|
||||||
|
rowProperties.forEach((rowProp) => {
|
||||||
|
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
|
||||||
|
|
||||||
|
// Calculate the starting X for the current row to center it horizontally around centerX
|
||||||
|
let currentX = centerX - rowWidth / 2;
|
||||||
|
|
||||||
|
unitBounds.forEach((unitBound) => {
|
||||||
|
// Calculate the offset needed to position this atomic unit
|
||||||
|
const [originalMinX, originalMinY] = unitBound.bounds;
|
||||||
|
const offsetX = currentX - originalMinX;
|
||||||
|
const offsetY = currentY - originalMinY;
|
||||||
|
|
||||||
|
// Apply the offset to all elements in this atomic unit
|
||||||
|
unitBound.elements.forEach((element) => {
|
||||||
|
res.push(
|
||||||
|
newElementWith(element, {
|
||||||
|
x: element.x + offsetX,
|
||||||
|
y: element.y + offsetY,
|
||||||
|
} as ElementUpdate<TElement>),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move X for the next unit in the row
|
||||||
|
currentX += unitBound.width + padding;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move Y to the starting position for the next row
|
||||||
|
// This accounts for the tallest unit in the current row and the inter-row padding
|
||||||
|
currentY += rowMaxHeight + padding;
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { getStroke } from "perfect-freehand";
|
|
||||||
|
|
||||||
import { isRightAngleRads } from "@excalidraw/math";
|
import { isRightAngleRads } from "@excalidraw/math";
|
||||||
|
|
||||||
@@ -54,9 +53,11 @@ import {
|
|||||||
isImageElement,
|
isImageElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { getContainingFrame } from "./frame";
|
import { getContainingFrame } from "./frame";
|
||||||
import { getCornerRadius } from "./shapes";
|
import { getCornerRadius } from "./utils";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./shape";
|
||||||
|
|
||||||
|
import { getFreeDrawSvgPath } from "./freedraw";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -70,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
|
||||||
@@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
|
|||||||
return element.strokeWidth * 12;
|
return element.strokeWidth * 12;
|
||||||
case "text":
|
case "text":
|
||||||
return element.fontSize / 2;
|
return element.fontSize / 2;
|
||||||
|
case "arrow":
|
||||||
|
if (element.endArrowhead || element.endArrowhead) {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
return 20;
|
||||||
default:
|
default:
|
||||||
return 20;
|
return 20;
|
||||||
}
|
}
|
||||||
@@ -1032,57 +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) {
|
|
||||||
// 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 getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
pointCenter,
|
pointCenter,
|
||||||
normalizeRadians,
|
normalizeRadians,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointFromPair,
|
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
type Radians,
|
type Radians,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
@@ -36,6 +35,7 @@ import {
|
|||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
|
computeBoundTextPosition,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import {
|
import {
|
||||||
getMinTextElementWidth,
|
getMinTextElementWidth,
|
||||||
@@ -104,18 +104,6 @@ export const transformElements = (
|
|||||||
);
|
);
|
||||||
updateBoundElements(element, scene);
|
updateBoundElements(element, scene);
|
||||||
}
|
}
|
||||||
} else if (isTextElement(element) && transformHandleType) {
|
|
||||||
resizeSingleTextElement(
|
|
||||||
originalElements,
|
|
||||||
element,
|
|
||||||
scene,
|
|
||||||
transformHandleType,
|
|
||||||
shouldResizeFromCenter,
|
|
||||||
pointerX,
|
|
||||||
pointerY,
|
|
||||||
);
|
|
||||||
updateBoundElements(element, scene);
|
|
||||||
return true;
|
|
||||||
} else if (transformHandleType) {
|
} else if (transformHandleType) {
|
||||||
const elementId = selectedElements[0].id;
|
const elementId = selectedElements[0].id;
|
||||||
const latestElement = elementsMap.get(elementId);
|
const latestElement = elementsMap.get(elementId);
|
||||||
@@ -150,6 +138,9 @@ export const transformElements = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
updateBoundElements(element, scene);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} else if (selectedElements.length > 1) {
|
} else if (selectedElements.length > 1) {
|
||||||
if (transformHandleType === "rotation") {
|
if (transformHandleType === "rotation") {
|
||||||
@@ -235,7 +226,16 @@ const rotateSingleElement = (
|
|||||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||||
|
|
||||||
if (textElement && !isArrowElement(element)) {
|
if (textElement && !isArrowElement(element)) {
|
||||||
scene.mutateElement(textElement, { angle });
|
const { x, y } = computeBoundTextPosition(
|
||||||
|
element,
|
||||||
|
textElement,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
scene.mutateElement(textElement, {
|
||||||
|
angle,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -282,151 +282,50 @@ export const measureFontSizeFromWidth = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const resizeSingleTextElement = (
|
export const resizeSingleTextElement = (
|
||||||
originalElements: PointerDownState["originalElements"],
|
origElement: NonDeleted<ExcalidrawTextElement>,
|
||||||
element: NonDeleted<ExcalidrawTextElement>,
|
element: NonDeleted<ExcalidrawTextElement>,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
transformHandleType: TransformHandleDirection,
|
transformHandleType: TransformHandleDirection,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
pointerX: number,
|
nextWidth: number,
|
||||||
pointerY: number,
|
nextHeight: number,
|
||||||
) => {
|
) => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
// rotation pointer with reverse angle
|
|
||||||
const [rotatedX, rotatedY] = pointRotateRads(
|
|
||||||
pointFrom(pointerX, pointerY),
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
-element.angle as Radians,
|
|
||||||
);
|
|
||||||
let scaleX = 0;
|
|
||||||
let scaleY = 0;
|
|
||||||
|
|
||||||
if (transformHandleType !== "e" && transformHandleType !== "w") {
|
const metricsWidth = element.width * (nextHeight / element.height);
|
||||||
if (transformHandleType.includes("e")) {
|
|
||||||
scaleX = (rotatedX - x1) / (x2 - x1);
|
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
|
||||||
}
|
if (metrics === null) {
|
||||||
if (transformHandleType.includes("w")) {
|
return;
|
||||||
scaleX = (x2 - rotatedX) / (x2 - x1);
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("n")) {
|
|
||||||
scaleY = (y2 - rotatedY) / (y2 - y1);
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("s")) {
|
|
||||||
scaleY = (rotatedY - y1) / (y2 - y1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scale = Math.max(scaleX, scaleY);
|
if (transformHandleType.includes("n") || transformHandleType.includes("s")) {
|
||||||
|
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||||
|
|
||||||
if (scale > 0) {
|
const newOrigin = getResizedOrigin(
|
||||||
const nextWidth = element.width * scale;
|
previousOrigin,
|
||||||
const nextHeight = element.height * scale;
|
origElement.width,
|
||||||
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
|
origElement.height,
|
||||||
if (metrics === null) {
|
metricsWidth,
|
||||||
return;
|
nextHeight,
|
||||||
}
|
origElement.angle,
|
||||||
|
transformHandleType,
|
||||||
const startTopLeft = [x1, y1];
|
false,
|
||||||
const startBottomRight = [x2, y2];
|
shouldResizeFromCenter,
|
||||||
const startCenter = [cx, cy];
|
|
||||||
|
|
||||||
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
|
||||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
|
||||||
startBottomRight[0] - Math.abs(nextWidth),
|
|
||||||
startBottomRight[1] - Math.abs(nextHeight),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (transformHandleType === "ne") {
|
|
||||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
|
||||||
bottomLeft[0],
|
|
||||||
bottomLeft[1] - Math.abs(nextHeight),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (transformHandleType === "sw") {
|
|
||||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
|
||||||
topRight[0] - Math.abs(nextWidth),
|
|
||||||
topRight[1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (["s", "n"].includes(transformHandleType)) {
|
|
||||||
newTopLeft[0] = startCenter[0] - nextWidth / 2;
|
|
||||||
}
|
|
||||||
if (["e", "w"].includes(transformHandleType)) {
|
|
||||||
newTopLeft[1] = startCenter[1] - nextHeight / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldResizeFromCenter) {
|
|
||||||
newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
|
|
||||||
newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const angle = element.angle;
|
|
||||||
const rotatedTopLeft = pointRotateRads(
|
|
||||||
newTopLeft,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
angle,
|
|
||||||
);
|
);
|
||||||
const newCenter = pointFrom<GlobalPoint>(
|
|
||||||
newTopLeft[0] + Math.abs(nextWidth) / 2,
|
|
||||||
newTopLeft[1] + Math.abs(nextHeight) / 2,
|
|
||||||
);
|
|
||||||
const rotatedNewCenter = pointRotateRads(
|
|
||||||
newCenter,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
angle,
|
|
||||||
);
|
|
||||||
newTopLeft = pointRotateRads(
|
|
||||||
rotatedTopLeft,
|
|
||||||
rotatedNewCenter,
|
|
||||||
-angle as Radians,
|
|
||||||
);
|
|
||||||
const [nextX, nextY] = newTopLeft;
|
|
||||||
|
|
||||||
scene.mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
fontSize: metrics.size,
|
fontSize: metrics.size,
|
||||||
width: nextWidth,
|
width: metricsWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
x: nextX,
|
x: newOrigin.x,
|
||||||
y: nextY,
|
y: newOrigin.y,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transformHandleType === "e" || transformHandleType === "w") {
|
if (transformHandleType === "e" || transformHandleType === "w") {
|
||||||
const stateAtResizeStart = originalElements.get(element.id)!;
|
|
||||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
|
||||||
stateAtResizeStart,
|
|
||||||
stateAtResizeStart.width,
|
|
||||||
stateAtResizeStart.height,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
|
||||||
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
|
|
||||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
|
||||||
|
|
||||||
const rotatedPointer = pointRotateRads(
|
|
||||||
pointFrom(pointerX, pointerY),
|
|
||||||
startCenter,
|
|
||||||
-stateAtResizeStart.angle as Radians,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
|
|
||||||
element,
|
|
||||||
element.width,
|
|
||||||
element.height,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const boundsCurrentWidth = esx2 - esx1;
|
|
||||||
|
|
||||||
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
|
|
||||||
const minWidth = getMinTextElementWidth(
|
const minWidth = getMinTextElementWidth(
|
||||||
getFontString({
|
getFontString({
|
||||||
fontSize: element.fontSize,
|
fontSize: element.fontSize,
|
||||||
@@ -435,17 +334,7 @@ const resizeSingleTextElement = (
|
|||||||
element.lineHeight,
|
element.lineHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
const newWidth = Math.max(minWidth, nextWidth);
|
||||||
|
|
||||||
if (transformHandleType.includes("e")) {
|
|
||||||
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("w")) {
|
|
||||||
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newWidth =
|
|
||||||
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
|
|
||||||
|
|
||||||
const text = wrapText(
|
const text = wrapText(
|
||||||
element.originalText,
|
element.originalText,
|
||||||
@@ -458,49 +347,27 @@ const resizeSingleTextElement = (
|
|||||||
element.lineHeight,
|
element.lineHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
const eleNewHeight = metrics.height;
|
const newHeight = metrics.height;
|
||||||
|
|
||||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||||
getResizedElementAbsoluteCoords(
|
|
||||||
stateAtResizeStart,
|
|
||||||
newWidth,
|
|
||||||
eleNewHeight,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
|
||||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
|
||||||
|
|
||||||
let newTopLeft = [...startTopLeft] as [number, number];
|
const newOrigin = getResizedOrigin(
|
||||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
previousOrigin,
|
||||||
newTopLeft = [
|
origElement.width,
|
||||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
origElement.height,
|
||||||
startTopLeft[1],
|
newWidth,
|
||||||
];
|
newHeight,
|
||||||
}
|
element.angle,
|
||||||
|
transformHandleType,
|
||||||
// adjust topLeft to new rotation point
|
false,
|
||||||
const angle = stateAtResizeStart.angle;
|
shouldResizeFromCenter,
|
||||||
const rotatedTopLeft = pointRotateRads(
|
|
||||||
pointFromPair(newTopLeft),
|
|
||||||
startCenter,
|
|
||||||
angle,
|
|
||||||
);
|
|
||||||
const newCenter = pointFrom(
|
|
||||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
|
||||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
|
||||||
);
|
|
||||||
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
|
|
||||||
newTopLeft = pointRotateRads(
|
|
||||||
rotatedTopLeft,
|
|
||||||
rotatedNewCenter,
|
|
||||||
-angle as Radians,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const resizedElement: Partial<ExcalidrawTextElement> = {
|
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||||
width: Math.abs(newWidth),
|
width: Math.abs(newWidth),
|
||||||
height: Math.abs(metrics.height),
|
height: Math.abs(metrics.height),
|
||||||
x: newTopLeft[0],
|
x: newOrigin.x,
|
||||||
y: newTopLeft[1],
|
y: newOrigin.y,
|
||||||
text,
|
text,
|
||||||
autoResize: false,
|
autoResize: false,
|
||||||
};
|
};
|
||||||
@@ -559,9 +426,15 @@ const rotateMultipleElements = (
|
|||||||
|
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
if (boundText && !isArrowElement(element)) {
|
if (boundText && !isArrowElement(element)) {
|
||||||
|
const { x, y } = computeBoundTextPosition(
|
||||||
|
element,
|
||||||
|
boundText,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
scene.mutateElement(boundText, {
|
scene.mutateElement(boundText, {
|
||||||
x: boundText.x + (rotatedCX - cx),
|
x,
|
||||||
y: boundText.y + (rotatedCY - cy),
|
y,
|
||||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -821,6 +694,18 @@ export const resizeSingleElement = (
|
|||||||
shouldInformMutation?: boolean;
|
shouldInformMutation?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
|
if (isTextElement(latestElement) && isTextElement(origElement)) {
|
||||||
|
return resizeSingleTextElement(
|
||||||
|
origElement,
|
||||||
|
latestElement,
|
||||||
|
scene,
|
||||||
|
handleDirection,
|
||||||
|
shouldResizeFromCenter,
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let boundTextFont: { fontSize?: number } = {};
|
let boundTextFont: { fontSize?: number } = {};
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
@@ -1518,11 +1403,7 @@ export const resizeMultipleElements = (
|
|||||||
} of elementsAndUpdates) {
|
} of elementsAndUpdates) {
|
||||||
const { width, height, angle } = update;
|
const { width, height, angle } = update;
|
||||||
|
|
||||||
scene.mutateElement(element, update, {
|
scene.mutateElement(element, update);
|
||||||
informMutation: true,
|
|
||||||
// needed for the fixed binding point udpate to take effect
|
|
||||||
isDragging: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
updateBoundElements(element, scene, {
|
updateBoundElements(element, scene, {
|
||||||
simultaneouslyUpdated: elementsToUpdate,
|
simultaneouslyUpdated: elementsToUpdate,
|
||||||
|
|||||||
@@ -1,26 +1,66 @@
|
|||||||
import { simplify } from "points-on-curve";
|
import { simplify } from "points-on-curve";
|
||||||
|
|
||||||
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
|
import {
|
||||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
type GeometricShape,
|
||||||
|
getClosedCurveShape,
|
||||||
|
getCurveShape,
|
||||||
|
getEllipseShape,
|
||||||
|
getFreedrawShape,
|
||||||
|
getPolygonShape,
|
||||||
|
} from "@excalidraw/utils/shape";
|
||||||
|
|
||||||
|
import {
|
||||||
|
pointFrom,
|
||||||
|
pointDistance,
|
||||||
|
type LocalPoint,
|
||||||
|
pointRotateRads,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
import {
|
||||||
|
ROUGHNESS,
|
||||||
|
isTransparent,
|
||||||
|
assertNever,
|
||||||
|
COLOR_PALETTE,
|
||||||
|
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||||
|
STROKE_WIDTH,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { RoughGenerator } from "roughjs/bin/generator";
|
||||||
|
|
||||||
|
import type { GlobalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
import type {
|
||||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
AppState,
|
||||||
|
EmbedsValidationStatus,
|
||||||
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
import type {
|
||||||
|
ElementShape,
|
||||||
|
ElementShapes,
|
||||||
|
} from "@excalidraw/excalidraw/scene/types";
|
||||||
|
|
||||||
|
import { elementWithCanvasCache } from "./renderElement";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
canBecomePolygon,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isEmbeddableElement,
|
isEmbeddableElement,
|
||||||
isIframeElement,
|
isIframeElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
import { getCornerRadius, isPathALoop } from "./utils";
|
||||||
import { headingForPointIsHorizontal } from "./heading";
|
import { headingForPointIsHorizontal } from "./heading";
|
||||||
|
|
||||||
import { canChangeRoundness } from "./comparisons";
|
import { canChangeRoundness } from "./comparisons";
|
||||||
import { generateFreeDrawShape } from "./renderElement";
|
import { generateFreeDrawShape } from "./renderElement";
|
||||||
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
|
import {
|
||||||
|
getArrowheadPoints,
|
||||||
|
getCenterForBounds,
|
||||||
|
getDiamondPoints,
|
||||||
|
getElementAbsoluteCoords,
|
||||||
|
} from "./bounds";
|
||||||
|
import { shouldTestInside } from "./collision";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -28,12 +68,89 @@ import type {
|
|||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawLineElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type { Drawable, Options } from "roughjs/bin/core";
|
import type { Drawable, Options } from "roughjs/bin/core";
|
||||||
import type { RoughGenerator } from "roughjs/bin/generator";
|
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
|
|
||||||
|
export class ShapeCache {
|
||||||
|
private static rg = new RoughGenerator();
|
||||||
|
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves shape from cache if available. Use this only if shape
|
||||||
|
* is optional and you have a fallback in case it's not cached.
|
||||||
|
*/
|
||||||
|
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||||
|
return ShapeCache.cache.get(
|
||||||
|
element,
|
||||||
|
) as T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]] | undefined
|
||||||
|
: ElementShape | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
public static set = <T extends ExcalidrawElement>(
|
||||||
|
element: T,
|
||||||
|
shape: T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]]
|
||||||
|
: Drawable,
|
||||||
|
) => ShapeCache.cache.set(element, shape);
|
||||||
|
|
||||||
|
public static delete = (element: ExcalidrawElement) =>
|
||||||
|
ShapeCache.cache.delete(element);
|
||||||
|
|
||||||
|
public static destroy = () => {
|
||||||
|
ShapeCache.cache = new WeakMap();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates & caches shape for element if not already cached, otherwise
|
||||||
|
* returns cached shape.
|
||||||
|
*/
|
||||||
|
public static generateElementShape = <
|
||||||
|
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
|
>(
|
||||||
|
element: T,
|
||||||
|
renderConfig: {
|
||||||
|
isExporting: boolean;
|
||||||
|
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||||
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
|
} | null,
|
||||||
|
) => {
|
||||||
|
// when exporting, always regenerated to guarantee the latest shape
|
||||||
|
const cachedShape = renderConfig?.isExporting
|
||||||
|
? undefined
|
||||||
|
: ShapeCache.get(element);
|
||||||
|
|
||||||
|
// `null` indicates no rc shape applicable for this element type,
|
||||||
|
// but it's considered a valid cache value (= do not regenerate)
|
||||||
|
if (cachedShape !== undefined) {
|
||||||
|
return cachedShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
elementWithCanvasCache.delete(element);
|
||||||
|
|
||||||
|
const shape = generateElementShape(
|
||||||
|
element,
|
||||||
|
ShapeCache.rg,
|
||||||
|
renderConfig || {
|
||||||
|
isExporting: false,
|
||||||
|
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||||
|
embedsValidationStatus: null,
|
||||||
|
},
|
||||||
|
) as T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]]
|
||||||
|
: Drawable | null;
|
||||||
|
|
||||||
|
ShapeCache.cache.set(element, shape);
|
||||||
|
|
||||||
|
return shape;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||||
|
|
||||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||||
@@ -86,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:
|
||||||
@@ -303,6 +420,182 @@ const getArrowheadShapes = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateLinearCollisionShape = (
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
) => {
|
||||||
|
const generator = new RoughGenerator();
|
||||||
|
const options: Options = {
|
||||||
|
seed: element.seed,
|
||||||
|
disableMultiStroke: true,
|
||||||
|
disableMultiStrokeFill: true,
|
||||||
|
roughness: 0,
|
||||||
|
preserveVertices: true,
|
||||||
|
};
|
||||||
|
const center = getCenterForBounds(
|
||||||
|
// Need a non-rotated center point
|
||||||
|
element.points.reduce(
|
||||||
|
(acc, point) => {
|
||||||
|
return [
|
||||||
|
Math.min(element.x + point[0], acc[0]),
|
||||||
|
Math.min(element.y + point[1], acc[1]),
|
||||||
|
Math.max(element.x + point[0], acc[2]),
|
||||||
|
Math.max(element.y + point[1], acc[3]),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[Infinity, Infinity, -Infinity, -Infinity],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (element.type) {
|
||||||
|
case "line":
|
||||||
|
case "arrow": {
|
||||||
|
// points array can be empty in the beginning, so it is important to add
|
||||||
|
// initial position to it
|
||||||
|
const points = element.points.length
|
||||||
|
? element.points
|
||||||
|
: [pointFrom<LocalPoint>(0, 0)];
|
||||||
|
|
||||||
|
if (isElbowArrow(element)) {
|
||||||
|
return generator.path(generateElbowArrowShape(points, 16), options)
|
||||||
|
.sets[0].ops;
|
||||||
|
} else if (!element.roundness) {
|
||||||
|
return points.map((point, idx) => {
|
||||||
|
const p = pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: idx === 0 ? "move" : "lineTo",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return generator
|
||||||
|
.curve(points as unknown as RoughPoint[], options)
|
||||||
|
.sets[0].ops.slice(0, element.points.length)
|
||||||
|
.map((op, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
const p = pointRotateRads<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "move",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "bcurveTo",
|
||||||
|
data: [
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[2],
|
||||||
|
element.y + op.data[3],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[4],
|
||||||
|
element.y + op.data[5],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((p) =>
|
||||||
|
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
)
|
||||||
|
.flat(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "freedraw": {
|
||||||
|
if (element.points.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const simplifiedPoints = simplify(
|
||||||
|
element.points as Mutable<LocalPoint[]>,
|
||||||
|
0.75,
|
||||||
|
);
|
||||||
|
|
||||||
|
return generator
|
||||||
|
.curve(simplifiedPoints as [number, number][], options)
|
||||||
|
.sets[0].ops.slice(0, element.points.length)
|
||||||
|
.map((op, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
const p = pointRotateRads<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "move",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "bcurveTo",
|
||||||
|
data: [
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[2],
|
||||||
|
element.y + op.data[3],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[4],
|
||||||
|
element.y + op.data[5],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((p) =>
|
||||||
|
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
)
|
||||||
|
.flat(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the roughjs shape for given element.
|
* Generates the roughjs shape for given element.
|
||||||
*
|
*
|
||||||
@@ -310,7 +603,7 @@ const getArrowheadShapes = (
|
|||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
export const _generateElementShape = (
|
const generateElementShape = (
|
||||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
generator: RoughGenerator,
|
generator: RoughGenerator,
|
||||||
{
|
{
|
||||||
@@ -514,15 +807,21 @@ export 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;
|
||||||
}
|
}
|
||||||
@@ -611,3 +910,103 @@ const generateElbowArrowShape = (
|
|||||||
|
|
||||||
return d.join(" ");
|
return d.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the pure geometric shape of an excalidraw elementw
|
||||||
|
* which is then used for hit detection
|
||||||
|
*/
|
||||||
|
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): GeometricShape<Point> => {
|
||||||
|
switch (element.type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "diamond":
|
||||||
|
case "frame":
|
||||||
|
case "magicframe":
|
||||||
|
case "embeddable":
|
||||||
|
case "image":
|
||||||
|
case "iframe":
|
||||||
|
case "text":
|
||||||
|
case "selection":
|
||||||
|
return getPolygonShape(element);
|
||||||
|
case "arrow":
|
||||||
|
case "line": {
|
||||||
|
const roughShape =
|
||||||
|
ShapeCache.get(element)?.[0] ??
|
||||||
|
ShapeCache.generateElementShape(element, null)[0];
|
||||||
|
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
|
||||||
|
return shouldTestInside(element)
|
||||||
|
? getClosedCurveShape<Point>(
|
||||||
|
element,
|
||||||
|
roughShape,
|
||||||
|
pointFrom<Point>(element.x, element.y),
|
||||||
|
element.angle,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
)
|
||||||
|
: getCurveShape<Point>(
|
||||||
|
roughShape,
|
||||||
|
pointFrom<Point>(element.x, element.y),
|
||||||
|
element.angle,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ellipse":
|
||||||
|
return getEllipseShape(element);
|
||||||
|
|
||||||
|
case "freedraw": {
|
||||||
|
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
return getFreedrawShape(
|
||||||
|
element,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
shouldTestInside(element),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleLinePolygonState = (
|
||||||
|
element: ExcalidrawLineElement,
|
||||||
|
nextPolygonState: boolean,
|
||||||
|
): {
|
||||||
|
polygon: ExcalidrawLineElement["polygon"];
|
||||||
|
points: ExcalidrawLineElement["points"];
|
||||||
|
} | null => {
|
||||||
|
const updatedPoints = [...element.points];
|
||||||
|
|
||||||
|
if (nextPolygonState) {
|
||||||
|
if (!canBecomePolygon(element.points)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPoint = updatedPoints[0];
|
||||||
|
const lastPoint = updatedPoints[updatedPoints.length - 1];
|
||||||
|
|
||||||
|
const distance = Math.hypot(
|
||||||
|
firstPoint[0] - lastPoint[0],
|
||||||
|
firstPoint[1] - lastPoint[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
|
||||||
|
updatedPoints.length < 4
|
||||||
|
) {
|
||||||
|
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
|
||||||
|
} else {
|
||||||
|
updatedPoints[updatedPoints.length - 1] = pointFrom(
|
||||||
|
firstPoint[0],
|
||||||
|
firstPoint[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
|
||||||
|
const ret = {
|
||||||
|
polygon: nextPolygonState,
|
||||||
|
points: updatedPoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
import {
|
|
||||||
DEFAULT_ADAPTIVE_RADIUS,
|
|
||||||
DEFAULT_PROPORTIONAL_RADIUS,
|
|
||||||
LINE_CONFIRM_THRESHOLD,
|
|
||||||
ROUNDNESS,
|
|
||||||
invariant,
|
|
||||||
elementCenterPoint,
|
|
||||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
|
||||||
} from "@excalidraw/common";
|
|
||||||
import {
|
|
||||||
isPoint,
|
|
||||||
pointFrom,
|
|
||||||
pointDistance,
|
|
||||||
pointFromPair,
|
|
||||||
pointRotateRads,
|
|
||||||
pointsEqual,
|
|
||||||
type GlobalPoint,
|
|
||||||
type LocalPoint,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
import {
|
|
||||||
getClosedCurveShape,
|
|
||||||
getCurvePathOps,
|
|
||||||
getCurveShape,
|
|
||||||
getEllipseShape,
|
|
||||||
getFreedrawShape,
|
|
||||||
getPolygonShape,
|
|
||||||
type GeometricShape,
|
|
||||||
} from "@excalidraw/utils/shape";
|
|
||||||
|
|
||||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
import { shouldTestInside } from "./collision";
|
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
|
||||||
import { getBoundTextElement } from "./textElement";
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
|
|
||||||
|
|
||||||
import { canBecomePolygon } from "./typeChecks";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
ExcalidrawLineElement,
|
|
||||||
NonDeleted,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get the pure geometric shape of an excalidraw elementw
|
|
||||||
* which is then used for hit detection
|
|
||||||
*/
|
|
||||||
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): GeometricShape<Point> => {
|
|
||||||
switch (element.type) {
|
|
||||||
case "rectangle":
|
|
||||||
case "diamond":
|
|
||||||
case "frame":
|
|
||||||
case "magicframe":
|
|
||||||
case "embeddable":
|
|
||||||
case "image":
|
|
||||||
case "iframe":
|
|
||||||
case "text":
|
|
||||||
case "selection":
|
|
||||||
return getPolygonShape(element);
|
|
||||||
case "arrow":
|
|
||||||
case "line": {
|
|
||||||
const roughShape =
|
|
||||||
ShapeCache.get(element)?.[0] ??
|
|
||||||
ShapeCache.generateElementShape(element, null)[0];
|
|
||||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
|
||||||
|
|
||||||
return shouldTestInside(element)
|
|
||||||
? getClosedCurveShape<Point>(
|
|
||||||
element,
|
|
||||||
roughShape,
|
|
||||||
pointFrom<Point>(element.x, element.y),
|
|
||||||
element.angle,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
)
|
|
||||||
: getCurveShape<Point>(
|
|
||||||
roughShape,
|
|
||||||
pointFrom<Point>(element.x, element.y),
|
|
||||||
element.angle,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case "ellipse":
|
|
||||||
return getEllipseShape(element);
|
|
||||||
|
|
||||||
case "freedraw": {
|
|
||||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
|
||||||
return getFreedrawShape(
|
|
||||||
element,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
shouldTestInside(element),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): GeometricShape<Point> | null => {
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
if (element.type === "arrow") {
|
|
||||||
return getElementShape(
|
|
||||||
{
|
|
||||||
...boundTextElement,
|
|
||||||
// arrow's bound text accurate position is not stored in the element's property
|
|
||||||
// but rather calculated and returned from the following static method
|
|
||||||
...LinearElementEditor.getBoundTextElementPosition(
|
|
||||||
element,
|
|
||||||
boundTextElement,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return getElementShape(boundTextElement, elementsMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getControlPointsForBezierCurve = <
|
|
||||||
P extends GlobalPoint | LocalPoint,
|
|
||||||
>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const shape = ShapeCache.generateElementShape(element, null);
|
|
||||||
if (!shape) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ops = getCurvePathOps(shape[0]);
|
|
||||||
let currentP = pointFrom<P>(0, 0);
|
|
||||||
let index = 0;
|
|
||||||
let minDistance = Infinity;
|
|
||||||
let controlPoints: P[] | null = null;
|
|
||||||
|
|
||||||
while (index < ops.length) {
|
|
||||||
const { op, data } = ops[index];
|
|
||||||
if (op === "move") {
|
|
||||||
invariant(
|
|
||||||
isPoint(data),
|
|
||||||
"The returned ops is not compatible with a point",
|
|
||||||
);
|
|
||||||
currentP = pointFromPair(data);
|
|
||||||
}
|
|
||||||
if (op === "bcurveTo") {
|
|
||||||
const p0 = currentP;
|
|
||||||
const p1 = pointFrom<P>(data[0], data[1]);
|
|
||||||
const p2 = pointFrom<P>(data[2], data[3]);
|
|
||||||
const p3 = pointFrom<P>(data[4], data[5]);
|
|
||||||
const distance = pointDistance(p3, endPoint);
|
|
||||||
if (distance < minDistance) {
|
|
||||||
minDistance = distance;
|
|
||||||
controlPoints = [p0, p1, p2, p3];
|
|
||||||
}
|
|
||||||
currentP = p3;
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return controlPoints;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
p0: P,
|
|
||||||
p1: P,
|
|
||||||
p2: P,
|
|
||||||
p3: P,
|
|
||||||
t: number,
|
|
||||||
): P => {
|
|
||||||
const equation = (t: number, idx: number) =>
|
|
||||||
Math.pow(1 - t, 3) * p3[idx] +
|
|
||||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
|
||||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
|
||||||
p0[idx] * Math.pow(t, 3);
|
|
||||||
const tx = equation(t, 0);
|
|
||||||
const ty = equation(t, 1);
|
|
||||||
return pointFrom(tx, ty);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
|
|
||||||
if (!controlPoints) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const pointsOnCurve: P[] = [];
|
|
||||||
let t = 1;
|
|
||||||
// Take 20 points on curve for better accuracy
|
|
||||||
while (t > 0) {
|
|
||||||
const p = getBezierXY(
|
|
||||||
controlPoints[0],
|
|
||||||
controlPoints[1],
|
|
||||||
controlPoints[2],
|
|
||||||
controlPoints[3],
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
pointsOnCurve.push(pointFrom(p[0], p[1]));
|
|
||||||
t -= 0.05;
|
|
||||||
}
|
|
||||||
if (pointsOnCurve.length) {
|
|
||||||
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
|
|
||||||
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pointsOnCurve;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const arcLengths: number[] = [];
|
|
||||||
arcLengths[0] = 0;
|
|
||||||
const points = getPointsInBezierCurve(element, endPoint);
|
|
||||||
let index = 0;
|
|
||||||
let distance = 0;
|
|
||||||
while (index < points.length - 1) {
|
|
||||||
const segmentDistance = pointDistance(points[index], points[index + 1]);
|
|
||||||
distance += segmentDistance;
|
|
||||||
arcLengths.push(distance);
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return arcLengths;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
|
||||||
return arcLengths.at(-1) as number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
|
|
||||||
export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
|
|
||||||
) => {
|
|
||||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
|
||||||
const pointsCount = arcLengths.length - 1;
|
|
||||||
const curveLength = arcLengths.at(-1) as number;
|
|
||||||
const targetLength = interval * curveLength;
|
|
||||||
let low = 0;
|
|
||||||
let high = pointsCount;
|
|
||||||
let index = 0;
|
|
||||||
// Doing a binary search to find the largest length that is less than the target length
|
|
||||||
while (low < high) {
|
|
||||||
index = Math.floor(low + (high - low) / 2);
|
|
||||||
if (arcLengths[index] < targetLength) {
|
|
||||||
low = index + 1;
|
|
||||||
} else {
|
|
||||||
high = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (arcLengths[index] > targetLength) {
|
|
||||||
index--;
|
|
||||||
}
|
|
||||||
if (arcLengths[index] === targetLength) {
|
|
||||||
return index / pointsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
1 -
|
|
||||||
(index +
|
|
||||||
(targetLength - arcLengths[index]) /
|
|
||||||
(arcLengths[index + 1] - arcLengths[index])) /
|
|
||||||
pointsCount
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the axis-aligned bounding box for a given element
|
|
||||||
*/
|
|
||||||
export const aabbForElement = (
|
|
||||||
element: Readonly<ExcalidrawElement>,
|
|
||||||
offset?: [number, number, number, number],
|
|
||||||
) => {
|
|
||||||
const bbox = {
|
|
||||||
minX: element.x,
|
|
||||||
minY: element.y,
|
|
||||||
maxX: element.x + element.width,
|
|
||||||
maxY: element.y + element.height,
|
|
||||||
midX: element.x + element.width / 2,
|
|
||||||
midY: element.y + element.height / 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const center = elementCenterPoint(element);
|
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.minX, bbox.minY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [topRightX, topRightY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.maxX, bbox.minY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.maxX, bbox.maxY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.minX, bbox.maxY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
const bounds = [
|
|
||||||
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
|
||||||
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
|
||||||
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
|
||||||
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
|
||||||
] as Bounds;
|
|
||||||
|
|
||||||
if (offset) {
|
|
||||||
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
|
||||||
return [
|
|
||||||
bounds[0] - leftOffset,
|
|
||||||
bounds[1] - topOffset,
|
|
||||||
bounds[2] + rightOffset,
|
|
||||||
bounds[3] + downOffset,
|
|
||||||
] as Bounds;
|
|
||||||
}
|
|
||||||
|
|
||||||
return bounds;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
p: P,
|
|
||||||
bounds: Bounds,
|
|
||||||
): boolean =>
|
|
||||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
|
||||||
|
|
||||||
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
|
|
||||||
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[0], b[3]), a);
|
|
||||||
|
|
||||||
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
|
||||||
if (
|
|
||||||
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
|
|
||||||
element.roundness?.type === ROUNDNESS.LEGACY
|
|
||||||
) {
|
|
||||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
|
|
||||||
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
|
|
||||||
|
|
||||||
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
|
|
||||||
|
|
||||||
if (x <= CUTOFF_SIZE) {
|
|
||||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fixedRadiusSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Checks if the first and last point are close enough
|
|
||||||
// to be considered a loop
|
|
||||||
export const isPathALoop = (
|
|
||||||
points: ExcalidrawLinearElement["points"],
|
|
||||||
/** supply if you want the loop detection to account for current zoom */
|
|
||||||
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
|
|
||||||
): boolean => {
|
|
||||||
if (points.length >= 3) {
|
|
||||||
const [first, last] = [points[0], points[points.length - 1]];
|
|
||||||
const distance = pointDistance(first, last);
|
|
||||||
|
|
||||||
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
|
|
||||||
// really close we make the threshold smaller, and vice versa.
|
|
||||||
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const toggleLinePolygonState = (
|
|
||||||
element: ExcalidrawLineElement,
|
|
||||||
nextPolygonState: boolean,
|
|
||||||
): {
|
|
||||||
polygon: ExcalidrawLineElement["polygon"];
|
|
||||||
points: ExcalidrawLineElement["points"];
|
|
||||||
} | null => {
|
|
||||||
const updatedPoints = [...element.points];
|
|
||||||
|
|
||||||
if (nextPolygonState) {
|
|
||||||
if (!canBecomePolygon(element.points)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstPoint = updatedPoints[0];
|
|
||||||
const lastPoint = updatedPoints[updatedPoints.length - 1];
|
|
||||||
|
|
||||||
const distance = Math.hypot(
|
|
||||||
firstPoint[0] - lastPoint[0],
|
|
||||||
firstPoint[1] - lastPoint[1],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
|
|
||||||
updatedPoints.length < 4
|
|
||||||
) {
|
|
||||||
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
|
|
||||||
} else {
|
|
||||||
updatedPoints[updatedPoints.length - 1] = pointFrom(
|
|
||||||
firstPoint[0],
|
|
||||||
firstPoint[1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
|
|
||||||
const ret = {
|
|
||||||
polygon: nextPolygonState,
|
|
||||||
points: updatedPoints,
|
|
||||||
};
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
};
|
|
||||||
+116
-51
@@ -19,9 +19,21 @@ import { newElementWith } from "./mutateElement";
|
|||||||
|
|
||||||
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
||||||
|
|
||||||
import { hashElementsVersion, hashString } from "./index";
|
import {
|
||||||
|
syncInvalidIndicesImmutable,
|
||||||
|
hashElementsVersion,
|
||||||
|
hashString,
|
||||||
|
isInitializedImageElement,
|
||||||
|
isImageElement,
|
||||||
|
} from "./index";
|
||||||
|
|
||||||
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
|
import type { ApplyToOptions } from "./delta";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
SceneElementsMap,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export const CaptureUpdateAction = {
|
export const CaptureUpdateAction = {
|
||||||
/**
|
/**
|
||||||
@@ -64,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
|
|||||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||||
*/
|
*/
|
||||||
export class Store {
|
export class Store {
|
||||||
// internally used by history
|
// for internal use by history
|
||||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||||
|
// for public use as part of onIncrement API
|
||||||
public readonly onStoreIncrementEmitter = new Emitter<
|
public readonly onStoreIncrementEmitter = new Emitter<
|
||||||
[DurableIncrement | EphemeralIncrement]
|
[DurableIncrement | EphemeralIncrement]
|
||||||
>();
|
>();
|
||||||
@@ -105,7 +118,7 @@ export class Store {
|
|||||||
params:
|
params:
|
||||||
| {
|
| {
|
||||||
action: CaptureUpdateActionType;
|
action: CaptureUpdateActionType;
|
||||||
elements: SceneElementsMap | undefined;
|
elements: readonly ExcalidrawElement[] | undefined;
|
||||||
appState: AppState | ObservedAppState | undefined;
|
appState: AppState | ObservedAppState | undefined;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -129,13 +142,21 @@ export class Store {
|
|||||||
} else {
|
} else {
|
||||||
// immediately create an immutable change of the scheduled updates,
|
// immediately create an immutable change of the scheduled updates,
|
||||||
// compared to the current state, so that they won't mutate later on during batching
|
// compared to the current state, so that they won't mutate later on during batching
|
||||||
|
// also, we have to compare against the current state,
|
||||||
|
// as comparing against the snapshot might include yet uncomitted changes (i.e. async freedraw / text / image, etc.)
|
||||||
const currentSnapshot = StoreSnapshot.create(
|
const currentSnapshot = StoreSnapshot.create(
|
||||||
this.app.scene.getElementsMapIncludingDeleted(),
|
this.app.scene.getElementsMapIncludingDeleted(),
|
||||||
this.app.state,
|
this.app.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
const scheduledSnapshot = currentSnapshot.maybeClone(
|
const scheduledSnapshot = currentSnapshot.maybeClone(
|
||||||
action,
|
action,
|
||||||
params.elements,
|
// let's sync invalid indices first, so that we could detect this change
|
||||||
|
// also have the synced elements immutable, so that we don't mutate elements,
|
||||||
|
// that are already in the scene, otherwise we wouldn't see any change
|
||||||
|
params.elements
|
||||||
|
? syncInvalidIndicesImmutable(params.elements)
|
||||||
|
: undefined,
|
||||||
params.appState,
|
params.appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -213,22 +234,12 @@ export class Store {
|
|||||||
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
|
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
|
||||||
storeDelta = delta;
|
storeDelta = delta;
|
||||||
} else {
|
} else {
|
||||||
// calculate the deltas based on the previous and next snapshot
|
storeDelta = StoreDelta.calculate(prevSnapshot, snapshot);
|
||||||
const elementsDelta = snapshot.metadata.didElementsChange
|
|
||||||
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
|
|
||||||
: ElementsDelta.empty();
|
|
||||||
|
|
||||||
const appStateDelta = snapshot.metadata.didAppStateChange
|
|
||||||
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
|
|
||||||
: AppStateDelta.empty();
|
|
||||||
|
|
||||||
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!storeDelta.isEmpty()) {
|
if (!storeDelta.isEmpty()) {
|
||||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||||
|
|
||||||
// Notify listeners with the increment
|
|
||||||
this.onDurableIncrementEmitter.trigger(increment);
|
this.onDurableIncrementEmitter.trigger(increment);
|
||||||
this.onStoreIncrementEmitter.trigger(increment);
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
}
|
}
|
||||||
@@ -505,6 +516,24 @@ export class StoreDelta {
|
|||||||
return new this(opts.id, elements, appState);
|
return new this(opts.id, elements, appState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the delta between the previous and next snapshot.
|
||||||
|
*/
|
||||||
|
public static calculate(
|
||||||
|
prevSnapshot: StoreSnapshot,
|
||||||
|
nextSnapshot: StoreSnapshot,
|
||||||
|
) {
|
||||||
|
const elementsDelta = nextSnapshot.metadata.didElementsChange
|
||||||
|
? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
|
||||||
|
: ElementsDelta.empty();
|
||||||
|
|
||||||
|
const appStateDelta = nextSnapshot.metadata.didAppStateChange
|
||||||
|
? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
|
||||||
|
: AppStateDelta.empty();
|
||||||
|
|
||||||
|
return this.create(elementsDelta, appStateDelta);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore a store delta instance from a DTO.
|
* Restore a store delta instance from a DTO.
|
||||||
*/
|
*/
|
||||||
@@ -523,38 +552,35 @@ export class StoreDelta {
|
|||||||
public static load({
|
public static load({
|
||||||
id,
|
id,
|
||||||
elements: { added, removed, updated },
|
elements: { added, removed, updated },
|
||||||
|
appState: { delta: appStateDelta },
|
||||||
}: DTO<StoreDelta>) {
|
}: DTO<StoreDelta>) {
|
||||||
const elements = ElementsDelta.create(added, removed, updated, {
|
const elements = ElementsDelta.create(added, removed, updated);
|
||||||
shouldRedistribute: false,
|
const appState = AppStateDelta.create(appStateDelta);
|
||||||
});
|
|
||||||
|
|
||||||
return new this(id, elements, AppStateDelta.empty());
|
return new this(id, elements, appState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squash the passed deltas into the aggregated delta instance.
|
||||||
|
*/
|
||||||
|
public static squash(...deltas: StoreDelta[]) {
|
||||||
|
const aggregatedDelta = StoreDelta.empty();
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
aggregatedDelta.elements.squash(delta.elements);
|
||||||
|
aggregatedDelta.appState.squash(delta.appState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggregatedDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inverse store delta, creates new instance of `StoreDelta`.
|
* Inverse store delta, creates new instance of `StoreDelta`.
|
||||||
*/
|
*/
|
||||||
public static inverse(delta: StoreDelta): StoreDelta {
|
public static inverse(delta: StoreDelta) {
|
||||||
return this.create(delta.elements.inverse(), delta.appState.inverse());
|
return this.create(delta.elements.inverse(), delta.appState.inverse());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
|
|
||||||
*/
|
|
||||||
public static applyLatestChanges(
|
|
||||||
delta: StoreDelta,
|
|
||||||
elements: SceneElementsMap,
|
|
||||||
modifierOptions: "deleted" | "inserted",
|
|
||||||
): StoreDelta {
|
|
||||||
return this.create(
|
|
||||||
delta.elements.applyLatestChanges(elements, modifierOptions),
|
|
||||||
delta.appState,
|
|
||||||
{
|
|
||||||
id: delta.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the delta to the passed elements and appState, does not modify the snapshot.
|
* Apply the delta to the passed elements and appState, does not modify the snapshot.
|
||||||
*/
|
*/
|
||||||
@@ -562,11 +588,12 @@ export class StoreDelta {
|
|||||||
delta: StoreDelta,
|
delta: StoreDelta,
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
|
options?: ApplyToOptions,
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||||
elements,
|
elements,
|
||||||
prevSnapshot.elements,
|
StoreSnapshot.empty().elements,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [nextAppState, appStateContainsVisibleChange] =
|
const [nextAppState, appStateContainsVisibleChange] =
|
||||||
@@ -578,6 +605,32 @@ export class StoreDelta {
|
|||||||
return [nextElements, nextAppState, appliedVisibleChanges];
|
return [nextElements, nextAppState, appliedVisibleChanges];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
|
||||||
|
*/
|
||||||
|
public static applyLatestChanges(
|
||||||
|
delta: StoreDelta,
|
||||||
|
prevElements: SceneElementsMap,
|
||||||
|
nextElements: SceneElementsMap,
|
||||||
|
modifierOptions?: "deleted" | "inserted",
|
||||||
|
): StoreDelta {
|
||||||
|
return this.create(
|
||||||
|
delta.elements.applyLatestChanges(
|
||||||
|
prevElements,
|
||||||
|
nextElements,
|
||||||
|
modifierOptions,
|
||||||
|
),
|
||||||
|
delta.appState,
|
||||||
|
{
|
||||||
|
id: delta.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static empty() {
|
||||||
|
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
||||||
|
}
|
||||||
|
|
||||||
public isEmpty() {
|
public isEmpty() {
|
||||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||||
}
|
}
|
||||||
@@ -687,11 +740,10 @@ export class StoreSnapshot {
|
|||||||
nextElements.set(id, changedElement);
|
nextElements.set(id, changedElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAppState = Object.assign(
|
const nextAppState = getObservedAppState({
|
||||||
{},
|
...this.appState,
|
||||||
this.appState,
|
...change.appState,
|
||||||
change.appState,
|
});
|
||||||
) as ObservedAppState;
|
|
||||||
|
|
||||||
return StoreSnapshot.create(nextElements, nextAppState, {
|
return StoreSnapshot.create(nextElements, nextAppState, {
|
||||||
// by default we assume that change is different from what we have in the snapshot
|
// by default we assume that change is different from what we have in the snapshot
|
||||||
@@ -847,7 +899,7 @@ export class StoreSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if there any changed elements.
|
* Detect if there are any changed elements.
|
||||||
*/
|
*/
|
||||||
private detectChangedElements(
|
private detectChangedElements(
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
@@ -882,6 +934,14 @@ export class StoreSnapshot {
|
|||||||
!prevElement || // element was added
|
!prevElement || // element was added
|
||||||
prevElement.version < nextElement.version // element was updated
|
prevElement.version < nextElement.version // element was updated
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
isImageElement(nextElement) &&
|
||||||
|
!isInitializedImageElement(nextElement)
|
||||||
|
) {
|
||||||
|
// ignore any updates on uninitialized image elements
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
changedElements.set(nextElement.id, nextElement);
|
changedElements.set(nextElement.id, nextElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -936,26 +996,31 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
|||||||
viewBackgroundColor: COLOR_PALETTE.white,
|
viewBackgroundColor: COLOR_PALETTE.white,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
editingLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
selectedLinearElementId: null,
|
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
export const getObservedAppState = (
|
||||||
|
appState: AppState | ObservedAppState,
|
||||||
|
): ObservedAppState => {
|
||||||
const observedAppState = {
|
const observedAppState = {
|
||||||
name: appState.name,
|
name: appState.name,
|
||||||
editingGroupId: appState.editingGroupId,
|
editingGroupId: appState.editingGroupId,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
selectedGroupIds: appState.selectedGroupIds,
|
selectedGroupIds: appState.selectedGroupIds,
|
||||||
editingLinearElementId: appState.editingLinearElement?.elementId || null,
|
|
||||||
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
|
|
||||||
croppingElementId: appState.croppingElementId,
|
croppingElementId: appState.croppingElementId,
|
||||||
activeLockedId: appState.activeLockedId,
|
activeLockedId: appState.activeLockedId,
|
||||||
lockedMultiSelections: appState.lockedMultiSelections,
|
lockedMultiSelections: appState.lockedMultiSelections,
|
||||||
|
selectedLinearElement: appState.selectedLinearElement
|
||||||
|
? {
|
||||||
|
elementId: appState.selectedLinearElement.elementId,
|
||||||
|
isEditing: !!appState.selectedLinearElement.isEditing,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import {
|
|||||||
invariant,
|
invariant,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
@@ -254,6 +254,26 @@ export const computeBoundTextPosition = (
|
|||||||
x =
|
x =
|
||||||
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
||||||
}
|
}
|
||||||
|
const angle = (container.angle ?? 0) as Radians;
|
||||||
|
|
||||||
|
if (angle !== 0) {
|
||||||
|
const contentCenter = pointFrom(
|
||||||
|
containerCoords.x + maxContainerWidth / 2,
|
||||||
|
containerCoords.y + maxContainerHeight / 2,
|
||||||
|
);
|
||||||
|
const textCenter = pointFrom(
|
||||||
|
x + boundTextElement.width / 2,
|
||||||
|
y + boundTextElement.height / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: rx - boundTextElement.width / 2,
|
||||||
|
y: ry - boundTextElement.height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -326,10 +346,7 @@ export const getContainerCenter = (
|
|||||||
if (!midSegmentMidpoint) {
|
if (!midSegmentMidpoint) {
|
||||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
container,
|
container,
|
||||||
points[index],
|
|
||||||
points[index + 1],
|
|
||||||
index + 1,
|
index + 1,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||||
isAndroid,
|
isAndroid,
|
||||||
isIOS,
|
isIOS,
|
||||||
|
isMobileOrTablet,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||||
@@ -326,11 +327,11 @@ export const getTransformHandles = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldShowBoundingBox = (
|
export const hasBoundingBox = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (elements.length > 1) {
|
if (elements.length > 1) {
|
||||||
@@ -345,5 +346,7 @@ export const shouldShowBoundingBox = (
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return element.points.length > 2;
|
// on mobile/tablet we currently don't show bbox because of resize issues
|
||||||
|
// (also prob best for simplicity's sake)
|
||||||
|
return element.points.length > 2 && !isMobileOrTablet();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,6 +129,15 @@ export const isElbowArrow = (
|
|||||||
return isArrowElement(element) && element.elbowed;
|
return isArrowElement(element) && element.elbowed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sharp or curved arrow, but not elbow
|
||||||
|
*/
|
||||||
|
export const isSimpleArrow = (
|
||||||
|
element?: ExcalidrawElement,
|
||||||
|
): element is ExcalidrawArrowElement => {
|
||||||
|
return isArrowElement(element) && !element.elbowed;
|
||||||
|
};
|
||||||
|
|
||||||
export const isSharpArrow = (
|
export const isSharpArrow = (
|
||||||
element?: ExcalidrawElement,
|
element?: ExcalidrawElement,
|
||||||
): element is ExcalidrawArrowElement => {
|
): element is ExcalidrawArrowElement => {
|
||||||
|
|||||||
@@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
|
|||||||
| ExcalidrawFreeDrawElement
|
| ExcalidrawFreeDrawElement
|
||||||
| ExcalidrawIframeLikeElement
|
| ExcalidrawIframeLikeElement
|
||||||
| ExcalidrawFrameLikeElement
|
| ExcalidrawFrameLikeElement
|
||||||
| ExcalidrawEmbeddableElement;
|
| ExcalidrawEmbeddableElement
|
||||||
|
| ExcalidrawSelectionElement;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||||
@@ -379,6 +380,11 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
|||||||
pressures: readonly number[];
|
pressures: readonly number[];
|
||||||
simulatePressure: boolean;
|
simulatePressure: boolean;
|
||||||
lastCommittedPoint: LocalPoint | null;
|
lastCommittedPoint: LocalPoint | null;
|
||||||
|
freedrawOptions: {
|
||||||
|
streamline?: number;
|
||||||
|
simplify?: number;
|
||||||
|
fixedStrokeWidth?: boolean;
|
||||||
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type FileId = string & { _brand: "FileId" };
|
export type FileId = string & { _brand: "FileId" };
|
||||||
|
|||||||
+373
-245
@@ -1,259 +1,346 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_ADAPTIVE_RADIUS,
|
||||||
|
DEFAULT_PROPORTIONAL_RADIUS,
|
||||||
|
LINE_CONFIRM_THRESHOLD,
|
||||||
|
ROUNDNESS,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
curve,
|
curve,
|
||||||
|
curveCatmullRomCubicApproxPoints,
|
||||||
|
curveOffsetPoints,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
|
pointDistance,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointFromVector,
|
pointFromArray,
|
||||||
rectangle,
|
rectangle,
|
||||||
vectorFromPoint,
|
|
||||||
vectorNormalize,
|
|
||||||
vectorScale,
|
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { elementCenterPoint } from "@excalidraw/common";
|
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getCornerRadius } from "./shapes";
|
|
||||||
|
|
||||||
import { getDiamondPoints } from "./bounds";
|
import { getDiamondPoints } from "./bounds";
|
||||||
|
|
||||||
|
import { generateLinearCollisionShape } from "./shape";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
|
||||||
|
|
||||||
|
const ElementShapesCache = new WeakMap<
|
||||||
|
ExcalidrawElement,
|
||||||
|
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||||
|
element: T,
|
||||||
|
offset: number,
|
||||||
|
): ElementShape | undefined => {
|
||||||
|
const record = ElementShapesCache.get(element);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { version, shapes } = record;
|
||||||
|
|
||||||
|
if (version !== element.version) {
|
||||||
|
ElementShapesCache.delete(element);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shapes.get(offset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||||
|
element: T,
|
||||||
|
shape: ElementShape,
|
||||||
|
offset: number,
|
||||||
|
) => {
|
||||||
|
const record = ElementShapesCache.get(element);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
ElementShapesCache.set(element, {
|
||||||
|
version: element.version,
|
||||||
|
shapes: new Map([[offset, shape]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { version, shapes } = record;
|
||||||
|
|
||||||
|
if (version !== element.version) {
|
||||||
|
ElementShapesCache.set(element, {
|
||||||
|
version: element.version,
|
||||||
|
shapes: new Map([[offset, shape]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shapes.set(offset, shape);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the **rotated** components of freedraw, line or arrow elements.
|
||||||
|
*
|
||||||
|
* @param element The linear element to deconstruct
|
||||||
|
* @returns The rotated in components.
|
||||||
|
*/
|
||||||
|
export function deconstructLinearOrFreeDrawElement(
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||||
|
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||||
|
|
||||||
|
if (cachedShape) {
|
||||||
|
return cachedShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops = generateLinearCollisionShape(element) as {
|
||||||
|
op: string;
|
||||||
|
data: number[];
|
||||||
|
}[];
|
||||||
|
const lines = [];
|
||||||
|
const curves = [];
|
||||||
|
|
||||||
|
for (let idx = 0; idx < ops.length; idx += 1) {
|
||||||
|
const op = ops[idx];
|
||||||
|
const prevPoint =
|
||||||
|
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
|
||||||
|
switch (op.op) {
|
||||||
|
case "move":
|
||||||
|
continue;
|
||||||
|
case "lineTo":
|
||||||
|
if (!prevPoint) {
|
||||||
|
throw new Error("prevPoint is undefined");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + prevPoint[0],
|
||||||
|
element.y + prevPoint[1],
|
||||||
|
),
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
case "bcurveTo":
|
||||||
|
if (!prevPoint) {
|
||||||
|
throw new Error("prevPoint is undefined");
|
||||||
|
}
|
||||||
|
|
||||||
|
curves.push(
|
||||||
|
curve<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + prevPoint[0],
|
||||||
|
element.y + prevPoint[1],
|
||||||
|
),
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[2],
|
||||||
|
element.y + op.data[3],
|
||||||
|
),
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[4],
|
||||||
|
element.y + op.data[5],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
default: {
|
||||||
|
console.error("Unknown op type", op.op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shape = [lines, curves] as ElementShape;
|
||||||
|
setElementShapesCacheEntry(element, shape, 0);
|
||||||
|
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the building components of a rectanguloid element in the form of
|
* Get the building components of a rectanguloid element in the form of
|
||||||
* line segments and curves.
|
* line segments and curves **unrotated**.
|
||||||
*
|
*
|
||||||
* @param element Target rectanguloid element
|
* @param element Target rectanguloid element
|
||||||
* @param offset Optional offset to expand the rectanguloid shape
|
* @param offset Optional offset to expand the rectanguloid shape
|
||||||
* @returns Tuple of line segments (0) and curves (1)
|
* @returns Tuple of **unrotated** line segments (0) and curves (1)
|
||||||
*/
|
*/
|
||||||
export function deconstructRectanguloidElement(
|
export function deconstructRectanguloidElement(
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||||
const roundness = getCornerRadius(
|
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||||
|
|
||||||
|
if (cachedShape) {
|
||||||
|
return cachedShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = getCornerRadius(
|
||||||
Math.min(element.width, element.height),
|
Math.min(element.width, element.height),
|
||||||
element,
|
element,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (roundness <= 0) {
|
if (radius === 0) {
|
||||||
const r = rectangle(
|
radius = 0.01;
|
||||||
pointFrom(element.x - offset, element.y - offset),
|
|
||||||
pointFrom(
|
|
||||||
element.x + element.width + offset,
|
|
||||||
element.y + element.height + offset,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const top = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
|
||||||
);
|
|
||||||
const right = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
|
||||||
);
|
|
||||||
const bottom = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
|
||||||
);
|
|
||||||
const left = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
|
||||||
);
|
|
||||||
const sides = [top, right, bottom, left];
|
|
||||||
|
|
||||||
return [sides, []];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = elementCenterPoint(element);
|
|
||||||
|
|
||||||
const r = rectangle(
|
const r = rectangle(
|
||||||
pointFrom(element.x, element.y),
|
pointFrom(element.x, element.y),
|
||||||
pointFrom(element.x + element.width, element.y + element.height),
|
pointFrom(element.x + element.width, element.y + element.height),
|
||||||
);
|
);
|
||||||
|
|
||||||
const top = lineSegment<GlobalPoint>(
|
const top = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
|
||||||
);
|
);
|
||||||
const right = lineSegment<GlobalPoint>(
|
const right = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
|
||||||
);
|
);
|
||||||
const bottom = lineSegment<GlobalPoint>(
|
const bottom = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
|
||||||
);
|
);
|
||||||
const left = lineSegment<GlobalPoint>(
|
const left = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
|
||||||
);
|
);
|
||||||
|
|
||||||
const offsets = [
|
const baseCorners = [
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), // TOP LEFT
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), //TOP RIGHT
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), // BOTTOM RIGHT
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), // BOTTOM LEFT
|
|
||||||
];
|
|
||||||
|
|
||||||
const corners = [
|
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[0], left[1]),
|
left[1],
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[0],
|
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||||
pointFrom<GlobalPoint>(
|
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
|
||||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[0],
|
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||||
pointFrom<GlobalPoint>(
|
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
|
||||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(offsets[0], top[0]),
|
top[0],
|
||||||
), // TOP LEFT
|
), // TOP LEFT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[1], top[1]),
|
top[1],
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[1],
|
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
||||||
pointFrom<GlobalPoint>(
|
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
||||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
|
||||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[1],
|
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||||
pointFrom<GlobalPoint>(
|
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
|
||||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(offsets[1], right[0]),
|
right[0],
|
||||||
), // TOP RIGHT
|
), // TOP RIGHT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[2], right[1]),
|
right[1],
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[2],
|
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||||
pointFrom<GlobalPoint>(
|
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
|
||||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[2],
|
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||||
pointFrom<GlobalPoint>(
|
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
|
||||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(offsets[2], bottom[1]),
|
bottom[1],
|
||||||
), // BOTTOM RIGHT
|
), // BOTTOM RIGHT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[3], bottom[0]),
|
bottom[0],
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[3],
|
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||||
pointFrom<GlobalPoint>(
|
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
|
||||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[3],
|
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||||
pointFrom<GlobalPoint>(
|
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
|
||||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(offsets[3], left[0]),
|
left[0],
|
||||||
), // BOTTOM LEFT
|
), // BOTTOM LEFT
|
||||||
];
|
];
|
||||||
|
|
||||||
const sides = [
|
const corners =
|
||||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
offset > 0
|
||||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
? baseCorners.map(
|
||||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
(corner) =>
|
||||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
curveCatmullRomCubicApproxPoints(
|
||||||
];
|
curveOffsetPoints(corner, offset),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
[baseCorners[0]],
|
||||||
|
[baseCorners[1]],
|
||||||
|
[baseCorners[2]],
|
||||||
|
[baseCorners[3]],
|
||||||
|
];
|
||||||
|
|
||||||
return [sides, corners];
|
const sides = [
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[0][corners[0].length - 1][3],
|
||||||
|
corners[1][0][0],
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[1][corners[1].length - 1][3],
|
||||||
|
corners[2][0][0],
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[2][corners[2].length - 1][3],
|
||||||
|
corners[3][0][0],
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[3][corners[3].length - 1][3],
|
||||||
|
corners[0][0][0],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const shape = [sides, corners.flat()] as ElementShape;
|
||||||
|
|
||||||
|
setElementShapesCacheEntry(element, shape, offset);
|
||||||
|
|
||||||
|
return shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the building components of a diamond element in the form of
|
* Get the **unrotated** building components of a diamond element
|
||||||
* line segments and curves as a tuple, in this order.
|
* in the form of line segments and curves as a tuple, in this order.
|
||||||
*
|
*
|
||||||
* @param element The element to deconstruct
|
* @param element The element to deconstruct
|
||||||
* @param offset An optional offset
|
* @param offset An optional offset
|
||||||
* @returns Tuple of line segments (0) and curves (1)
|
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||||
*/
|
*/
|
||||||
export function deconstructDiamondElement(
|
export function deconstructDiamondElement(
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||||
getDiamondPoints(element);
|
|
||||||
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
|
|
||||||
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
|
|
||||||
|
|
||||||
if (element.roundness?.type == null) {
|
if (cachedShape) {
|
||||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
return cachedShape;
|
||||||
pointFrom(element.x + topX, element.y + topY - offset),
|
|
||||||
pointFrom(element.x + rightX + offset, element.y + rightY),
|
|
||||||
pointFrom(element.x + bottomX, element.y + bottomY + offset),
|
|
||||||
pointFrom(element.x + leftX - offset, element.y + leftY),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Create the line segment parts of the diamond
|
|
||||||
// NOTE: Horizontal and vertical seems to be flipped here
|
|
||||||
const topRight = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
|
|
||||||
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
|
|
||||||
);
|
|
||||||
const bottomRight = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
|
|
||||||
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
|
|
||||||
);
|
|
||||||
const bottomLeft = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
|
|
||||||
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
|
|
||||||
);
|
|
||||||
const topLeft = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
|
|
||||||
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
|
|
||||||
);
|
|
||||||
|
|
||||||
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = elementCenterPoint(element);
|
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||||
|
getDiamondPoints(element);
|
||||||
|
const verticalRadius = element.roundness
|
||||||
|
? getCornerRadius(Math.abs(topX - leftX), element)
|
||||||
|
: (topX - leftX) * 0.01;
|
||||||
|
const horizontalRadius = element.roundness
|
||||||
|
? getCornerRadius(Math.abs(rightY - topY), element)
|
||||||
|
: (rightY - topY) * 0.01;
|
||||||
|
|
||||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||||
pointFrom(element.x + topX, element.y + topY),
|
pointFrom(element.x + topX, element.y + topY),
|
||||||
@@ -262,94 +349,135 @@ export function deconstructDiamondElement(
|
|||||||
pointFrom(element.x + leftX, element.y + leftY),
|
pointFrom(element.x + leftX, element.y + leftY),
|
||||||
];
|
];
|
||||||
|
|
||||||
const offsets = [
|
const baseCorners = [
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
|
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
|
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
|
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
|
|
||||||
];
|
|
||||||
|
|
||||||
const corners = [
|
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[0],
|
right[0] - verticalRadius,
|
||||||
pointFrom<GlobalPoint>(
|
right[1] - horizontalRadius,
|
||||||
right[0] - verticalRadius,
|
|
||||||
right[1] - horizontalRadius,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(offsets[0], right),
|
right,
|
||||||
pointFromVector(offsets[0], right),
|
right,
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[0],
|
right[0] - verticalRadius,
|
||||||
pointFrom<GlobalPoint>(
|
right[1] + horizontalRadius,
|
||||||
right[0] - verticalRadius,
|
|
||||||
right[1] + horizontalRadius,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
), // RIGHT
|
), // RIGHT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[1],
|
bottom[0] + verticalRadius,
|
||||||
pointFrom<GlobalPoint>(
|
bottom[1] - horizontalRadius,
|
||||||
bottom[0] + verticalRadius,
|
|
||||||
bottom[1] - horizontalRadius,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(offsets[1], bottom),
|
bottom,
|
||||||
pointFromVector(offsets[1], bottom),
|
bottom,
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[1],
|
bottom[0] - verticalRadius,
|
||||||
pointFrom<GlobalPoint>(
|
bottom[1] - horizontalRadius,
|
||||||
bottom[0] - verticalRadius,
|
|
||||||
bottom[1] - horizontalRadius,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
), // BOTTOM
|
), // BOTTOM
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[2],
|
left[0] + verticalRadius,
|
||||||
pointFrom<GlobalPoint>(
|
left[1] + horizontalRadius,
|
||||||
left[0] + verticalRadius,
|
|
||||||
left[1] + horizontalRadius,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(offsets[2], left),
|
left,
|
||||||
pointFromVector(offsets[2], left),
|
left,
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[2],
|
left[0] + verticalRadius,
|
||||||
pointFrom<GlobalPoint>(
|
left[1] - horizontalRadius,
|
||||||
left[0] + verticalRadius,
|
|
||||||
left[1] - horizontalRadius,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
), // LEFT
|
), // LEFT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[3],
|
top[0] - verticalRadius,
|
||||||
pointFrom<GlobalPoint>(
|
top[1] + horizontalRadius,
|
||||||
top[0] - verticalRadius,
|
|
||||||
top[1] + horizontalRadius,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pointFromVector(offsets[3], top),
|
top,
|
||||||
pointFromVector(offsets[3], top),
|
top,
|
||||||
pointFromVector(
|
pointFrom<GlobalPoint>(
|
||||||
offsets[3],
|
top[0] + verticalRadius,
|
||||||
pointFrom<GlobalPoint>(
|
top[1] + horizontalRadius,
|
||||||
top[0] + verticalRadius,
|
|
||||||
top[1] + horizontalRadius,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
), // TOP
|
), // TOP
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const corners =
|
||||||
|
offset > 0
|
||||||
|
? baseCorners.map(
|
||||||
|
(corner) =>
|
||||||
|
curveCatmullRomCubicApproxPoints(
|
||||||
|
curveOffsetPoints(corner, offset),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
[baseCorners[0]],
|
||||||
|
[baseCorners[1]],
|
||||||
|
[baseCorners[2]],
|
||||||
|
[baseCorners[3]],
|
||||||
|
];
|
||||||
|
|
||||||
const sides = [
|
const sides = [
|
||||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
lineSegment<GlobalPoint>(
|
||||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
corners[0][corners[0].length - 1][3],
|
||||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
corners[1][0][0],
|
||||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[1][corners[1].length - 1][3],
|
||||||
|
corners[2][0][0],
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[2][corners[2].length - 1][3],
|
||||||
|
corners[3][0][0],
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[3][corners[3].length - 1][3],
|
||||||
|
corners[0][0][0],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return [sides, corners];
|
const shape = [sides, corners.flat()] as ElementShape;
|
||||||
|
|
||||||
|
setElementShapesCacheEntry(element, shape, offset);
|
||||||
|
|
||||||
|
return shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checks if the first and last point are close enough
|
||||||
|
// to be considered a loop
|
||||||
|
export const isPathALoop = (
|
||||||
|
points: ExcalidrawLinearElement["points"],
|
||||||
|
/** supply if you want the loop detection to account for current zoom */
|
||||||
|
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
|
||||||
|
): boolean => {
|
||||||
|
if (points.length >= 3) {
|
||||||
|
const [first, last] = [points[0], points[points.length - 1]];
|
||||||
|
const distance = pointDistance(first, last);
|
||||||
|
|
||||||
|
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
|
||||||
|
// really close we make the threshold smaller, and vice versa.
|
||||||
|
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||||
|
if (
|
||||||
|
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
|
||||||
|
element.roundness?.type === ROUNDNESS.LEGACY
|
||||||
|
) {
|
||||||
|
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
|
||||||
|
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
|
||||||
|
|
||||||
|
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
|
||||||
|
|
||||||
|
if (x <= CUTOFF_SIZE) {
|
||||||
|
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixedRadiusSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
|||||||
class="excalidraw-wysiwyg"
|
class="excalidraw-wysiwyg"
|
||||||
data-type="wysiwyg"
|
data-type="wysiwyg"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
|
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
wrap="off"
|
wrap="off"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -202,6 +204,7 @@ describe("aligning", () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,6 +218,7 @@ describe("aligning", () => {
|
|||||||
// Add the created group to the current selection
|
// Add the created group to the current selection
|
||||||
mouse.restorePosition(0, 0);
|
mouse.restorePosition(0, 0);
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -316,6 +320,7 @@ describe("aligning", () => {
|
|||||||
// The second rectangle is already selected because it was the last element created
|
// The second rectangle is already selected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -330,7 +335,7 @@ describe("aligning", () => {
|
|||||||
mouse.down();
|
mouse.down();
|
||||||
mouse.up(100, 100);
|
mouse.up(100, 100);
|
||||||
|
|
||||||
mouse.restorePosition(200, 200);
|
mouse.restorePosition(210, 200);
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
@@ -341,6 +346,7 @@ describe("aligning", () => {
|
|||||||
// The second group is already selected because it was the last group created
|
// The second group is already selected because it was the last group created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -454,6 +460,7 @@ describe("aligning", () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -466,7 +473,7 @@ describe("aligning", () => {
|
|||||||
mouse.up(100, 100);
|
mouse.up(100, 100);
|
||||||
|
|
||||||
// Add group to current selection
|
// Add group to current selection
|
||||||
mouse.restorePosition(0, 0);
|
mouse.restorePosition(10, 0);
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
@@ -482,6 +489,7 @@ describe("aligning", () => {
|
|||||||
// Select the nested group, the rectangle is already selected
|
// Select the nested group, the rectangle is already selected
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -581,4 +589,424 @@ describe("aligning", () => {
|
|||||||
expect(API.getSelectedElements()[2].x).toEqual(250);
|
expect(API.getSelectedElements()[2].x).toEqual(250);
|
||||||
expect(API.getSelectedElements()[3].x).toEqual(150);
|
expect(API.getSelectedElements()[3].x).toEqual(150);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createGroupAndSelectInEditGroupMode = () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(0, 0);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// select the first element.
|
||||||
|
// The second rectangle is already reselected because it was the last element created
|
||||||
|
mouse.reset();
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.doubleClick();
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.click();
|
||||||
|
mouse.moveTo(100, 100);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the top", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignTop);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignBottom);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the left", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignLeft);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the right", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignRight);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignVerticallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||||
|
});
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignHorizontallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createNestedGroupAndSelectInEditGroupMode = () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(0, 0);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// Select the first element.
|
||||||
|
// The second rectangle is already reselected because it was the last element created
|
||||||
|
mouse.reset();
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(200, 200);
|
||||||
|
// create third element
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(0, 0);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// third element is already selected, select the initial group and group together
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
|
||||||
|
// double click to enter edit mode
|
||||||
|
mouse.doubleClick();
|
||||||
|
|
||||||
|
// select nested group and other element within the group
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(200, 200);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("aligns element and nested group while in group edit mode correctly to the top", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignTop);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignBottom);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
});
|
||||||
|
it("aligns element and nested group while in group edit mode correctly to the left", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignLeft);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns element and nested group while in group edit mode correctly to the right", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignRight);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
});
|
||||||
|
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignVerticallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignHorizontallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAndSelectSingleGroup = () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(0, 0);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// Select the first element.
|
||||||
|
// The second rectangle is already reselected because it was the last element created
|
||||||
|
mouse.reset();
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("aligns elements within a single-selected group correctly to the top", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignTop);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group correctly to the bottom", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignBottom);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group correctly to the left", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignLeft);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group correctly to the right", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignRight);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group correctly to the vertical center", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignVerticallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group correctly to the horizontal center", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignHorizontallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAndSelectSingleGroupWithNestedGroup = () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(0, 0);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// Select the first element.
|
||||||
|
// The second rectangle is already reselected because it was the last element created
|
||||||
|
mouse.reset();
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(200, 200);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// Add group to current selection
|
||||||
|
mouse.restorePosition(10, 0);
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the nested group
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
};
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignTop);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignBottom);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignLeft);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignRight);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignVerticallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignHorizontallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|||||||
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
|
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
|
import { LinearElementEditor } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getTransformHandles } from "../src/transformHandles";
|
import { getTransformHandles } from "../src/transformHandles";
|
||||||
|
import {
|
||||||
|
getTextEditor,
|
||||||
|
TEXT_EDITOR_SELECTOR,
|
||||||
|
} from "../../excalidraw/tests/queries/dom";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@@ -151,10 +157,10 @@ describe("element binding", () => {
|
|||||||
// NOTE this mouse down/up + await needs to be done in order to repro
|
// NOTE this mouse down/up + await needs to be done in order to repro
|
||||||
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
expect(h.state.editingLinearElement).not.toBe(null);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
mouse.down(0, 0);
|
mouse.down(0, 0);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
expect(h.state.editingLinearElement).toBe(null);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||||
mouse.up();
|
mouse.up();
|
||||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||||
@@ -172,12 +178,12 @@ describe("element binding", () => {
|
|||||||
const arrow = UI.createElement("arrow", {
|
const arrow = UI.createElement("arrow", {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
size: 50,
|
size: 49,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(arrow.endBinding).toBe(null);
|
expect(arrow.endBinding).toBe(null);
|
||||||
|
|
||||||
mouse.downAt(50, 50);
|
mouse.downAt(49, 49);
|
||||||
mouse.moveTo(51, 0);
|
mouse.moveTo(51, 0);
|
||||||
mouse.up(0, 0);
|
mouse.up(0, 0);
|
||||||
|
|
||||||
@@ -244,18 +250,12 @@ describe("element binding", () => {
|
|||||||
|
|
||||||
mouse.clickAt(text.x + 50, text.y + 50);
|
mouse.clickAt(text.x + 50, text.y + 50);
|
||||||
|
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
expect(editor).not.toBe(null);
|
|
||||||
|
|
||||||
fireEvent.change(editor, { target: { value: "" } });
|
fireEvent.change(editor, { target: { value: "" } });
|
||||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||||
|
|
||||||
expect(
|
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
|
||||||
).toBe(null);
|
|
||||||
expect(arrow.endBinding).toBe(null);
|
expect(arrow.endBinding).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -285,18 +285,14 @@ describe("element binding", () => {
|
|||||||
UI.clickTool("text");
|
UI.clickTool("text");
|
||||||
|
|
||||||
mouse.clickAt(text.x + 50, text.y + 50);
|
mouse.clickAt(text.x + 50, text.y + 50);
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
expect(editor).not.toBe(null);
|
expect(editor).not.toBe(null);
|
||||||
|
|
||||||
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
|
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
|
||||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||||
|
|
||||||
expect(
|
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
|
||||||
).toBe(null);
|
|
||||||
expect(arrow.endBinding?.elementId).toBe(text.id);
|
expect(arrow.endBinding?.elementId).toBe(text.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -419,16 +415,12 @@ describe("element binding", () => {
|
|||||||
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||||
|
|
||||||
// Drag arrow off of bound rectangle range
|
// Drag arrow off of bound rectangle range
|
||||||
const handles = getTransformHandles(
|
const [elX, elY] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
arrow,
|
arrow,
|
||||||
h.state.zoom,
|
-1,
|
||||||
arrayToMap(h.elements),
|
h.scene.getNonDeletedElementsMap(),
|
||||||
"mouse",
|
);
|
||||||
).se!;
|
|
||||||
|
|
||||||
Keyboard.keyDown(KEYS.CTRL_OR_CMD);
|
Keyboard.keyDown(KEYS.CTRL_OR_CMD);
|
||||||
const elX = handles[0] + handles[2] / 2;
|
|
||||||
const elY = handles[1] + handles[3] / 2;
|
|
||||||
mouse.downAt(elX, elY);
|
mouse.downAt(elX, elY);
|
||||||
mouse.moveTo(300, 400);
|
mouse.moveTo(300, 400);
|
||||||
mouse.up();
|
mouse.up();
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
|
||||||
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
|
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
|
import "@excalidraw/utils/test-utils";
|
||||||
|
import { render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
|
import { hitElementItself } from "../src/collision";
|
||||||
|
|
||||||
|
describe("check rotated elements can be hit:", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arrow", () => {
|
||||||
|
UI.createElement("arrow", {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 124,
|
||||||
|
height: 302,
|
||||||
|
angle: 1.8700426423973724,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[120, -198],
|
||||||
|
[-4, -302],
|
||||||
|
] as LocalPoint[],
|
||||||
|
});
|
||||||
|
//const p = [120, -211];
|
||||||
|
//const p = [0, 13];
|
||||||
|
const hit = hitElementItself({
|
||||||
|
point: pointFrom<GlobalPoint>(88, -68),
|
||||||
|
element: window.h.elements[0],
|
||||||
|
threshold: 10,
|
||||||
|
elementsMap: window.h.scene.getNonDeletedElementsMap(),
|
||||||
|
});
|
||||||
|
expect(hit).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,345 @@
|
|||||||
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||||
import type { LinearElementEditor } from "@excalidraw/element";
|
import type { LinearElementEditor } from "@excalidraw/element";
|
||||||
|
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { AppStateDelta } from "../src/delta";
|
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
||||||
|
|
||||||
|
describe("ElementsDelta", () => {
|
||||||
|
describe("elements delta calculation", () => {
|
||||||
|
it("should not throw when element gets removed but was already deleted", () => {
|
||||||
|
const element = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
isDeleted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevElements = new Map([[element.id, element]]);
|
||||||
|
const nextElements = new Map();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
ElementsDelta.calculate(prevElements, nextElements),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw when adding element as already deleted", () => {
|
||||||
|
const element = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
isDeleted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevElements = new Map();
|
||||||
|
const nextElements = new Map([[element.id, element]]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
ElementsDelta.calculate(prevElements, nextElements),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create updated delta even when there is only version and versionNonce change", () => {
|
||||||
|
const baseElement = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
strokeColor: "#000000",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
});
|
||||||
|
|
||||||
|
const modifiedElement = {
|
||||||
|
...baseElement,
|
||||||
|
version: baseElement.version + 1,
|
||||||
|
versionNonce: baseElement.versionNonce + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create maps for the delta calculation
|
||||||
|
const prevElements = new Map([[baseElement.id, baseElement]]);
|
||||||
|
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
|
||||||
|
|
||||||
|
// Calculate the delta
|
||||||
|
const delta = ElementsDelta.calculate(
|
||||||
|
prevElements as SceneElementsMap,
|
||||||
|
nextElements as SceneElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(delta).toEqual(
|
||||||
|
ElementsDelta.create(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
[baseElement.id]: Delta.create(
|
||||||
|
{
|
||||||
|
version: baseElement.version,
|
||||||
|
versionNonce: baseElement.versionNonce,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: baseElement.version + 1,
|
||||||
|
versionNonce: baseElement.versionNonce + 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("squash", () => {
|
||||||
|
it("should not squash when second delta is empty", () => {
|
||||||
|
const updatedDelta = Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1 },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta1 = ElementsDelta.create(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{ id1: updatedDelta },
|
||||||
|
);
|
||||||
|
const elementsDelta2 = ElementsDelta.empty();
|
||||||
|
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||||
|
|
||||||
|
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(elementsDelta).toBe(elementsDelta1);
|
||||||
|
expect(elementsDelta.updated.id1).toBe(updatedDelta);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash mutually exclusive delta types", () => {
|
||||||
|
const addedDelta = Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const removedDelta = Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedDelta = Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1 },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta1 = ElementsDelta.create(
|
||||||
|
{ id1: addedDelta },
|
||||||
|
{ id2: removedDelta },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta2 = ElementsDelta.create(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{ id3: updatedDelta },
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||||
|
|
||||||
|
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(elementsDelta).toBe(elementsDelta1);
|
||||||
|
expect(elementsDelta.added.id1).toBe(addedDelta);
|
||||||
|
expect(elementsDelta.removed.id2).toBe(removedDelta);
|
||||||
|
expect(elementsDelta.updated.id3).toBe(updatedDelta);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash the same delta types", () => {
|
||||||
|
const elementsDelta1 = ElementsDelta.create(
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id2: Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id3: Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1 },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2 },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta2 = ElementsDelta.create(
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id2: Delta.create(
|
||||||
|
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id3: Delta.create(
|
||||||
|
{ y: 100, version: 2, versionNonce: 2 },
|
||||||
|
{ y: 200, version: 3, versionNonce: 3 },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||||
|
|
||||||
|
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(elementsDelta).toBe(elementsDelta1);
|
||||||
|
expect(elementsDelta.added.id1).toEqual(
|
||||||
|
Delta.create(
|
||||||
|
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(elementsDelta.removed.id2).toEqual(
|
||||||
|
Delta.create(
|
||||||
|
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(elementsDelta.updated.id3).toEqual(
|
||||||
|
Delta.create(
|
||||||
|
{ x: 100, y: 100, version: 2, versionNonce: 2 },
|
||||||
|
{ x: 200, y: 200, version: 3, versionNonce: 3 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash different delta types ", () => {
|
||||||
|
// id1: added -> updated => added
|
||||||
|
// id2: removed -> added => added
|
||||||
|
// id3: updated -> removed => removed
|
||||||
|
const elementsDelta1 = ElementsDelta.create(
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||||
|
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id2: Delta.create(
|
||||||
|
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
|
||||||
|
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id3: Delta.create(
|
||||||
|
{ x: 300, version: 1, versionNonce: 1 },
|
||||||
|
{ x: 301, version: 2, versionNonce: 2 },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta2 = ElementsDelta.create(
|
||||||
|
{
|
||||||
|
id2: Delta.create(
|
||||||
|
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id3: Delta.create(
|
||||||
|
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{ y: 100, version: 2, versionNonce: 2 },
|
||||||
|
{ y: 101, version: 3, versionNonce: 3 },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||||
|
|
||||||
|
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(elementsDelta).toBe(elementsDelta1);
|
||||||
|
expect(elementsDelta.added).toEqual({
|
||||||
|
id1: Delta.create(
|
||||||
|
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
|
||||||
|
),
|
||||||
|
id2: Delta.create(
|
||||||
|
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(elementsDelta.removed).toEqual({
|
||||||
|
id3: Delta.create(
|
||||||
|
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(elementsDelta.updated).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash bound elements", () => {
|
||||||
|
const elementsDelta1 = ElementsDelta.create(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
versionNonce: 1,
|
||||||
|
boundElements: [{ id: "t1", type: "text" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
versionNonce: 2,
|
||||||
|
boundElements: [{ id: "t2", type: "text" }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta2 = ElementsDelta.create(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
versionNonce: 2,
|
||||||
|
boundElements: [{ id: "a1", type: "arrow" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 3,
|
||||||
|
versionNonce: 3,
|
||||||
|
boundElements: [{ id: "a2", type: "arrow" }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||||
|
|
||||||
|
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
|
||||||
|
{ id: "t1", type: "text" },
|
||||||
|
{ id: "a1", type: "arrow" },
|
||||||
|
]);
|
||||||
|
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
|
||||||
|
{ id: "t2", type: "text" },
|
||||||
|
{ id: "a2", type: "arrow" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("AppStateDelta", () => {
|
describe("AppStateDelta", () => {
|
||||||
describe("ensure stable delta properties order", () => {
|
describe("ensure stable delta properties order", () => {
|
||||||
it("should maintain stable order for root properties", () => {
|
it("should maintain stable order for root properties", () => {
|
||||||
const name = "untitled scene";
|
const name = "untitled scene";
|
||||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
const selectedLinearElement = {
|
||||||
|
elementId: "id1" as LinearElementEditor["elementId"],
|
||||||
|
isEditing: false,
|
||||||
|
};
|
||||||
|
|
||||||
const commonAppState = {
|
const commonAppState = {
|
||||||
viewBackgroundColor: "#ffffff",
|
viewBackgroundColor: "#ffffff",
|
||||||
@@ -16,6 +348,7 @@ describe("AppStateDelta", () => {
|
|||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
editingLinearElementId: null,
|
editingLinearElementId: null,
|
||||||
|
selectedLinearElementIsEditing: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
};
|
};
|
||||||
@@ -23,23 +356,23 @@ describe("AppStateDelta", () => {
|
|||||||
const prevAppState1: ObservedAppState = {
|
const prevAppState1: ObservedAppState = {
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
name: "",
|
name: "",
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextAppState1: ObservedAppState = {
|
const nextAppState1: ObservedAppState = {
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
name,
|
name,
|
||||||
selectedLinearElementId,
|
selectedLinearElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevAppState2: ObservedAppState = {
|
const prevAppState2: ObservedAppState = {
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
name: "",
|
name: "",
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextAppState2: ObservedAppState = {
|
const nextAppState2: ObservedAppState = {
|
||||||
selectedLinearElementId,
|
selectedLinearElement,
|
||||||
name,
|
name,
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
};
|
};
|
||||||
@@ -57,8 +390,7 @@ describe("AppStateDelta", () => {
|
|||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
editingLinearElementId: null,
|
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
@@ -104,8 +436,7 @@ describe("AppStateDelta", () => {
|
|||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
editingLinearElementId: null,
|
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
@@ -146,4 +477,97 @@ describe("AppStateDelta", () => {
|
|||||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("squash", () => {
|
||||||
|
it("should not squash when second delta is empty", () => {
|
||||||
|
const delta = Delta.create(
|
||||||
|
{ name: "untitled scene" },
|
||||||
|
{ name: "titled scene" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const appStateDelta1 = AppStateDelta.create(delta);
|
||||||
|
const appStateDelta2 = AppStateDelta.empty();
|
||||||
|
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||||
|
|
||||||
|
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(appStateDelta).toBe(appStateDelta1);
|
||||||
|
expect(appStateDelta.delta).toBe(delta);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash exclusive properties", () => {
|
||||||
|
const delta1 = Delta.create(
|
||||||
|
{ name: "untitled scene" },
|
||||||
|
{ name: "titled scene" },
|
||||||
|
);
|
||||||
|
const delta2 = Delta.create(
|
||||||
|
{ viewBackgroundColor: "#ffffff" },
|
||||||
|
{ viewBackgroundColor: "#000000" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||||
|
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||||
|
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||||
|
|
||||||
|
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(appStateDelta).toBe(appStateDelta1);
|
||||||
|
expect(appStateDelta.delta).toEqual(
|
||||||
|
Delta.create(
|
||||||
|
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
|
||||||
|
{ name: "titled scene", viewBackgroundColor: "#000000" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
|
||||||
|
const delta1 = Delta.create<Partial<ObservedAppState>>(
|
||||||
|
{
|
||||||
|
name: "untitled scene",
|
||||||
|
selectedElementIds: { id1: true },
|
||||||
|
selectedGroupIds: {},
|
||||||
|
lockedMultiSelections: { g1: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "titled scene",
|
||||||
|
selectedElementIds: { id2: true },
|
||||||
|
selectedGroupIds: { g1: true },
|
||||||
|
lockedMultiSelections: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const delta2 = Delta.create<Partial<ObservedAppState>>(
|
||||||
|
{
|
||||||
|
selectedElementIds: { id3: true },
|
||||||
|
selectedGroupIds: { g1: true },
|
||||||
|
lockedMultiSelections: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selectedElementIds: { id2: true },
|
||||||
|
selectedGroupIds: { g2: true, g3: true },
|
||||||
|
lockedMultiSelections: { g3: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||||
|
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||||
|
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||||
|
|
||||||
|
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(appStateDelta).toBe(appStateDelta1);
|
||||||
|
expect(appStateDelta.delta).toEqual(
|
||||||
|
Delta.create<Partial<ObservedAppState>>(
|
||||||
|
{
|
||||||
|
name: "untitled scene",
|
||||||
|
selectedElementIds: { id1: true, id3: true },
|
||||||
|
selectedGroupIds: { g1: true },
|
||||||
|
lockedMultiSelections: { g1: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "titled scene",
|
||||||
|
selectedElementIds: { id2: true },
|
||||||
|
selectedGroupIds: { g1: true, g2: true, g3: true },
|
||||||
|
lockedMultiSelections: { g3: true },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
distributeHorizontally,
|
||||||
|
distributeVertically,
|
||||||
|
} from "@excalidraw/excalidraw/actions";
|
||||||
|
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
|
||||||
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
unmountComponent,
|
||||||
|
render,
|
||||||
|
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
|
// Scenario: three rectangles that will be distributed with gaps
|
||||||
|
const createAndSelectThreeRectanglesWithGap = () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(100, 100);
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(10, 10);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(300, 300);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
// Last rectangle is selected by default
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.click(0, 10);
|
||||||
|
mouse.click(10, 0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scenario: three rectangles that will be distributed by their centers
|
||||||
|
const createAndSelectThreeRectanglesWithoutGap = () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(100, 100);
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(10, 10);
|
||||||
|
mouse.up(200, 200);
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(200, 200);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
// Last rectangle is selected by default
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.click(0, 10);
|
||||||
|
mouse.click(10, 0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("distributing", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
unmountComponent();
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
await act(() => {
|
||||||
|
return setLanguage(defaultLang);
|
||||||
|
});
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should distribute selected elements horizontally", async () => {
|
||||||
|
createAndSelectThreeRectanglesWithGap();
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(10);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(300);
|
||||||
|
|
||||||
|
API.executeAction(distributeHorizontally);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should distribute selected elements vertically", async () => {
|
||||||
|
createAndSelectThreeRectanglesWithGap();
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(10);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(300);
|
||||||
|
|
||||||
|
API.executeAction(distributeVertically);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should distribute selected elements horizontally based on their centers", async () => {
|
||||||
|
createAndSelectThreeRectanglesWithoutGap();
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(10);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(distributeHorizontally);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should distribute selected elements vertically with based on their centers", async () => {
|
||||||
|
createAndSelectThreeRectanglesWithoutGap();
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(10);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(distributeVertically);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -505,8 +505,6 @@ describe("group-related duplication", () => {
|
|||||||
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
|
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log(h.elements);
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ id: frame.id },
|
{ id: frame.id },
|
||||||
{ id: rectangle1.id, frameId: frame.id },
|
{ id: rectangle1.id, frameId: frame.id },
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { getEmbedLink } from "../src/embeddable";
|
||||||
|
|
||||||
|
describe("YouTube timestamp parsing", () => {
|
||||||
|
it("should parse YouTube URLs with timestamp in seconds", () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
|
||||||
|
expectedStart: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
|
||||||
|
expectedStart: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
|
||||||
|
expectedStart: 150,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(({ url, expectedStart }) => {
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).toContain(`start=${expectedStart}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse YouTube URLs with timestamp in time format", () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
|
||||||
|
expectedStart: 90, // 1*60 + 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
|
||||||
|
expectedStart: 165, // 2*60 + 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
|
||||||
|
expectedStart: 3723, // 1*3600 + 2*60 + 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
|
||||||
|
expectedStart: 45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
|
||||||
|
expectedStart: 300, // 5*60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
|
||||||
|
expectedStart: 7200, // 2*3600
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(({ url, expectedStart }) => {
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).toContain(`start=${expectedStart}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle YouTube URLs without timestamps", () => {
|
||||||
|
const testCases = [
|
||||||
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
"https://youtu.be/dQw4w9WgXcQ",
|
||||||
|
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach((url) => {
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).not.toContain("start=");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle YouTube shorts URLs with timestamps", () => {
|
||||||
|
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).toContain("start=30");
|
||||||
|
}
|
||||||
|
// Shorts should have portrait aspect ratio
|
||||||
|
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle playlist URLs with timestamps", () => {
|
||||||
|
const url =
|
||||||
|
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).toContain("start=60");
|
||||||
|
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle malformed or edge case timestamps", () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
|
||||||
|
expectedStart: 0, // Invalid timestamp should default to 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
|
||||||
|
expectedStart: 0, // Empty timestamp should default to 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
|
||||||
|
expectedStart: 0, // Zero timestamp should be handled
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(({ url, expectedStart }) => {
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
if (expectedStart === 0) {
|
||||||
|
expect(result.link).not.toContain("start=");
|
||||||
|
} else {
|
||||||
|
expect(result.link).toContain(`start=${expectedStart}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve other URL parameters", () => {
|
||||||
|
const url =
|
||||||
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).toContain("start=90");
|
||||||
|
expect(result.link).toContain("enablejsapi=1");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { pointCenter, pointFrom } from "@excalidraw/math";
|
import { pointCenter, pointFrom } from "@excalidraw/math";
|
||||||
import { act, queryByTestId, queryByText } from "@testing-library/react";
|
import { act, queryByTestId, queryByText } from "@testing-library/react";
|
||||||
import React from "react";
|
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +32,11 @@ import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
|
|||||||
import { LinearElementEditor } from "../src";
|
import { LinearElementEditor } from "../src";
|
||||||
import { newArrowElement } from "../src";
|
import { newArrowElement } from "../src";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTextEditor,
|
||||||
|
TEXT_EDITOR_SELECTOR,
|
||||||
|
} from "../../excalidraw/tests/queries/dom";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@@ -132,7 +136,8 @@ describe("Test Linear Elements", () => {
|
|||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
});
|
});
|
||||||
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
|
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
|
||||||
@@ -249,17 +254,99 @@ describe("Test Linear Elements", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
|
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
|
||||||
|
|
||||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enter line editor when using double clicked with ctrl key", () => {
|
it("should enter line editor via enter (line)", () => {
|
||||||
createTwoPointerLinearElement("line");
|
createTwoPointerLinearElement("line");
|
||||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ctrl+enter alias (to align with arrows)
|
||||||
|
it("should enter line editor via ctrl+enter (line)", () => {
|
||||||
|
createTwoPointerLinearElement("line");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
});
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enter line editor via ctrl+enter (arrow)", () => {
|
||||||
|
createTwoPointerLinearElement("arrow");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
});
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
|
||||||
|
createTwoPointerLinearElement("arrow");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
mouse.doubleClick();
|
mouse.doubleClick();
|
||||||
});
|
});
|
||||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enter line editor on ctrl+dblclick (line)", () => {
|
||||||
|
createTwoPointerLinearElement("line");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
mouse.doubleClick();
|
||||||
|
});
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enter line editor on dblclick (line)", () => {
|
||||||
|
createTwoPointerLinearElement("line");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
mouse.doubleClick();
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not enter line editor on dblclick (arrow)", async () => {
|
||||||
|
createTwoPointerLinearElement("arrow");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
mouse.doubleClick();
|
||||||
|
expect(h.state.selectedLinearElement).toBe(null);
|
||||||
|
await getTextEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shouldn't create text element on double click in line editor (arrow)", async () => {
|
||||||
|
createTwoPointerLinearElement("arrow");
|
||||||
|
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||||
|
enterLineEditingMode(arrow);
|
||||||
|
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
||||||
|
|
||||||
|
mouse.doubleClick();
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
||||||
|
expect(h.elements.length).toEqual(1);
|
||||||
|
|
||||||
|
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Inside editor", () => {
|
describe("Inside editor", () => {
|
||||||
@@ -290,7 +377,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(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
@@ -346,12 +433,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"55.96978",
|
"54.27552",
|
||||||
"47.44233",
|
"46.16120",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"76.08587",
|
"76.95494",
|
||||||
"43.29417",
|
"44.56052",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@@ -392,7 +479,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(startPoint, endPoint);
|
drag(startPoint, endPoint);
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
@@ -411,12 +498,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"105.96978",
|
"104.27552",
|
||||||
"67.44233",
|
"66.16120",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"126.08587",
|
"126.95494",
|
||||||
"63.29417",
|
"64.56052",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@@ -460,7 +547,7 @@ describe("Test Linear Elements", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`16`,
|
`14`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
@@ -511,7 +598,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
@@ -552,7 +639,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
@@ -600,7 +687,7 @@ describe("Test Linear Elements", () => {
|
|||||||
deletePoint(points[2]);
|
deletePoint(points[2]);
|
||||||
expect(line.points.length).toEqual(3);
|
expect(line.points.length).toEqual(3);
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`18`,
|
`17`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
@@ -658,7 +745,7 @@ describe("Test Linear Elements", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`16`,
|
`14`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
expect(line.points.length).toEqual(5);
|
expect(line.points.length).toEqual(5);
|
||||||
@@ -727,12 +814,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"31.88408",
|
"29.28349",
|
||||||
"23.13276",
|
"20.91105",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"77.74793",
|
"78.86048",
|
||||||
"44.57841",
|
"46.12277",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@@ -756,7 +843,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
@@ -816,12 +903,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"55.96978",
|
"54.27552",
|
||||||
"47.44233",
|
"46.16120",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"76.08587",
|
"76.95494",
|
||||||
"43.29417",
|
"44.56052",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@@ -983,19 +1070,17 @@ describe("Test Linear Elements", () => {
|
|||||||
);
|
);
|
||||||
expect(position).toMatchInlineSnapshot(`
|
expect(position).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
"x": "85.82202",
|
"x": "86.17305",
|
||||||
"y": "75.63461",
|
"y": "76.11251",
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should match styles for text editor", () => {
|
it("should match styles for text editor", async () => {
|
||||||
createTwoPointerLinearElement("arrow");
|
createTwoPointerLinearElement("arrow");
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
expect(editor).toMatchSnapshot();
|
expect(editor).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1012,9 +1097,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(text.type).toBe("text");
|
expect(text.type).toBe("text");
|
||||||
expect(text.containerId).toBe(arrow.id);
|
expect(text.containerId).toBe(arrow.id);
|
||||||
mouse.down();
|
mouse.down();
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
fireEvent.change(editor, {
|
fireEvent.change(editor, {
|
||||||
target: { value: DEFAULT_TEXT },
|
target: { value: DEFAULT_TEXT },
|
||||||
@@ -1042,9 +1125,7 @@ describe("Test Linear Elements", () => {
|
|||||||
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
|
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(textElement.type).toBe("text");
|
expect(textElement.type).toBe("text");
|
||||||
expect(textElement.containerId).toBe(arrow.id);
|
expect(textElement.containerId).toBe(arrow.id);
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
fireEvent.change(editor, {
|
fireEvent.change(editor, {
|
||||||
target: { value: DEFAULT_TEXT },
|
target: { value: DEFAULT_TEXT },
|
||||||
@@ -1063,13 +1144,7 @@ describe("Test Linear Elements", () => {
|
|||||||
|
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
mouse.doubleClickAt(line.x, line.y);
|
mouse.doubleClickAt(line.x, line.y);
|
||||||
|
expect(h.elements.length).toBe(1);
|
||||||
expect(h.elements.length).toBe(2);
|
|
||||||
|
|
||||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
|
||||||
expect(text.type).toBe("text");
|
|
||||||
expect(text.containerId).toBeNull();
|
|
||||||
expect(line.boundElements).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO fix #7029 and rewrite this test
|
// TODO fix #7029 and rewrite this test
|
||||||
@@ -1234,9 +1309,7 @@ describe("Test Linear Elements", () => {
|
|||||||
|
|
||||||
mouse.select(arrow);
|
mouse.select(arrow);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
|
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
@@ -1262,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(204, 0);
|
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(
|
||||||
|
|||||||
@@ -510,12 +510,12 @@ describe("arrow element", () => {
|
|||||||
h.state,
|
h.state,
|
||||||
)[0] as ExcalidrawElbowArrowElement;
|
)[0] as ExcalidrawElbowArrowElement;
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
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);
|
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);
|
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);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -819,7 +819,7 @@ describe("image element", () => {
|
|||||||
|
|
||||||
UI.resize(image, "ne", [40, 0]);
|
UI.resize(image, "ne", [40, 0]);
|
||||||
|
|
||||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 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;
|
||||||
@@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
|
|||||||
|
|
||||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||||
expect(leftBoundArrow.width).toBeCloseTo(143, 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();
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
import * as constants from "@excalidraw/common";
|
|
||||||
|
|
||||||
import { getPerfectElementSize } from "../src/sizeHelpers";
|
import { getPerfectElementSize } from "../src/sizeHelpers";
|
||||||
|
|
||||||
const EPSILON_DIGITS = 3;
|
const EPSILON_DIGITS = 3;
|
||||||
@@ -57,13 +55,4 @@ describe("getPerfectElementSize", () => {
|
|||||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
|
|
||||||
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
|
|
||||||
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
|
||||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
|
||||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
|
||||||
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { getLineHeight } from "@excalidraw/common";
|
import { getLineHeight } from "@excalidraw/common";
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import { FONT_FAMILY } from "@excalidraw/common";
|
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
|
computeBoundTextPosition,
|
||||||
} from "../src/textElement";
|
} from "../src/textElement";
|
||||||
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
||||||
|
|
||||||
@@ -207,3 +208,172 @@ describe("Test getDefaultLineHeight", () => {
|
|||||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Test computeBoundTextPosition", () => {
|
||||||
|
const createMockElementsMap = () => new Map();
|
||||||
|
|
||||||
|
// Helper function to create rectangle test case with 90-degree rotation
|
||||||
|
const createRotatedRectangleTestCase = (
|
||||||
|
textAlign: string,
|
||||||
|
verticalAlign: string,
|
||||||
|
) => {
|
||||||
|
const container = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
angle: (Math.PI / 2) as any, // 90 degrees
|
||||||
|
});
|
||||||
|
|
||||||
|
const boundTextElement = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
width: 80,
|
||||||
|
height: 40,
|
||||||
|
text: "hello darkness my old friend",
|
||||||
|
textAlign: textAlign as any,
|
||||||
|
verticalAlign: verticalAlign as any,
|
||||||
|
containerId: container.id,
|
||||||
|
}) as ExcalidrawTextElementWithContainer;
|
||||||
|
|
||||||
|
const elementsMap = createMockElementsMap();
|
||||||
|
|
||||||
|
return { container, boundTextElement, elementsMap };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("90-degree rotation with all alignment combinations", () => {
|
||||||
|
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
|
||||||
|
|
||||||
|
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(185, 1);
|
||||||
|
expect(result.y).toBeCloseTo(75, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(160, 1);
|
||||||
|
expect(result.y).toBeCloseTo(75, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(135, 1);
|
||||||
|
expect(result.y).toBeCloseTo(75, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(185, 1);
|
||||||
|
expect(result.y).toBeCloseTo(130, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(
|
||||||
|
TEXT_ALIGN.CENTER,
|
||||||
|
VERTICAL_ALIGN.MIDDLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(160, 1);
|
||||||
|
expect(result.y).toBeCloseTo(130, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(
|
||||||
|
TEXT_ALIGN.CENTER,
|
||||||
|
VERTICAL_ALIGN.BOTTOM,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(135, 1);
|
||||||
|
expect(result.y).toBeCloseTo(130, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(185, 1);
|
||||||
|
expect(result.y).toBeCloseTo(185, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(160, 1);
|
||||||
|
expect(result.y).toBeCloseTo(185, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(135, 1);
|
||||||
|
expect(result.y).toBeCloseTo(185, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { isFrameLikeElement } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { alignElements } from "@excalidraw/element";
|
import { alignElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Alignment } from "@excalidraw/element";
|
import type { Alignment } from "@excalidraw/element";
|
||||||
@@ -28,6 +30,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
@@ -38,7 +42,11 @@ export const alignActionsPredicate = (
|
|||||||
) => {
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return (
|
return (
|
||||||
selectedElements.length > 1 &&
|
getSelectedElementsByGroup(
|
||||||
|
selectedElements,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
appState as Readonly<AppState>,
|
||||||
|
).length > 1 &&
|
||||||
// TODO enable aligning frames when implemented properly
|
// TODO enable aligning frames when implemented properly
|
||||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||||
);
|
);
|
||||||
@@ -52,7 +60,12 @@ const alignSelectedElements = (
|
|||||||
) => {
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
|
||||||
const updatedElements = alignElements(selectedElements, alignment, app.scene);
|
const updatedElements = alignElements(
|
||||||
|
selectedElements,
|
||||||
|
alignment,
|
||||||
|
app.scene,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
getOriginalContainerHeightFromCache,
|
getOriginalContainerHeightFromCache,
|
||||||
|
isBoundToContainer,
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
@@ -225,7 +226,9 @@ export const actionWrapTextInContainer = register({
|
|||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
const someTextElements = selectedElements.some((el) => isTextElement(el));
|
const someTextElements = selectedElements.some(
|
||||||
|
(el) => isTextElement(el) && !isBoundToContainer(el),
|
||||||
|
);
|
||||||
return selectedElements.length > 0 && someTextElements;
|
return selectedElements.length > 0 && someTextElements;
|
||||||
},
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -234,7 +237,7 @@ export const actionWrapTextInContainer = register({
|
|||||||
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
||||||
|
|
||||||
for (const textElement of selectedElements) {
|
for (const textElement of selectedElements) {
|
||||||
if (isTextElement(textElement)) {
|
if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
|
||||||
const container = newElement({
|
const container = newElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
backgroundColor: appState.currentItemBackgroundColor,
|
backgroundColor: appState.currentItemBackgroundColor,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
MIN_ZOOM,
|
MIN_ZOOM,
|
||||||
THEME,
|
THEME,
|
||||||
ZOOM_STEP,
|
ZOOM_STEP,
|
||||||
getShortcutKey,
|
|
||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
CODES,
|
CODES,
|
||||||
KEYS,
|
KEYS,
|
||||||
@@ -46,6 +45,7 @@ import { t } from "../i18n";
|
|||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
|
||||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||||
return (
|
return (
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
@@ -83,6 +83,7 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={appState.stylesPanelMode === "compact"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -121,7 +122,10 @@ export const actionClearCanvas = register({
|
|||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
appState.activeTool.type === "image"
|
appState.activeTool.type === "image"
|
||||||
? { ...appState.activeTool, type: "selection" }
|
? {
|
||||||
|
...appState.activeTool,
|
||||||
|
type: app.state.preferredSelectionTool.type,
|
||||||
|
}
|
||||||
: appState.activeTool,
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
@@ -494,13 +498,13 @@ export const actionToggleEraserTool = register({
|
|||||||
name: "toggleEraserTool",
|
name: "toggleEraserTool",
|
||||||
label: "toolBar.eraser",
|
label: "toolBar.eraser",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
if (isEraserActive(appState)) {
|
if (isEraserActive(appState)) {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: "selection",
|
type: app.state.preferredSelectionTool.type,
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
@@ -530,6 +534,9 @@ export const actionToggleLassoTool = register({
|
|||||||
label: "toolBar.lasso",
|
label: "toolBar.lasso",
|
||||||
icon: LassoIcon,
|
icon: LassoIcon,
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
|
predicate: (elements, appState, props, app) => {
|
||||||
|
return app.state.preferredSelectionTool.type !== "lasso";
|
||||||
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
import {
|
||||||
|
KEYS,
|
||||||
|
MOBILE_ACTION_BUTTON_BG,
|
||||||
|
updateActiveTool,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
||||||
@@ -205,16 +209,19 @@ export const actionDeleteSelected = register({
|
|||||||
icon: TrashIcon,
|
icon: TrashIcon,
|
||||||
trackEvent: { category: "element", action: "delete" },
|
trackEvent: { category: "element", action: "delete" },
|
||||||
perform: (elements, appState, formData, app) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
selectedPointsIndices,
|
selectedPointsIndices,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.editingLinearElement;
|
} = appState.selectedLinearElement;
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const linearElement = LinearElementEditor.getElement(
|
||||||
if (!element) {
|
elementId,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
if (!linearElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// case: no point selected → do nothing, as deleting the whole element
|
// case: no point selected → do nothing, as deleting the whole element
|
||||||
@@ -225,10 +232,10 @@ export const actionDeleteSelected = register({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// case: deleting last remaining point
|
// case: deleting all points
|
||||||
if (element.points.length < 2) {
|
if (selectedPointsIndices.length >= linearElement.points.length) {
|
||||||
const nextElements = elements.map((el) => {
|
const nextElements = elements.map((el) => {
|
||||||
if (el.id === element.id) {
|
if (el.id === linearElement.id) {
|
||||||
return newElementWith(el, { isDeleted: true });
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
@@ -239,7 +246,7 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
editingLinearElement: null,
|
selectedLinearElement: null,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
@@ -252,20 +259,24 @@ export const actionDeleteSelected = register({
|
|||||||
? null
|
? null
|
||||||
: startBindingElement,
|
: startBindingElement,
|
||||||
endBindingElement: selectedPointsIndices?.includes(
|
endBindingElement: selectedPointsIndices?.includes(
|
||||||
element.points.length - 1,
|
linearElement.points.length - 1,
|
||||||
)
|
)
|
||||||
? null
|
? null
|
||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
|
LinearElementEditor.deletePoints(
|
||||||
|
linearElement,
|
||||||
|
app,
|
||||||
|
selectedPointsIndices,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
editingLinearElement: {
|
selectedLinearElement: {
|
||||||
...appState.editingLinearElement,
|
...appState.selectedLinearElement,
|
||||||
...binding,
|
...binding,
|
||||||
selectedPointsIndices:
|
selectedPointsIndices:
|
||||||
selectedPointsIndices?.[0] > 0
|
selectedPointsIndices?.[0] > 0
|
||||||
@@ -291,7 +302,9 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
activeTool: updateActiveTool(appState, {
|
||||||
|
type: app.state.preferredSelectionTool.type,
|
||||||
|
}),
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
selectedLinearElement: null,
|
selectedLinearElement: null,
|
||||||
@@ -314,7 +327,15 @@ export const actionDeleteSelected = register({
|
|||||||
title={t("labels.delete")}
|
title={t("labels.delete")}
|
||||||
aria-label={t("labels.delete")}
|
aria-label={t("labels.delete")}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
disabled={
|
||||||
|
!isSomeElementSelected(getNonDeletedElements(elements), appState)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...(appState.stylesPanelMode === "mobile" &&
|
||||||
|
appState.openPopup !== "compactOtherProperties"
|
||||||
|
? MOBILE_ACTION_BUTTON_BG
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { isFrameLikeElement } from "@excalidraw/element";
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ import { distributeElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Distribution } from "@excalidraw/element";
|
import type { Distribution } from "@excalidraw/element";
|
||||||
@@ -24,6 +26,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
@@ -31,7 +35,11 @@ import type { AppClassProperties, AppState } from "../types";
|
|||||||
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return (
|
return (
|
||||||
selectedElements.length > 1 &&
|
getSelectedElementsByGroup(
|
||||||
|
selectedElements,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
appState as Readonly<AppState>,
|
||||||
|
).length > 2 &&
|
||||||
// TODO enable distributing frames when implemented properly
|
// TODO enable distributing frames when implemented properly
|
||||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||||
);
|
);
|
||||||
@@ -49,6 +57,7 @@ const distributeSelectedElements = (
|
|||||||
selectedElements,
|
selectedElements,
|
||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
distribution,
|
distribution,
|
||||||
|
appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_GRID_SIZE,
|
DEFAULT_GRID_SIZE,
|
||||||
KEYS,
|
KEYS,
|
||||||
|
MOBILE_ACTION_BUTTON_BG,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
getShortcutKey,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
@@ -25,6 +25,7 @@ import { DuplicateIcon } from "../components/icons";
|
|||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export const actionDuplicateSelection = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||||
try {
|
try {
|
||||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||||
@@ -115,7 +116,15 @@ export const actionDuplicateSelection = register({
|
|||||||
)}`}
|
)}`}
|
||||||
aria-label={t("labels.duplicateSelection")}
|
aria-label={t("labels.duplicateSelection")}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
disabled={
|
||||||
|
!isSomeElementSelected(getNonDeletedElements(elements), appState)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...(appState.stylesPanelMode === "mobile" &&
|
||||||
|
appState.openPopup !== "compactOtherProperties"
|
||||||
|
? MOBILE_ACTION_BUTTON_BG
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "@excalidraw/element/binding";
|
} from "@excalidraw/element/binding";
|
||||||
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
import {
|
||||||
|
isValidPolygon,
|
||||||
|
LinearElementEditor,
|
||||||
|
newElementWith,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
@@ -14,7 +18,12 @@ import {
|
|||||||
isLineElement,
|
isLineElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
import {
|
||||||
|
KEYS,
|
||||||
|
arrayToMap,
|
||||||
|
tupleToCoors,
|
||||||
|
updateActiveTool,
|
||||||
|
} from "@excalidraw/common";
|
||||||
import { isPathALoop } from "@excalidraw/element";
|
import { isPathALoop } from "@excalidraw/element";
|
||||||
|
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||||
@@ -43,12 +52,16 @@ export const actionFinalize = register({
|
|||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, data, app) => {
|
perform: (elements, appState, data, app) => {
|
||||||
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?.event && appState.selectedLinearElement) {
|
if (event && appState.selectedLinearElement) {
|
||||||
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||||
data.event,
|
event,
|
||||||
appState.selectedLinearElement,
|
appState.selectedLinearElement,
|
||||||
appState,
|
appState,
|
||||||
app.scene,
|
app.scene,
|
||||||
@@ -69,7 +82,14 @@ export const actionFinalize = register({
|
|||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
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.filter((el) => el.id !== element!.id);
|
newElements = newElements.map((el) => {
|
||||||
|
if (el.id === element.id) {
|
||||||
|
return newElementWith(el, {
|
||||||
|
isDeleted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
@@ -85,9 +105,9 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.editingLinearElement) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
appState.editingLinearElement;
|
appState.selectedLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -108,12 +128,21 @@ export const actionFinalize = register({
|
|||||||
return {
|
return {
|
||||||
elements:
|
elements:
|
||||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||||
? elements.filter((el) => el.id !== element.id)
|
? elements.map((el) => {
|
||||||
|
if (el.id === element.id) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
editingLinearElement: null,
|
selectedLinearElement: new LinearElementEditor(
|
||||||
|
element,
|
||||||
|
arrayToMap(elementsMap),
|
||||||
|
false, // exit editing mode
|
||||||
|
),
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
@@ -122,18 +151,6 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
|
|
||||||
const pendingImageElement =
|
|
||||||
appState.pendingImageElementId &&
|
|
||||||
scene.getElement(appState.pendingImageElementId);
|
|
||||||
|
|
||||||
if (pendingImageElement) {
|
|
||||||
scene.mutateElement(
|
|
||||||
pendingImageElement,
|
|
||||||
{ isDeleted: true },
|
|
||||||
{ informMutation: false, isDragging: false },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
focusContainer();
|
focusContainer();
|
||||||
}
|
}
|
||||||
@@ -157,11 +174,7 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
// pen and mouse have hover
|
// pen and mouse have hover
|
||||||
if (
|
if (appState.multiElement && element.type !== "freedraw") {
|
||||||
appState.multiElement &&
|
|
||||||
element.type !== "freedraw" &&
|
|
||||||
appState.lastPointerDownWith !== "touch"
|
|
||||||
) {
|
|
||||||
const { points, lastCommittedPoint } = element;
|
const { points, lastCommittedPoint } = element;
|
||||||
if (
|
if (
|
||||||
!lastCommittedPoint ||
|
!lastCommittedPoint ||
|
||||||
@@ -175,7 +188,12 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
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.filter((el) => el.id !== element!.id);
|
newElements = newElements.map((el) => {
|
||||||
|
if (el.id === element?.id) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
@@ -216,12 +234,17 @@ export const actionFinalize = register({
|
|||||||
element.points.length > 1 &&
|
element.points.length > 1 &&
|
||||||
isBindingEnabled(appState)
|
isBindingEnabled(appState)
|
||||||
) {
|
) {
|
||||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
const coords =
|
||||||
element,
|
sceneCoords ??
|
||||||
-1,
|
tupleToCoors(
|
||||||
arrayToMap(elements),
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
);
|
element,
|
||||||
maybeBindLinearElement(element, appState, { x, y }, scene);
|
-1,
|
||||||
|
arrayToMap(elements),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
maybeBindLinearElement(element, appState, coords, scene);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,13 +261,13 @@ export const actionFinalize = register({
|
|||||||
if (appState.activeTool.type === "eraser") {
|
if (appState.activeTool.type === "eraser") {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: "selection",
|
type: app.state.preferredSelectionTool.type,
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
type: "selection",
|
type: app.state.preferredSelectionTool.type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +303,6 @@ export const actionFinalize = register({
|
|||||||
element && isLinearElement(element)
|
element && isLinearElement(element)
|
||||||
? new LinearElementEditor(element, arrayToMap(newElements))
|
? new LinearElementEditor(element, arrayToMap(newElements))
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
pendingImageElementId: null,
|
|
||||||
},
|
},
|
||||||
// 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,
|
||||||
@@ -288,7 +310,7 @@ export const actionFinalize = register({
|
|||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
(event.key === KEYS.ESCAPE &&
|
(event.key === KEYS.ESCAPE &&
|
||||||
(appState.editingLinearElement !== null ||
|
(appState.selectedLinearElement?.isEditing ||
|
||||||
(!appState.newElement && appState.multiElement === null))) ||
|
(!appState.newElement && appState.multiElement === null))) ||
|
||||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||||
appState.multiElement !== null),
|
appState.multiElement !== null),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, randomId, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSelectedGroupIds,
|
getSelectedGroupIds,
|
||||||
@@ -43,6 +43,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
import {
|
||||||
|
isWindows,
|
||||||
|
KEYS,
|
||||||
|
matchKey,
|
||||||
|
arrayToMap,
|
||||||
|
MOBILE_ACTION_BUTTON_BG,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -67,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: ({ updateData, data }) => {
|
PanelComponent: ({ appState, updateData, data }) => {
|
||||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(
|
new HistoryChangedEvent(
|
||||||
@@ -85,6 +91,11 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isUndoStackEmpty}
|
disabled={isUndoStackEmpty}
|
||||||
data-testid="button-undo"
|
data-testid="button-undo"
|
||||||
|
style={{
|
||||||
|
...(appState.stylesPanelMode === "mobile"
|
||||||
|
? MOBILE_ACTION_BUTTON_BG
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -103,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: ({ updateData, data }) => {
|
PanelComponent: ({ appState, updateData, data }) => {
|
||||||
const { isRedoStackEmpty } = useEmitter(
|
const { isRedoStackEmpty } = useEmitter(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(
|
new HistoryChangedEvent(
|
||||||
@@ -121,6 +132,11 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isRedoStackEmpty}
|
disabled={isRedoStackEmpty}
|
||||||
data-testid="button-redo"
|
data-testid="button-redo"
|
||||||
|
style={{
|
||||||
|
...(appState.stylesPanelMode === "mobile"
|
||||||
|
? MOBILE_ACTION_BUTTON_BG
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { LinearElementEditor } from "@excalidraw/element";
|
|
||||||
import {
|
import {
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isLineElement,
|
isLineElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap, invariant } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import {
|
||||||
|
toggleLinePolygonState,
|
||||||
|
CaptureUpdateAction,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@@ -22,8 +24,6 @@ import { ButtonIcon } from "../components/ButtonIcon";
|
|||||||
|
|
||||||
import { newElementWith } from "../../element/src/mutateElement";
|
import { newElementWith } from "../../element/src/mutateElement";
|
||||||
|
|
||||||
import { toggleLinePolygonState } from "../../element/src/shapes";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleLinearEditor = register({
|
export const actionToggleLinearEditor = register({
|
||||||
@@ -45,7 +45,7 @@ export const actionToggleLinearEditor = register({
|
|||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
if (
|
if (
|
||||||
!appState.editingLinearElement &&
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
selectedElements.length === 1 &&
|
selectedElements.length === 1 &&
|
||||||
isLinearElement(selectedElements[0]) &&
|
isLinearElement(selectedElements[0]) &&
|
||||||
!isElbowArrow(selectedElements[0])
|
!isElbowArrow(selectedElements[0])
|
||||||
@@ -60,14 +60,25 @@ export const actionToggleLinearEditor = register({
|
|||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
})[0] as ExcalidrawLinearElement;
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
const editingLinearElement =
|
invariant(selectedElement, "No selected element found");
|
||||||
appState.editingLinearElement?.elementId === selectedElement.id
|
invariant(
|
||||||
? null
|
appState.selectedLinearElement,
|
||||||
: new LinearElementEditor(selectedElement, arrayToMap(elements));
|
"No selected linear element found",
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
selectedElement.id === appState.selectedLinearElement.elementId,
|
||||||
|
"Selected element ID and linear editor elementId does not match",
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedLinearElement = {
|
||||||
|
...appState.selectedLinearElement,
|
||||||
|
isEditing: !appState.selectedLinearElement.isEditing,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
editingLinearElement,
|
selectedLinearElement,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
@@ -77,6 +88,10 @@ export const actionToggleLinearEditor = register({
|
|||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
})[0] as ExcalidrawLinearElement;
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
if (!selectedElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const label = t(
|
const label = t(
|
||||||
selectedElement.type === "arrow"
|
selectedElement.type === "arrow"
|
||||||
? "labels.lineEditor.editArrow"
|
? "labels.lineEditor.editArrow"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isEmbeddableElement } from "@excalidraw/element";
|
import { isEmbeddableElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
import { KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||||
import { LinkIcon } from "../components/icons";
|
import { LinkIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,11 @@
|
|||||||
import { KEYS } from "@excalidraw/common";
|
import { KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { showSelectedShapeActions } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { HelpIconThin } from "../components/icons";
|
||||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
|
||||||
name: "toggleCanvasMenu",
|
|
||||||
label: "buttons.menu",
|
|
||||||
trackEvent: { category: "menu" },
|
|
||||||
perform: (_, appState) => ({
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
|
||||||
},
|
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
|
||||||
}),
|
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
icon={HamburgerMenuIcon}
|
|
||||||
aria-label={t("buttons.menu")}
|
|
||||||
onClick={updateData}
|
|
||||||
selected={appState.openMenu === "canvas"}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionToggleEditMenu = register({
|
|
||||||
name: "toggleEditMenu",
|
|
||||||
label: "buttons.edit",
|
|
||||||
trackEvent: { category: "menu" },
|
|
||||||
perform: (_elements, appState) => ({
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
|
||||||
},
|
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
|
||||||
}),
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
visible={showSelectedShapeActions(
|
|
||||||
appState,
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
)}
|
|
||||||
type="button"
|
|
||||||
icon={palette}
|
|
||||||
aria-label={t("buttons.edit")}
|
|
||||||
onClick={updateData}
|
|
||||||
selected={appState.openMenu === "shape"}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
label: "welcomeScreen.defaults.helpHint",
|
label: "welcomeScreen.defaults.helpHint",
|
||||||
@@ -79,6 +25,8 @@ export const actionShortcuts = register({
|
|||||||
: {
|
: {
|
||||||
name: "help",
|
name: "help",
|
||||||
},
|
},
|
||||||
|
openMenu: null,
|
||||||
|
openPopup: null,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
randomInteger,
|
randomInteger,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
getFontFamilyString,
|
getFontFamilyString,
|
||||||
getShortcutKey,
|
|
||||||
tupleToCoors,
|
|
||||||
getLineHeight,
|
getLineHeight,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
reduceToCommonValue,
|
reduceToCommonValue,
|
||||||
@@ -28,9 +26,7 @@ import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
bindLinearElement,
|
bindLinearElement,
|
||||||
bindPointToSnapToElementOutline,
|
|
||||||
calculateFixedPointForElbowArrowBinding,
|
calculateFixedPointForElbowArrowBinding,
|
||||||
getHoveredElementForBinding,
|
|
||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -47,6 +43,7 @@ import {
|
|||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isLineElement,
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
@@ -55,9 +52,11 @@ import {
|
|||||||
|
|
||||||
import { hasStrokeColor } from "@excalidraw/element";
|
import { hasStrokeColor } from "@excalidraw/element";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "@excalidraw/element";
|
import {
|
||||||
|
updateElbowArrowPoints,
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
CaptureUpdateAction,
|
||||||
|
toggleLinePolygonState,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
@@ -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";
|
||||||
@@ -138,7 +140,12 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
import { toggleLinePolygonState } from "../../element/src/shapes";
|
import {
|
||||||
|
withCaretPositionPreservation,
|
||||||
|
restoreCaretPosition,
|
||||||
|
} from "../hooks/useTextEditorFocus";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@@ -324,9 +331,11 @@ export const actionChangeStrokeColor = register({
|
|||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<>
|
<>
|
||||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||||
|
)}
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
||||||
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
||||||
@@ -344,6 +353,10 @@ export const actionChangeStrokeColor = register({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -401,9 +414,11 @@ export const actionChangeBackgroundColor = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<>
|
<>
|
||||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||||
|
)}
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
||||||
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
||||||
@@ -421,6 +436,10 @@ export const actionChangeBackgroundColor = register({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -506,6 +525,33 @@ export const actionChangeFillStyle = register({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const WIDTHS = [
|
||||||
|
{
|
||||||
|
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({
|
export const actionChangeStrokeWidth = register({
|
||||||
name: "changeStrokeWidth",
|
name: "changeStrokeWidth",
|
||||||
label: "labels.strokeWidth",
|
label: "labels.strokeWidth",
|
||||||
@@ -521,32 +567,13 @@ export const actionChangeStrokeWidth = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.strokeWidth")}</legend>
|
<legend>{t("labels.strokeWidth")}</legend>
|
||||||
<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,
|
||||||
@@ -578,7 +605,7 @@ export const actionChangeSloppiness = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.sloppiness")}</legend>
|
<legend>{t("labels.sloppiness")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -631,7 +658,7 @@ export const actionChangeStrokeStyle = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.strokeStyle")}</legend>
|
<legend>{t("labels.strokeStyle")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -669,6 +696,70 @@ export const actionChangeStrokeStyle = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
label: "labels.opacity",
|
label: "labels.opacity",
|
||||||
@@ -700,7 +791,7 @@ export const actionChangeFontSize = register({
|
|||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, app, () => value, value);
|
return changeFontSize(elements, appState, app, () => value, value);
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontSize")}</legend>
|
<legend>{t("labels.fontSize")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -759,7 +850,15 @@ export const actionChangeFontSize = register({
|
|||||||
? null
|
? null
|
||||||
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => updateData(value),
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
data?.onPreventClose,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -1096,21 +1195,30 @@ export const actionChangeFontFamily = register({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<>
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<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={appState.stylesPanelMode !== "full"}
|
||||||
onSelect={(fontFamily) => {
|
onSelect={(fontFamily) => {
|
||||||
setBatchedData({
|
withCaretPositionPreservation(
|
||||||
openPopup: null,
|
() => {
|
||||||
currentHoveredFontFamily: null,
|
setBatchedData({
|
||||||
currentItemFontFamily: fontFamily,
|
openPopup: null,
|
||||||
});
|
currentHoveredFontFamily: null,
|
||||||
|
currentItemFontFamily: fontFamily,
|
||||||
// defensive clear so immediate close won't abuse the cached elements
|
});
|
||||||
cachedElementsRef.current.clear();
|
// defensive clear so immediate close won't abuse the cached elements
|
||||||
|
cachedElementsRef.current.clear();
|
||||||
|
},
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onHover={(fontFamily) => {
|
onHover={(fontFamily) => {
|
||||||
setBatchedData({
|
setBatchedData({
|
||||||
@@ -1167,29 +1275,33 @@ export const actionChangeFontFamily = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBatchedData({
|
setBatchedData({
|
||||||
|
...batchedData,
|
||||||
openPopup: "fontFamily",
|
openPopup: "fontFamily",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// close, use the cache and clear it afterwards
|
const fontFamilyData = {
|
||||||
const data = {
|
|
||||||
openPopup: null,
|
|
||||||
currentHoveredFontFamily: null,
|
currentHoveredFontFamily: null,
|
||||||
cachedElements: new Map(cachedElementsRef.current),
|
cachedElements: new Map(cachedElementsRef.current),
|
||||||
resetAll: true,
|
resetAll: true,
|
||||||
} as ChangeFontFamilyData;
|
} as ChangeFontFamilyData;
|
||||||
|
|
||||||
if (isUnmounted.current) {
|
setBatchedData({
|
||||||
// in case the component was unmounted by the parent, trigger the update directly
|
...fontFamilyData,
|
||||||
updateData({ ...batchedData, ...data });
|
});
|
||||||
} else {
|
|
||||||
setBatchedData(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedElementsRef.current.clear();
|
cachedElementsRef.current.clear();
|
||||||
|
|
||||||
|
// Refocus text editor when font picker closes if we were editing text
|
||||||
|
if (
|
||||||
|
(appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile") &&
|
||||||
|
appState.editingTextElement
|
||||||
|
) {
|
||||||
|
restoreCaretPosition(null); // Just refocus without saved position
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1228,8 +1340,9 @@ export const actionChangeTextAlign = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.textAlign")}</legend>
|
<legend>{t("labels.textAlign")}</legend>
|
||||||
@@ -1278,7 +1391,15 @@ export const actionChangeTextAlign = register({
|
|||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
hasSelection ? null : appState.currentItemTextAlign,
|
hasSelection ? null : appState.currentItemTextAlign,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => updateData(value),
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
data?.onPreventClose,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -1320,7 +1441,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -1370,7 +1491,15 @@ export const actionChangeVerticalAlign = register({
|
|||||||
) !== null,
|
) !== null,
|
||||||
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => updateData(value),
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
data?.onPreventClose,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -1619,6 +1748,25 @@ export const actionChangeArrowhead = register({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionChangeArrowProperties = register({
|
||||||
|
name: "changeArrowProperties",
|
||||||
|
label: "Change arrow properties",
|
||||||
|
trackEvent: false,
|
||||||
|
perform: (elements, appState, value, app) => {
|
||||||
|
// This action doesn't perform any changes directly
|
||||||
|
// It's just a container for the arrow type and arrowhead actions
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
||||||
|
return (
|
||||||
|
<div className="selected-shape-actions">
|
||||||
|
{renderAction("changeArrowhead")}
|
||||||
|
{renderAction("changeArrowType")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const actionChangeArrowType = register({
|
export const actionChangeArrowType = register({
|
||||||
name: "changeArrowType",
|
name: "changeArrowType",
|
||||||
label: "Change arrow types",
|
label: "Change arrow types",
|
||||||
@@ -1661,63 +1809,16 @@ export const actionChangeArrowType = register({
|
|||||||
-1,
|
-1,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
const startHoveredElement =
|
const startElement =
|
||||||
!newElement.startBinding &&
|
newElement.startBinding &&
|
||||||
getHoveredElementForBinding(
|
(elementsMap.get(
|
||||||
tupleToCoors(startGlobalPoint),
|
newElement.startBinding.elementId,
|
||||||
elements,
|
) as ExcalidrawBindableElement);
|
||||||
elementsMap,
|
const endElement =
|
||||||
appState.zoom,
|
newElement.endBinding &&
|
||||||
false,
|
(elementsMap.get(
|
||||||
true,
|
newElement.endBinding.elementId,
|
||||||
);
|
) as ExcalidrawBindableElement);
|
||||||
const endHoveredElement =
|
|
||||||
!newElement.endBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(endGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startElement = startHoveredElement
|
|
||||||
? startHoveredElement
|
|
||||||
: newElement.startBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.startBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
const endElement = endHoveredElement
|
|
||||||
? endHoveredElement
|
|
||||||
: newElement.endBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.endBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
|
|
||||||
const finalStartPoint = startHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
newElement,
|
|
||||||
startHoveredElement,
|
|
||||||
"start",
|
|
||||||
)
|
|
||||||
: startGlobalPoint;
|
|
||||||
const finalEndPoint = endHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
newElement,
|
|
||||||
endHoveredElement,
|
|
||||||
"end",
|
|
||||||
)
|
|
||||||
: endGlobalPoint;
|
|
||||||
|
|
||||||
startHoveredElement &&
|
|
||||||
bindLinearElement(
|
|
||||||
newElement,
|
|
||||||
startHoveredElement,
|
|
||||||
"start",
|
|
||||||
app.scene,
|
|
||||||
);
|
|
||||||
endHoveredElement &&
|
|
||||||
bindLinearElement(newElement, endHoveredElement, "end", app.scene);
|
|
||||||
|
|
||||||
const startBinding =
|
const startBinding =
|
||||||
startElement && newElement.startBinding
|
startElement && newElement.startBinding
|
||||||
@@ -1728,6 +1829,7 @@ export const actionChangeArrowType = register({
|
|||||||
newElement,
|
newElement,
|
||||||
startElement,
|
startElement,
|
||||||
"start",
|
"start",
|
||||||
|
elementsMap,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -1740,6 +1842,7 @@ export const actionChangeArrowType = register({
|
|||||||
newElement,
|
newElement,
|
||||||
endElement,
|
endElement,
|
||||||
"end",
|
"end",
|
||||||
|
elementsMap,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -1749,7 +1852,7 @@ export const actionChangeArrowType = register({
|
|||||||
startBinding,
|
startBinding,
|
||||||
endBinding,
|
endBinding,
|
||||||
...updateElbowArrowPoints(newElement, elementsMap, {
|
...updateElbowArrowPoints(newElement, elementsMap, {
|
||||||
points: [finalStartPoint, finalEndPoint].map(
|
points: [startGlobalPoint, endGlobalPoint].map(
|
||||||
(p): LocalPoint =>
|
(p): LocalPoint =>
|
||||||
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const actionSelectAll = register({
|
|||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export const actionToggleSearchMenu = register({
|
|||||||
predicate: (appState) => appState.gridModeEnabled,
|
predicate: (appState) => appState.gridModeEnabled,
|
||||||
},
|
},
|
||||||
perform(elements, appState, _, app) {
|
perform(elements, appState, _, app) {
|
||||||
|
if (appState.openDialog) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { KEYS, CODES, getShortcutKey, isDarwin } from "@excalidraw/common";
|
import { KEYS, CODES, isDarwin } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
moveOneLeft,
|
moveOneLeft,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SendToBackIcon,
|
SendToBackIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ export {
|
|||||||
actionChangeStrokeWidth,
|
actionChangeStrokeWidth,
|
||||||
actionChangeFillStyle,
|
actionChangeFillStyle,
|
||||||
actionChangeSloppiness,
|
actionChangeSloppiness,
|
||||||
|
actionChangePressureSensitivity,
|
||||||
actionChangeOpacity,
|
actionChangeOpacity,
|
||||||
actionChangeFontSize,
|
actionChangeFontSize,
|
||||||
actionChangeFontFamily,
|
actionChangeFontFamily,
|
||||||
actionChangeTextAlign,
|
actionChangeTextAlign,
|
||||||
actionChangeVerticalAlign,
|
actionChangeVerticalAlign,
|
||||||
|
actionChangeArrowProperties,
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -43,11 +45,7 @@ export {
|
|||||||
} from "./actionExport";
|
} from "./actionExport";
|
||||||
|
|
||||||
export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
||||||
export {
|
export { actionShortcuts } from "./actionMenu";
|
||||||
actionToggleCanvasMenu,
|
|
||||||
actionToggleEditMenu,
|
|
||||||
actionShortcuts,
|
|
||||||
} from "./actionMenu";
|
|
||||||
|
|
||||||
export { actionGroup, actionUngroup } from "./actionGroup";
|
export { actionGroup, actionUngroup } from "./actionGroup";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { isDarwin, getShortcutKey } from "@excalidraw/common";
|
import { isDarwin } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { SubtypeOf } from "@excalidraw/common/utility-types";
|
import type { SubtypeOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import type { ActionName } from "./types";
|
import type { ActionName } from "./types";
|
||||||
|
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ export type ActionName =
|
|||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
| "changeArrowType"
|
| "changeArrowType"
|
||||||
|
| "changeStrokeType"
|
||||||
|
| "changeArrowProperties"
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
|
||||||
| "toggleEditMenu"
|
|
||||||
| "undo"
|
| "undo"
|
||||||
| "redo"
|
| "redo"
|
||||||
| "finalize"
|
| "finalize"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
STATS_PANELS,
|
STATS_PANELS,
|
||||||
THEME,
|
THEME,
|
||||||
DEFAULT_GRID_STEP,
|
DEFAULT_GRID_STEP,
|
||||||
|
isTestEnv,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { AppState, NormalizedZoomValue } from "./types";
|
import type { AppState, NormalizedZoomValue } from "./types";
|
||||||
@@ -33,10 +34,11 @@ 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,
|
||||||
currentItemRoundness: "round",
|
currentItemRoundness: isTestEnv() ? "sharp" : "round",
|
||||||
currentItemArrowType: ARROW_TYPE.round,
|
currentItemArrowType: ARROW_TYPE.round,
|
||||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||||
@@ -47,7 +49,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
newElement: null,
|
newElement: null,
|
||||||
editingTextElement: null,
|
editingTextElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
|
||||||
activeTool: {
|
activeTool: {
|
||||||
type: "selection",
|
type: "selection",
|
||||||
customType: null,
|
customType: null,
|
||||||
@@ -55,6 +56,10 @@ export const getDefaultAppState = (): Omit<
|
|||||||
fromSelection: false,
|
fromSelection: false,
|
||||||
lastActiveTool: null,
|
lastActiveTool: null,
|
||||||
},
|
},
|
||||||
|
preferredSelectionTool: {
|
||||||
|
type: "selection",
|
||||||
|
initialized: false,
|
||||||
|
},
|
||||||
penMode: false,
|
penMode: false,
|
||||||
penDetected: false,
|
penDetected: false,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -108,7 +113,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
value: 1 as NormalizedZoomValue,
|
value: 1 as NormalizedZoomValue,
|
||||||
},
|
},
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
pendingImageElementId: null,
|
|
||||||
showHyperlinkPopup: false,
|
showHyperlinkPopup: false,
|
||||||
selectedLinearElement: null,
|
selectedLinearElement: null,
|
||||||
snapLines: [],
|
snapLines: [],
|
||||||
@@ -124,6 +128,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
searchMatches: null,
|
searchMatches: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
|
stylesPanelMode: "full",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,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 },
|
||||||
@@ -175,8 +185,8 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
newElement: { browser: false, export: false, server: false },
|
newElement: { browser: false, export: false, server: false },
|
||||||
editingTextElement: { browser: false, export: false, server: false },
|
editingTextElement: { browser: false, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false, server: false },
|
|
||||||
activeTool: { browser: true, export: false, server: false },
|
activeTool: { browser: true, export: false, server: false },
|
||||||
|
preferredSelectionTool: { browser: true, export: false, server: false },
|
||||||
penMode: { browser: true, export: false, server: false },
|
penMode: { browser: true, export: false, server: false },
|
||||||
penDetected: { browser: true, export: false, server: false },
|
penDetected: { browser: true, export: false, server: false },
|
||||||
errorMessage: { browser: false, export: false, server: false },
|
errorMessage: { browser: false, export: false, server: false },
|
||||||
@@ -237,7 +247,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
zenModeEnabled: { browser: true, export: false, server: false },
|
zenModeEnabled: { browser: true, export: false, server: false },
|
||||||
zoom: { browser: true, export: false, server: false },
|
zoom: { browser: true, export: false, server: false },
|
||||||
viewModeEnabled: { browser: false, export: false, server: false },
|
viewModeEnabled: { browser: false, export: false, server: false },
|
||||||
pendingImageElementId: { browser: false, export: false, server: false },
|
|
||||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||||
selectedLinearElement: { browser: true, export: false, server: false },
|
selectedLinearElement: { browser: true, export: false, server: false },
|
||||||
snapLines: { browser: false, export: false, server: false },
|
snapLines: { browser: false, export: false, server: false },
|
||||||
@@ -250,6 +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 },
|
||||||
|
stylesPanelMode: { browser: false, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createPasteEvent,
|
createPasteEvent,
|
||||||
parseClipboard,
|
parseClipboard,
|
||||||
|
parseDataTransferEvent,
|
||||||
serializeAsClipboardJSON,
|
serializeAsClipboardJSON,
|
||||||
} from "./clipboard";
|
} from "./clipboard";
|
||||||
import { API } from "./tests/helpers/api";
|
import { API } from "./tests/helpers/api";
|
||||||
@@ -13,7 +14,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "123";
|
text = "123";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@@ -21,7 +24,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "[123]";
|
text = "[123]";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@@ -29,7 +34,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = JSON.stringify({ val: 42 });
|
text = JSON.stringify({ val: 42 });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
});
|
});
|
||||||
@@ -39,11 +46,13 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/plain": json,
|
types: {
|
||||||
},
|
"text/plain": json,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
});
|
});
|
||||||
@@ -56,21 +65,25 @@ describe("parseClipboard()", () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": json,
|
types: {
|
||||||
},
|
"text/html": json,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<div> ${json}</div>`,
|
types: {
|
||||||
},
|
"text/html": `<div> ${json}</div>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<img src="https://example.com/image.png" />`,
|
types: {
|
||||||
},
|
"text/html": `<img src="https://example.com/image.png" />`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
|
|||||||
]);
|
]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
types: {
|
||||||
},
|
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@@ -114,11 +131,13 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
types: {
|
||||||
},
|
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@@ -141,14 +160,16 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/plain": `a b
|
types: {
|
||||||
1 2
|
"text/plain": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@@ -157,14 +178,16 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `a b
|
types: {
|
||||||
1 2
|
"text/html": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@@ -173,19 +196,21 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<html>
|
types: {
|
||||||
<body>
|
"text/html": `<html>
|
||||||
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
<body>
|
||||||
</body>
|
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
||||||
</html>`,
|
</body>
|
||||||
"text/plain": `a b
|
</html>`,
|
||||||
1 2
|
"text/plain": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
isMemberOf,
|
isMemberOf,
|
||||||
isPromiseLike,
|
isPromiseLike,
|
||||||
|
EVENT,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { mutateElement } from "@excalidraw/element";
|
import { mutateElement } from "@excalidraw/element";
|
||||||
@@ -16,15 +17,26 @@ import {
|
|||||||
|
|
||||||
import { getContainingFrame } from "@excalidraw/element";
|
import { getContainingFrame } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ExcalidrawError } from "./errors";
|
import { ExcalidrawError } from "./errors";
|
||||||
import { createFile, isSupportedImageFileType } from "./data/blob";
|
import {
|
||||||
|
createFile,
|
||||||
|
getFileHandle,
|
||||||
|
isSupportedImageFileType,
|
||||||
|
normalizeFile,
|
||||||
|
} from "./data/blob";
|
||||||
|
|
||||||
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
|
|
||||||
|
import type { FileSystemHandle } from "./data/filesystem";
|
||||||
|
|
||||||
import type { Spreadsheet } from "./charts";
|
import type { Spreadsheet } from "./charts";
|
||||||
|
|
||||||
import type { BinaryFiles } from "./types";
|
import type { BinaryFiles } from "./types";
|
||||||
@@ -92,7 +104,7 @@ export const createPasteEvent = ({
|
|||||||
console.warn("createPasteEvent: no types or files provided");
|
console.warn("createPasteEvent: no types or files provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = new ClipboardEvent("paste", {
|
const event = new ClipboardEvent(EVENT.PASTE, {
|
||||||
clipboardData: new DataTransfer(),
|
clipboardData: new DataTransfer(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,10 +113,11 @@ export const createPasteEvent = ({
|
|||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
files = files || [];
|
files = files || [];
|
||||||
files.push(value);
|
files.push(value);
|
||||||
|
event.clipboardData?.items.add(value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
event.clipboardData?.setData(type, value);
|
event.clipboardData?.items.add(value, type);
|
||||||
if (event.clipboardData?.getData(type) !== value) {
|
if (event.clipboardData?.getData(type) !== value) {
|
||||||
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
||||||
}
|
}
|
||||||
@@ -229,14 +242,10 @@ function parseHTMLTree(el: ChildNode) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maybeParseHTMLPaste = (
|
const maybeParseHTMLDataItem = (
|
||||||
event: ClipboardEvent,
|
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
|
||||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||||
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
const html = dataItem.value;
|
||||||
|
|
||||||
if (!html) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
||||||
@@ -332,18 +341,21 @@ export const readSystemClipboard = async () => {
|
|||||||
* Parses "paste" ClipboardEvent.
|
* Parses "paste" ClipboardEvent.
|
||||||
*/
|
*/
|
||||||
const parseClipboardEventTextData = async (
|
const parseClipboardEventTextData = async (
|
||||||
event: ClipboardEvent,
|
dataList: ParsedDataTranferList,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ParsedClipboardEventTextData> => {
|
): Promise<ParsedClipboardEventTextData> => {
|
||||||
try {
|
try {
|
||||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
const htmlItem = dataList.findByType(MIME_TYPES.html);
|
||||||
|
|
||||||
|
const mixedContent =
|
||||||
|
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
|
||||||
|
|
||||||
if (mixedContent) {
|
if (mixedContent) {
|
||||||
if (mixedContent.value.every((item) => item.type === "text")) {
|
if (mixedContent.value.every((item) => item.type === "text")) {
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
value:
|
||||||
event.clipboardData?.getData(MIME_TYPES.text) ||
|
dataList.getData(MIME_TYPES.text) ??
|
||||||
mixedContent.value
|
mixedContent.value
|
||||||
.map((item) => item.value)
|
.map((item) => item.value)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@@ -354,23 +366,156 @@ const parseClipboardEventTextData = async (
|
|||||||
return mixedContent;
|
return mixedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
return {
|
||||||
|
type: "text",
|
||||||
return { type: "text", value: (text || "").trim() };
|
value: (dataList.getData(MIME_TYPES.text) || "").trim(),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { type: "text", value: "" };
|
return { type: "text", value: "" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AllowedParsedDataTransferItem =
|
||||||
|
| {
|
||||||
|
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||||
|
kind: "file";
|
||||||
|
file: File;
|
||||||
|
fileHandle: FileSystemHandle | null;
|
||||||
|
}
|
||||||
|
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
||||||
|
|
||||||
|
type ParsedDataTransferItem =
|
||||||
|
| {
|
||||||
|
type: string;
|
||||||
|
kind: "file";
|
||||||
|
file: File;
|
||||||
|
fileHandle: FileSystemHandle | null;
|
||||||
|
}
|
||||||
|
| { type: string; kind: "string"; value: string };
|
||||||
|
|
||||||
|
type ParsedDataTransferItemType<
|
||||||
|
T extends AllowedParsedDataTransferItem["type"],
|
||||||
|
> = AllowedParsedDataTransferItem & { type: T };
|
||||||
|
|
||||||
|
export type ParsedDataTransferFile = Extract<
|
||||||
|
AllowedParsedDataTransferItem,
|
||||||
|
{ kind: "file" }
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ParsedDataTranferList = ParsedDataTransferItem[] & {
|
||||||
|
/**
|
||||||
|
* Only allows filtering by known `string` data types, since `file`
|
||||||
|
* types can have multiple items of the same type (e.g. multiple image files)
|
||||||
|
* unlike `string` data transfer items.
|
||||||
|
*/
|
||||||
|
findByType: typeof findDataTransferItemType;
|
||||||
|
/**
|
||||||
|
* Only allows filtering by known `string` data types, since `file`
|
||||||
|
* types can have multiple items of the same type (e.g. multiple image files)
|
||||||
|
* unlike `string` data transfer items.
|
||||||
|
*/
|
||||||
|
getData: typeof getDataTransferItemData;
|
||||||
|
getFiles: typeof getDataTransferFiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDataTransferItemType = function <
|
||||||
|
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||||
|
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
|
||||||
|
return (
|
||||||
|
this.find(
|
||||||
|
(item): item is ParsedDataTransferItemType<T> => item.type === type,
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const getDataTransferItemData = function <
|
||||||
|
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||||
|
>(
|
||||||
|
this: ParsedDataTranferList,
|
||||||
|
type: T,
|
||||||
|
):
|
||||||
|
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
|
||||||
|
| null {
|
||||||
|
const item = this.find(
|
||||||
|
(
|
||||||
|
item,
|
||||||
|
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
|
||||||
|
item.type === type,
|
||||||
|
);
|
||||||
|
|
||||||
|
return item?.value ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDataTransferFiles = function (
|
||||||
|
this: ParsedDataTranferList,
|
||||||
|
): ParsedDataTransferFile[] {
|
||||||
|
return this.filter(
|
||||||
|
(item): item is ParsedDataTransferFile => item.kind === "file",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseDataTransferEvent = async (
|
||||||
|
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
|
||||||
|
): Promise<ParsedDataTranferList> => {
|
||||||
|
let items: DataTransferItemList | undefined = undefined;
|
||||||
|
|
||||||
|
if (isClipboardEvent(event)) {
|
||||||
|
items = event.clipboardData?.items;
|
||||||
|
} else {
|
||||||
|
const dragEvent = event;
|
||||||
|
items = dragEvent.dataTransfer?.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataItems = (
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(items || []).map(
|
||||||
|
async (item): Promise<ParsedDataTransferItem | null> => {
|
||||||
|
if (item.kind === "file") {
|
||||||
|
let file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
const fileHandle = await getFileHandle(item);
|
||||||
|
file = await normalizeFile(file);
|
||||||
|
return {
|
||||||
|
type: file.type,
|
||||||
|
kind: "file",
|
||||||
|
file,
|
||||||
|
fileHandle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (item.kind === "string") {
|
||||||
|
const { type } = item;
|
||||||
|
let value: string;
|
||||||
|
if ("clipboardData" in event && event.clipboardData) {
|
||||||
|
value = event.clipboardData?.getData(type);
|
||||||
|
} else {
|
||||||
|
value = await new Promise<string>((resolve) => {
|
||||||
|
item.getAsString((str) => resolve(str));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type, kind: "string", value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).filter((data): data is ParsedDataTransferItem => data != null);
|
||||||
|
|
||||||
|
return Object.assign(dataItems, {
|
||||||
|
findByType: findDataTransferItemType,
|
||||||
|
getData: getDataTransferItemData,
|
||||||
|
getFiles: getDataTransferFiles,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to parse clipboard event.
|
* Attempts to parse clipboard event.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent,
|
dataList: ParsedDataTranferList,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const parsedEventData = await parseClipboardEventTextData(
|
const parsedEventData = await parseClipboardEventTextData(
|
||||||
event,
|
dataList,
|
||||||
isPlainPaste,
|
isPlainPaste,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -519,3 +664,14 @@ const copyTextViaExecCommand = (text: string | null) => {
|
|||||||
|
|
||||||
return success;
|
return success;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isClipboardEvent = (
|
||||||
|
event: React.SyntheticEvent | Event,
|
||||||
|
): event is ClipboardEvent => {
|
||||||
|
/** not using instanceof ClipboardEvent due to tests (jsdom) */
|
||||||
|
return (
|
||||||
|
event.type === EVENT.PASTE ||
|
||||||
|
event.type === EVENT.COPY ||
|
||||||
|
event.type === EVENT.CUT
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -91,3 +91,118 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-shape-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
|
||||||
|
.compact-action-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
--default-button-size: 2rem;
|
||||||
|
|
||||||
|
.compact-action-button {
|
||||||
|
width: var(--mobile-action-button-size);
|
||||||
|
height: var(--mobile-action-button-size);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
background: var(--mobile-action-button-bg);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(
|
||||||
|
--color-surface-primary-container,
|
||||||
|
var(--mobile-action-button-bg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-popover-content {
|
||||||
|
.popover-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon {
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: var(--mobile-action-button-size);
|
||||||
|
height: var(--mobile-action-button-size);
|
||||||
|
|
||||||
|
background: var(--mobile-action-button-bg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-shape-actions-island {
|
||||||
|
width: fit-content;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-shape-actions {
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-actions-theme-scope {
|
||||||
|
--button-border: transparent;
|
||||||
|
--button-bg: var(--color-surface-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.theme--dark .shape-actions-theme-scope {
|
||||||
|
--button-hover-bg: #363541;
|
||||||
|
--button-bg: var(--color-surface-high);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user