Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06dae6edf2 | ||
|
|
ecbaeb1701 | ||
|
|
40c5c743b1 | ||
|
|
ae89608985 | ||
|
|
3085f4af81 | ||
|
|
531f3e5524 | ||
|
|
90ec2739ae | ||
|
|
f29e9df72d | ||
|
|
b5ad7ae4e3 | ||
|
|
c78e4aab7f | ||
|
|
b4903a7eab | ||
|
|
c6f8ef9ad2 | ||
|
|
2535d73054 | ||
|
|
dda3affcb0 | ||
|
|
54c148f390 | ||
|
|
cc8e490c75 | ||
|
|
9036812b6d | ||
|
|
df25de7e68 | ||
|
|
a3763648fe | ||
|
|
178eca5828 | ||
|
|
cb33de25f4 | ||
|
|
37ad85cbaf | ||
|
|
d6a934ed19 | ||
|
|
416da62138 | ||
|
|
f38f381989 | ||
|
|
e5e07260c6 | ||
|
|
8492b144b0 | ||
|
|
e46f038132 | ||
|
|
678dff25ed | ||
|
|
0cfa53b764 | ||
|
|
cde46793f8 | ||
|
|
2d127f8c22 | ||
|
|
4eadb891f8 | ||
|
|
258605d1d5 | ||
|
|
c141500400 | ||
|
|
8e27de2cdc | ||
|
|
0a19c93509 | ||
|
|
958597dfaa | ||
|
|
058918f8e5 | ||
|
|
3f194918e6 | ||
|
|
93c92d13e9 | ||
|
|
84e96e9393 | ||
|
|
320af405e9 | ||
|
|
60512f13d5 | ||
|
|
f0458cc216 | ||
|
|
9f3fdf5505 | ||
|
|
f42e1ab64e | ||
|
|
18808481fd | ||
|
|
5082142b36 | ||
|
|
74cb027fd7 | ||
|
|
bc09ac757f | ||
|
|
66e347f7d2 | ||
|
|
d5974e66b2 | ||
|
|
2a1b22a504 | ||
|
|
b3d241ba7f | ||
|
|
8ff1ac8097 | ||
|
|
d967123383 | ||
|
|
05cd1a79cc | ||
|
|
bd08bdf4c7 | ||
|
|
011b268dde | ||
|
|
b6a7f05761 | ||
|
|
8787c7d8cf | ||
|
|
6d21d7cab1 | ||
|
|
c9df3e143b | ||
|
|
5b11660cc0 | ||
|
|
bf0b2965e6 | ||
|
|
8f8b6e7144 | ||
|
|
b63d17045e | ||
|
|
70d48d5472 | ||
|
|
097000a2b7 | ||
|
|
461661afc6 | ||
|
|
c88f3c84eb | ||
|
|
7d791b86f8 | ||
|
|
e615056302 | ||
|
|
14ad745d00 | ||
|
|
9c3ff73a73 | ||
|
|
79cf71cccb | ||
|
|
e094b8b539 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -615,6 +615,52 @@ export default function ExampleApp({
|
|||||||
const renderMenu = () => {
|
const renderMenu = () => {
|
||||||
return (
|
return (
|
||||||
<MainMenu>
|
<MainMenu>
|
||||||
|
<MainMenu.Sub>
|
||||||
|
<MainMenu.Sub.Trigger
|
||||||
|
title="Custom trigger"
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.042 21.672L13.684 16.6m0 0l-2.51 2.225.569-9.47 5.227 7.917-3.286-.672zm-7.518-.267A8.25 8.25 0 1120.25 10.5M8.288 14.212A5.25 5.25 0 1117.25 10.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Submenu trigger
|
||||||
|
</MainMenu.Sub.Trigger>
|
||||||
|
<MainMenu.Sub.Content>
|
||||||
|
<MainMenu.Sub.Item
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
onSelect={() => window.alert("You clicked on sub item")}
|
||||||
|
>
|
||||||
|
Sub item
|
||||||
|
</MainMenu.Sub.Item>
|
||||||
|
</MainMenu.Sub.Content>
|
||||||
|
</MainMenu.Sub>
|
||||||
<MainMenu.DefaultItems.SaveAsImage />
|
<MainMenu.DefaultItems.SaveAsImage />
|
||||||
<MainMenu.DefaultItems.Export />
|
<MainMenu.DefaultItems.Export />
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
@@ -622,10 +668,57 @@ export default function ExampleApp({
|
|||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
onSelect={() => window.alert("You clicked on collab button")}
|
onSelect={() => window.alert("You clicked on collab button")}
|
||||||
/>
|
/>
|
||||||
|
<MainMenu.Sub>
|
||||||
|
<MainMenu.Sub.Trigger>Trigger</MainMenu.Sub.Trigger>
|
||||||
|
<MainMenu.Sub.Content>
|
||||||
|
<MainMenu.Sub.Item
|
||||||
|
onSelect={() => window.alert("You clicked on sub item")}
|
||||||
|
>
|
||||||
|
Sub item
|
||||||
|
</MainMenu.Sub.Item>
|
||||||
|
</MainMenu.Sub.Content>
|
||||||
|
</MainMenu.Sub>
|
||||||
<MainMenu.Group title="Excalidraw links">
|
<MainMenu.Group title="Excalidraw links">
|
||||||
<MainMenu.DefaultItems.Socials />
|
<MainMenu.DefaultItems.Socials />
|
||||||
</MainMenu.Group>
|
</MainMenu.Group>
|
||||||
<MainMenu.Separator />
|
{/* <MainMenu.Separator /> */}
|
||||||
|
<MainMenu.Sub>
|
||||||
|
<MainMenu.Sub.Trigger className="custom-classname">
|
||||||
|
Another submenu trigger
|
||||||
|
</MainMenu.Sub.Trigger>
|
||||||
|
<MainMenu.Sub.Content className="custom-classname-for-content">
|
||||||
|
<MainMenu.Sub.Item
|
||||||
|
title="Sub item"
|
||||||
|
onSelect={() => window.alert("You clicked on sub item")}
|
||||||
|
>
|
||||||
|
Sub item
|
||||||
|
</MainMenu.Sub.Item>
|
||||||
|
</MainMenu.Sub.Content>
|
||||||
|
</MainMenu.Sub>
|
||||||
|
<MainMenu.Sub>
|
||||||
|
<MainMenu.Sub.Trigger>Trigger me</MainMenu.Sub.Trigger>
|
||||||
|
<MainMenu.Sub.Content>
|
||||||
|
<MainMenu.Sub>
|
||||||
|
<MainMenu.Sub.Trigger>Trigger me inside</MainMenu.Sub.Trigger>
|
||||||
|
<MainMenu.Sub.Content>
|
||||||
|
<MainMenu.Sub.Item
|
||||||
|
onSelect={() => {
|
||||||
|
alert("wow, nested submenus!");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Item wow
|
||||||
|
</MainMenu.Sub.Item>
|
||||||
|
</MainMenu.Sub.Content>
|
||||||
|
</MainMenu.Sub>
|
||||||
|
<MainMenu.Sub.Item
|
||||||
|
onSelect={() => {
|
||||||
|
alert("wow, nested submenus! very cool");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Another one
|
||||||
|
</MainMenu.Sub.Item>
|
||||||
|
</MainMenu.Sub.Content>
|
||||||
|
</MainMenu.Sub>
|
||||||
<MainMenu.ItemCustom>
|
<MainMenu.ItemCustom>
|
||||||
<button
|
<button
|
||||||
style={{ height: "2rem" }}
|
style={{ height: "2rem" }}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
APP_NAME,
|
APP_NAME,
|
||||||
EVENT,
|
EVENT,
|
||||||
THEME,
|
THEME,
|
||||||
TITLE_TIMEOUT,
|
|
||||||
VERSION_TIMEOUT,
|
VERSION_TIMEOUT,
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
@@ -499,11 +498,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 +588,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
visibilityChange,
|
visibilityChange,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
clearTimeout(titleTimeout);
|
|
||||||
};
|
};
|
||||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const AppMainMenu: React.FC<{
|
|||||||
<MainMenu.DefaultItems.SearchMenu />
|
<MainMenu.DefaultItems.SearchMenu />
|
||||||
<MainMenu.DefaultItems.Help />
|
<MainMenu.DefaultItems.Help />
|
||||||
<MainMenu.DefaultItems.ClearCanvas />
|
<MainMenu.DefaultItems.ClearCanvas />
|
||||||
|
<MainMenu.DefaultItems.Preferences />
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.ItemLink
|
<MainMenu.ItemLink
|
||||||
icon={ExcalLogo}
|
icon={ExcalLogo}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+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": [
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -514,3 +522,5 @@ 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;
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
import { average } from "@excalidraw/math";
|
||||||
import { getCenterForBounds, getElementBounds } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
FontString,
|
FontString,
|
||||||
ExcalidrawElement,
|
|
||||||
ElementsMap,
|
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -1239,17 +1236,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,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
xOffset: number = 0,
|
|
||||||
yOffset: number = 0,
|
|
||||||
) => {
|
|
||||||
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
|
||||||
|
|
||||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
invariant,
|
invariant,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
elementCenterPoint,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -34,9 +33,9 @@ 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 { distanceToElement } from "./distance";
|
import { distanceToElement } from "./distance";
|
||||||
@@ -61,7 +60,7 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { aabbForElement } 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";
|
||||||
@@ -385,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,
|
||||||
@@ -514,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>,
|
||||||
|
|||||||
@@ -33,8 +33,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 {
|
||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { getElementShape } from "./shapes";
|
import { getElementShape } from "./shape";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
@@ -584,7 +584,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,
|
||||||
@@ -1165,6 +1165,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,
|
||||||
@@ -1178,3 +1243,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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
import { isTransparent } from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
curveIntersectLineSegment,
|
curveIntersectLineSegment,
|
||||||
isPointWithinBounds,
|
isPointWithinBounds,
|
||||||
@@ -18,15 +18,22 @@ import {
|
|||||||
ellipseSegmentInterceptPoints,
|
ellipseSegmentInterceptPoints,
|
||||||
} from "@excalidraw/math/ellipse";
|
} from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math";
|
import type {
|
||||||
|
Curve,
|
||||||
|
GlobalPoint,
|
||||||
|
LineSegment,
|
||||||
|
Radians,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { isPathALoop } from "./shapes";
|
import { isPathALoop } from "./utils";
|
||||||
import {
|
import {
|
||||||
type Bounds,
|
type Bounds,
|
||||||
doBoundsIntersect,
|
doBoundsIntersect,
|
||||||
|
elementCenterPoint,
|
||||||
getCenterForBounds,
|
getCenterForBounds,
|
||||||
|
getCubicBezierCurveBound,
|
||||||
getElementBounds,
|
getElementBounds,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import {
|
import {
|
||||||
@@ -250,26 +257,79 @@ export const intersectElementWithLineSegment = (
|
|||||||
case "line":
|
case "line":
|
||||||
case "freedraw":
|
case "freedraw":
|
||||||
case "arrow":
|
case "arrow":
|
||||||
return intersectLinearOrFreeDrawWithLineSegment(
|
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
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 = (
|
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
elementsMap: ElementsMap,
|
|
||||||
segment: LineSegment<GlobalPoint>,
|
segment: LineSegment<GlobalPoint>,
|
||||||
onlyFirst = false,
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
// NOTE: This is the only one which return the decomposed elements
|
||||||
element,
|
// rotated! This is due to taking advantage of roughjs definitions.
|
||||||
elementsMap,
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
);
|
const intersections: GlobalPoint[] = [];
|
||||||
const intersections = [];
|
|
||||||
|
|
||||||
for (const l of lines) {
|
for (const l of lines) {
|
||||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||||
@@ -283,6 +343,19 @@ const intersectLinearOrFreeDrawWithLineSegment = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const c of curves) {
|
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);
|
const hits = curveIntersectLineSegment(c, segment);
|
||||||
|
|
||||||
if (hits.length > 0) {
|
if (hits.length > 0) {
|
||||||
@@ -300,7 +373,7 @@ const intersectLinearOrFreeDrawWithLineSegment = (
|
|||||||
const intersectRectanguloidWithLineSegment = (
|
const intersectRectanguloidWithLineSegment = (
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
l: LineSegment<GlobalPoint>,
|
segment: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
onlyFirst = false,
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
@@ -308,48 +381,43 @@ const intersectRectanguloidWithLineSegment = (
|
|||||||
// 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);
|
||||||
|
|
||||||
const intersections: GlobalPoint[] = [];
|
const intersections: GlobalPoint[] = [];
|
||||||
|
|
||||||
for (const s of sides) {
|
lineIntersections(
|
||||||
const intersection = lineSegmentIntersectionPoints(
|
sides,
|
||||||
lineSegment(rotatedA, rotatedB),
|
rotatedIntersector,
|
||||||
s,
|
intersections,
|
||||||
);
|
center,
|
||||||
if (intersection) {
|
element.angle,
|
||||||
intersections.push(pointRotateRads(intersection, center, element.angle));
|
onlyFirst,
|
||||||
|
);
|
||||||
|
|
||||||
if (onlyFirst) {
|
if (onlyFirst && intersections.length > 0) {
|
||||||
return intersections;
|
return intersections;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const t of corners) {
|
curveIntersections(
|
||||||
const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
|
corners,
|
||||||
|
rotatedIntersector,
|
||||||
if (hits.length > 0) {
|
intersections,
|
||||||
for (const j of hits) {
|
center,
|
||||||
intersections.push(pointRotateRads(j, center, element.angle));
|
element.angle,
|
||||||
}
|
onlyFirst,
|
||||||
|
);
|
||||||
if (onlyFirst) {
|
|
||||||
return intersections;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return intersections;
|
return intersections;
|
||||||
};
|
};
|
||||||
@@ -374,38 +442,32 @@ const intersectDiamondWithLineSegment = (
|
|||||||
// 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, corners] = deconstructDiamondElement(element, offset);
|
const [sides, corners] = deconstructDiamondElement(element, offset);
|
||||||
|
|
||||||
const intersections: GlobalPoint[] = [];
|
const intersections: GlobalPoint[] = [];
|
||||||
|
|
||||||
for (const s of sides) {
|
lineIntersections(
|
||||||
const intersection = lineSegmentIntersectionPoints(
|
sides,
|
||||||
lineSegment(rotatedA, rotatedB),
|
rotatedIntersector,
|
||||||
s,
|
intersections,
|
||||||
);
|
center,
|
||||||
if (intersection) {
|
element.angle,
|
||||||
intersections.push(pointRotateRads(intersection, center, element.angle));
|
onlyFirst,
|
||||||
|
);
|
||||||
|
|
||||||
if (onlyFirst) {
|
if (onlyFirst && intersections.length > 0) {
|
||||||
return intersections;
|
return intersections;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const t of corners) {
|
curveIntersections(
|
||||||
const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
|
corners,
|
||||||
|
rotatedIntersector,
|
||||||
if (hits.length > 0) {
|
intersections,
|
||||||
for (const j of hits) {
|
center,
|
||||||
intersections.push(pointRotateRads(j, center, element.angle));
|
element.angle,
|
||||||
}
|
onlyFirst,
|
||||||
|
);
|
||||||
if (onlyFirst) {
|
|
||||||
return intersections;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return intersections;
|
return intersections;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
+360
-135
@@ -55,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
|
|||||||
|
|
||||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||||
|
|
||||||
import { Scene } from "./Scene";
|
|
||||||
|
|
||||||
import { StoreSnapshot } from "./store";
|
import { StoreSnapshot } from "./store";
|
||||||
|
|
||||||
|
import { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { BindableProp, BindingProp } from "./binding";
|
import type { BindableProp, BindingProp } from "./binding";
|
||||||
|
|
||||||
import type { ElementUpdate } from "./mutateElement";
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
@@ -150,13 +150,27 @@ export class Delta<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges two deltas into a new one.
|
||||||
|
*/
|
||||||
|
public static merge<T>(
|
||||||
|
delta1: Delta<T>,
|
||||||
|
delta2: Delta<T>,
|
||||||
|
delta3: Delta<T> = Delta.empty(),
|
||||||
|
) {
|
||||||
|
return Delta.create(
|
||||||
|
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
|
||||||
|
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges deleted and inserted object partials.
|
* Merges deleted and inserted object partials.
|
||||||
*/
|
*/
|
||||||
public static mergeObjects<T extends { [key: string]: unknown }>(
|
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||||
prev: T,
|
prev: T,
|
||||||
added: T,
|
added: T,
|
||||||
removed: T,
|
removed: T = {} as T,
|
||||||
) {
|
) {
|
||||||
const cloned = { ...prev };
|
const cloned = { ...prev };
|
||||||
|
|
||||||
@@ -496,6 +510,11 @@ export interface DeltaContainer<T> {
|
|||||||
*/
|
*/
|
||||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squashes the current delta with the given one.
|
||||||
|
*/
|
||||||
|
squash(delta: DeltaContainer<T>): this;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether all `Delta`s are empty.
|
* Checks whether all `Delta`s are empty.
|
||||||
*/
|
*/
|
||||||
@@ -503,7 +522,11 @@ export interface DeltaContainer<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||||
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
private constructor(public delta: Delta<ObservedAppState>) {}
|
||||||
|
|
||||||
|
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
|
||||||
|
return new AppStateDelta(delta);
|
||||||
|
}
|
||||||
|
|
||||||
public static calculate<T extends ObservedAppState>(
|
public static calculate<T extends ObservedAppState>(
|
||||||
prevAppState: T,
|
prevAppState: T,
|
||||||
@@ -534,53 +557,124 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
return new AppStateDelta(inversedDelta);
|
return new AppStateDelta(inversedDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public squash(delta: AppStateDelta): this {
|
||||||
|
if (delta.isEmpty()) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
|
||||||
|
this.delta.deleted.selectedElementIds ?? {},
|
||||||
|
delta.delta.deleted.selectedElementIds ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
|
||||||
|
this.delta.inserted.selectedElementIds ?? {},
|
||||||
|
delta.delta.inserted.selectedElementIds ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
|
||||||
|
this.delta.deleted.selectedGroupIds ?? {},
|
||||||
|
delta.delta.deleted.selectedGroupIds ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
|
||||||
|
this.delta.inserted.selectedGroupIds ?? {},
|
||||||
|
delta.delta.inserted.selectedGroupIds ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
|
||||||
|
this.delta.deleted.lockedMultiSelections ?? {},
|
||||||
|
delta.delta.deleted.lockedMultiSelections ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
|
||||||
|
this.delta.inserted.lockedMultiSelections ?? {},
|
||||||
|
delta.delta.inserted.lockedMultiSelections ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedInserted: Partial<ObservedAppState> = {};
|
||||||
|
const mergedDeleted: Partial<ObservedAppState> = {};
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(mergedDeletedSelectedElementIds).length ||
|
||||||
|
Object.keys(mergedInsertedSelectedElementIds).length
|
||||||
|
) {
|
||||||
|
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
|
||||||
|
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(mergedDeletedSelectedGroupIds).length ||
|
||||||
|
Object.keys(mergedInsertedSelectedGroupIds).length
|
||||||
|
) {
|
||||||
|
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
|
||||||
|
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(mergedDeletedLockedMultiSelections).length ||
|
||||||
|
Object.keys(mergedInsertedLockedMultiSelections).length
|
||||||
|
) {
|
||||||
|
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
|
||||||
|
mergedInserted.lockedMultiSelections =
|
||||||
|
mergedInsertedLockedMultiSelections;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.delta = Delta.merge(
|
||||||
|
this.delta,
|
||||||
|
delta.delta,
|
||||||
|
Delta.create(mergedDeleted, mergedInserted),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
): [AppState, boolean] {
|
): [AppState, boolean] {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
selectedElementIds: removedSelectedElementIds = {},
|
selectedElementIds: deletedSelectedElementIds = {},
|
||||||
selectedGroupIds: removedSelectedGroupIds = {},
|
selectedGroupIds: deletedSelectedGroupIds = {},
|
||||||
|
lockedMultiSelections: deletedLockedMultiSelections = {},
|
||||||
} = this.delta.deleted;
|
} = this.delta.deleted;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedElementIds: addedSelectedElementIds = {},
|
selectedElementIds: insertedSelectedElementIds = {},
|
||||||
selectedGroupIds: addedSelectedGroupIds = {},
|
selectedGroupIds: insertedSelectedGroupIds = {},
|
||||||
selectedLinearElementId,
|
lockedMultiSelections: insertedLockedMultiSelections = {},
|
||||||
editingLinearElementId,
|
selectedLinearElement: insertedSelectedLinearElement,
|
||||||
...directlyApplicablePartial
|
...directlyApplicablePartial
|
||||||
} = this.delta.inserted;
|
} = this.delta.inserted;
|
||||||
|
|
||||||
const mergedSelectedElementIds = Delta.mergeObjects(
|
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||||
appState.selectedElementIds,
|
appState.selectedElementIds,
|
||||||
addedSelectedElementIds,
|
insertedSelectedElementIds,
|
||||||
removedSelectedElementIds,
|
deletedSelectedElementIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mergedSelectedGroupIds = Delta.mergeObjects(
|
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||||
appState.selectedGroupIds,
|
appState.selectedGroupIds,
|
||||||
addedSelectedGroupIds,
|
insertedSelectedGroupIds,
|
||||||
removedSelectedGroupIds,
|
deletedSelectedGroupIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedLockedMultiSelections = Delta.mergeObjects(
|
||||||
|
appState.lockedMultiSelections,
|
||||||
|
insertedLockedMultiSelections,
|
||||||
|
deletedLockedMultiSelections,
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedLinearElement =
|
const selectedLinearElement =
|
||||||
selectedLinearElementId && nextElements.has(selectedLinearElementId)
|
insertedSelectedLinearElement &&
|
||||||
|
nextElements.has(insertedSelectedLinearElement.elementId)
|
||||||
? new LinearElementEditor(
|
? new LinearElementEditor(
|
||||||
nextElements.get(
|
nextElements.get(
|
||||||
selectedLinearElementId,
|
insertedSelectedLinearElement.elementId,
|
||||||
) as NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
nextElements,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const editingLinearElement =
|
|
||||||
editingLinearElementId && nextElements.has(editingLinearElementId)
|
|
||||||
? new LinearElementEditor(
|
|
||||||
nextElements.get(
|
|
||||||
editingLinearElementId,
|
|
||||||
) as NonDeleted<ExcalidrawLinearElement>,
|
) as NonDeleted<ExcalidrawLinearElement>,
|
||||||
nextElements,
|
nextElements,
|
||||||
|
insertedSelectedLinearElement.isEditing,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -589,14 +683,11 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
...directlyApplicablePartial,
|
...directlyApplicablePartial,
|
||||||
selectedElementIds: mergedSelectedElementIds,
|
selectedElementIds: mergedSelectedElementIds,
|
||||||
selectedGroupIds: mergedSelectedGroupIds,
|
selectedGroupIds: mergedSelectedGroupIds,
|
||||||
|
lockedMultiSelections: mergedLockedMultiSelections,
|
||||||
selectedLinearElement:
|
selectedLinearElement:
|
||||||
typeof selectedLinearElementId !== "undefined"
|
typeof insertedSelectedLinearElement !== "undefined"
|
||||||
? selectedLinearElement // element was either inserted or deleted
|
? selectedLinearElement
|
||||||
: appState.selectedLinearElement, // otherwise assign what we had before
|
: appState.selectedLinearElement,
|
||||||
editingLinearElement:
|
|
||||||
typeof editingLinearElementId !== "undefined"
|
|
||||||
? editingLinearElement // element was either inserted or deleted
|
|
||||||
: appState.editingLinearElement, // otherwise assign what we had before
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const constainsVisibleChanges = this.filterInvisibleChanges(
|
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||||
@@ -725,52 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "selectedLinearElementId":
|
case "selectedLinearElement":
|
||||||
case "editingLinearElementId":
|
const nextLinearElement = nextAppState[key];
|
||||||
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
|
||||||
const linearElement = nextAppState[appStateKey];
|
|
||||||
|
|
||||||
if (!linearElement) {
|
if (!nextLinearElement) {
|
||||||
// previously there was a linear element (assuming visible), now there is none
|
// previously there was a linear element (assuming visible), now there is none
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
} else {
|
} else {
|
||||||
const element = nextElements.get(linearElement.elementId);
|
const element = nextElements.get(nextLinearElement.elementId);
|
||||||
|
|
||||||
if (element && !element.isDeleted) {
|
if (element && !element.isDeleted) {
|
||||||
// previously there wasn't a linear element, now there is one which is visible
|
// previously there wasn't a linear element, now there is one which is visible
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
} else {
|
} else {
|
||||||
// there was assigned a linear element now, but it's deleted
|
// there was assigned a linear element now, but it's deleted
|
||||||
nextAppState[appStateKey] = null;
|
nextAppState[key] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "lockedMultiSelections": {
|
case "lockedMultiSelections":
|
||||||
const prevLockedUnits = prevAppState[key] || {};
|
const prevLockedUnits = prevAppState[key] || {};
|
||||||
const nextLockedUnits = nextAppState[key] || {};
|
const nextLockedUnits = nextAppState[key] || {};
|
||||||
|
|
||||||
|
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||||
|
// hence instead we should check whether elements are actually visible,
|
||||||
|
// so that once these changes are applied they actually result in a visible change to the user
|
||||||
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
case "activeLockedId":
|
||||||
case "activeLockedId": {
|
|
||||||
const prevHitLockedId = prevAppState[key] || null;
|
const prevHitLockedId = prevAppState[key] || null;
|
||||||
const nextHitLockedId = nextAppState[key] || null;
|
const nextHitLockedId = nextAppState[key] || null;
|
||||||
|
|
||||||
|
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||||
|
// hence instead we should check whether elements are actually visible,
|
||||||
|
// so that once these changes are applied they actually result in a visible change to the user
|
||||||
if (prevHitLockedId !== nextHitLockedId) {
|
if (prevHitLockedId !== nextHitLockedId) {
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
default:
|
||||||
default: {
|
|
||||||
assertNever(
|
assertNever(
|
||||||
key,
|
key,
|
||||||
`Unknown ObservedElementsAppState's key "${key}"`,
|
`Unknown ObservedElementsAppState's key "${key}"`,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -778,20 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
return visibleDifferenceFlag.value;
|
return visibleDifferenceFlag.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static convertToAppStateKey(
|
|
||||||
key: keyof Pick<
|
|
||||||
ObservedElementsAppState,
|
|
||||||
"selectedLinearElementId" | "editingLinearElementId"
|
|
||||||
>,
|
|
||||||
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
|
|
||||||
switch (key) {
|
|
||||||
case "selectedLinearElementId":
|
|
||||||
return "selectedLinearElement";
|
|
||||||
case "editingLinearElementId":
|
|
||||||
return "editingLinearElement";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static filterSelectedElements(
|
private static filterSelectedElements(
|
||||||
selectedElementIds: AppState["selectedElementIds"],
|
selectedElementIds: AppState["selectedElementIds"],
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
@@ -856,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
editingGroupId,
|
editingGroupId,
|
||||||
selectedGroupIds,
|
selectedGroupIds,
|
||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
editingLinearElementId,
|
selectedLinearElement,
|
||||||
selectedLinearElementId,
|
|
||||||
croppingElementId,
|
croppingElementId,
|
||||||
lockedMultiSelections,
|
lockedMultiSelections,
|
||||||
activeLockedId,
|
activeLockedId,
|
||||||
@@ -911,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
"lockedMultiSelections",
|
"lockedMultiSelections",
|
||||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||||
);
|
);
|
||||||
Delta.diffObjects(
|
|
||||||
deleted,
|
|
||||||
inserted,
|
|
||||||
"activeLockedId",
|
|
||||||
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
||||||
console.error(`Couldn't postprocess appstate change deltas.`);
|
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||||
@@ -945,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
|||||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
||||||
|
|
||||||
export type ApplyToOptions = {
|
export type ApplyToOptions = {
|
||||||
excludedProperties: Set<keyof ElementPartial>;
|
excludedProperties?: Set<keyof ElementPartial>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ApplyToFlags = {
|
type ApplyToFlags = {
|
||||||
containsVisibleDifference: boolean;
|
containsVisibleDifference: boolean;
|
||||||
containsZindexDifference: boolean;
|
containsZindexDifference: boolean;
|
||||||
|
applyDirection: "forward" | "backward" | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1039,18 +1111,27 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
inserted,
|
inserted,
|
||||||
}: Delta<ElementPartial>) =>
|
}: Delta<ElementPartial>) =>
|
||||||
!!(
|
!!(
|
||||||
deleted.version &&
|
|
||||||
inserted.version &&
|
|
||||||
// versions are required integers
|
// versions are required integers
|
||||||
Number.isInteger(deleted.version) &&
|
(
|
||||||
Number.isInteger(inserted.version) &&
|
Number.isInteger(deleted.version) &&
|
||||||
// versions should be positive, zero included
|
Number.isInteger(inserted.version) &&
|
||||||
deleted.version >= 0 &&
|
// versions should be positive, zero included
|
||||||
inserted.version >= 0 &&
|
deleted.version! >= 0 &&
|
||||||
// versions should never be the same
|
inserted.version! >= 0 &&
|
||||||
deleted.version !== inserted.version
|
// versions should never be the same
|
||||||
|
deleted.version !== inserted.version
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private static satisfiesUniqueInvariants = (
|
||||||
|
elementsDelta: ElementsDelta,
|
||||||
|
id: string,
|
||||||
|
) => {
|
||||||
|
const { added, removed, updated } = elementsDelta;
|
||||||
|
// it's required that there is only one unique delta type per element
|
||||||
|
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
|
||||||
|
};
|
||||||
|
|
||||||
private static validate(
|
private static validate(
|
||||||
elementsDelta: ElementsDelta,
|
elementsDelta: ElementsDelta,
|
||||||
type: "added" | "removed" | "updated",
|
type: "added" | "removed" | "updated",
|
||||||
@@ -1059,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||||
if (
|
if (
|
||||||
!this.satisfiesCommmonInvariants(delta) ||
|
!this.satisfiesCommmonInvariants(delta) ||
|
||||||
|
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
|
||||||
!satifiesSpecialInvariants(delta)
|
!satifiesSpecialInvariants(delta)
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -1095,7 +1177,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const nextElement = nextElements.get(prevElement.id);
|
const nextElement = nextElements.get(prevElement.id);
|
||||||
|
|
||||||
if (!nextElement) {
|
if (!nextElement) {
|
||||||
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
const deleted = { ...prevElement } as ElementPartial;
|
||||||
|
|
||||||
const inserted = {
|
const inserted = {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
@@ -1109,7 +1191,11 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
ElementsDelta.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
removed[prevElement.id] = delta;
|
if (!prevElement.isDeleted) {
|
||||||
|
removed[prevElement.id] = delta;
|
||||||
|
} else {
|
||||||
|
updated[prevElement.id] = delta;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1125,7 +1211,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
|
|
||||||
const inserted = {
|
const inserted = {
|
||||||
...nextElement,
|
...nextElement,
|
||||||
isDeleted: false,
|
|
||||||
} as ElementPartial;
|
} as ElementPartial;
|
||||||
|
|
||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
@@ -1134,7 +1219,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
ElementsDelta.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
added[nextElement.id] = delta;
|
// ignore updates which would "delete" already deleted element
|
||||||
|
if (!nextElement.isDeleted) {
|
||||||
|
added[nextElement.id] = delta;
|
||||||
|
} else {
|
||||||
|
updated[nextElement.id] = delta;
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1163,10 +1253,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// making sure there are at least some changes
|
updated[nextElement.id] = delta;
|
||||||
if (!Delta.isEmpty(delta)) {
|
|
||||||
updated[nextElement.id] = delta;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1181,8 +1268,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(deltas)) {
|
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
|
||||||
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
return inversedDeltas;
|
return inversedDeltas;
|
||||||
@@ -1301,9 +1388,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
public applyTo(
|
public applyTo(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
||||||
options: ApplyToOptions = {
|
options?: ApplyToOptions,
|
||||||
excludedProperties: new Set(),
|
|
||||||
},
|
|
||||||
): [SceneElementsMap, boolean] {
|
): [SceneElementsMap, boolean] {
|
||||||
let nextElements = new Map(elements) as SceneElementsMap;
|
let nextElements = new Map(elements) as SceneElementsMap;
|
||||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
@@ -1311,22 +1396,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const flags: ApplyToFlags = {
|
const flags: ApplyToFlags = {
|
||||||
containsVisibleDifference: false,
|
containsVisibleDifference: false,
|
||||||
containsZindexDifference: false,
|
containsZindexDifference: false,
|
||||||
|
applyDirection: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||||
try {
|
try {
|
||||||
const applyDeltas = ElementsDelta.createApplier(
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
|
elements,
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
snapshot,
|
||||||
options,
|
|
||||||
flags,
|
flags,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
const addedElements = applyDeltas(this.added);
|
const addedElements = applyDeltas(this.added);
|
||||||
const removedElements = applyDeltas(this.removed);
|
const removedElements = applyDeltas(this.removed);
|
||||||
const updatedElements = applyDeltas(this.updated);
|
const updatedElements = applyDeltas(this.updated);
|
||||||
|
|
||||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
const affectedElements = this.resolveConflicts(
|
||||||
|
elements,
|
||||||
|
nextElements,
|
||||||
|
flags.applyDirection,
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
||||||
changedElements = new Map([
|
changedElements = new Map([
|
||||||
@@ -1350,22 +1441,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
// the following reorder performs mutations, but only on new instances of changed elements,
|
||||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
// unless something goes really bad and it fallbacks to fixing all invalid indices
|
||||||
nextElements = ElementsDelta.reorderElements(
|
nextElements = ElementsDelta.reorderElements(
|
||||||
nextElements,
|
nextElements,
|
||||||
changedElements,
|
changedElements,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
ElementsDelta.redrawElements(nextElements, changedElements);
|
||||||
// we also don't have a scene on the server
|
|
||||||
// so we are creating a temp scene just to query and mutate elements
|
|
||||||
const tempScene = new Scene(nextElements);
|
|
||||||
|
|
||||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
|
||||||
// Need ordered nextElements to avoid z-index binding issues
|
|
||||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`Couldn't mutate elements after applying elements change`,
|
`Couldn't mutate elements after applying elements change`,
|
||||||
@@ -1380,12 +1464,113 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public squash(delta: ElementsDelta): this {
|
||||||
|
if (delta.isEmpty()) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { added, removed, updated } = delta;
|
||||||
|
|
||||||
|
const mergeBoundElements = (
|
||||||
|
prevDelta: Delta<ElementPartial>,
|
||||||
|
nextDelta: Delta<ElementPartial>,
|
||||||
|
) => {
|
||||||
|
const mergedDeletedBoundElements =
|
||||||
|
Delta.mergeArrays(
|
||||||
|
prevDelta.deleted.boundElements ?? [],
|
||||||
|
nextDelta.deleted.boundElements ?? [],
|
||||||
|
undefined,
|
||||||
|
(x) => x.id,
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const mergedInsertedBoundElements =
|
||||||
|
Delta.mergeArrays(
|
||||||
|
prevDelta.inserted.boundElements ?? [],
|
||||||
|
nextDelta.inserted.boundElements ?? [],
|
||||||
|
undefined,
|
||||||
|
(x) => x.id,
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!mergedDeletedBoundElements.length &&
|
||||||
|
!mergedInsertedBoundElements.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Delta.create(
|
||||||
|
{
|
||||||
|
boundElements: mergedDeletedBoundElements,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
boundElements: mergedInsertedBoundElements,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [id, nextDelta] of Object.entries(added)) {
|
||||||
|
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||||
|
|
||||||
|
if (!prevDelta) {
|
||||||
|
this.added[id] = nextDelta;
|
||||||
|
} else {
|
||||||
|
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||||
|
delete this.removed[id];
|
||||||
|
delete this.updated[id];
|
||||||
|
|
||||||
|
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, nextDelta] of Object.entries(removed)) {
|
||||||
|
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||||
|
|
||||||
|
if (!prevDelta) {
|
||||||
|
this.removed[id] = nextDelta;
|
||||||
|
} else {
|
||||||
|
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||||
|
delete this.added[id];
|
||||||
|
delete this.updated[id];
|
||||||
|
|
||||||
|
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, nextDelta] of Object.entries(updated)) {
|
||||||
|
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||||
|
|
||||||
|
if (!prevDelta) {
|
||||||
|
this.updated[id] = nextDelta;
|
||||||
|
} else {
|
||||||
|
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||||
|
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||||
|
|
||||||
|
if (prevDelta === this.added[id]) {
|
||||||
|
this.added[id] = updatedDelta;
|
||||||
|
} else if (prevDelta === this.removed[id]) {
|
||||||
|
this.removed[id] = updatedDelta;
|
||||||
|
} else {
|
||||||
|
this.updated[id] = updatedDelta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTestEnv() || isDevEnv()) {
|
||||||
|
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
|
||||||
|
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
|
||||||
|
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
private static createApplier =
|
private static createApplier =
|
||||||
(
|
(
|
||||||
|
prevElements: SceneElementsMap,
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
snapshot: StoreSnapshot["elements"],
|
snapshot: StoreSnapshot["elements"],
|
||||||
options: ApplyToOptions,
|
|
||||||
flags: ApplyToFlags,
|
flags: ApplyToFlags,
|
||||||
|
options?: ApplyToOptions,
|
||||||
) =>
|
) =>
|
||||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
(deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const getElement = ElementsDelta.createGetter(
|
const getElement = ElementsDelta.createGetter(
|
||||||
@@ -1398,15 +1583,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const element = getElement(id, delta.inserted);
|
const element = getElement(id, delta.inserted);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const newElement = ElementsDelta.applyDelta(
|
const nextElement = ElementsDelta.applyDelta(
|
||||||
element,
|
element,
|
||||||
delta,
|
delta,
|
||||||
options,
|
|
||||||
flags,
|
flags,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
nextElements.set(newElement.id, newElement);
|
nextElements.set(nextElement.id, nextElement);
|
||||||
acc.set(newElement.id, newElement);
|
acc.set(nextElement.id, nextElement);
|
||||||
|
|
||||||
|
if (!flags.applyDirection) {
|
||||||
|
const prevElement = prevElements.get(id);
|
||||||
|
|
||||||
|
if (prevElement) {
|
||||||
|
flags.applyDirection =
|
||||||
|
prevElement.version > nextElement.version
|
||||||
|
? "backward"
|
||||||
|
: "forward";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
@@ -1451,8 +1647,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
private static applyDelta(
|
private static applyDelta(
|
||||||
element: OrderedExcalidrawElement,
|
element: OrderedExcalidrawElement,
|
||||||
delta: Delta<ElementPartial>,
|
delta: Delta<ElementPartial>,
|
||||||
options: ApplyToOptions,
|
|
||||||
flags: ApplyToFlags,
|
flags: ApplyToFlags,
|
||||||
|
options?: ApplyToOptions,
|
||||||
) {
|
) {
|
||||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
||||||
|
|
||||||
@@ -1466,7 +1662,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.excludedProperties.has(key)) {
|
if (options?.excludedProperties?.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1506,7 +1702,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
delta.deleted.index !== delta.inserted.index;
|
delta.deleted.index !== delta.inserted.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newElementWith(element, directlyApplicablePartial);
|
return newElementWith(element, directlyApplicablePartial, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1546,6 +1742,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
private resolveConflicts(
|
private resolveConflicts(
|
||||||
prevElements: SceneElementsMap,
|
prevElements: SceneElementsMap,
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
|
applyDirection: "forward" | "backward" = "forward",
|
||||||
) {
|
) {
|
||||||
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||||
const updater = (
|
const updater = (
|
||||||
@@ -1557,21 +1754,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prevElement = prevElements.get(element.id);
|
||||||
|
const nextVersion =
|
||||||
|
applyDirection === "forward"
|
||||||
|
? nextElement.version + 1
|
||||||
|
: nextElement.version - 1;
|
||||||
|
|
||||||
|
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
|
||||||
|
|
||||||
let affectedElement: OrderedExcalidrawElement;
|
let affectedElement: OrderedExcalidrawElement;
|
||||||
|
|
||||||
if (prevElements.get(element.id) === nextElement) {
|
if (prevElement === nextElement) {
|
||||||
// create the new element instance in case we didn't modify the element yet
|
// create the new element instance in case we didn't modify the element yet
|
||||||
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
||||||
affectedElement = newElementWith(
|
affectedElement = newElementWith(
|
||||||
nextElement,
|
nextElement,
|
||||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
{
|
||||||
|
...elementUpdates,
|
||||||
|
version: nextVersion,
|
||||||
|
},
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
affectedElement = mutateElement(
|
affectedElement = mutateElement(nextElement, nextElements, {
|
||||||
nextElement,
|
...elementUpdates,
|
||||||
nextElements,
|
// don't modify the version further, if it's already different
|
||||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
version:
|
||||||
);
|
prevElement?.version !== nextElement.version
|
||||||
|
? nextElement.version
|
||||||
|
: nextVersion,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||||
@@ -1609,25 +1821,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
// calculate complete deltas for affected elements, and squash them back to the current deltas
|
||||||
// technically we could do better here if perf. would become an issue
|
this.squash(
|
||||||
const { added, removed, updated } = ElementsDelta.calculate(
|
// technically we could do better here if perf. would become an issue
|
||||||
prevAffectedElements,
|
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
|
||||||
nextAffectedElements,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(added)) {
|
|
||||||
this.added[id] = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(removed)) {
|
|
||||||
this.removed[id] = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(updated)) {
|
|
||||||
this.updated[id] = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextAffectedElements;
|
return nextAffectedElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1689,6 +1888,31 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static redrawElements(
|
||||||
|
nextElements: SceneElementsMap,
|
||||||
|
changedElements: Map<string, OrderedExcalidrawElement>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||||
|
// we also don't have a scene on the server
|
||||||
|
// so we are creating a temp scene just to query and mutate elements
|
||||||
|
const tempScene = new Scene(nextElements, { skipValidation: true });
|
||||||
|
|
||||||
|
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||||
|
|
||||||
|
// needs ordered nextElements to avoid z-index binding issues
|
||||||
|
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Couldn't redraw elements`, e);
|
||||||
|
|
||||||
|
if (isTestEnv() || isDevEnv()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
return nextElements;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static redrawTextBoundingBoxes(
|
private static redrawTextBoundingBoxes(
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
changed: Map<string, OrderedExcalidrawElement>,
|
changed: Map<string, OrderedExcalidrawElement>,
|
||||||
@@ -1743,6 +1967,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
) {
|
) {
|
||||||
for (const element of changed.values()) {
|
for (const element of changed.values()) {
|
||||||
if (!element.isDeleted && isBindableElement(element)) {
|
if (!element.isDeleted && isBindableElement(element)) {
|
||||||
|
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
|
||||||
updateBoundElements(element, scene, {
|
updateBoundElements(element, scene, {
|
||||||
changedElements: changed,
|
changedElements: changed,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ 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 {
|
||||||
@@ -16,6 +14,8 @@ import {
|
|||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
|
import { elementCenterPoint } from "./bounds";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
@@ -48,7 +48,7 @@ export const distanceToElement = (
|
|||||||
case "line":
|
case "line":
|
||||||
case "arrow":
|
case "arrow":
|
||||||
case "freedraw":
|
case "freedraw":
|
||||||
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
|
return distanceToLinearOrFreeDraElement(element, p);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,13 +133,9 @@ const distanceToEllipseElement = (
|
|||||||
|
|
||||||
const distanceToLinearOrFreeDraElement = (
|
const distanceToLinearOrFreeDraElement = (
|
||||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
elementsMap: ElementsMap,
|
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
) => {
|
) => {
|
||||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
return Math.min(
|
return Math.min(
|
||||||
...lines.map((s) => distanceToLineSegment(p, s)),
|
...lines.map((s) => distanceToLineSegment(p, s)),
|
||||||
...curves.map((a) => curvePointDistance(a, p)),
|
...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";
|
||||||
@@ -51,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";
|
||||||
@@ -358,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
|
||||||
@@ -705,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) =>
|
||||||
@@ -740,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)),
|
||||||
@@ -800,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,
|
||||||
@@ -2070,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) =>
|
||||||
@@ -2208,20 +2222,12 @@ const getGlobalPoint = (
|
|||||||
return initialPoint;
|
return initialPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element && elementsMap) {
|
if (element) {
|
||||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
return getGlobalFixedPointForBindableElement(
|
||||||
fixedPointRatio || [0, 0],
|
fixedPointRatio || [0, 0],
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap ?? arrayToMap([element]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOTE: Resize scales the binding position point too, so we need to update it
|
|
||||||
return Math.abs(
|
|
||||||
distanceToElement(element, elementsMap, fixedGlobalPoint) -
|
|
||||||
FIXED_BINDING_DISTANCE,
|
|
||||||
) > 0.01
|
|
||||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
|
|
||||||
: fixedGlobalPoint;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialPoint;
|
return initialPoint;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
};
|
||||||
|
|||||||
@@ -102,9 +102,7 @@ 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";
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ 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 type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +1,27 @@
|
|||||||
import { simplify } from "points-on-curve";
|
import { simplify } from "points-on-curve";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type GeometricShape,
|
||||||
|
getClosedCurveShape,
|
||||||
|
getCurveShape,
|
||||||
|
getEllipseShape,
|
||||||
|
getFreedrawShape,
|
||||||
|
getPolygonShape,
|
||||||
|
} from "@excalidraw/utils/shape";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointDistance,
|
pointDistance,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
import {
|
||||||
|
ROUGHNESS,
|
||||||
|
isTransparent,
|
||||||
|
assertNever,
|
||||||
|
COLOR_PALETTE,
|
||||||
|
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
import { RoughGenerator } from "roughjs/bin/generator";
|
||||||
|
|
||||||
@@ -14,17 +29,26 @@ 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";
|
||||||
@@ -33,8 +57,9 @@ import {
|
|||||||
getArrowheadPoints,
|
getArrowheadPoints,
|
||||||
getCenterForBounds,
|
getCenterForBounds,
|
||||||
getDiamondPoints,
|
getDiamondPoints,
|
||||||
getElementBounds,
|
getElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
|
import { shouldTestInside } from "./collision";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -44,11 +69,87 @@ import type {
|
|||||||
Arrowhead,
|
Arrowhead,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
|
ExcalidrawLineElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type { Drawable, Options } from "roughjs/bin/core";
|
import type { Drawable, Options } from "roughjs/bin/core";
|
||||||
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];
|
||||||
@@ -320,7 +421,6 @@ const getArrowheadShapes = (
|
|||||||
|
|
||||||
export const generateLinearCollisionShape = (
|
export const generateLinearCollisionShape = (
|
||||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
elementsMap: ElementsMap,
|
|
||||||
) => {
|
) => {
|
||||||
const generator = new RoughGenerator();
|
const generator = new RoughGenerator();
|
||||||
const options: Options = {
|
const options: Options = {
|
||||||
@@ -331,7 +431,18 @@ export const generateLinearCollisionShape = (
|
|||||||
preserveVertices: true,
|
preserveVertices: true,
|
||||||
};
|
};
|
||||||
const center = getCenterForBounds(
|
const center = getCenterForBounds(
|
||||||
getElementBounds(element, elementsMap, true),
|
// 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) {
|
switch (element.type) {
|
||||||
@@ -491,7 +602,7 @@ export const generateLinearCollisionShape = (
|
|||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
export const _generateElementShape = (
|
const generateElementShape = (
|
||||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
generator: RoughGenerator,
|
generator: RoughGenerator,
|
||||||
{
|
{
|
||||||
@@ -792,3 +903,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,447 +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>,
|
|
||||||
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 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;
|
|
||||||
};
|
|
||||||
@@ -23,8 +23,12 @@ import {
|
|||||||
syncInvalidIndicesImmutable,
|
syncInvalidIndicesImmutable,
|
||||||
hashElementsVersion,
|
hashElementsVersion,
|
||||||
hashString,
|
hashString,
|
||||||
|
isInitializedImageElement,
|
||||||
|
isImageElement,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
|
|
||||||
|
import type { ApplyToOptions } from "./delta";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
@@ -72,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]
|
||||||
>();
|
>();
|
||||||
@@ -137,6 +142,8 @@ 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,
|
||||||
@@ -233,7 +240,6 @@ export class Store {
|
|||||||
if (!storeDelta.isEmpty()) {
|
if (!storeDelta.isEmpty()) {
|
||||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||||
|
|
||||||
// Notify listeners with the increment
|
|
||||||
this.onDurableIncrementEmitter.trigger(increment);
|
this.onDurableIncrementEmitter.trigger(increment);
|
||||||
this.onStoreIncrementEmitter.trigger(increment);
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
}
|
}
|
||||||
@@ -546,10 +552,26 @@ export class StoreDelta {
|
|||||||
public static load({
|
public static load({
|
||||||
id,
|
id,
|
||||||
elements: { added, removed, updated },
|
elements: { added, removed, updated },
|
||||||
|
appState: { delta: appStateDelta },
|
||||||
}: DTO<StoreDelta>) {
|
}: DTO<StoreDelta>) {
|
||||||
const elements = ElementsDelta.create(added, removed, updated);
|
const elements = ElementsDelta.create(added, removed, updated);
|
||||||
|
const appState = AppStateDelta.create(appStateDelta);
|
||||||
|
|
||||||
return new this(id, elements, 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -566,9 +588,13 @@ export class StoreDelta {
|
|||||||
delta: StoreDelta,
|
delta: StoreDelta,
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
options?: ApplyToOptions,
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
const [nextElements, elementsContainVisibleChange] =
|
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||||
delta.elements.applyTo(elements);
|
elements,
|
||||||
|
StoreSnapshot.empty().elements,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
const [nextAppState, appStateContainsVisibleChange] =
|
const [nextAppState, appStateContainsVisibleChange] =
|
||||||
delta.appState.applyTo(appState, nextElements);
|
delta.appState.applyTo(appState, nextElements);
|
||||||
@@ -601,6 +627,10 @@ export class StoreDelta {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static empty() {
|
||||||
|
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
||||||
|
}
|
||||||
|
|
||||||
public isEmpty() {
|
public isEmpty() {
|
||||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||||
}
|
}
|
||||||
@@ -869,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,
|
||||||
@@ -904,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -958,8 +996,7 @@ 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: {},
|
||||||
@@ -978,14 +1015,12 @@ export const getObservedAppState = (
|
|||||||
croppingElementId: appState.croppingElementId,
|
croppingElementId: appState.croppingElementId,
|
||||||
activeLockedId: appState.activeLockedId,
|
activeLockedId: appState.activeLockedId,
|
||||||
lockedMultiSelections: appState.lockedMultiSelections,
|
lockedMultiSelections: appState.lockedMultiSelections,
|
||||||
editingLinearElementId:
|
selectedLinearElement: appState.selectedLinearElement
|
||||||
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
|
? {
|
||||||
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
|
elementId: appState.selectedLinearElement.elementId,
|
||||||
null,
|
isEditing: !!appState.selectedLinearElement.isEditing,
|
||||||
selectedLinearElementId:
|
}
|
||||||
(appState as AppState).selectedLinearElement?.elementId ??
|
: null,
|
||||||
(appState as ObservedAppState).selectedLinearElementId ??
|
|
||||||
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] };
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export const shouldShowBoundingBox = (
|
|||||||
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) {
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_ADAPTIVE_RADIUS,
|
||||||
|
DEFAULT_PROPORTIONAL_RADIUS,
|
||||||
|
LINE_CONFIRM_THRESHOLD,
|
||||||
|
ROUNDNESS,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
curve,
|
curve,
|
||||||
curveCatmullRomCubicApproxPoints,
|
curveCatmullRomCubicApproxPoints,
|
||||||
curveOffsetPoints,
|
curveOffsetPoints,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
|
pointDistance,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointFromArray,
|
pointFromArray,
|
||||||
rectangle,
|
rectangle,
|
||||||
@@ -11,14 +19,13 @@ import {
|
|||||||
|
|
||||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import { getCornerRadius } from "./shapes";
|
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getDiamondPoints } from "./bounds";
|
import { getDiamondPoints } from "./bounds";
|
||||||
|
|
||||||
import { generateLinearCollisionShape } from "./Shape";
|
import { generateLinearCollisionShape } from "./shape";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
@@ -83,9 +90,14 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
|||||||
shapes.set(offset, shape);
|
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(
|
export function deconstructLinearOrFreeDrawElement(
|
||||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||||
|
|
||||||
@@ -93,7 +105,7 @@ export function deconstructLinearOrFreeDrawElement(
|
|||||||
return cachedShape;
|
return cachedShape;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ops = generateLinearCollisionShape(element, elementsMap) as {
|
const ops = generateLinearCollisionShape(element) as {
|
||||||
op: string;
|
op: string;
|
||||||
data: number[];
|
data: number[];
|
||||||
}[];
|
}[];
|
||||||
@@ -165,11 +177,11 @@ export function deconstructLinearOrFreeDrawElement(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
||||||
@@ -304,12 +316,12 @@ export function deconstructRectanguloidElement(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
||||||
@@ -428,3 +440,44 @@ export function deconstructDiamondElement(
|
|||||||
|
|
||||||
return shape;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -589,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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -155,10 +155,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");
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe("check rotated elements can be hit:", () => {
|
|||||||
//const p = [120, -211];
|
//const p = [120, -211];
|
||||||
//const p = [0, 13];
|
//const p = [0, 13];
|
||||||
const hit = hitElementItself({
|
const hit = hitElementItself({
|
||||||
point: pointFrom<GlobalPoint>(87, -68),
|
point: pointFrom<GlobalPoint>(88, -68),
|
||||||
element: window.h.elements[0],
|
element: window.h.elements[0],
|
||||||
threshold: 10,
|
threshold: 10,
|
||||||
elementsMap: window.h.scene.getNonDeletedElementsMap(),
|
elementsMap: window.h.scene.getNonDeletedElementsMap(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -136,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) => {
|
||||||
@@ -253,75 +254,82 @@ 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 via enter (line)", () => {
|
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]);
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ctrl+enter alias (to align with arrows)
|
// ctrl+enter alias (to align with arrows)
|
||||||
it("should enter line editor via ctrl+enter (line)", () => {
|
it("should enter line editor via ctrl+enter (line)", () => {
|
||||||
createTwoPointerLinearElement("line");
|
createTwoPointerLinearElement("line");
|
||||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
});
|
});
|
||||||
expect(h.state.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 via ctrl+enter (arrow)", () => {
|
it("should enter line editor via ctrl+enter (arrow)", () => {
|
||||||
createTwoPointerLinearElement("arrow");
|
createTwoPointerLinearElement("arrow");
|
||||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
});
|
});
|
||||||
expect(h.state.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 (simple arrow)", () => {
|
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
|
||||||
createTwoPointerLinearElement("arrow");
|
createTwoPointerLinearElement("arrow");
|
||||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
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)", () => {
|
it("should enter line editor on ctrl+dblclick (line)", () => {
|
||||||
createTwoPointerLinearElement("line");
|
createTwoPointerLinearElement("line");
|
||||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
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 dblclick (line)", () => {
|
it("should enter line editor on dblclick (line)", () => {
|
||||||
createTwoPointerLinearElement("line");
|
createTwoPointerLinearElement("line");
|
||||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
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 not enter line editor on dblclick (arrow)", async () => {
|
it("should not enter line editor on dblclick (arrow)", async () => {
|
||||||
createTwoPointerLinearElement("arrow");
|
createTwoPointerLinearElement("arrow");
|
||||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
mouse.doubleClick();
|
mouse.doubleClick();
|
||||||
expect(h.state.editingLinearElement).toEqual(null);
|
expect(h.state.selectedLinearElement).toBe(null);
|
||||||
await getTextEditor();
|
await getTextEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -330,10 +338,12 @@ describe("Test Linear Elements", () => {
|
|||||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||||
enterLineEditingMode(arrow);
|
enterLineEditingMode(arrow);
|
||||||
|
|
||||||
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
||||||
|
|
||||||
mouse.doubleClick();
|
mouse.doubleClick();
|
||||||
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
|
|
||||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||||
@@ -367,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`);
|
||||||
|
|
||||||
@@ -423,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",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@@ -469,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`);
|
||||||
|
|
||||||
@@ -488,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",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@@ -537,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`);
|
||||||
|
|
||||||
@@ -588,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`);
|
||||||
|
|
||||||
@@ -629,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`);
|
||||||
|
|
||||||
@@ -677,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`);
|
||||||
|
|
||||||
@@ -735,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);
|
||||||
@@ -804,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",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@@ -833,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`);
|
||||||
|
|
||||||
@@ -893,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",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@@ -1060,8 +1070,8 @@ describe("Test Linear Elements", () => {
|
|||||||
);
|
);
|
||||||
expect(position).toMatchInlineSnapshot(`
|
expect(position).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
"x": "85.82202",
|
"x": "86.17305",
|
||||||
"y": "75.63461",
|
"y": "76.11251",
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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";
|
||||||
@@ -38,7 +40,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 +58,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);
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export const actionClearCanvas = register({
|
|||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
appState.activeTool.type === "image"
|
appState.activeTool.type === "image"
|
||||||
? { ...appState.activeTool, type: "selection" }
|
? { ...appState.activeTool, type: app.defaultSelectionTool }
|
||||||
: appState.activeTool,
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
@@ -494,13 +494,13 @@ export const actionToggleEraserTool = register({
|
|||||||
name: "toggleEraserTool",
|
name: "toggleEraserTool",
|
||||||
label: "toolBar.eraser",
|
label: "toolBar.eraser",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
perform: (elements, appState) => {
|
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.defaultSelectionTool,
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
@@ -530,6 +530,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.defaultSelectionTool !== "lasso";
|
||||||
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
|
|||||||
@@ -205,16 +205,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 +228,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 +242,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 +255,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 +298,9 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
activeTool: updateActiveTool(appState, {
|
||||||
|
type: app.defaultSelectionTool,
|
||||||
|
}),
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
selectedLinearElement: null,
|
selectedLinearElement: null,
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -31,7 +33,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 +55,7 @@ const distributeSelectedElements = (
|
|||||||
selectedElements,
|
selectedElements,
|
||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
distribution,
|
distribution,
|
||||||
|
appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.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(
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -145,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 ||
|
||||||
@@ -163,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)) {
|
||||||
@@ -204,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,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.defaultSelectionTool,
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
type: "selection",
|
type: app.defaultSelectionTool,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,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),
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,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";
|
||||||
|
|
||||||
@@ -135,8 +137,6 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
import { toggleLinePolygonState } from "../../element/src/shapes";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export type ShortcutName =
|
|||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "imageExport"
|
| "imageExport"
|
||||||
| "commandPalette"
|
| "commandPalette"
|
||||||
| "searchMenu";
|
| "searchMenu"
|
||||||
|
| "toolLock";
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||||
@@ -116,6 +117,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
toggleShortcuts: [getShortcutKey("?")],
|
toggleShortcuts: [getShortcutKey("?")],
|
||||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||||
wrapSelectionInFrame: [],
|
wrapSelectionInFrame: [],
|
||||||
|
toolLock: [getShortcutKey("Q")],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||||
|
|||||||
@@ -48,7 +48,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,
|
||||||
@@ -175,7 +174,6 @@ 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 },
|
||||||
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 },
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
hasStrokeWidth,
|
hasStrokeWidth,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
import { SHAPES } from "./shapes";
|
import { getToolbarTools } from "./shapes";
|
||||||
|
|
||||||
import "./Actions.scss";
|
import "./Actions.scss";
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ export const SelectedShapeActions = ({
|
|||||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||||
|
|
||||||
const showLineEditorAction =
|
const showLineEditorAction =
|
||||||
!appState.editingLinearElement &&
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
targetElements.length === 1 &&
|
targetElements.length === 1 &&
|
||||||
isLinearElement(targetElements[0]) &&
|
isLinearElement(targetElements[0]) &&
|
||||||
!isElbowArrow(targetElements[0]);
|
!isElbowArrow(targetElements[0]);
|
||||||
@@ -295,7 +295,8 @@ export const ShapesSwitcher = ({
|
|||||||
|
|
||||||
const frameToolSelected = activeTool.type === "frame";
|
const frameToolSelected = activeTool.type === "frame";
|
||||||
const laserToolSelected = activeTool.type === "laser";
|
const laserToolSelected = activeTool.type === "laser";
|
||||||
const lassoToolSelected = activeTool.type === "lasso";
|
const lassoToolSelected =
|
||||||
|
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
|
||||||
|
|
||||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||||
|
|
||||||
@@ -303,63 +304,68 @@ export const ShapesSwitcher = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
{getToolbarTools(app).map(
|
||||||
if (
|
({ value, icon, key, numericKey, fillable }, index) => {
|
||||||
UIOptions.tools?.[
|
if (
|
||||||
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
|
UIOptions.tools?.[
|
||||||
] === false
|
value as Extract<
|
||||||
) {
|
typeof value,
|
||||||
return null;
|
keyof AppProps["UIOptions"]["tools"]
|
||||||
}
|
>
|
||||||
|
] === false
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const letter =
|
const letter =
|
||||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||||
const shortcut = letter
|
const shortcut = letter
|
||||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||||
: `${numericKey}`;
|
: `${numericKey}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className={clsx("Shape", { fillable })}
|
className={clsx("Shape", { fillable })}
|
||||||
key={value}
|
key={value}
|
||||||
type="radio"
|
type="radio"
|
||||||
icon={icon}
|
icon={icon}
|
||||||
checked={activeTool.type === value}
|
checked={activeTool.type === value}
|
||||||
name="editor-current-shape"
|
name="editor-current-shape"
|
||||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||||
keyBindingLabel={numericKey || letter}
|
keyBindingLabel={numericKey || letter}
|
||||||
aria-label={capitalizeString(label)}
|
aria-label={capitalizeString(label)}
|
||||||
aria-keyshortcuts={shortcut}
|
aria-keyshortcuts={shortcut}
|
||||||
data-testid={`toolbar-${value}`}
|
data-testid={`toolbar-${value}`}
|
||||||
onPointerDown={({ pointerType }) => {
|
onPointerDown={({ pointerType }) => {
|
||||||
if (!appState.penDetected && pointerType === "pen") {
|
if (!appState.penDetected && pointerType === "pen") {
|
||||||
app.togglePenMode(true);
|
app.togglePenMode(true);
|
||||||
}
|
|
||||||
|
|
||||||
if (value === "selection") {
|
|
||||||
if (appState.activeTool.type === "selection") {
|
|
||||||
app.setActiveTool({ type: "lasso" });
|
|
||||||
} else {
|
|
||||||
app.setActiveTool({ type: "selection" });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}}
|
if (value === "selection") {
|
||||||
onChange={({ pointerType }) => {
|
if (appState.activeTool.type === "selection") {
|
||||||
if (appState.activeTool.type !== value) {
|
app.setActiveTool({ type: "lasso" });
|
||||||
trackEvent("toolbar", value, "ui");
|
} else {
|
||||||
}
|
app.setActiveTool({ type: "selection" });
|
||||||
if (value === "image") {
|
}
|
||||||
app.setActiveTool({
|
}
|
||||||
type: value,
|
}}
|
||||||
});
|
onChange={({ pointerType }) => {
|
||||||
} else {
|
if (appState.activeTool.type !== value) {
|
||||||
app.setActiveTool({ type: value });
|
trackEvent("toolbar", value, "ui");
|
||||||
}
|
}
|
||||||
}}
|
if (value === "image") {
|
||||||
/>
|
app.setActiveTool({
|
||||||
);
|
type: value,
|
||||||
})}
|
});
|
||||||
|
} else {
|
||||||
|
app.setActiveTool({ type: value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
<div className="App-toolbar__divider" />
|
<div className="App-toolbar__divider" />
|
||||||
|
|
||||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||||
@@ -391,6 +397,7 @@ export const ShapesSwitcher = ({
|
|||||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||||
onSelect={() => setIsExtraToolsMenuOpen(false)}
|
onSelect={() => setIsExtraToolsMenuOpen(false)}
|
||||||
className="App-toolbar__extra-tools-dropdown"
|
className="App-toolbar__extra-tools-dropdown"
|
||||||
|
align="end"
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => app.setActiveTool({ type: "frame" })}
|
onSelect={() => app.setActiveTool({ type: "frame" })}
|
||||||
@@ -418,14 +425,16 @@ export const ShapesSwitcher = ({
|
|||||||
>
|
>
|
||||||
{t("toolBar.laser")}
|
{t("toolBar.laser")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
{app.defaultSelectionTool !== "lasso" && (
|
||||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
<DropdownMenu.Item
|
||||||
icon={LassoIcon}
|
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||||
data-testid="toolbar-lasso"
|
icon={LassoIcon}
|
||||||
selected={lassoToolSelected}
|
data-testid="toolbar-lasso"
|
||||||
>
|
selected={lassoToolSelected}
|
||||||
{t("toolBar.lasso")}
|
>
|
||||||
</DropdownMenu.Item>
|
{t("toolBar.lasso")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||||
Generate
|
Generate
|
||||||
</div>
|
</div>
|
||||||
@@ -442,10 +451,10 @@ export const ShapesSwitcher = ({
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => app.onMagicframeToolSelect()}
|
onSelect={() => app.onMagicframeToolSelect()}
|
||||||
icon={MagicIcon}
|
icon={MagicIcon}
|
||||||
|
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
|
||||||
data-testid="toolbar-magicframe"
|
data-testid="toolbar-magicframe"
|
||||||
>
|
>
|
||||||
{t("toolBar.magicframe")}
|
{t("toolBar.magicframe")}
|
||||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -505,15 +514,3 @@ export const ExitZenModeAction = ({
|
|||||||
{t("buttons.exitZenMode")}
|
{t("buttons.exitZenMode")}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FinalizeAction = ({
|
|
||||||
renderAction,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
renderAction: ActionManager["renderAction"];
|
|
||||||
className?: string;
|
|
||||||
}) => (
|
|
||||||
<div className={`finalize-button ${className}`}>
|
|
||||||
{renderAction("finalize", { size: "small" })}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ interface ButtonProps
|
|||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
> {
|
> {
|
||||||
type?: "button" | "submit" | "reset";
|
type?: "button" | "submit" | "reset";
|
||||||
onSelect: () => any;
|
onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => any;
|
||||||
/** whether button is in active state */
|
/** whether button is in active state */
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -34,7 +34,7 @@ export const Button = ({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={composeEventHandlers(rest.onClick, (event) => {
|
onClick={composeEventHandlers(rest.onClick, (event) => {
|
||||||
onSelect();
|
onSelect(event);
|
||||||
})}
|
})}
|
||||||
type={type}
|
type={type}
|
||||||
className={clsx("excalidraw-button", className, { selected })}
|
className={clsx("excalidraw-button", className, { selected })}
|
||||||
|
|||||||
@@ -30,6 +30,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#canvas-bg-color-picker-container {
|
||||||
|
.color-picker__top-picks {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-container {
|
||||||
|
@include isMobile {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.color-picker__button {
|
.color-picker__button {
|
||||||
--radius: 4px;
|
--radius: 4px;
|
||||||
--size: 1.375rem;
|
--size: 1.375rem;
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ $verticalBreakpoint: 861px;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback";
|
|||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import { useStable } from "../../hooks/useStable";
|
import { useStable } from "../../hooks/useStable";
|
||||||
|
|
||||||
|
import { Ellipsify } from "../Ellipsify";
|
||||||
|
|
||||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||||
|
|
||||||
import "./CommandPalette.scss";
|
import "./CommandPalette.scss";
|
||||||
@@ -964,7 +966,7 @@ const CommandItem = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{command.label}
|
<Ellipsify>{command.label}</Ellipsify>
|
||||||
</div>
|
</div>
|
||||||
{showShortcut && command.shortcut && (
|
{showShortcut && command.shortcut && (
|
||||||
<CommandShortcutHint shortcut={command.shortcut} />
|
<CommandShortcutHint shortcut={command.shortcut} />
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const Ellipsify = ({
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...rest}
|
||||||
|
style={{
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
overflow: "hidden",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
...rest.style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -25,10 +25,6 @@ import { PropertiesPopover } from "../PropertiesPopover";
|
|||||||
import { QuickSearch } from "../QuickSearch";
|
import { QuickSearch } from "../QuickSearch";
|
||||||
import { ScrollableList } from "../ScrollableList";
|
import { ScrollableList } from "../ScrollableList";
|
||||||
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
|
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
|
||||||
import DropdownMenuItem, {
|
|
||||||
DropDownMenuItemBadgeType,
|
|
||||||
DropDownMenuItemBadge,
|
|
||||||
} from "../dropdownMenu/DropdownMenuItem";
|
|
||||||
import {
|
import {
|
||||||
FontFamilyCodeIcon,
|
FontFamilyCodeIcon,
|
||||||
FontFamilyHeadingIcon,
|
FontFamilyHeadingIcon,
|
||||||
@@ -36,8 +32,15 @@ import {
|
|||||||
FreedrawIcon,
|
FreedrawIcon,
|
||||||
} from "../icons";
|
} from "../icons";
|
||||||
|
|
||||||
|
import { Ellipsify } from "../Ellipsify";
|
||||||
|
|
||||||
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
||||||
|
|
||||||
|
import {
|
||||||
|
FontPickerListItem,
|
||||||
|
FontPickerListItemBadgeType,
|
||||||
|
} from "./FontPickerListItem";
|
||||||
|
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
export interface FontDescriptor {
|
export interface FontDescriptor {
|
||||||
@@ -46,7 +49,7 @@ export interface FontDescriptor {
|
|||||||
text: string;
|
text: string;
|
||||||
deprecated?: true;
|
deprecated?: true;
|
||||||
badge?: {
|
badge?: {
|
||||||
type: ValueOf<typeof DropDownMenuItemBadgeType>;
|
type: ValueOf<typeof FontPickerListItemBadgeType>;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -112,7 +115,7 @@ export const FontPickerList = React.memo(
|
|||||||
Object.assign(fontDescriptor, {
|
Object.assign(fontDescriptor, {
|
||||||
deprecated: metadata.deprecated,
|
deprecated: metadata.deprecated,
|
||||||
badge: {
|
badge: {
|
||||||
type: DropDownMenuItemBadgeType.RED,
|
type: FontPickerListItemBadgeType.RED,
|
||||||
placeholder: t("fontList.badge.old"),
|
placeholder: t("fontList.badge.old"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -227,7 +230,7 @@ export const FontPickerList = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderFont = (font: FontDescriptor, index: number) => (
|
const renderFont = (font: FontDescriptor, index: number) => (
|
||||||
<DropdownMenuItem
|
<FontPickerListItem
|
||||||
key={font.value}
|
key={font.value}
|
||||||
icon={font.icon}
|
icon={font.icon}
|
||||||
value={font.value}
|
value={font.value}
|
||||||
@@ -239,8 +242,8 @@ export const FontPickerList = React.memo(
|
|||||||
selected={font.value === selectedFontFamily}
|
selected={font.value === selectedFontFamily}
|
||||||
// allow to tab between search and selected font
|
// allow to tab between search and selected font
|
||||||
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
||||||
onClick={(e) => {
|
onSelect={() => {
|
||||||
onSelect(Number(e.currentTarget.value));
|
onSelect(font.value);
|
||||||
}}
|
}}
|
||||||
onMouseMove={() => {
|
onMouseMove={() => {
|
||||||
if (hoveredFont?.value !== font.value) {
|
if (hoveredFont?.value !== font.value) {
|
||||||
@@ -248,13 +251,13 @@ export const FontPickerList = React.memo(
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{font.text}
|
<Ellipsify>{font.text}</Ellipsify>
|
||||||
{font.badge && (
|
{font.badge && (
|
||||||
<DropDownMenuItemBadge type={font.badge.type}>
|
<FontPickerListItem.Badge type={font.badge.type}>
|
||||||
{font.badge.placeholder}
|
{font.badge.placeholder}
|
||||||
</DropDownMenuItemBadge>
|
</FontPickerListItem.Badge>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</FontPickerListItem>
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups = [];
|
const groups = [];
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { THEME } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import { Button } from "../Button";
|
||||||
|
|
||||||
|
import { useExcalidrawAppState } from "../App";
|
||||||
|
|
||||||
|
import { useDevice } from "../App";
|
||||||
|
|
||||||
|
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
|
||||||
|
|
||||||
|
import type { JSX } from "react";
|
||||||
|
|
||||||
|
const MenuItemContent = ({
|
||||||
|
textStyle,
|
||||||
|
icon,
|
||||||
|
shortcut,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
shortcut?: string;
|
||||||
|
textStyle?: React.CSSProperties;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const device = useDevice();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||||
|
<div style={textStyle} className="dropdown-menu-item__text">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{shortcut && !device.editor.isMobile && (
|
||||||
|
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FontPickerListItem = ({
|
||||||
|
icon,
|
||||||
|
value,
|
||||||
|
order,
|
||||||
|
children,
|
||||||
|
shortcut,
|
||||||
|
className,
|
||||||
|
hovered,
|
||||||
|
selected,
|
||||||
|
textStyle,
|
||||||
|
onSelect,
|
||||||
|
onClick,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
icon?: JSX.Element;
|
||||||
|
value?: string | number | undefined;
|
||||||
|
order?: number;
|
||||||
|
onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
shortcut?: string;
|
||||||
|
hovered?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
textStyle?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hovered) {
|
||||||
|
if (order === 0) {
|
||||||
|
// scroll into the first item differently, so it's visible what is above (i.e. group title)
|
||||||
|
ref.current?.scrollIntoView({ block: "end" });
|
||||||
|
} else {
|
||||||
|
ref.current?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hovered, order]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="radix-menu-item">
|
||||||
|
<Button
|
||||||
|
{...rest}
|
||||||
|
ref={ref}
|
||||||
|
onSelect={onSelect}
|
||||||
|
className={getDropdownMenuItemClassName(className, selected, hovered)}
|
||||||
|
title={rest.title ?? rest["aria-label"]}
|
||||||
|
>
|
||||||
|
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
|
||||||
|
{children}
|
||||||
|
</MenuItemContent>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
FontPickerListItem.displayName = "FontPickerListItem";
|
||||||
|
|
||||||
|
export const FontPickerListItemBadgeType = {
|
||||||
|
GREEN: "green",
|
||||||
|
RED: "red",
|
||||||
|
BLUE: "blue",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const FontPickerListItemBadge = ({
|
||||||
|
type = FontPickerListItemBadgeType.BLUE,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
type?: ValueOf<typeof FontPickerListItemBadgeType>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const { theme } = useExcalidrawAppState();
|
||||||
|
const style = {
|
||||||
|
display: "inline-flex",
|
||||||
|
marginLeft: "auto",
|
||||||
|
padding: "2px 4px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 9,
|
||||||
|
fontFamily: "Cascadia, monospace",
|
||||||
|
border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case FontPickerListItemBadgeType.GREEN:
|
||||||
|
Object.assign(style, {
|
||||||
|
backgroundColor: "var(--background-color-badge)",
|
||||||
|
color: "var(--color-badge)",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case FontPickerListItemBadgeType.RED:
|
||||||
|
Object.assign(style, {
|
||||||
|
backgroundColor: "pink",
|
||||||
|
color: "darkred",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case FontPickerListItemBadgeType.BLUE:
|
||||||
|
default:
|
||||||
|
Object.assign(style, {
|
||||||
|
background: "var(--color-promo)",
|
||||||
|
color: "var(--color-surface-lowest)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="DropDownMenuItemBadge" style={style}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
FontPickerListItemBadge.displayName = "DropdownMenuItemBadge";
|
||||||
|
|
||||||
|
FontPickerListItem.Badge = FontPickerListItemBadge;
|
||||||
@@ -238,7 +238,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
|
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
|
||||||
isOr={true}
|
isOr={true}
|
||||||
/>
|
/>
|
||||||
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
|
<Shortcut
|
||||||
|
label={t("toolBar.lock")}
|
||||||
|
shortcuts={[getShortcutFromShortcutName("toolLock")]}
|
||||||
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.preventBinding")}
|
label={t("helpDialog.preventBinding")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const getHints = ({
|
|||||||
appState.selectionElement &&
|
appState.selectionElement &&
|
||||||
!selectedElements.length &&
|
!selectedElements.length &&
|
||||||
!appState.editingTextElement &&
|
!appState.editingTextElement &&
|
||||||
!appState.editingLinearElement
|
!appState.selectedLinearElement?.isEditing
|
||||||
) {
|
) {
|
||||||
return [t("hints.deepBoxSelect")];
|
return [t("hints.deepBoxSelect")];
|
||||||
}
|
}
|
||||||
@@ -130,8 +130,8 @@ const getHints = ({
|
|||||||
|
|
||||||
if (selectedElements.length === 1) {
|
if (selectedElements.length === 1) {
|
||||||
if (isLinearElement(selectedElements[0])) {
|
if (isLinearElement(selectedElements[0])) {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
return appState.editingLinearElement.selectedPointsIndices
|
return appState.selectedLinearElement.selectedPointsIndices
|
||||||
? t("hints.lineEditor_pointSelected")
|
? t("hints.lineEditor_pointSelected")
|
||||||
: t("hints.lineEditor_nothingSelected");
|
: t("hints.lineEditor_nothingSelected");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
|
|||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
lineHeight: 0,
|
lineHeight: 0,
|
||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
|
flex: "0 0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export const LibraryDropdownMenuButton: React.FC<{
|
|||||||
<DropdownMenu open={isLibraryMenuOpen}>
|
<DropdownMenu open={isLibraryMenuOpen}>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
|
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
|
||||||
|
aria-label="Library menu"
|
||||||
>
|
>
|
||||||
{DotsIcon}
|
{DotsIcon}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
@@ -201,6 +202,7 @@ export const LibraryDropdownMenuButton: React.FC<{
|
|||||||
onClickOutside={() => setIsLibraryMenuOpen(false)}
|
onClickOutside={() => setIsLibraryMenuOpen(false)}
|
||||||
onSelect={() => setIsLibraryMenuOpen(false)}
|
onSelect={() => setIsLibraryMenuOpen(false)}
|
||||||
className="library-menu"
|
className="library-menu"
|
||||||
|
align="end"
|
||||||
>
|
>
|
||||||
{!itemsSelected && (
|
{!itemsSelected && (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import { resizeSingleElement } from "@excalidraw/element";
|
import { resizeSingleElement } from "@excalidraw/element";
|
||||||
import { isImageElement } from "@excalidraw/element";
|
import { isImageElement } from "@excalidraw/element";
|
||||||
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
|
import { getElementsInResizingFrame } from "@excalidraw/element";
|
||||||
|
import { replaceAllElementsInFrame } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@@ -15,7 +18,10 @@ import type { Scene } from "@excalidraw/element";
|
|||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
|
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type {
|
||||||
|
DragFinishedCallbackType,
|
||||||
|
DragInputCallbackType,
|
||||||
|
} from "./DragInput";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
interface DimensionDragInputProps {
|
interface DimensionDragInputProps {
|
||||||
@@ -43,6 +49,8 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
originalAppState,
|
originalAppState,
|
||||||
instantChange,
|
instantChange,
|
||||||
scene,
|
scene,
|
||||||
|
app,
|
||||||
|
setAppState,
|
||||||
}) => {
|
}) => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const origElement = originalElements[0];
|
const origElement = originalElements[0];
|
||||||
@@ -153,6 +161,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User types in a value to stats then presses Enter
|
||||||
if (nextValue !== undefined) {
|
if (nextValue !== undefined) {
|
||||||
const nextWidth = Math.max(
|
const nextWidth = Math.max(
|
||||||
property === "width"
|
property === "width"
|
||||||
@@ -184,52 +193,123 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle frame membership update for resized frames
|
||||||
|
if (isFrameLikeElement(latestElement)) {
|
||||||
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
latestElement,
|
||||||
|
originalAppState,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedElements = replaceAllElementsInFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
nextElementsInFrame,
|
||||||
|
latestElement,
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
|
||||||
|
scene.replaceAllElements(updatedElements);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
|
||||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
|
||||||
|
|
||||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
// Stats slider is dragged
|
||||||
if (property === "width") {
|
{
|
||||||
if (shouldChangeByStepSize) {
|
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||||
} else {
|
|
||||||
nextWidth = Math.round(nextWidth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||||
if (property === "height") {
|
|
||||||
if (shouldChangeByStepSize) {
|
|
||||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
|
||||||
} else {
|
|
||||||
nextHeight = Math.round(nextHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keepAspectRatio) {
|
|
||||||
if (property === "width") {
|
if (property === "width") {
|
||||||
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
if (shouldChangeByStepSize) {
|
||||||
} else {
|
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||||
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
} else {
|
||||||
|
nextWidth = Math.round(nextWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||||
|
if (property === "height") {
|
||||||
|
if (shouldChangeByStepSize) {
|
||||||
|
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||||
|
} else {
|
||||||
|
nextHeight = Math.round(nextHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keepAspectRatio) {
|
||||||
|
if (property === "width") {
|
||||||
|
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||||
|
} else {
|
||||||
|
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||||
|
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||||
|
|
||||||
|
resizeSingleElement(
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
latestElement,
|
||||||
|
origElement,
|
||||||
|
originalElementsMap,
|
||||||
|
scene,
|
||||||
|
property === "width" ? "e" : "s",
|
||||||
|
{
|
||||||
|
shouldMaintainAspectRatio: keepAspectRatio,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle highlighting frame element candidates
|
||||||
|
if (isFrameLikeElement(latestElement)) {
|
||||||
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
latestElement,
|
||||||
|
originalAppState,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
elementsToHighlight: nextElementsInFrame,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
const handleDragFinished: DragFinishedCallbackType = ({
|
||||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
setAppState,
|
||||||
|
app,
|
||||||
|
originalElements,
|
||||||
|
originalAppState,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
const origElement = originalElements?.[0];
|
||||||
|
const latestElement = origElement && elementsMap.get(origElement.id);
|
||||||
|
|
||||||
resizeSingleElement(
|
// Handle frame membership update for resized frames
|
||||||
nextWidth,
|
if (latestElement && isFrameLikeElement(latestElement)) {
|
||||||
nextHeight,
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
app.scene.getElementsIncludingDeleted(),
|
||||||
latestElement,
|
latestElement,
|
||||||
origElement,
|
originalAppState,
|
||||||
originalElementsMap,
|
app.scene.getNonDeletedElementsMap(),
|
||||||
scene,
|
|
||||||
property === "width" ? "e" : "s",
|
|
||||||
{
|
|
||||||
shouldMaintainAspectRatio: keepAspectRatio,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updatedElements = replaceAllElementsInFrame(
|
||||||
|
app.scene.getElementsIncludingDeleted(),
|
||||||
|
nextElementsInFrame,
|
||||||
|
latestElement,
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.scene.replaceAllElements(updatedElements);
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
elementsToHighlight: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -269,6 +349,7 @@ const DimensionDragInput = ({
|
|||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
property={property}
|
property={property}
|
||||||
|
dragFinishedCallback={handleDragFinished}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
|||||||
|
|
||||||
import type { Scene } from "@excalidraw/element";
|
import type { Scene } from "@excalidraw/element";
|
||||||
|
|
||||||
import { useApp } from "../App";
|
import { useApp, useExcalidrawSetAppState } from "../App";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
|
|
||||||
import { SMALLEST_DELTA } from "./utils";
|
import { SMALLEST_DELTA } from "./utils";
|
||||||
@@ -36,6 +36,15 @@ export type DragInputCallbackType<
|
|||||||
property: P;
|
property: P;
|
||||||
originalAppState: AppState;
|
originalAppState: AppState;
|
||||||
setInputValue: (value: number) => void;
|
setInputValue: (value: number) => void;
|
||||||
|
app: ReturnType<typeof useApp>;
|
||||||
|
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export type DragFinishedCallbackType<E = ExcalidrawElement> = (props: {
|
||||||
|
app: ReturnType<typeof useApp>;
|
||||||
|
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
|
||||||
|
originalElements: readonly E[] | null;
|
||||||
|
originalAppState: AppState;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
interface StatsDragInputProps<
|
interface StatsDragInputProps<
|
||||||
@@ -54,6 +63,7 @@ interface StatsDragInputProps<
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
/** how many px you need to drag to get 1 unit change */
|
/** how many px you need to drag to get 1 unit change */
|
||||||
sensitivity?: number;
|
sensitivity?: number;
|
||||||
|
dragFinishedCallback?: DragFinishedCallbackType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatsDragInput = <
|
const StatsDragInput = <
|
||||||
@@ -71,8 +81,10 @@ const StatsDragInput = <
|
|||||||
scene,
|
scene,
|
||||||
appState,
|
appState,
|
||||||
sensitivity = 1,
|
sensitivity = 1,
|
||||||
|
dragFinishedCallback,
|
||||||
}: StatsDragInputProps<T, E>) => {
|
}: StatsDragInputProps<T, E>) => {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const labelRef = useRef<HTMLDivElement>(null);
|
const labelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -137,6 +149,8 @@ const StatsDragInput = <
|
|||||||
property,
|
property,
|
||||||
originalAppState: appState,
|
originalAppState: appState,
|
||||||
setInputValue: (value) => setInputValue(String(value)),
|
setInputValue: (value) => setInputValue(String(value)),
|
||||||
|
app,
|
||||||
|
setAppState,
|
||||||
});
|
});
|
||||||
app.syncActionResult({
|
app.syncActionResult({
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
@@ -263,6 +277,8 @@ const StatsDragInput = <
|
|||||||
scene,
|
scene,
|
||||||
originalAppState,
|
originalAppState,
|
||||||
setInputValue: (value) => setInputValue(String(value)),
|
setInputValue: (value) => setInputValue(String(value)),
|
||||||
|
app,
|
||||||
|
setAppState,
|
||||||
});
|
});
|
||||||
|
|
||||||
stepChange = 0;
|
stepChange = 0;
|
||||||
@@ -287,6 +303,14 @@ const StatsDragInput = <
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify implementors
|
||||||
|
dragFinishedCallback?.({
|
||||||
|
app,
|
||||||
|
setAppState,
|
||||||
|
originalElements,
|
||||||
|
originalAppState,
|
||||||
|
});
|
||||||
|
|
||||||
lastPointer = null;
|
lastPointer = null;
|
||||||
accumulatedChange = 0;
|
accumulatedChange = 0;
|
||||||
stepChange = 0;
|
stepChange = 0;
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { pointFrom, type GlobalPoint } from "@excalidraw/math";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
|
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
|
||||||
import { updateBoundElements } from "@excalidraw/element";
|
import {
|
||||||
|
getElementsInResizingFrame,
|
||||||
|
isFrameLikeElement,
|
||||||
|
replaceAllElementsInFrame,
|
||||||
|
updateBoundElements,
|
||||||
|
} from "@excalidraw/element";
|
||||||
import {
|
import {
|
||||||
rescalePointsInElement,
|
rescalePointsInElement,
|
||||||
resizeSingleElement,
|
resizeSingleElement,
|
||||||
@@ -25,7 +30,10 @@ import DragInput from "./DragInput";
|
|||||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
import { getElementsInAtomicUnit } from "./utils";
|
import { getElementsInAtomicUnit } from "./utils";
|
||||||
|
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type {
|
||||||
|
DragFinishedCallbackType,
|
||||||
|
DragInputCallbackType,
|
||||||
|
} from "./DragInput";
|
||||||
import type { AtomicUnit } from "./utils";
|
import type { AtomicUnit } from "./utils";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
@@ -153,6 +161,8 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
nextValue,
|
nextValue,
|
||||||
scene,
|
scene,
|
||||||
property,
|
property,
|
||||||
|
setAppState,
|
||||||
|
app,
|
||||||
}) => {
|
}) => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
||||||
@@ -239,6 +249,25 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
shouldInformMutation: false,
|
shouldInformMutation: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle frame membership update for resized frames
|
||||||
|
if (isFrameLikeElement(latestElement)) {
|
||||||
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
latestElement,
|
||||||
|
originalAppState,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedElements = replaceAllElementsInFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
nextElementsInFrame,
|
||||||
|
latestElement,
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
|
||||||
|
scene.replaceAllElements(updatedElements);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,6 +279,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
|
|
||||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||||
|
const elementsToHighlight: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
for (const atomicUnit of atomicUnits) {
|
for (const atomicUnit of atomicUnits) {
|
||||||
const elementsInUnit = getElementsInAtomicUnit(
|
const elementsInUnit = getElementsInAtomicUnit(
|
||||||
@@ -342,13 +372,63 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
shouldInformMutation: false,
|
shouldInformMutation: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle highlighting frame element candidates
|
||||||
|
if (isFrameLikeElement(latestElement)) {
|
||||||
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
latestElement,
|
||||||
|
originalAppState,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
elementsToHighlight.push(...nextElementsInFrame);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
elementsToHighlight,
|
||||||
|
});
|
||||||
|
|
||||||
scene.triggerUpdate();
|
scene.triggerUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragFinished: DragFinishedCallbackType = ({
|
||||||
|
setAppState,
|
||||||
|
app,
|
||||||
|
originalElements,
|
||||||
|
originalAppState,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
const origElement = originalElements?.[0];
|
||||||
|
const latestElement = origElement && elementsMap.get(origElement.id);
|
||||||
|
|
||||||
|
// Handle frame membership update for resized frames
|
||||||
|
if (latestElement && isFrameLikeElement(latestElement)) {
|
||||||
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
app.scene.getElementsIncludingDeleted(),
|
||||||
|
latestElement,
|
||||||
|
originalAppState,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedElements = replaceAllElementsInFrame(
|
||||||
|
app.scene.getElementsIncludingDeleted(),
|
||||||
|
nextElementsInFrame,
|
||||||
|
latestElement,
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.scene.replaceAllElements(updatedElements);
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
elementsToHighlight: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const MultiDimension = ({
|
const MultiDimension = ({
|
||||||
property,
|
property,
|
||||||
elements,
|
elements,
|
||||||
@@ -396,6 +476,7 @@ const MultiDimension = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
property={property}
|
property={property}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
|
dragFinishedCallback={handleDragFinished}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -401,11 +401,23 @@ describe("stats for a non-generic element", () => {
|
|||||||
UI.updateInput(input, "36");
|
UI.updateInput(input, "36");
|
||||||
expect(text.fontSize).toBe(36);
|
expect(text.fontSize).toBe(36);
|
||||||
|
|
||||||
// cannot change width or height
|
// can change width or height
|
||||||
const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
|
const width = UI.queryStatsProperty("W")?.querySelector(
|
||||||
expect(width).toBeUndefined();
|
".drag-input",
|
||||||
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
|
) as HTMLInputElement;
|
||||||
expect(height).toBeUndefined();
|
expect(width).toBeDefined();
|
||||||
|
const height = UI.queryStatsProperty("H")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(height).toBeDefined();
|
||||||
|
|
||||||
|
const textHeightBeforeWrapping = text.height;
|
||||||
|
const textBeforeWrapping = text.text;
|
||||||
|
const originalTextBeforeWrapping = textBeforeWrapping;
|
||||||
|
UI.updateInput(width, "30");
|
||||||
|
expect(text.height).toBeGreaterThan(textHeightBeforeWrapping);
|
||||||
|
expect(text.text).not.toBe(textBeforeWrapping);
|
||||||
|
expect(text.originalText).toBe(originalTextBeforeWrapping);
|
||||||
|
|
||||||
// min font size is 4
|
// min font size is 4
|
||||||
UI.updateInput(input, "0");
|
UI.updateInput(input, "0");
|
||||||
@@ -627,12 +639,11 @@ describe("stats for multiple elements", () => {
|
|||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(fontSize).toBeDefined();
|
expect(fontSize).toBeDefined();
|
||||||
|
|
||||||
// changing width does not affect text
|
|
||||||
UI.updateInput(width, "200");
|
UI.updateInput(width, "200");
|
||||||
|
|
||||||
expect(rectangle?.width).toBe(200);
|
expect(rectangle?.width).toBe(200);
|
||||||
expect(frame.width).toBe(200);
|
expect(frame.width).toBe(200);
|
||||||
expect(text?.width).not.toBe(200);
|
expect(text?.width).toBe(200);
|
||||||
|
|
||||||
UI.updateInput(angle, "40");
|
UI.updateInput(angle, "40");
|
||||||
|
|
||||||
@@ -726,3 +737,196 @@ describe("stats for multiple elements", () => {
|
|||||||
expect(newGroupHeight).toBeCloseTo(500, 4);
|
expect(newGroupHeight).toBeCloseTo(500, 4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("frame resizing behavior", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
renderStaticScene.mockClear();
|
||||||
|
reseed(7);
|
||||||
|
setDateTimeForTests("201933152653");
|
||||||
|
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
|
API.setElements([]);
|
||||||
|
|
||||||
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
|
button: 2,
|
||||||
|
clientX: 1,
|
||||||
|
clientY: 1,
|
||||||
|
});
|
||||||
|
const contextMenu = UI.queryContextMenu();
|
||||||
|
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||||
|
stats = UI.queryStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mockBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
restoreOriginalGetBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add shapes to frame when resizing frame to encompass them", () => {
|
||||||
|
// Create a frame
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a rectangle outside the frame
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 150,
|
||||||
|
y: 50,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle]);
|
||||||
|
|
||||||
|
// Initially, rectangle should not be in the frame
|
||||||
|
expect(rectangle.frameId).toBe(null);
|
||||||
|
|
||||||
|
// Select the frame
|
||||||
|
API.setAppState({
|
||||||
|
selectedElementIds: {
|
||||||
|
[frame.id]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
// Find the width input and update it to encompass the rectangle
|
||||||
|
const widthInput = UI.queryStatsProperty("W")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(widthInput).toBeDefined();
|
||||||
|
expect(widthInput.value).toBe("100");
|
||||||
|
|
||||||
|
// Resize frame to width 250, which should encompass the rectangle
|
||||||
|
UI.updateInput(widthInput, "250");
|
||||||
|
|
||||||
|
// After resizing, the rectangle should now be part of the frame
|
||||||
|
expect(h.elements.find((el) => el.id === rectangle.id)?.frameId).toBe(
|
||||||
|
frame.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add multiple shapes when frame encompasses them through height resize", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rectangle1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 50,
|
||||||
|
y: 150,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rectangle2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 180,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle1, rectangle2]);
|
||||||
|
|
||||||
|
// Initially, rectangles should not be in the frame
|
||||||
|
expect(rectangle1.frameId).toBe(null);
|
||||||
|
expect(rectangle2.frameId).toBe(null);
|
||||||
|
|
||||||
|
// Select the frame
|
||||||
|
API.setAppState({
|
||||||
|
selectedElementIds: {
|
||||||
|
[frame.id]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
// Resize frame height to encompass both rectangles
|
||||||
|
const heightInput = UI.queryStatsProperty("H")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
// Resize frame to height 250, which should encompass both rectangles
|
||||||
|
UI.updateInput(heightInput, "250");
|
||||||
|
|
||||||
|
// After resizing, both rectangles should now be part of the frame
|
||||||
|
expect(h.elements.find((el) => el.id === rectangle1.id)?.frameId).toBe(
|
||||||
|
frame.id,
|
||||||
|
);
|
||||||
|
expect(h.elements.find((el) => el.id === rectangle2.id)?.frameId).toBe(
|
||||||
|
frame.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect shapes that remain outside frame after resize", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const insideRect = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 120,
|
||||||
|
y: 50,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const outsideRect = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 300,
|
||||||
|
y: 50,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([frame, insideRect, outsideRect]);
|
||||||
|
|
||||||
|
// Initially, both rectangles should not be in the frame
|
||||||
|
expect(insideRect.frameId).toBe(null);
|
||||||
|
expect(outsideRect.frameId).toBe(null);
|
||||||
|
|
||||||
|
// Select the frame
|
||||||
|
API.setAppState({
|
||||||
|
selectedElementIds: {
|
||||||
|
[frame.id]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
// Resize frame width to 200, which should only encompass insideRect
|
||||||
|
const widthInput = UI.queryStatsProperty("W")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
UI.updateInput(widthInput, "200");
|
||||||
|
|
||||||
|
// After resizing, only insideRect should be in the frame
|
||||||
|
expect(h.elements.find((el) => el.id === insideRect.id)?.frameId).toBe(
|
||||||
|
frame.id,
|
||||||
|
);
|
||||||
|
expect(h.elements.find((el) => el.id === outsideRect.id)?.frameId).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||||
|
|
||||||
import { getBoundTextElement } from "@excalidraw/element";
|
import { getBoundTextElement } from "@excalidraw/element";
|
||||||
import { isFrameLikeElement, isTextElement } from "@excalidraw/element";
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSelectedGroupIds,
|
getSelectedGroupIds,
|
||||||
@@ -41,12 +41,6 @@ export const isPropertyEditable = (
|
|||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
property: keyof ExcalidrawElement,
|
property: keyof ExcalidrawElement,
|
||||||
) => {
|
) => {
|
||||||
if (property === "height" && isTextElement(element)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (property === "width" && isTextElement(element)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (property === "angle" && isFrameLikeElement(element)) {
|
if (property === "angle" && isFrameLikeElement(element)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export const TTDDialogTrigger = ({
|
|||||||
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
|
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
|
||||||
}}
|
}}
|
||||||
icon={icon ?? brainIcon}
|
icon={icon ?? brainIcon}
|
||||||
|
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
|
||||||
>
|
>
|
||||||
{children ?? t("labels.textToDiagram")}
|
{children ?? t("labels.textToDiagram")}
|
||||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</TTDDialogTriggerTunnel.In>
|
</TTDDialogTriggerTunnel.In>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ const getRelevantAppStateProps = (
|
|||||||
viewModeEnabled: appState.viewModeEnabled,
|
viewModeEnabled: appState.viewModeEnabled,
|
||||||
openDialog: appState.openDialog,
|
openDialog: appState.openDialog,
|
||||||
editingGroupId: appState.editingGroupId,
|
editingGroupId: appState.editingGroupId,
|
||||||
editingLinearElement: appState.editingLinearElement,
|
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
frameToHighlight: appState.frameToHighlight,
|
frameToHighlight: appState.frameToHighlight,
|
||||||
offsetLeft: appState.offsetLeft,
|
offsetLeft: appState.offsetLeft,
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ const StaticCanvas = (props: StaticCanvasProps) => {
|
|||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
props.canvas.style.width = `${props.appState.width}px`;
|
||||||
|
props.canvas.style.height = `${props.appState.height}px`;
|
||||||
|
props.canvas.width = props.appState.width * props.scale;
|
||||||
|
props.canvas.height = props.appState.height * props.scale;
|
||||||
|
}, [props.appState.height, props.appState.width, props.canvas, props.scale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wrapper = wrapperRef.current;
|
const wrapper = wrapperRef.current;
|
||||||
if (!wrapper) {
|
if (!wrapper) {
|
||||||
@@ -49,26 +56,6 @@ const StaticCanvas = (props: StaticCanvasProps) => {
|
|||||||
canvas.classList.add("excalidraw__canvas", "static");
|
canvas.classList.add("excalidraw__canvas", "static");
|
||||||
}
|
}
|
||||||
|
|
||||||
const widthString = `${props.appState.width}px`;
|
|
||||||
const heightString = `${props.appState.height}px`;
|
|
||||||
if (canvas.style.width !== widthString) {
|
|
||||||
canvas.style.width = widthString;
|
|
||||||
}
|
|
||||||
if (canvas.style.height !== heightString) {
|
|
||||||
canvas.style.height = heightString;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scaledWidth = props.appState.width * props.scale;
|
|
||||||
const scaledHeight = props.appState.height * props.scale;
|
|
||||||
// setting width/height resets the canvas even if dimensions not changed,
|
|
||||||
// which would cause flicker when we skip frame (due to throttling)
|
|
||||||
if (canvas.width !== scaledWidth) {
|
|
||||||
canvas.width = scaledWidth;
|
|
||||||
}
|
|
||||||
if (canvas.height !== scaledHeight) {
|
|
||||||
canvas.height = scaledHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderStaticScene(
|
renderStaticScene(
|
||||||
{
|
{
|
||||||
canvas,
|
canvas,
|
||||||
|
|||||||
@@ -1,24 +1,51 @@
|
|||||||
@import "../../css/variables.module.scss";
|
@import "../../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
[data-dropdown-menu-trigger] + [data-radix-popper-content-wrapper] {
|
||||||
|
z-index: 2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: absolute;
|
max-width: 16rem;
|
||||||
top: 100%;
|
margin-top: 0.25rem;
|
||||||
margin-top: 0.5rem;
|
|
||||||
|
&__submenu-trigger {
|
||||||
|
&[aria-expanded="true"] {
|
||||||
|
.dropdown-menu-item {
|
||||||
|
background-color: var(--button-hover-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submenu-trigger-icon {
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radix-menu-item {
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-submenu {
|
||||||
|
margin-left: -0.75rem;
|
||||||
|
min-width: 16rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
&--mobile {
|
&--mobile {
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
row-gap: 0.75rem;
|
|
||||||
|
|
||||||
.dropdown-menu-container {
|
.dropdown-menu-container {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
padding: 8px 8px;
|
padding: 8px 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
// background-color: var(--island-bg-color);
|
background-color: var(--island-bg-color);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
transition: box-shadow 0.5s ease-in-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
&.zen-mode {
|
&.zen-mode {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -28,13 +55,14 @@
|
|||||||
|
|
||||||
.dropdown-menu-container {
|
.dropdown-menu-container {
|
||||||
background-color: var(--island-bg-color);
|
background-color: var(--island-bg-color);
|
||||||
max-height: calc(100vh - 150px);
|
max-height: var(--radix-popper-available-height);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
--gap: 2;
|
--gap: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu-item-base {
|
.dropdown-menu-item-base {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
padding: 0 0.625rem;
|
||||||
column-gap: 0.625rem;
|
column-gap: 0.625rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
@@ -42,6 +70,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.manual-hover {
|
&.manual-hover {
|
||||||
@@ -100,6 +129,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
|
flex: 1 0 auto;
|
||||||
|
|
||||||
@media screen and (min-width: 1921px) {
|
@media screen and (min-width: 1921px) {
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
||||||
import DropdownMenuContent from "./DropdownMenuContent";
|
import DropdownMenuContent from "./DropdownMenuContent";
|
||||||
import DropdownMenuGroup from "./DropdownMenuGroup";
|
import DropdownMenuGroup from "./DropdownMenuGroup";
|
||||||
import DropdownMenuItem from "./DropdownMenuItem";
|
import DropdownMenuItem from "./DropdownMenuItem";
|
||||||
@@ -23,11 +25,12 @@ const DropdownMenu = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const MenuTriggerComp = getMenuTriggerComponent(children);
|
const MenuTriggerComp = getMenuTriggerComponent(children);
|
||||||
const MenuContentComp = getMenuContentComponent(children);
|
const MenuContentComp = getMenuContentComponent(children);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DropdownMenuPrimitive.Root open={open} modal={false}>
|
||||||
{MenuTriggerComp}
|
{MenuTriggerComp}
|
||||||
{open && MenuContentComp}
|
{MenuContentComp}
|
||||||
</>
|
</DropdownMenuPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import React, { useEffect, useRef } from "react";
|
|||||||
|
|
||||||
import { EVENT, KEYS } from "@excalidraw/common";
|
import { EVENT, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
||||||
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
||||||
import { useStable } from "../../hooks/useStable";
|
import { useStable } from "../../hooks/useStable";
|
||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
@@ -17,6 +19,9 @@ const MenuContent = ({
|
|||||||
className = "",
|
className = "",
|
||||||
onSelect,
|
onSelect,
|
||||||
style,
|
style,
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "start",
|
||||||
|
collisionPadding,
|
||||||
}: {
|
}: {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClickOutside?: () => void;
|
onClickOutside?: () => void;
|
||||||
@@ -26,6 +31,11 @@ const MenuContent = ({
|
|||||||
*/
|
*/
|
||||||
onSelect?: (event: Event) => void;
|
onSelect?: (event: Event) => void;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
sideOffset?: number;
|
||||||
|
align?: "start" | "center" | "end";
|
||||||
|
collisionPadding?:
|
||||||
|
| number
|
||||||
|
| Partial<Record<"top" | "right" | "bottom" | "left", number>>;
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -62,11 +72,15 @@ const MenuContent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
|
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
|
||||||
<div
|
<DropdownMenuPrimitive.Content
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={classNames}
|
className={classNames}
|
||||||
style={style}
|
style={style}
|
||||||
data-testid="dropdown-menu"
|
data-testid="dropdown-menu"
|
||||||
|
side="bottom"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
collisionPadding={collisionPadding}
|
||||||
>
|
>
|
||||||
{/* the zIndex ensures this menu has higher stacking order,
|
{/* the zIndex ensures this menu has higher stacking order,
|
||||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||||
@@ -81,7 +95,7 @@ const MenuContent = ({
|
|||||||
{children}
|
{children}
|
||||||
</Island>
|
</Island>
|
||||||
)}
|
)}
|
||||||
</div>
|
</DropdownMenuPrimitive.Content>
|
||||||
</DropdownMenuContentPropsContext.Provider>
|
</DropdownMenuContentPropsContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useRef } from "react";
|
||||||
|
|
||||||
import { THEME } from "@excalidraw/common";
|
import { THEME } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
||||||
import type { ValueOf } from "@excalidraw/common/utility-types";
|
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import { Button } from "../Button";
|
||||||
|
|
||||||
import { useExcalidrawAppState } from "../App";
|
import { useExcalidrawAppState } from "../App";
|
||||||
|
|
||||||
import MenuItemContent from "./DropdownMenuItemContent";
|
import MenuItemContent from "./DropdownMenuItemContent";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getDropdownMenuItemClassName,
|
getDropdownMenuItemClassName,
|
||||||
useHandleDropdownMenuItemClick,
|
useHandleDropdownMenuItemClick,
|
||||||
@@ -17,55 +22,45 @@ import type { JSX } from "react";
|
|||||||
const DropdownMenuItem = ({
|
const DropdownMenuItem = ({
|
||||||
icon,
|
icon,
|
||||||
value,
|
value,
|
||||||
|
badge,
|
||||||
order,
|
order,
|
||||||
children,
|
children,
|
||||||
shortcut,
|
shortcut,
|
||||||
className,
|
className,
|
||||||
hovered,
|
|
||||||
selected,
|
selected,
|
||||||
textStyle,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
onClick,
|
onClick,
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
|
badge?: React.ReactNode;
|
||||||
value?: string | number | undefined;
|
value?: string | number | undefined;
|
||||||
order?: number;
|
order?: number;
|
||||||
onSelect?: (event: Event) => void;
|
onSelect?: (event: Event) => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
hovered?: boolean;
|
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
textStyle?: React.CSSProperties;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||||
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
|
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
|
||||||
const ref = useRef<HTMLButtonElement>(null);
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hovered) {
|
|
||||||
if (order === 0) {
|
|
||||||
// scroll into the first item differently, so it's visible what is above (i.e. group title)
|
|
||||||
ref.current?.scrollIntoView({ block: "end" });
|
|
||||||
} else {
|
|
||||||
ref.current?.scrollIntoView({ block: "nearest" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [hovered, order]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<DropdownMenuPrimitive.Item className="radix-menu-item">
|
||||||
{...rest}
|
<Button
|
||||||
ref={ref}
|
{...rest}
|
||||||
value={value}
|
ref={ref}
|
||||||
onClick={handleClick}
|
onSelect={handleClick}
|
||||||
className={getDropdownMenuItemClassName(className, selected, hovered)}
|
className={getDropdownMenuItemClassName(className)}
|
||||||
title={rest.title ?? rest["aria-label"]}
|
title={rest.title ?? rest["aria-label"]}
|
||||||
>
|
>
|
||||||
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
|
<MenuItemContent icon={icon} shortcut={shortcut} badge={badge}>
|
||||||
{children}
|
{children}
|
||||||
</MenuItemContent>
|
</MenuItemContent>
|
||||||
</button>
|
</Button>
|
||||||
|
</DropdownMenuPrimitive.Item>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
|
|
||||||
import type { JSX } from "react";
|
import { Ellipsify } from "../Ellipsify";
|
||||||
|
|
||||||
const MenuItemContent = ({
|
const MenuItemContent = ({
|
||||||
textStyle,
|
|
||||||
icon,
|
icon,
|
||||||
|
badge,
|
||||||
shortcut,
|
shortcut,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
icon?: JSX.Element;
|
icon?: React.ReactNode;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
textStyle?: React.CSSProperties;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
badge?: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||||
<div style={textStyle} className="dropdown-menu-item__text">
|
<div className="dropdown-menu-item__text">
|
||||||
{children}
|
<Ellipsify>{children}</Ellipsify>
|
||||||
|
{badge}
|
||||||
</div>
|
</div>
|
||||||
{shortcut && !device.editor.isMobile && (
|
{shortcut && !device.editor.isMobile && (
|
||||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSubMenuContentComponent,
|
||||||
|
getSubMenuTriggerComponent,
|
||||||
|
} from "./dropdownMenuUtils";
|
||||||
|
import DropdownMenuSubTrigger from "./DropdownMenuSubTrigger";
|
||||||
|
import DropdownMenuSubContent from "./DropdownMenuSubContent";
|
||||||
|
import DropdownMenuSubItem from "./DropdownMenuSubItem";
|
||||||
|
|
||||||
|
const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => {
|
||||||
|
const MenuTriggerComp = getSubMenuTriggerComponent(children);
|
||||||
|
const MenuContentComp = getSubMenuContentComponent(children);
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Sub>
|
||||||
|
{MenuTriggerComp}
|
||||||
|
{MenuContentComp}
|
||||||
|
</DropdownMenuPrimitive.Sub>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
DropdownMenuSub.Trigger = DropdownMenuSubTrigger;
|
||||||
|
DropdownMenuSub.Content = DropdownMenuSubContent;
|
||||||
|
DropdownMenuSub.Item = DropdownMenuSubItem;
|
||||||
|
|
||||||
|
export default DropdownMenuSub;
|
||||||
|
DropdownMenuSub.displayName = "DropdownMenuSub";
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { useDevice } from "../App";
|
||||||
|
import Stack from "../Stack";
|
||||||
|
import { Island } from "../Island";
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const device = useDevice();
|
||||||
|
|
||||||
|
const classNames = clsx(`dropdown-menu dropdown-submenu ${className}`, {
|
||||||
|
"dropdown-menu--mobile": device.editor.isMobile,
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
className={classNames}
|
||||||
|
sideOffset={8}
|
||||||
|
alignOffset={-4}
|
||||||
|
>
|
||||||
|
{device.editor.isMobile ? (
|
||||||
|
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
|
||||||
|
) : (
|
||||||
|
<Island
|
||||||
|
className="dropdown-menu-container"
|
||||||
|
padding={1}
|
||||||
|
style={{ zIndex: 1 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Island>
|
||||||
|
)}
|
||||||
|
</DropdownMenuPrimitive.SubContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownMenuSubContent;
|
||||||
|
DropdownMenuSubContent.displayName = "DropdownMenuSubContent";
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
||||||
|
import { Button } from "../Button";
|
||||||
|
|
||||||
|
import MenuItemContent from "./DropdownMenuItemContent";
|
||||||
|
import {
|
||||||
|
getDropdownMenuItemClassName,
|
||||||
|
useHandleDropdownMenuItemClick,
|
||||||
|
} from "./common";
|
||||||
|
|
||||||
|
const DropdownMenuSubItem = ({
|
||||||
|
icon,
|
||||||
|
onSelect,
|
||||||
|
children,
|
||||||
|
shortcut,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
onSelect: (event: Event) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
shortcut?: string;
|
||||||
|
className?: string;
|
||||||
|
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||||
|
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item className="radix-menu-item">
|
||||||
|
<Button
|
||||||
|
{...rest}
|
||||||
|
onSelect={handleClick}
|
||||||
|
type="button"
|
||||||
|
className={getDropdownMenuItemClassName(className)}
|
||||||
|
title={rest.title ?? rest["aria-label"]}
|
||||||
|
>
|
||||||
|
<MenuItemContent icon={icon} shortcut={shortcut}>
|
||||||
|
{children}
|
||||||
|
</MenuItemContent>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuPrimitive.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownMenuSubItem;
|
||||||
|
DropdownMenuSubItem.displayName = "DropdownMenuSubItem";
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { ChevronRight } from "../icons";
|
||||||
|
|
||||||
|
import MenuItemContent from "./DropdownMenuItemContent";
|
||||||
|
import { getDropdownMenuItemClassName } from "./common";
|
||||||
|
|
||||||
|
import type { JSX } from "react";
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = ({
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
icon?: JSX.Element;
|
||||||
|
className?: string;
|
||||||
|
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger className="radix-menu-item dropdown-menu__submenu-trigger">
|
||||||
|
<div
|
||||||
|
{...rest}
|
||||||
|
className={getDropdownMenuItemClassName(className)}
|
||||||
|
title={rest.title ?? rest["aria-label"]}
|
||||||
|
>
|
||||||
|
<MenuItemContent icon={icon}>{children}</MenuItemContent>
|
||||||
|
<div className="dropdown-menu__submenu-trigger-icon">
|
||||||
|
{ChevronRight}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownMenuSubTrigger;
|
||||||
|
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger";
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
|
|
||||||
const MenuTrigger = ({
|
const MenuTrigger = ({
|
||||||
@@ -23,7 +25,8 @@ const MenuTrigger = ({
|
|||||||
},
|
},
|
||||||
).trim();
|
).trim();
|
||||||
return (
|
return (
|
||||||
<button
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-dropdown-menu-trigger
|
||||||
data-prevent-outside-click
|
data-prevent-outside-click
|
||||||
className={classNames}
|
className={classNames}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
@@ -33,7 +36,7 @@ const MenuTrigger = ({
|
|||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</DropdownMenuPrimitive.Trigger>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export const getMenuTriggerComponent = (children: React.ReactNode) => {
|
const getMenuComponent = (component: string) => (children: React.ReactNode) => {
|
||||||
const comp = React.Children.toArray(children).find(
|
const comp = React.Children.toArray(children).find(
|
||||||
(child) =>
|
(child) =>
|
||||||
React.isValidElement(child) &&
|
React.isValidElement(child) &&
|
||||||
@@ -8,7 +8,7 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
|
|||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
child?.type.displayName &&
|
child?.type.displayName &&
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
child.type.displayName === "DropdownMenuTrigger",
|
child.type.displayName === component,
|
||||||
);
|
);
|
||||||
if (!comp) {
|
if (!comp) {
|
||||||
return null;
|
return null;
|
||||||
@@ -17,19 +17,11 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
|
|||||||
return comp;
|
return comp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMenuContentComponent = (children: React.ReactNode) => {
|
export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger");
|
||||||
const comp = React.Children.toArray(children).find(
|
export const getMenuContentComponent = getMenuComponent("DropdownMenuContent");
|
||||||
(child) =>
|
export const getSubMenuTriggerComponent = getMenuComponent(
|
||||||
React.isValidElement(child) &&
|
"DropdownMenuSubTrigger",
|
||||||
typeof child.type !== "string" &&
|
);
|
||||||
//@ts-ignore
|
export const getSubMenuContentComponent = getMenuComponent(
|
||||||
child?.type.displayName &&
|
"DropdownMenuSubContent",
|
||||||
//@ts-ignore
|
);
|
||||||
child.type.displayName === "DropdownMenuContent",
|
|
||||||
);
|
|
||||||
if (!comp) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
//@ts-ignore
|
|
||||||
return comp;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import clsx from "clsx";
|
|||||||
|
|
||||||
import { actionShortcuts } from "../../actions";
|
import { actionShortcuts } from "../../actions";
|
||||||
import { useTunnels } from "../../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
import {
|
import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions";
|
||||||
ExitZenModeAction,
|
|
||||||
FinalizeAction,
|
|
||||||
UndoRedoActions,
|
|
||||||
ZoomActions,
|
|
||||||
} from "../Actions";
|
|
||||||
import { useDevice } from "../App";
|
|
||||||
import { HelpButton } from "../HelpButton";
|
import { HelpButton } from "../HelpButton";
|
||||||
import { Section } from "../Section";
|
import { Section } from "../Section";
|
||||||
import Stack from "../Stack";
|
import Stack from "../Stack";
|
||||||
@@ -29,10 +23,6 @@ const Footer = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
|
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
|
||||||
|
|
||||||
const device = useDevice();
|
|
||||||
const showFinalize =
|
|
||||||
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
role="contentinfo"
|
role="contentinfo"
|
||||||
@@ -60,15 +50,6 @@ const Footer = ({
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showFinalize && (
|
|
||||||
<FinalizeAction
|
|
||||||
renderAction={actionManager.renderAction}
|
|
||||||
className={clsx("zen-mode-transition", {
|
|
||||||
"layer-ui__wrapper__footer-left--transition-left":
|
|
||||||
appState.zenModeEnabled,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ const modifiedTablerIconProps: Opts = {
|
|||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
//tabler-icons: chevron-right
|
||||||
|
export const ChevronRight = createIcon(
|
||||||
|
<g strokeWidth="1.5">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<polyline points="9 6 15 12 9 18" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
// tabler-icons: present
|
// tabler-icons: present
|
||||||
export const PlusPromoIcon = createIcon(
|
export const PlusPromoIcon = createIcon(
|
||||||
<g strokeWidth="1.5">
|
<g strokeWidth="1.5">
|
||||||
@@ -2269,3 +2278,21 @@ export const elementLinkIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const settingsIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||||
|
<path d="M4 6l8 0" />
|
||||||
|
<path d="M16 6l4 0" />
|
||||||
|
<path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||||
|
<path d="M4 12l2 0" />
|
||||||
|
<path d="M10 12l10 0" />
|
||||||
|
<path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||||
|
<path d="M4 18l11 0" />
|
||||||
|
<path d="M19 18l1 0" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const emptyIcon = <div style={{ width: "1rem", height: "1rem" }} />;
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import {
|
|||||||
actionLoadScene,
|
actionLoadScene,
|
||||||
actionSaveToActiveFile,
|
actionSaveToActiveFile,
|
||||||
actionShortcuts,
|
actionShortcuts,
|
||||||
|
actionToggleGridMode,
|
||||||
|
actionToggleObjectsSnapMode,
|
||||||
actionToggleSearchMenu,
|
actionToggleSearchMenu,
|
||||||
actionToggleTheme,
|
actionToggleTheme,
|
||||||
|
actionToggleZenMode,
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
@@ -23,13 +26,23 @@ import {
|
|||||||
useExcalidrawActionManager,
|
useExcalidrawActionManager,
|
||||||
useExcalidrawElements,
|
useExcalidrawElements,
|
||||||
useAppProps,
|
useAppProps,
|
||||||
|
useApp,
|
||||||
} from "../App";
|
} from "../App";
|
||||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||||
import Trans from "../Trans";
|
import Trans from "../Trans";
|
||||||
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
|
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
|
||||||
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
|
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
|
||||||
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
|
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
|
||||||
import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons";
|
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
|
||||||
|
import { actionToggleViewMode } from "../../actions/actionToggleViewMode";
|
||||||
|
import {
|
||||||
|
GithubIcon,
|
||||||
|
DiscordIcon,
|
||||||
|
XBrandIcon,
|
||||||
|
settingsIcon,
|
||||||
|
checkIcon,
|
||||||
|
emptyIcon,
|
||||||
|
} from "../icons";
|
||||||
import {
|
import {
|
||||||
boltIcon,
|
boltIcon,
|
||||||
DeviceDesktopIcon,
|
DeviceDesktopIcon,
|
||||||
@@ -313,7 +326,10 @@ export const ChangeCanvasBackground = () => {
|
|||||||
>
|
>
|
||||||
{t("labels.canvasBackground")}
|
{t("labels.canvasBackground")}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: "0 0.625rem" }}>
|
<div
|
||||||
|
style={{ padding: "0 0.625rem" }}
|
||||||
|
id="canvas-bg-color-picker-container"
|
||||||
|
>
|
||||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,3 +409,73 @@ export const LiveCollaborationTrigger = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
|
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
|
||||||
|
|
||||||
|
export const Preferences = ({ children }: { children?: React.ReactNode }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
const appState = useUIAppState();
|
||||||
|
const app = useApp();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSub.Trigger icon={settingsIcon}>
|
||||||
|
{t("labels.preferences")}
|
||||||
|
</DropdownMenuSub.Trigger>
|
||||||
|
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
|
||||||
|
<DropdownMenuSub.Item
|
||||||
|
icon={appState.activeTool.locked ? checkIcon : emptyIcon}
|
||||||
|
shortcut={getShortcutFromShortcutName("toolLock")}
|
||||||
|
onSelect={(event) => {
|
||||||
|
app.toggleLock();
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("labels.preferences_toolLock")}
|
||||||
|
</DropdownMenuSub.Item>
|
||||||
|
<DropdownMenuSub.Item
|
||||||
|
icon={appState.objectsSnapModeEnabled ? checkIcon : emptyIcon}
|
||||||
|
shortcut={getShortcutFromShortcutName("objectsSnapMode")}
|
||||||
|
onSelect={(event) => {
|
||||||
|
actionManager.executeAction(actionToggleObjectsSnapMode);
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.objectsSnapMode")}
|
||||||
|
</DropdownMenuSub.Item>
|
||||||
|
<DropdownMenuSub.Item
|
||||||
|
icon={appState.gridModeEnabled ? checkIcon : emptyIcon}
|
||||||
|
shortcut={getShortcutFromShortcutName("gridMode")}
|
||||||
|
onSelect={(event) => {
|
||||||
|
actionManager.executeAction(actionToggleGridMode);
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("labels.toggleGrid")}
|
||||||
|
</DropdownMenuSub.Item>
|
||||||
|
<DropdownMenuSub.Item
|
||||||
|
icon={appState.zenModeEnabled ? checkIcon : emptyIcon}
|
||||||
|
shortcut={getShortcutFromShortcutName("zenMode")}
|
||||||
|
onSelect={(event) => {
|
||||||
|
actionManager.executeAction(actionToggleZenMode);
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.zenMode")}
|
||||||
|
</DropdownMenuSub.Item>
|
||||||
|
<DropdownMenuSub.Item
|
||||||
|
icon={appState.viewModeEnabled ? checkIcon : emptyIcon}
|
||||||
|
shortcut={getShortcutFromShortcutName("viewMode")}
|
||||||
|
onSelect={(event) => {
|
||||||
|
actionManager.executeAction(actionToggleViewMode);
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("labels.viewMode")}
|
||||||
|
</DropdownMenuSub.Item>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuSub.Content>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Preferences.displayName = "Preferences";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user