Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ed78d4895 | |||
| 5ba2798306 | |||
| b8aae34e32 | |||
| 6317ac16ee | |||
| 9ad75b8375 | |||
| 4c9ad1a22f | |||
| 023895b49b | |||
| eb37be953a | |||
| 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 |
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: Auto release
|
||||
run: |
|
||||
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 }}"
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm install -g yarn && yarn
|
||||
- name: Install Playwright Browsers
|
||||
run: yarn playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: yarn playwright test
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
@@ -17,9 +17,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
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
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: excalidraw/excalidraw:latest
|
||||
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
||||
|
||||
@@ -27,3 +27,9 @@ dev-dist
|
||||
html
|
||||
meta*.json
|
||||
.claude
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
FROM node:18 AS build
|
||||
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
@@ -6,13 +6,14 @@ COPY . .
|
||||
|
||||
# do not ignore optional dependencies:
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -28,32 +28,12 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
|
||||
|
||||
## 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
|
||||
|
||||
To release the next stable version follow the below steps:
|
||||
|
||||
```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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -33,6 +33,7 @@ const ExcalidrawScope = {
|
||||
initialData,
|
||||
useI18n: ExcalidrawComp.useI18n,
|
||||
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
||||
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
|
||||
};
|
||||
|
||||
export default ExcalidrawScope;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"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",
|
||||
"dev": "yarn build:workspace && next dev -p 3005",
|
||||
"build": "yarn build:workspace && next build",
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5002",
|
||||
"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",
|
||||
"installCommand": "yarn install",
|
||||
"buildCommand": "yarn build:package && yarn build"
|
||||
"buildCommand": "yarn build:packages && yarn build"
|
||||
}
|
||||
|
||||
+1
-271
@@ -4,7 +4,6 @@ import {
|
||||
TTDDialogTrigger,
|
||||
CaptureUpdateAction,
|
||||
reconcileElements,
|
||||
convertToExcalidrawElements,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||
@@ -75,6 +74,7 @@ import type {
|
||||
import type { ResolutionType } from "@excalidraw/common/utility-types";
|
||||
import type { ResolvablePromise } from "@excalidraw/common/utils";
|
||||
|
||||
import "./record";
|
||||
import CustomStats from "./CustomStats";
|
||||
import {
|
||||
Provider,
|
||||
@@ -472,276 +472,6 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
||||
loadImages(data, /* isInitialLoad */ true);
|
||||
if (data.scene) {
|
||||
data.scene.elements = data.scene.elements?.length
|
||||
? data.scene.elements
|
||||
: convertToExcalidrawElements([
|
||||
{
|
||||
id: "MyWwgGwbjyZoxKSa2pfBI",
|
||||
type: "rectangle",
|
||||
x: 43.5,
|
||||
y: 52.5,
|
||||
width: 357,
|
||||
height: 280,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor: "#ff8787",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: { type: 3 },
|
||||
seed: 246249428,
|
||||
version: 427,
|
||||
versionNonce: 2079096276,
|
||||
boundElements: [],
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: "ABor4pClpJJs6adQb4iqs",
|
||||
type: "rectangle",
|
||||
x: 357.5,
|
||||
y: 481.5,
|
||||
width: 357,
|
||||
height: 280,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor: "#b2f2bb",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: { type: 3 },
|
||||
seed: 1800887636,
|
||||
version: 445,
|
||||
versionNonce: 650486612,
|
||||
boundElements: [],
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: "-FGknlZxdFGYZNoUr2lG5",
|
||||
type: "rectangle",
|
||||
x: 1280.5,
|
||||
y: 47.5,
|
||||
width: 357,
|
||||
height: 280,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor: "#69db7c",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: { type: 3 },
|
||||
seed: 883462868,
|
||||
version: 532,
|
||||
versionNonce: 912136404,
|
||||
boundElements: [],
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: "XQ3bRtQnS_ydtq13c_p99",
|
||||
type: "rectangle",
|
||||
x: 863.5,
|
||||
y: 334.5,
|
||||
width: 357,
|
||||
height: 280,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor: "#a5d8ff",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: { type: 3 },
|
||||
seed: 2033653844,
|
||||
version: 459,
|
||||
versionNonce: 1984683604,
|
||||
boundElements: [],
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: "GmC3zgvVq3xUd2Qn0cNX3",
|
||||
type: "rectangle",
|
||||
x: 897.5,
|
||||
y: 697.5,
|
||||
width: 357,
|
||||
height: 280,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor: "#ffec99",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: { type: 3 },
|
||||
seed: 81325524,
|
||||
version: 488,
|
||||
versionNonce: 1332016084,
|
||||
boundElements: [],
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: "8nvmvwbmFmNAcLAmwZryR",
|
||||
type: "rectangle",
|
||||
x: 456.5,
|
||||
y: 229.5,
|
||||
width: 357,
|
||||
height: 280,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor: "#eebefa",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: { type: 3 },
|
||||
seed: 950276948,
|
||||
version: 649,
|
||||
versionNonce: 532633940,
|
||||
boundElements: [],
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: "7dyXEvhPDLA0-e3XCaM2H",
|
||||
type: "rectangle",
|
||||
x: 1446.5,
|
||||
y: 609.5,
|
||||
width: 357,
|
||||
height: 280,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor: "#d0bfff",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: { type: 3 },
|
||||
seed: 1659799764,
|
||||
version: 570,
|
||||
versionNonce: 313593556,
|
||||
boundElements: [],
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: "gJubXeAjkEAr6yywtiZ0T",
|
||||
type: "rectangle",
|
||||
x: 1182.5,
|
||||
y: 800.5,
|
||||
width: 357,
|
||||
height: 280,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor: "#96f2d7",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: { type: 3 },
|
||||
seed: 391007828,
|
||||
version: 563,
|
||||
versionNonce: 2026902612,
|
||||
boundElements: [],
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: "pSB2Izj9osR_XxVCzyVnD",
|
||||
type: "rectangle",
|
||||
x: 1477.5,
|
||||
y: 295.5,
|
||||
width: 357,
|
||||
height: 280,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor: "#9775fa",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: { type: 3 },
|
||||
seed: 110715860,
|
||||
version: 590,
|
||||
versionNonce: 535980500,
|
||||
boundElements: [],
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: "yc7IKdtbnjLRaxJI5_q7m",
|
||||
type: "rectangle",
|
||||
x: 696.5,
|
||||
y: 21.5,
|
||||
width: 357,
|
||||
height: 280,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor: "#4dabf7",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: { type: 3 },
|
||||
seed: 1457899860,
|
||||
version: 587,
|
||||
versionNonce: 724438868,
|
||||
boundElements: [],
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: "7KjY1vcMQm_i_i05wowTC",
|
||||
type: "rectangle",
|
||||
x: 447.5,
|
||||
y: 771.5,
|
||||
width: 357,
|
||||
height: 280,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor: "#ffc9c9",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: { type: 3 },
|
||||
seed: 2072608468,
|
||||
version: 610,
|
||||
versionNonce: 1792607444,
|
||||
boundElements: [],
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
initialStatePromiseRef.current.promise.resolve(data.scene);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@excalidraw/excalidraw/renderer/helpers";
|
||||
import { type AppState } from "@excalidraw/excalidraw/types";
|
||||
import { throttleRAF } from "@excalidraw/common";
|
||||
import { useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import {
|
||||
isLineSegment,
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
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 { DebugElement } from "@excalidraw/utils/visualdebug";
|
||||
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
const renderLine = (
|
||||
@@ -113,10 +115,6 @@ const _debugRenderer = (
|
||||
scale,
|
||||
);
|
||||
|
||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
const context = bootstrapCanvas({
|
||||
canvas,
|
||||
scale,
|
||||
@@ -314,35 +312,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
||||
interface DebugCanvasProps {
|
||||
appState: AppState;
|
||||
scale: number;
|
||||
ref?: React.Ref<HTMLCanvasElement>;
|
||||
}
|
||||
|
||||
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
||||
const { width, height } = appState;
|
||||
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||
({ appState, scale }, ref) => {
|
||||
const { width, height } = appState;
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
||||
ref,
|
||||
() => canvasRef.current,
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={canvasRef}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={ref}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default DebugCanvas;
|
||||
|
||||
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
|
||||
}
|
||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
|
||||
@@ -258,11 +258,16 @@ export const loadScene = async (
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
{
|
||||
repairBindings: true,
|
||||
refreshDimensions: false,
|
||||
deleteInvisibleElements: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||
<title>
|
||||
Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw
|
||||
</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||
@@ -14,7 +16,7 @@
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta
|
||||
name="title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"start": "yarn && vite",
|
||||
"start:test": "yarn && vite --mode test",
|
||||
"start:production": "yarn build && yarn serve",
|
||||
"serve": "npx http-server build -a localhost -p 5001 -o",
|
||||
"build:preview": "yarn build && vite preview --port 5000"
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { isDevEnv } from "@excalidraw/common";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
record: typeof Record;
|
||||
}
|
||||
}
|
||||
|
||||
export class Record {
|
||||
private static recording: boolean = false;
|
||||
private static events: string = "";
|
||||
private static timestamp: number = 0;
|
||||
|
||||
public static get isRecording() {
|
||||
return Record.recording;
|
||||
}
|
||||
|
||||
private static header() {
|
||||
Record.events += " await page.addInitScript(() => {\n";
|
||||
Record.events += " Math.random = () => 0.42;\n\n";
|
||||
|
||||
// Capture LocalStorage, which is essential to re-establish state
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key != null) {
|
||||
const value = JSON.stringify(localStorage.getItem(key));
|
||||
Record.events += ` localStorage.getItem("${key}");\n`;
|
||||
Record.events += ` localStorage.setItem("${key}", ${value});\n`;
|
||||
}
|
||||
}
|
||||
Record.events += " });\n";
|
||||
Record.events += ` await page.setViewportSize({ width: ${window.innerWidth}, height: ${window.innerHeight} });\n`;
|
||||
Record.events += ` await page.goto("http://localhost:3000");\n`;
|
||||
Record.events += ` await page.waitForLoadState("load");\n`;
|
||||
}
|
||||
|
||||
public static restart() {
|
||||
if (!Record.recording) {
|
||||
Record.start();
|
||||
return;
|
||||
}
|
||||
|
||||
Record.events += `});\n\n`;
|
||||
Record.events += `test("${
|
||||
Date.now() + Math.floor(Math.random() * Date.now()).toString(36)
|
||||
}", async ({ page }) => {\n`;
|
||||
|
||||
Record.header();
|
||||
}
|
||||
|
||||
public static start() {
|
||||
Record.recording = true;
|
||||
|
||||
// Record header
|
||||
this.header();
|
||||
|
||||
// Set up the events
|
||||
Record.timestamp = performance.now();
|
||||
|
||||
window.addEventListener("mousemove", this.onMouseMove);
|
||||
window.addEventListener("mousedown", this.onMouseDown);
|
||||
window.addEventListener("mouseup", this.onMouseUp);
|
||||
window.addEventListener("keydown", this.onKeyDown);
|
||||
window.addEventListener("keyup", this.onKeyUp);
|
||||
}
|
||||
|
||||
public static stop() {
|
||||
window.removeEventListener("mousemove", this.onMouseMove);
|
||||
window.removeEventListener("mousedown", this.onMouseDown);
|
||||
window.removeEventListener("mouseup", this.onMouseUp);
|
||||
window.removeEventListener("keydown", this.onKeyDown);
|
||||
window.removeEventListener("keyup", this.onKeyUp);
|
||||
Record.recording = false;
|
||||
}
|
||||
|
||||
/// Displays a window as an absolutely positioned DIV with the generated
|
||||
/// events within <pre> tags as formatted JSON, so it can be copied easily.
|
||||
public static showGeneratedEvents() {
|
||||
if (Record.recording) {
|
||||
Record.stop();
|
||||
}
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.style.position = "absolute";
|
||||
div.style.top = "10px";
|
||||
div.style.right = "10px";
|
||||
div.style.left = "10px";
|
||||
div.style.height = "60vh";
|
||||
div.style.backgroundColor = "gray";
|
||||
div.style.padding = "10px";
|
||||
div.style.zIndex = "10000";
|
||||
|
||||
const pre = document.createElement("pre");
|
||||
|
||||
let textContent = `import { expect, test } from "@playwright/test";\n\n`;
|
||||
textContent += `test("${
|
||||
Date.now() + Math.floor(Math.random() * Date.now()).toString(36)
|
||||
}", async ({ page }) => {\n`;
|
||||
textContent += Record.events;
|
||||
textContent += `});\n`;
|
||||
|
||||
pre.textContent = textContent;
|
||||
//pre.textContent = Record.events;
|
||||
|
||||
pre.style.marginTop = "18px";
|
||||
pre.style.maxHeight = "60vh";
|
||||
pre.style.overflow = "auto";
|
||||
div.appendChild(pre);
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.textContent = "Copy";
|
||||
copyBtn.title = "Copy generated events to clipboard";
|
||||
copyBtn.setAttribute("aria-label", "Copy generated events to clipboard");
|
||||
copyBtn.style.position = "absolute";
|
||||
copyBtn.style.top = "4px";
|
||||
copyBtn.style.left = "4px";
|
||||
copyBtn.style.border = "none";
|
||||
copyBtn.style.background = "transparent";
|
||||
copyBtn.style.fontSize = "12px";
|
||||
copyBtn.style.lineHeight = "1";
|
||||
copyBtn.style.cursor = "pointer";
|
||||
copyBtn.style.padding = "4px 8px";
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
const text = pre.textContent ?? "";
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
const orig = copyBtn.textContent;
|
||||
copyBtn.textContent = "Copied";
|
||||
setTimeout(() => (copyBtn.textContent = orig), 1000);
|
||||
} catch {}
|
||||
});
|
||||
div.appendChild(copyBtn);
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.textContent = "×";
|
||||
closeBtn.title = "Close";
|
||||
closeBtn.style.position = "absolute";
|
||||
closeBtn.style.top = "4px";
|
||||
closeBtn.style.right = "4px";
|
||||
closeBtn.style.border = "none";
|
||||
closeBtn.style.background = "transparent";
|
||||
closeBtn.style.fontSize = "18px";
|
||||
closeBtn.style.lineHeight = "1";
|
||||
closeBtn.style.cursor = "pointer";
|
||||
closeBtn.addEventListener("click", () => {
|
||||
// remove the dialog from DOM
|
||||
if (div.parentNode) {
|
||||
div.parentNode.removeChild(div);
|
||||
}
|
||||
});
|
||||
div.appendChild(closeBtn);
|
||||
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
private static onMouseMove(event: MouseEvent) {
|
||||
if (
|
||||
event.clientX < 0 ||
|
||||
event.clientX > window.innerWidth ||
|
||||
event.clientY < 0 ||
|
||||
event.clientY > window.innerHeight
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
Record.events += ` await page.mouse.move(${event.clientX}, ${event.clientY});\n`;
|
||||
}
|
||||
|
||||
private static onMouseDown(event: MouseEvent) {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
const button =
|
||||
event.button === 0 ? "left" : event.button === 1 ? "middle" : "right";
|
||||
Record.events += ` await page.mouse.down({ button: "${button}" });\n`;
|
||||
}
|
||||
|
||||
private static onMouseUp(event: MouseEvent) {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
const button =
|
||||
event.button === 0 ? "left" : event.button === 1 ? "middle" : "right";
|
||||
Record.events += ` await page.mouse.up({ button: "${button}" });\n`;
|
||||
|
||||
Record.events += " await expect(page).toHaveScreenshot({\n";
|
||||
Record.events += " maxDiffPixels: 100,\n";
|
||||
Record.events += " maxDiffPixelRatio: 0.01,\n";
|
||||
Record.events += " });\n";
|
||||
}
|
||||
|
||||
private static onKeyDown(event: KeyboardEvent) {
|
||||
// Only record if the recording key is not pressed
|
||||
if (event.key !== "F2") {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
Record.events += ` await page.keyboard.down("${event.key}");\n`;
|
||||
}
|
||||
}
|
||||
|
||||
private static onKeyUp(event: KeyboardEvent) {
|
||||
// Only record if the recording key is not pressed
|
||||
if (event.key !== "F2") {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
Record.events += ` await page.keyboard.up("${event.key}");\n`;
|
||||
|
||||
Record.events += " await expect(page).toHaveScreenshot({\n";
|
||||
Record.events += " maxDiffPixels: 100,\n";
|
||||
Record.events += " maxDiffPixelRatio: 0.01,\n";
|
||||
Record.events += " });\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDevEnv()) {
|
||||
window.record = Record;
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "F2") {
|
||||
if (Record.isRecording) {
|
||||
if (event.ctrlKey) {
|
||||
console.info("Stopping Playwright recording");
|
||||
Record.stop();
|
||||
} else {
|
||||
Record.restart();
|
||||
}
|
||||
} else {
|
||||
console.info("Starting Playwright recording");
|
||||
Record.start();
|
||||
}
|
||||
} else if (event.key === "Enter" && event.ctrlKey) {
|
||||
Record.showGeneratedEvents();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("17562123239901g67cqde", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Math.random = () => 0.42;
|
||||
|
||||
localStorage.getItem("i18nextLng");
|
||||
localStorage.setItem("i18nextLng", "en");
|
||||
localStorage.getItem("excalidraw-collab");
|
||||
localStorage.setItem("excalidraw-collab", '{"username":""}');
|
||||
localStorage.getItem("excalidraw-debug");
|
||||
localStorage.setItem("excalidraw-debug", '{"enabled":true}');
|
||||
localStorage.getItem("excalidraw-theme");
|
||||
localStorage.setItem("excalidraw-theme", "dark");
|
||||
localStorage.getItem("version-files");
|
||||
localStorage.setItem("version-files", "1756212319038");
|
||||
localStorage.getItem("version-dataState");
|
||||
localStorage.setItem("version-dataState", "1756212319038");
|
||||
localStorage.getItem("excalidraw-state");
|
||||
localStorage.setItem(
|
||||
"excalidraw-state",
|
||||
'{"showWelcomeScreen":true,"theme":"dark","currentChartType":"bar","currentItemBackgroundColor":"#a5d8ff","currentItemEndArrowhead":"arrow","currentItemFillStyle":"solid","currentItemFontFamily":5,"currentItemFontSize":20,"currentItemOpacity":100,"currentItemRoughness":2,"currentItemStartArrowhead":null,"currentItemStrokeColor":"#1e1e1e","currentItemRoundness":"round","currentItemArrowType":"round","currentItemStrokeStyle":"solid","currentItemStrokeWidth":2,"currentItemTextAlign":"left","cursorButton":"up","editingGroupId":null,"activeTool":{"type":"arrow","customType":null,"locked":false,"fromSelection":false,"lastActiveTool":null},"penMode":false,"penDetected":false,"exportBackground":true,"exportScale":1,"exportEmbedScene":false,"exportWithDarkMode":false,"gridSize":20,"gridStep":5,"gridModeEnabled":false,"defaultSidebarDockedPreference":false,"lastPointerDownWith":"mouse","name":"Untitled-2025-07-28-1603","openMenu":null,"openSidebar":null,"previousSelectedElementIds":{},"scrolledOutside":false,"scrollX":688.1079394457738,"scrollY":349.585883261872,"selectedElementIds":{},"selectedGroupIds":{},"shouldCacheIgnoreZoom":false,"stats":{"open":true,"panels":3},"viewBackgroundColor":"#ffffff","zenModeEnabled":false,"zoom":{"value":1.331666},"selectedLinearElement":null,"objectsSnapModeEnabled":false,"lockedMultiSelections":{}}',
|
||||
);
|
||||
localStorage.getItem("excalidraw");
|
||||
localStorage.setItem(
|
||||
"excalidraw",
|
||||
'[{"id":"hJuTw4QcwTsFtadNNnkLj","type":"rectangle","x":-100,"y":-100,"width":200,"height":200,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#a5d8ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a1","roundness":{"type":3},"seed":43277494,"version":839,"versionNonce":1923298088,"isDeleted":false,"boundElements":[{"id":"0xSZCPMN8RzKiJOpvGaKB","type":"arrow"}],"updated":1756212298830,"link":null,"locked":false},{"id":"qXw5KqKvAjHRr5uwPi9B-","type":"rectangle","x":-523.1841597523046,"y":-129.52451989693097,"width":200,"height":200,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#a5d8ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a2","roundness":{"type":3},"seed":392633439,"version":963,"versionNonce":210394408,"isDeleted":false,"boundElements":[{"id":"0xSZCPMN8RzKiJOpvGaKB","type":"arrow"}],"updated":1756212298830,"link":null,"locked":false}]',
|
||||
);
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 1001 });
|
||||
await page.goto("http://localhost:3000");
|
||||
await page.waitForLoadState("load");
|
||||
await page.waitForTimeout(3.599999999627471);
|
||||
await page.mouse.move(425, 390);
|
||||
await page.waitForTimeout(9.900000000372529);
|
||||
await page.mouse.move(424, 390);
|
||||
await page.waitForTimeout(51.09999999962747);
|
||||
await page.mouse.move(423, 390);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(423, 392);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(422, 392);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(420, 393);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(420, 395);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(418, 395);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(417, 396);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(416, 396);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(416, 397);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(414, 397);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(414, 399);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(413, 399);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(413, 401);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(413, 402);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(411, 402);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(411, 403);
|
||||
await page.waitForTimeout(32);
|
||||
await page.mouse.move(412, 403);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(412, 403);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(412, 402);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(413, 402);
|
||||
await page.waitForTimeout(0.900000000372529);
|
||||
await page.mouse.move(413, 400);
|
||||
await page.waitForTimeout(1.099999999627471);
|
||||
await page.mouse.move(415, 400);
|
||||
await page.waitForTimeout(7);
|
||||
await page.mouse.move(416, 400);
|
||||
await page.waitForTimeout(71);
|
||||
await page.mouse.move(417, 400);
|
||||
await page.waitForTimeout(450);
|
||||
await page.mouse.move(417, 399);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(418, 399);
|
||||
await page.waitForTimeout(6.1000000005587935);
|
||||
await page.mouse.move(419, 399);
|
||||
await page.mouse.down({ button: "left" });
|
||||
await page.waitForTimeout(11.899999999441206);
|
||||
await page.mouse.move(420, 399);
|
||||
await page.waitForTimeout(69);
|
||||
await page.mouse.move(421, 399);
|
||||
await page.waitForTimeout(13);
|
||||
await page.mouse.move(422, 399);
|
||||
await page.waitForTimeout(7.1000000005587935);
|
||||
await page.mouse.move(423, 399);
|
||||
await page.waitForTimeout(5.3999999994412065);
|
||||
await page.mouse.move(425, 399);
|
||||
await page.waitForTimeout(0.5);
|
||||
await page.mouse.move(427, 401);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(428, 401);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(428, 403);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(430, 403);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(432, 403);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(432, 404);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(433, 404);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(434, 404);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(436, 406);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(437, 406);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(438, 407);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(440, 407);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(442, 407);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(443, 409);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(445, 409);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(447, 411);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(451, 411);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(455, 411);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(457, 413);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(459, 413);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(461, 415);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(463, 417);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(467, 419);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(469, 419);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(471, 420);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(473, 424);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(477, 424);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(477, 426);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(478, 426);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(482, 428);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(484, 430);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(486, 430);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(488, 430);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(488, 432);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(489, 432);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(491, 432);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(493, 434);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(495, 434);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(499, 436);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(501, 436);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(502, 436);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(504, 438);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(506, 440);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(508, 440);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(510, 442);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(512, 444);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(514, 444);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(514, 445);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(515, 445);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(517, 445);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(517, 447);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(519, 448);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(521, 448);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(522, 450);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(524, 450);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(525, 450);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(527, 450);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(529, 450);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(530, 450);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(532, 450);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(533, 451);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(534, 451);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(535, 451);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(535, 453);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(537, 453);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(538, 453);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(540, 453);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(540, 454);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(541, 454);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(542, 454);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(543, 454);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(544, 454);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(544, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(546, 455);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(547, 455);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(548, 455);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(549, 455);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(551, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(552, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(554, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(555, 455);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(557, 455);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(559, 457);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(560, 457);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(562, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(564, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(566, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(567, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(569, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(570, 459);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(574, 460);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(575, 460);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(575, 462);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(577, 464);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(578, 464);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(580, 464);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(582, 465);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(584, 465);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(585, 465);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(586, 465);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(588, 467);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(590, 467);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(591, 467);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(591, 468);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(594, 468);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(595, 468);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(597, 468);
|
||||
await page.waitForTimeout(2.7000000001862645);
|
||||
await page.mouse.move(601, 470);
|
||||
await page.waitForTimeout(1.400000000372529);
|
||||
await page.mouse.move(602, 470);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(603, 470);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(605, 470);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(607, 470);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(608, 470);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(610, 470);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(612, 470);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(613, 470);
|
||||
await page.waitForTimeout(1.2000000001862645);
|
||||
await page.mouse.move(613, 471);
|
||||
await page.waitForTimeout(1.7999999998137355);
|
||||
await page.mouse.move(614, 471);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(616, 473);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(618, 473);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(621, 473);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(623, 473);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(625, 473);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(626, 473);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(628, 473);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(629, 473);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(631, 473);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(634, 475);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(636, 475);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(638, 475);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(639, 475);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(640, 475);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(642, 475);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(644, 475);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(647, 475);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(649, 475);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(650, 475);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(652, 475);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(653, 475);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(655, 475);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(656, 476);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(658, 476);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(659, 476);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(660, 476);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(662, 476);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(663, 476);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(664, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(666, 474);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(668, 474);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(670, 474);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(671, 474);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(673, 476);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(675, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(678, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(680, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(682, 476);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(683, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(684, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(686, 476);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(690, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(694, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(696, 478);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(698, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(699, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(703, 478);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(705, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(707, 478);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(708, 478);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(711, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(713, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(714, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(716, 478);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(717, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(719, 478);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(722, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(724, 478);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(725, 478);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(726, 478);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(727, 478);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(733, 480);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(734, 480);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(735, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(737, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(739, 480);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(740, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(742, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(744, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(744, 482);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(745, 482);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(747, 482);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(748, 482);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(749, 482);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(751, 482);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(753, 482);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(755, 482);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(758, 482);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(760, 482);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(761, 483);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(763, 484);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(765, 484);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(766, 485);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(767, 485);
|
||||
await page.waitForTimeout(11);
|
||||
await page.mouse.move(768, 485);
|
||||
await page.waitForTimeout(12.100000000558794);
|
||||
await page.mouse.move(769, 485);
|
||||
await page.waitForTimeout(51.89999999944121);
|
||||
await page.mouse.move(770, 485);
|
||||
await page.waitForTimeout(9);
|
||||
await page.mouse.move(771, 485);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(772, 485);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(773, 485);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(774, 485);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(776, 487);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(777, 487);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(778, 487);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(779, 487);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(780, 488);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(781, 488);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(782, 488);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(784, 488);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(785, 488);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(786, 488);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(788, 488);
|
||||
await page.waitForTimeout(8.899999999441206);
|
||||
await page.mouse.move(789, 488);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(790, 488);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(791, 488);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(792, 488);
|
||||
await page.waitForTimeout(6.1000000005587935);
|
||||
await page.mouse.move(793, 488);
|
||||
await page.waitForTimeout(3.8999999994412065);
|
||||
await page.mouse.move(793, 489);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(794, 489);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(795, 489);
|
||||
await page.waitForTimeout(7.599999999627471);
|
||||
await page.mouse.move(796, 489);
|
||||
await page.waitForTimeout(3.400000000372529);
|
||||
await page.mouse.move(797, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(798, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(799, 489);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(800, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(801, 489);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(802, 489);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(803, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(804, 489);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(805, 489);
|
||||
await page.waitForTimeout(5.8999999994412065);
|
||||
await page.mouse.move(806, 490);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(807, 490);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(809, 490);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(810, 490);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(812, 490);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(813, 490);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(815, 490);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(816, 492);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(818, 492);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(819, 492);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(820, 492);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(822, 492);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(823, 492);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(825, 492);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(826, 492);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(827, 492);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(828, 492);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(830, 492);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(831, 492);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(832, 492);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(833, 493);
|
||||
await page.waitForTimeout(6.1000000005587935);
|
||||
await page.mouse.move(835, 493);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(836, 493);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(838, 493);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(839, 493);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(841, 493);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(842, 493);
|
||||
await page.waitForTimeout(3.8999999994412065);
|
||||
await page.mouse.move(843, 493);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(843, 492);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(844, 492);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(844, 493);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(845, 493);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(846, 493);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(847, 494);
|
||||
await page.waitForTimeout(24.899999999441206);
|
||||
await page.mouse.move(847, 495);
|
||||
await page.waitForTimeout(29.100000000558794);
|
||||
await page.mouse.move(848, 495);
|
||||
await page.waitForTimeout(297.8999999994412);
|
||||
await page.mouse.move(848, 495);
|
||||
await page.waitForTimeout(5.7000000001862645);
|
||||
await page.mouse.up({ button: "left" });
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
maxDiffPixelRatio: 0.01,
|
||||
});
|
||||
await page.waitForTimeout(11.299999999813735);
|
||||
await page.mouse.move(847, 495);
|
||||
await page.waitForTimeout(199.1000000005588);
|
||||
await page.mouse.move(847, 495);
|
||||
await page.waitForTimeout(249.29999999981374);
|
||||
await page.mouse.move(846, 495);
|
||||
await page.waitForTimeout(5.7000000001862645);
|
||||
await page.mouse.move(846, 496);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(845, 496);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(845, 497);
|
||||
await page.waitForTimeout(9);
|
||||
await page.mouse.move(845, 498);
|
||||
await page.waitForTimeout(3.8999999994412065);
|
||||
await page.mouse.move(844, 498);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(844, 500);
|
||||
await page.waitForTimeout(8);
|
||||
await page.mouse.move(844, 501);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(843, 501);
|
||||
await page.waitForTimeout(137);
|
||||
await page.mouse.move(844, 501);
|
||||
await page.waitForTimeout(68.09999999962747);
|
||||
await page.mouse.move(845, 501);
|
||||
await page.waitForTimeout(7.7999999998137355);
|
||||
await page.mouse.move(845, 500);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(846, 500);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(847, 500);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(848, 500);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(849, 500);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(850, 500);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(851, 500);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(852, 499);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(854, 499);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(855, 499);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(856, 499);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(857, 499);
|
||||
await page.waitForTimeout(12);
|
||||
await page.mouse.move(858, 499);
|
||||
await page.waitForTimeout(24);
|
||||
await page.mouse.move(858, 498);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(858, 498);
|
||||
await page.waitForTimeout(59.200000000186265);
|
||||
await page.keyboard.down("Control");
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
+13
-5
@@ -11,9 +11,11 @@
|
||||
"@babel/preset-env": "7.26.9",
|
||||
"@excalidraw/eslint-config": "1.0.3",
|
||||
"@excalidraw/prettier-config": "1.0.2",
|
||||
"@playwright/test": "1.55.0",
|
||||
"@types/chai": "4.3.0",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/lodash.throttle": "4.1.7",
|
||||
"@types/node": "24.3.0",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/socket.io-client": "3.0.0",
|
||||
@@ -52,13 +54,18 @@
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||
"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": "yarn --cwd ./excalidraw-app build",
|
||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||
"start": "yarn --cwd ./excalidraw-app start",
|
||||
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
|
||||
"start:test": "yarn --cwd ./excalidraw-app start:test",
|
||||
"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:app": "vitest",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
@@ -76,9 +83,10 @@
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||
"release:excalidraw": "node scripts/release.js",
|
||||
"release": "node scripts/release.js",
|
||||
"release:test": "node scripts/release.js --tag=test",
|
||||
"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:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
||||
"clean-install": "yarn rm:node_modules && yarn install"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/common",
|
||||
"version": "0.1.0",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/common/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,7 +13,10 @@
|
||||
"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": [
|
||||
|
||||
@@ -18,13 +18,22 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
export const isIOS =
|
||||
/iPad|iPhone/.test(navigator.platform) ||
|
||||
/iPad|iPhone/i.test(navigator.platform) ||
|
||||
// iPadOS 13+
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||
// keeping function so it can be mocked in test
|
||||
export const isBrave = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
export const isMobile =
|
||||
isIOS ||
|
||||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
||||
navigator.userAgent.toLowerCase(),
|
||||
) ||
|
||||
/android|ios|ipod|blackberry|windows phone/i.test(
|
||||
navigator.platform.toLowerCase(),
|
||||
);
|
||||
|
||||
export const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
@@ -36,6 +45,7 @@ export const APP_NAME = "Excalidraw";
|
||||
// (happens a lot with fast clicks with the text tool)
|
||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||
export const DRAGGING_THRESHOLD = 10; // px
|
||||
export const MINIMUM_ARROW_SIZE = 20; // px
|
||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import { getCenterForBounds, getElementBounds } from "@excalidraw/element";
|
||||
import { average } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
ExcalidrawElement,
|
||||
ElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
@@ -1239,17 +1236,6 @@ export const escapeDoubleQuotes = (str: string) => {
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
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[] */
|
||||
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||
return Array.isArray(value);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/element",
|
||||
"version": "0.1.0",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/element/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,7 +13,10 @@
|
||||
"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": [
|
||||
@@ -52,5 +55,9 @@
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"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;
|
||||
}
|
||||
|
||||
constructor(elements: ElementsMapOrArray | null = null) {
|
||||
constructor(
|
||||
elements: ElementsMapOrArray | null = null,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
if (elements) {
|
||||
this.replaceAllElements(elements);
|
||||
this.replaceAllElements(elements, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,12 +268,19 @@ export class Scene {
|
||||
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
|
||||
const _nextElements = toArray(nextElements);
|
||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
|
||||
validateIndicesThrottled(_nextElements);
|
||||
if (!options?.skipValidation) {
|
||||
validateIndicesThrottled(_nextElements);
|
||||
}
|
||||
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
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 { getCommonBoundingBox } from "./bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
@@ -16,11 +18,12 @@ export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
);
|
||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
invariant,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
elementCenterPoint,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
@@ -34,9 +33,9 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
doBoundsIntersect,
|
||||
getCenterForBounds,
|
||||
getElementBounds,
|
||||
doBoundsIntersect,
|
||||
} from "./bounds";
|
||||
import { intersectElementWithLineSegment } from "./collision";
|
||||
import { distanceToElement } from "./distance";
|
||||
@@ -61,7 +60,7 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { aabbForElement } from "./shapes";
|
||||
import { aabbForElement, elementCenterPoint } from "./bounds";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
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 = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
@@ -514,7 +555,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
|
||||
|
||||
const isLinearElementSimple = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
): boolean => linearElement.points.length < 3;
|
||||
): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement);
|
||||
|
||||
const unbindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
|
||||
@@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { generateRoughOptions } from "./Shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { generateRoughOptions } from "./shape";
|
||||
import { ShapeCache } from "./shape";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getElementShape } from "./shapes";
|
||||
import { getElementShape } from "./shape";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
@@ -584,7 +584,7 @@ const solveQuadratic = (
|
||||
return [s1, s2];
|
||||
};
|
||||
|
||||
const getCubicBezierCurveBound = (
|
||||
export const getCubicBezierCurveBound = (
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
@@ -1165,6 +1165,71 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
||||
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 = (
|
||||
bounds1: Bounds | null,
|
||||
bounds2: Bounds | null,
|
||||
@@ -1178,3 +1243,14 @@ export const doBoundsIntersect = (
|
||||
|
||||
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 {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
@@ -18,15 +18,22 @@ import {
|
||||
ellipseSegmentInterceptPoints,
|
||||
} 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 { isPathALoop } from "./shapes";
|
||||
import { isPathALoop } from "./utils";
|
||||
import {
|
||||
type Bounds,
|
||||
doBoundsIntersect,
|
||||
elementCenterPoint,
|
||||
getCenterForBounds,
|
||||
getCubicBezierCurveBound,
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
import {
|
||||
@@ -250,26 +257,79 @@ export const intersectElementWithLineSegment = (
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "arrow":
|
||||
return intersectLinearOrFreeDrawWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
onlyFirst,
|
||||
);
|
||||
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||
}
|
||||
};
|
||||
|
||||
const curveIntersections = (
|
||||
curves: Curve<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
for (const j of hits) {
|
||||
intersections.push(pointRotateRads(j, center, angle));
|
||||
}
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const lineIntersections = (
|
||||
lines: LineSegment<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(pointRotateRads(intersection, center, angle));
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
const intersections = [];
|
||||
// NOTE: This is the only one which return the decomposed elements
|
||||
// rotated! This is due to taking advantage of roughjs definitions.
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
@@ -283,6 +343,19 @@ const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -300,7 +373,7 @@ const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
const intersectRectanguloidWithLineSegment = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
@@ -308,48 +381,43 @@ const intersectRectanguloidWithLineSegment = (
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||
l[0],
|
||||
segment[0],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||
l[1],
|
||||
segment[1],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
// Get the element's building components we can test against
|
||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const s of sides) {
|
||||
const intersection = lineSegmentIntersectionPoints(
|
||||
lineSegment(rotatedA, rotatedB),
|
||||
s,
|
||||
);
|
||||
if (intersection) {
|
||||
intersections.push(pointRotateRads(intersection, center, element.angle));
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
for (const t of corners) {
|
||||
const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
|
||||
|
||||
if (hits.length > 0) {
|
||||
for (const j of hits) {
|
||||
intersections.push(pointRotateRads(j, center, element.angle));
|
||||
}
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
@@ -374,38 +442,32 @@ const intersectDiamondWithLineSegment = (
|
||||
// points. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads(l[0], 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 intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const s of sides) {
|
||||
const intersection = lineSegmentIntersectionPoints(
|
||||
lineSegment(rotatedA, rotatedB),
|
||||
s,
|
||||
);
|
||||
if (intersection) {
|
||||
intersections.push(pointRotateRads(intersection, center, element.angle));
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
for (const t of corners) {
|
||||
const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
|
||||
|
||||
if (hits.length > 0) {
|
||||
for (const j of hits) {
|
||||
intersections.push(pointRotateRads(j, center, element.angle));
|
||||
}
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
@@ -14,9 +14,8 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { type Point } from "points-on-curve";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
elementCenterPoint,
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
|
||||
+370
-125
@@ -55,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
|
||||
|
||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import { StoreSnapshot } from "./store";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import type { BindableProp, BindingProp } from "./binding";
|
||||
|
||||
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.
|
||||
*/
|
||||
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||
prev: T,
|
||||
added: T,
|
||||
removed: T,
|
||||
removed: T = {} as T,
|
||||
) {
|
||||
const cloned = { ...prev };
|
||||
|
||||
@@ -496,6 +510,11 @@ export interface DeltaContainer<T> {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -503,7 +522,11 @@ export interface DeltaContainer<T> {
|
||||
}
|
||||
|
||||
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>(
|
||||
prevAppState: T,
|
||||
@@ -534,53 +557,124 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
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(
|
||||
appState: AppState,
|
||||
nextElements: SceneElementsMap,
|
||||
): [AppState, boolean] {
|
||||
try {
|
||||
const {
|
||||
selectedElementIds: removedSelectedElementIds = {},
|
||||
selectedGroupIds: removedSelectedGroupIds = {},
|
||||
selectedElementIds: deletedSelectedElementIds = {},
|
||||
selectedGroupIds: deletedSelectedGroupIds = {},
|
||||
lockedMultiSelections: deletedLockedMultiSelections = {},
|
||||
} = this.delta.deleted;
|
||||
|
||||
const {
|
||||
selectedElementIds: addedSelectedElementIds = {},
|
||||
selectedGroupIds: addedSelectedGroupIds = {},
|
||||
selectedLinearElementId,
|
||||
editingLinearElementId,
|
||||
selectedElementIds: insertedSelectedElementIds = {},
|
||||
selectedGroupIds: insertedSelectedGroupIds = {},
|
||||
lockedMultiSelections: insertedLockedMultiSelections = {},
|
||||
selectedLinearElement: insertedSelectedLinearElement,
|
||||
...directlyApplicablePartial
|
||||
} = this.delta.inserted;
|
||||
|
||||
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||
appState.selectedElementIds,
|
||||
addedSelectedElementIds,
|
||||
removedSelectedElementIds,
|
||||
insertedSelectedElementIds,
|
||||
deletedSelectedElementIds,
|
||||
);
|
||||
|
||||
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||
appState.selectedGroupIds,
|
||||
addedSelectedGroupIds,
|
||||
removedSelectedGroupIds,
|
||||
insertedSelectedGroupIds,
|
||||
deletedSelectedGroupIds,
|
||||
);
|
||||
|
||||
const mergedLockedMultiSelections = Delta.mergeObjects(
|
||||
appState.lockedMultiSelections,
|
||||
insertedLockedMultiSelections,
|
||||
deletedLockedMultiSelections,
|
||||
);
|
||||
|
||||
const selectedLinearElement =
|
||||
selectedLinearElementId && nextElements.has(selectedLinearElementId)
|
||||
insertedSelectedLinearElement &&
|
||||
nextElements.has(insertedSelectedLinearElement.elementId)
|
||||
? new LinearElementEditor(
|
||||
nextElements.get(
|
||||
selectedLinearElementId,
|
||||
) as NonDeleted<ExcalidrawLinearElement>,
|
||||
nextElements,
|
||||
)
|
||||
: null;
|
||||
|
||||
const editingLinearElement =
|
||||
editingLinearElementId && nextElements.has(editingLinearElementId)
|
||||
? new LinearElementEditor(
|
||||
nextElements.get(
|
||||
editingLinearElementId,
|
||||
insertedSelectedLinearElement.elementId,
|
||||
) as NonDeleted<ExcalidrawLinearElement>,
|
||||
nextElements,
|
||||
insertedSelectedLinearElement.isEditing,
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -589,14 +683,11 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
...directlyApplicablePartial,
|
||||
selectedElementIds: mergedSelectedElementIds,
|
||||
selectedGroupIds: mergedSelectedGroupIds,
|
||||
lockedMultiSelections: mergedLockedMultiSelections,
|
||||
selectedLinearElement:
|
||||
typeof selectedLinearElementId !== "undefined"
|
||||
? selectedLinearElement // element was either inserted or deleted
|
||||
: appState.selectedLinearElement, // otherwise assign what we had before
|
||||
editingLinearElement:
|
||||
typeof editingLinearElementId !== "undefined"
|
||||
? editingLinearElement // element was either inserted or deleted
|
||||
: appState.editingLinearElement, // otherwise assign what we had before
|
||||
typeof insertedSelectedLinearElement !== "undefined"
|
||||
? selectedLinearElement
|
||||
: appState.selectedLinearElement,
|
||||
};
|
||||
|
||||
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||
@@ -725,52 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
}
|
||||
|
||||
break;
|
||||
case "selectedLinearElementId":
|
||||
case "editingLinearElementId":
|
||||
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||
const linearElement = nextAppState[appStateKey];
|
||||
case "selectedLinearElement":
|
||||
const nextLinearElement = nextAppState[key];
|
||||
|
||||
if (!linearElement) {
|
||||
if (!nextLinearElement) {
|
||||
// previously there was a linear element (assuming visible), now there is none
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
const element = nextElements.get(linearElement.elementId);
|
||||
const element = nextElements.get(nextLinearElement.elementId);
|
||||
|
||||
if (element && !element.isDeleted) {
|
||||
// previously there wasn't a linear element, now there is one which is visible
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
// there was assigned a linear element now, but it's deleted
|
||||
nextAppState[appStateKey] = null;
|
||||
nextAppState[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case "lockedMultiSelections": {
|
||||
case "lockedMultiSelections":
|
||||
const prevLockedUnits = prevAppState[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)) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "activeLockedId": {
|
||||
case "activeLockedId":
|
||||
const prevHitLockedId = prevAppState[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) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
default:
|
||||
assertNever(
|
||||
key,
|
||||
`Unknown ObservedElementsAppState's key "${key}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -778,20 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
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(
|
||||
selectedElementIds: AppState["selectedElementIds"],
|
||||
elements: SceneElementsMap,
|
||||
@@ -856,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
editingGroupId,
|
||||
selectedGroupIds,
|
||||
selectedElementIds,
|
||||
editingLinearElementId,
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
croppingElementId,
|
||||
lockedMultiSelections,
|
||||
activeLockedId,
|
||||
@@ -911,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
"lockedMultiSelections",
|
||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"activeLockedId",
|
||||
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
||||
);
|
||||
} catch (e) {
|
||||
// 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.`);
|
||||
@@ -945,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
||||
|
||||
export type ApplyToOptions = {
|
||||
excludedProperties: Set<keyof ElementPartial>;
|
||||
excludedProperties?: Set<keyof ElementPartial>;
|
||||
};
|
||||
|
||||
type ApplyToFlags = {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
applyDirection: "forward" | "backward" | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1051,6 +1123,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
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(
|
||||
elementsDelta: ElementsDelta,
|
||||
type: "added" | "removed" | "updated",
|
||||
@@ -1059,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||
if (
|
||||
!this.satisfiesCommmonInvariants(delta) ||
|
||||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
|
||||
!satifiesSpecialInvariants(delta)
|
||||
) {
|
||||
console.error(
|
||||
@@ -1095,7 +1177,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const nextElement = nextElements.get(prevElement.id);
|
||||
|
||||
if (!nextElement) {
|
||||
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
||||
const deleted = { ...prevElement } as ElementPartial;
|
||||
|
||||
const inserted = {
|
||||
isDeleted: true,
|
||||
@@ -1109,7 +1191,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
removed[prevElement.id] = delta;
|
||||
// ignore updates which would "delete" already deleted element
|
||||
if (!prevElement.isDeleted) {
|
||||
removed[prevElement.id] = delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1125,7 +1210,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
const inserted = {
|
||||
...nextElement,
|
||||
isDeleted: false,
|
||||
} as ElementPartial;
|
||||
|
||||
const delta = Delta.create(
|
||||
@@ -1134,7 +1218,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
added[nextElement.id] = delta;
|
||||
// ignore updates which would "delete" already deleted element
|
||||
if (!nextElement.isDeleted) {
|
||||
added[nextElement.id] = delta;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -1163,8 +1250,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// making sure there are at least some changes
|
||||
if (!Delta.isEmpty(delta)) {
|
||||
const strippedDeleted = ElementsDelta.stripVersionProps(delta.deleted);
|
||||
const strippedInserted = ElementsDelta.stripVersionProps(
|
||||
delta.inserted,
|
||||
);
|
||||
|
||||
// making sure there are at least some changes and only changed version & versionNonce does not count!
|
||||
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted, true)) {
|
||||
updated[nextElement.id] = delta;
|
||||
}
|
||||
}
|
||||
@@ -1181,8 +1273,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||
|
||||
for (const [id, delta] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
|
||||
}
|
||||
|
||||
return inversedDeltas;
|
||||
@@ -1280,8 +1372,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
latestDelta = delta;
|
||||
}
|
||||
|
||||
const strippedDeleted = ElementsDelta.stripVersionProps(
|
||||
latestDelta.deleted,
|
||||
);
|
||||
const strippedInserted = ElementsDelta.stripVersionProps(
|
||||
latestDelta.inserted,
|
||||
);
|
||||
|
||||
// it might happen that after applying latest changes the delta itself does not contain any changes
|
||||
if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) {
|
||||
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted)) {
|
||||
modifiedDeltas[id] = latestDelta;
|
||||
}
|
||||
}
|
||||
@@ -1301,9 +1400,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
||||
options: ApplyToOptions = {
|
||||
excludedProperties: new Set(),
|
||||
},
|
||||
options?: ApplyToOptions,
|
||||
): [SceneElementsMap, boolean] {
|
||||
let nextElements = new Map(elements) as SceneElementsMap;
|
||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||
@@ -1311,22 +1408,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const flags: ApplyToFlags = {
|
||||
containsVisibleDifference: false,
|
||||
containsZindexDifference: false,
|
||||
applyDirection: undefined,
|
||||
};
|
||||
|
||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||
try {
|
||||
const applyDeltas = ElementsDelta.createApplier(
|
||||
elements,
|
||||
nextElements,
|
||||
snapshot,
|
||||
options,
|
||||
flags,
|
||||
options,
|
||||
);
|
||||
|
||||
const addedElements = applyDeltas(this.added);
|
||||
const removedElements = applyDeltas(this.removed);
|
||||
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
|
||||
changedElements = new Map([
|
||||
@@ -1350,22 +1453,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
try {
|
||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||
// 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
|
||||
nextElements = ElementsDelta.reorderElements(
|
||||
nextElements,
|
||||
changedElements,
|
||||
flags,
|
||||
);
|
||||
|
||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||
// we also don't have a scene on the server
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements);
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
// Need ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
ElementsDelta.redrawElements(nextElements, changedElements);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Couldn't mutate elements after applying elements change`,
|
||||
@@ -1380,12 +1476,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 =
|
||||
(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"],
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) =>
|
||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const getElement = ElementsDelta.createGetter(
|
||||
@@ -1398,15 +1595,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const element = getElement(id, delta.inserted);
|
||||
|
||||
if (element) {
|
||||
const newElement = ElementsDelta.applyDelta(
|
||||
const nextElement = ElementsDelta.applyDelta(
|
||||
element,
|
||||
delta,
|
||||
options,
|
||||
flags,
|
||||
options,
|
||||
);
|
||||
|
||||
nextElements.set(newElement.id, newElement);
|
||||
acc.set(newElement.id, newElement);
|
||||
nextElements.set(nextElement.id, nextElement);
|
||||
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;
|
||||
@@ -1451,8 +1659,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private static applyDelta(
|
||||
element: OrderedExcalidrawElement,
|
||||
delta: Delta<ElementPartial>,
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) {
|
||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
||||
|
||||
@@ -1466,7 +1674,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.excludedProperties.has(key)) {
|
||||
if (options?.excludedProperties?.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1506,7 +1714,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
delta.deleted.index !== delta.inserted.index;
|
||||
}
|
||||
|
||||
return newElementWith(element, directlyApplicablePartial);
|
||||
return newElementWith(element, directlyApplicablePartial, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1546,6 +1754,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private resolveConflicts(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
applyDirection: "forward" | "backward" = "forward",
|
||||
) {
|
||||
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
const updater = (
|
||||
@@ -1557,21 +1766,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
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;
|
||||
|
||||
if (prevElements.get(element.id) === nextElement) {
|
||||
if (prevElement === nextElement) {
|
||||
// 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
|
||||
affectedElement = newElementWith(
|
||||
nextElement,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
{
|
||||
...elementUpdates,
|
||||
version: nextVersion,
|
||||
},
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
affectedElement = mutateElement(
|
||||
nextElement,
|
||||
nextElements,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
);
|
||||
affectedElement = mutateElement(nextElement, nextElements, {
|
||||
...elementUpdates,
|
||||
// don't modify the version further, if it's already different
|
||||
version:
|
||||
prevElement?.version !== nextElement.version
|
||||
? nextElement.version
|
||||
: nextVersion,
|
||||
});
|
||||
}
|
||||
|
||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||
@@ -1609,25 +1833,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||
);
|
||||
|
||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||
// technically we could do better here if perf. would become an issue
|
||||
const { added, removed, updated } = ElementsDelta.calculate(
|
||||
prevAffectedElements,
|
||||
nextAffectedElements,
|
||||
// calculate complete deltas for affected elements, and squash them back to the current deltas
|
||||
this.squash(
|
||||
// technically we could do better here if perf. would become an issue
|
||||
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
|
||||
);
|
||||
|
||||
for (const [id, delta] of Object.entries(added)) {
|
||||
this.added[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(removed)) {
|
||||
this.removed[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(updated)) {
|
||||
this.updated[id] = delta;
|
||||
}
|
||||
|
||||
return nextAffectedElements;
|
||||
}
|
||||
|
||||
@@ -1689,6 +1900,31 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
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(
|
||||
scene: Scene,
|
||||
changed: Map<string, OrderedExcalidrawElement>,
|
||||
@@ -1743,6 +1979,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
) {
|
||||
for (const element of changed.values()) {
|
||||
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, {
|
||||
changedElements: changed,
|
||||
});
|
||||
@@ -1838,4 +2075,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
return strippedPartial;
|
||||
}
|
||||
|
||||
private static stripVersionProps(
|
||||
partial: Partial<OrderedExcalidrawElement>,
|
||||
): ElementPartial {
|
||||
const { version, versionNonce, ...strippedPartial } = partial;
|
||||
|
||||
return strippedPartial;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
|
||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
@@ -16,6 +14,8 @@ import {
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { elementCenterPoint } from "./bounds";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
@@ -48,7 +48,7 @@ export const distanceToElement = (
|
||||
case "line":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
|
||||
return distanceToLinearOrFreeDraElement(element, p);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,13 +133,9 @@ const distanceToEllipseElement = (
|
||||
|
||||
const distanceToLinearOrFreeDraElement = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
return Math.min(
|
||||
...lines.map((s) => distanceToLineSegment(p, s)),
|
||||
...curves.map((a) => curvePointDistance(a, p)),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
@@ -14,6 +16,7 @@ export const distributeElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
distribution: Distribution,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const [start, mid, end, extent] =
|
||||
distribution.axis === "x"
|
||||
@@ -21,7 +24,11 @@ export const distributeElements = (
|
||||
: (["minY", "midY", "maxY", "height"] as const);
|
||||
|
||||
const bounds = getCommonBoundingBox(selectedElements);
|
||||
const groups = getMaximumGroups(selectedElements, elementsMap)
|
||||
const groups = getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
appState,
|
||||
)
|
||||
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
||||
.sort((a, b) => a[1][mid] - b[1][mid]);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
tupleToCoors,
|
||||
getSizeFromPoints,
|
||||
isDevEnv,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
@@ -51,7 +52,7 @@ import {
|
||||
type NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import { aabbForElement, pointInsideBounds } from "./shapes";
|
||||
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { Heading } from "./heading";
|
||||
@@ -2208,20 +2209,12 @@ const getGlobalPoint = (
|
||||
return initialPoint;
|
||||
}
|
||||
|
||||
if (element && elementsMap) {
|
||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
||||
if (element) {
|
||||
return getGlobalFixedPointForBindableElement(
|
||||
fixedPointRatio || [0, 0],
|
||||
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;
|
||||
|
||||
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||
|
||||
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 =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
@@ -56,6 +56,35 @@ const RE_REDDIT =
|
||||
const RE_REDDIT_EMBED =
|
||||
/^<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([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
@@ -113,7 +142,8 @@ export const getEmbedLink = (
|
||||
let aspectRatio = { w: 560, h: 840 };
|
||||
const ytLink = link.match(RE_YOUTUBE);
|
||||
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");
|
||||
type = "video";
|
||||
switch (ytLink[1]) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import { aabbForElement } from "./shapes";
|
||||
import { aabbForElement } from "./bounds";
|
||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||
import {
|
||||
isBindableElement,
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { isBoundToContainer } from "./typeChecks";
|
||||
|
||||
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
||||
|
||||
import type {
|
||||
@@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = (
|
||||
|
||||
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 "./Scene";
|
||||
export * from "./selection";
|
||||
export * from "./Shape";
|
||||
export * from "./ShapeCache";
|
||||
export * from "./shapes";
|
||||
export * from "./shape";
|
||||
export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./sortElements";
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
vectorFromPoint,
|
||||
curveLength,
|
||||
curvePointAtLength,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
@@ -18,9 +20,14 @@ import {
|
||||
getGridPoint,
|
||||
invariant,
|
||||
tupleToCoors,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { Store } from "@excalidraw/element";
|
||||
import {
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
isPathALoop,
|
||||
type Store,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
@@ -39,6 +46,7 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
maybeSuggestBindingsForLinearElementAtCoords,
|
||||
} from "./binding";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@@ -55,16 +63,7 @@ import {
|
||||
isFixedPointBinding,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import {
|
||||
isPathALoop,
|
||||
getBezierCurveLength,
|
||||
getControlPointsForBezierCurve,
|
||||
mapIntervalToBezierT,
|
||||
getBezierXY,
|
||||
toggleLinePolygonState,
|
||||
} from "./shapes";
|
||||
import { ShapeCache, toggleLinePolygonState } from "./shape";
|
||||
|
||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||
|
||||
@@ -150,10 +149,12 @@ export class LinearElementEditor {
|
||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||
public readonly elbowed: boolean;
|
||||
public readonly customLineAngle: number | null;
|
||||
public readonly isEditing: boolean;
|
||||
|
||||
constructor(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
isEditing: boolean = false,
|
||||
) {
|
||||
this.elementId = element.id as string & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@@ -188,6 +189,7 @@ export class LinearElementEditor {
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||
this.customLineAngle = null;
|
||||
this.isEditing = isEditing;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -195,6 +197,7 @@ export class LinearElementEditor {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static POINT_HANDLE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* @param id the `elementId` from the instance of this class (so that we can
|
||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||
@@ -216,11 +219,14 @@ export class LinearElementEditor {
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
) {
|
||||
if (!appState.editingLinearElement || !appState.selectionElement) {
|
||||
if (
|
||||
!appState.selectedLinearElement?.isEditing ||
|
||||
!appState.selectionElement
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const { editingLinearElement } = appState;
|
||||
const { selectedPointsIndices, elementId } = editingLinearElement;
|
||||
const { selectedLinearElement } = appState;
|
||||
const { selectedPointsIndices, elementId } = selectedLinearElement;
|
||||
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
@@ -261,8 +267,8 @@ export class LinearElementEditor {
|
||||
});
|
||||
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...editingLinearElement,
|
||||
selectedLinearElement: {
|
||||
...selectedLinearElement,
|
||||
selectedPointsIndices: nextSelectedPoints.length
|
||||
? nextSelectedPoints
|
||||
: null,
|
||||
@@ -278,18 +284,13 @@ export class LinearElementEditor {
|
||||
app: AppClassProperties,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
maybeSuggestBinding: (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointSceneCoords: { x: number; y: number }[],
|
||||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
): LinearElementEditor | null {
|
||||
): Pick<AppState, keyof AppState> | null {
|
||||
if (!linearElementEditor) {
|
||||
return null;
|
||||
}
|
||||
const { elementId } = linearElementEditor;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
let customLineAngle = linearElementEditor.customLineAngle;
|
||||
if (!element) {
|
||||
@@ -350,7 +351,7 @@ export class LinearElementEditor {
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
app.scene,
|
||||
new Map([
|
||||
[
|
||||
selectedIndex,
|
||||
@@ -378,7 +379,7 @@ export class LinearElementEditor {
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
app.scene,
|
||||
new Map(
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition: LocalPoint =
|
||||
@@ -410,46 +411,59 @@ export class LinearElementEditor {
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, scene, false);
|
||||
handleBindTextResize(element, app.scene, false);
|
||||
}
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
let suggestedBindings: ExcalidrawBindableElement[] = [];
|
||||
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 firstSelectedIndex = selectedPointsIndices[0];
|
||||
if (firstSelectedIndex === 0) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[0],
|
||||
elementsMap,
|
||||
if (!firstSelectedIndex !== !lastSelectedIndex) {
|
||||
coords.push({ x: scenePointerX, y: scenePointerY });
|
||||
} else {
|
||||
if (firstSelectedIndex) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[0],
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const lastSelectedIndex =
|
||||
selectedPointsIndices[selectedPointsIndices.length - 1];
|
||||
if (lastSelectedIndex === element.points.length - 1) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[lastSelectedIndex],
|
||||
elementsMap,
|
||||
if (lastSelectedIndex) {
|
||||
coords.push(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[
|
||||
selectedPointsIndices[selectedPointsIndices.length - 1]
|
||||
],
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (coords.length) {
|
||||
maybeSuggestBinding(element, coords);
|
||||
suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords(
|
||||
element,
|
||||
coords,
|
||||
app.scene,
|
||||
app.state.zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const newLinearElementEditor = {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices,
|
||||
segmentMidPointHoveredCoords:
|
||||
@@ -469,6 +483,12 @@ export class LinearElementEditor {
|
||||
isDragging: true,
|
||||
customLineAngle,
|
||||
};
|
||||
|
||||
return {
|
||||
...app.state,
|
||||
selectedLinearElement: newLinearElementEditor,
|
||||
suggestedBindings,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -482,6 +502,7 @@ export class LinearElementEditor {
|
||||
): LinearElementEditor {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elements = scene.getNonDeletedElements();
|
||||
const pointerCoords = viewportCoordsToSceneCoords(event, appState);
|
||||
|
||||
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||
editingLinearElement;
|
||||
@@ -537,13 +558,15 @@ export class LinearElementEditor {
|
||||
|
||||
const bindingElement = isBindingEnabled(appState)
|
||||
? getHoveredElementForBinding(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
selectedPoint!,
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
(selectedPointsIndices?.length ?? 0) > 1
|
||||
? tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
selectedPoint!,
|
||||
elementsMap,
|
||||
),
|
||||
)
|
||||
: pointerCoords,
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
@@ -599,7 +622,7 @@ export class LinearElementEditor {
|
||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||
if (
|
||||
!isElbowArrow(element) &&
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
element.points.length > 2 &&
|
||||
!boundText
|
||||
) {
|
||||
@@ -629,10 +652,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
midpoints.push(segmentMidPoint);
|
||||
index++;
|
||||
@@ -668,7 +688,7 @@ export class LinearElementEditor {
|
||||
);
|
||||
if (
|
||||
points.length >= 3 &&
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
!isElbowArrow(element)
|
||||
) {
|
||||
return null;
|
||||
@@ -734,7 +754,18 @@ export class LinearElementEditor {
|
||||
|
||||
let distance = pointDistance(startPoint, endPoint);
|
||||
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;
|
||||
@@ -742,39 +773,42 @@ export class LinearElementEditor {
|
||||
|
||||
static getSegmentMidPoint(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
startPoint: GlobalPoint,
|
||||
endPoint: GlobalPoint,
|
||||
endPointIndex: number,
|
||||
elementsMap: ElementsMap,
|
||||
index: number,
|
||||
): GlobalPoint {
|
||||
let segmentMidPoint = pointCenter(startPoint, endPoint);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
const controlPoints = getControlPointsForBezierCurve(
|
||||
element,
|
||||
element.points[endPointIndex],
|
||||
if (isElbowArrow(element)) {
|
||||
invariant(
|
||||
element.points.length >= index,
|
||||
"Invalid segment index while calculating elbow arrow mid point",
|
||||
);
|
||||
if (controlPoints) {
|
||||
const t = mapIntervalToBezierT(
|
||||
element,
|
||||
element.points[endPointIndex],
|
||||
0.5,
|
||||
);
|
||||
|
||||
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
getBezierXY(
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
controlPoints[2],
|
||||
controlPoints[3],
|
||||
t,
|
||||
),
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
const p = pointCenter(element.points[index - 1], element.points[index]);
|
||||
|
||||
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -851,7 +885,7 @@ export class LinearElementEditor {
|
||||
segmentMidpoint,
|
||||
elementsMap,
|
||||
);
|
||||
} else if (event.altKey && appState.editingLinearElement) {
|
||||
} else if (event.altKey && appState.selectedLinearElement?.isEditing) {
|
||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||
scene.mutateElement(element, {
|
||||
points: [
|
||||
@@ -993,14 +1027,14 @@ export class LinearElementEditor {
|
||||
app: AppClassProperties,
|
||||
): LinearElementEditor | null {
|
||||
const appState = app.state;
|
||||
if (!appState.editingLinearElement) {
|
||||
if (!appState.selectedLinearElement?.isEditing) {
|
||||
return null;
|
||||
}
|
||||
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||
const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return appState.editingLinearElement;
|
||||
return appState.selectedLinearElement;
|
||||
}
|
||||
|
||||
const { points } = element;
|
||||
@@ -1010,10 +1044,12 @@ export class LinearElementEditor {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
};
|
||||
return appState.selectedLinearElement?.lastUncommittedPoint
|
||||
? {
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
}
|
||||
: appState.selectedLinearElement;
|
||||
}
|
||||
|
||||
let newPoint: LocalPoint;
|
||||
@@ -1037,8 +1073,8 @@ export class LinearElementEditor {
|
||||
newPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - appState.editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
||||
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
|
||||
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||
? null
|
||||
: app.getEffectiveGridSize(),
|
||||
@@ -1062,7 +1098,7 @@ export class LinearElementEditor {
|
||||
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||
};
|
||||
}
|
||||
@@ -1221,12 +1257,12 @@ export class LinearElementEditor {
|
||||
// ---------------------------------------------------------------------------
|
||||
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
||||
invariant(
|
||||
appState.editingLinearElement,
|
||||
appState.selectedLinearElement?.isEditing,
|
||||
"Not currently editing a linear element",
|
||||
);
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
||||
const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
invariant(
|
||||
@@ -1288,8 +1324,8 @@ export class LinearElementEditor {
|
||||
|
||||
return {
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
selectedLinearElement: {
|
||||
...appState.selectedLinearElement,
|
||||
selectedPointsIndices: nextSelectedIndices,
|
||||
},
|
||||
};
|
||||
@@ -1301,8 +1337,9 @@ export class LinearElementEditor {
|
||||
pointIndices: readonly number[],
|
||||
) {
|
||||
const isUncommittedPoint =
|
||||
app.state.editingLinearElement?.lastUncommittedPoint ===
|
||||
element.points[element.points.length - 1];
|
||||
app.state.selectedLinearElement?.isEditing &&
|
||||
app.state.selectedLinearElement?.lastUncommittedPoint ===
|
||||
element.points[element.points.length - 1];
|
||||
|
||||
const nextPoints = element.points.filter((_, idx) => {
|
||||
return !pointIndices.includes(idx);
|
||||
@@ -1475,7 +1512,7 @@ export class LinearElementEditor {
|
||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||
);
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
dist < DRAGGING_THRESHOLD / appState.zoom.value
|
||||
) {
|
||||
return false;
|
||||
@@ -1670,10 +1707,7 @@ export class LinearElementEditor {
|
||||
const index = element.points.length / 2 - 1;
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
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 { ShapeCache } from "./ShapeCache";
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
|
||||
@@ -54,9 +54,9 @@ import {
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { getCornerRadius } from "./shapes";
|
||||
import { getCornerRadius } from "./utils";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||
return element.strokeWidth * 12;
|
||||
case "text":
|
||||
return element.fontSize / 2;
|
||||
case "arrow":
|
||||
if (element.endArrowhead || element.endArrowhead) {
|
||||
return 40;
|
||||
}
|
||||
return 20;
|
||||
default:
|
||||
return 20;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
pointCenter,
|
||||
normalizeRadians,
|
||||
pointFrom,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
type LocalPoint,
|
||||
@@ -104,18 +103,6 @@ export const transformElements = (
|
||||
);
|
||||
updateBoundElements(element, scene);
|
||||
}
|
||||
} else if (isTextElement(element) && transformHandleType) {
|
||||
resizeSingleTextElement(
|
||||
originalElements,
|
||||
element,
|
||||
scene,
|
||||
transformHandleType,
|
||||
shouldResizeFromCenter,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
updateBoundElements(element, scene);
|
||||
return true;
|
||||
} else if (transformHandleType) {
|
||||
const elementId = selectedElements[0].id;
|
||||
const latestElement = elementsMap.get(elementId);
|
||||
@@ -150,6 +137,9 @@ export const transformElements = (
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isTextElement(element)) {
|
||||
updateBoundElements(element, scene);
|
||||
}
|
||||
return true;
|
||||
} else if (selectedElements.length > 1) {
|
||||
if (transformHandleType === "rotation") {
|
||||
@@ -282,151 +272,50 @@ export const measureFontSizeFromWidth = (
|
||||
};
|
||||
};
|
||||
|
||||
const resizeSingleTextElement = (
|
||||
originalElements: PointerDownState["originalElements"],
|
||||
export const resizeSingleTextElement = (
|
||||
origElement: NonDeleted<ExcalidrawTextElement>,
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
scene: Scene,
|
||||
transformHandleType: TransformHandleDirection,
|
||||
shouldResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
) => {
|
||||
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") {
|
||||
if (transformHandleType.includes("e")) {
|
||||
scaleX = (rotatedX - x1) / (x2 - x1);
|
||||
}
|
||||
if (transformHandleType.includes("w")) {
|
||||
scaleX = (x2 - rotatedX) / (x2 - x1);
|
||||
}
|
||||
if (transformHandleType.includes("n")) {
|
||||
scaleY = (y2 - rotatedY) / (y2 - y1);
|
||||
}
|
||||
if (transformHandleType.includes("s")) {
|
||||
scaleY = (rotatedY - y1) / (y2 - y1);
|
||||
}
|
||||
const metricsWidth = element.width * (nextHeight / element.height);
|
||||
|
||||
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
|
||||
if (metrics === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 nextWidth = element.width * scale;
|
||||
const nextHeight = element.height * scale;
|
||||
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
|
||||
if (metrics === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTopLeft = [x1, y1];
|
||||
const startBottomRight = [x2, y2];
|
||||
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 newOrigin = getResizedOrigin(
|
||||
previousOrigin,
|
||||
origElement.width,
|
||||
origElement.height,
|
||||
metricsWidth,
|
||||
nextHeight,
|
||||
origElement.angle,
|
||||
transformHandleType,
|
||||
false,
|
||||
shouldResizeFromCenter,
|
||||
);
|
||||
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, {
|
||||
fontSize: metrics.size,
|
||||
width: nextWidth,
|
||||
width: metricsWidth,
|
||||
height: nextHeight,
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
x: newOrigin.x,
|
||||
y: newOrigin.y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
getFontString({
|
||||
fontSize: element.fontSize,
|
||||
@@ -435,17 +324,7 @@ const resizeSingleTextElement = (
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
|
||||
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 newWidth = Math.max(minWidth, nextWidth);
|
||||
|
||||
const text = wrapText(
|
||||
element.originalText,
|
||||
@@ -458,49 +337,27 @@ const resizeSingleTextElement = (
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
const eleNewHeight = metrics.height;
|
||||
const newHeight = metrics.height;
|
||||
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
newWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||
|
||||
let newTopLeft = [...startTopLeft] as [number, number];
|
||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||
newTopLeft = [
|
||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
||||
startTopLeft[1],
|
||||
];
|
||||
}
|
||||
|
||||
// adjust topLeft to new rotation point
|
||||
const angle = stateAtResizeStart.angle;
|
||||
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 newOrigin = getResizedOrigin(
|
||||
previousOrigin,
|
||||
origElement.width,
|
||||
origElement.height,
|
||||
newWidth,
|
||||
newHeight,
|
||||
element.angle,
|
||||
transformHandleType,
|
||||
false,
|
||||
shouldResizeFromCenter,
|
||||
);
|
||||
|
||||
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||
width: Math.abs(newWidth),
|
||||
height: Math.abs(metrics.height),
|
||||
x: newTopLeft[0],
|
||||
y: newTopLeft[1],
|
||||
x: newOrigin.x,
|
||||
y: newOrigin.y,
|
||||
text,
|
||||
autoResize: false,
|
||||
};
|
||||
@@ -821,6 +678,18 @@ export const resizeSingleElement = (
|
||||
shouldInformMutation?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
if (isTextElement(latestElement) && isTextElement(origElement)) {
|
||||
return resizeSingleTextElement(
|
||||
origElement,
|
||||
latestElement,
|
||||
scene,
|
||||
handleDirection,
|
||||
shouldResizeFromCenter,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
);
|
||||
}
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
@@ -1518,11 +1387,7 @@ export const resizeMultipleElements = (
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
|
||||
scene.mutateElement(element, update, {
|
||||
informMutation: true,
|
||||
// needed for the fixed binding point udpate to take effect
|
||||
isDragging: true,
|
||||
});
|
||||
scene.mutateElement(element, update);
|
||||
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
|
||||
import {
|
||||
type GeometricShape,
|
||||
getClosedCurveShape,
|
||||
getCurveShape,
|
||||
getEllipseShape,
|
||||
getFreedrawShape,
|
||||
getPolygonShape,
|
||||
} from "@excalidraw/utils/shape";
|
||||
|
||||
import {
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
type LocalPoint,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
||||
import {
|
||||
ROUGHNESS,
|
||||
isTransparent,
|
||||
assertNever,
|
||||
COLOR_PALETTE,
|
||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||
isTestEnv,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
@@ -14,17 +30,26 @@ import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
||||
import type {
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { elementWithCanvasCache } from "./renderElement";
|
||||
|
||||
import {
|
||||
canBecomePolygon,
|
||||
isElbowArrow,
|
||||
isEmbeddableElement,
|
||||
isIframeElement,
|
||||
isIframeLikeElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
||||
import { getCornerRadius, isPathALoop } from "./utils";
|
||||
import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
@@ -33,8 +58,9 @@ import {
|
||||
getArrowheadPoints,
|
||||
getCenterForBounds,
|
||||
getDiamondPoints,
|
||||
getElementBounds,
|
||||
getElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { shouldTestInside } from "./collision";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -44,11 +70,87 @@ import type {
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ElementsMap,
|
||||
ExcalidrawLineElement,
|
||||
} from "./types";
|
||||
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
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 getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||
@@ -81,7 +183,7 @@ export const generateRoughOptions = (
|
||||
continuousPath = false,
|
||||
): Options => {
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
seed: isTestEnv() ? 1 : element.seed,
|
||||
strokeLineDash:
|
||||
element.strokeStyle === "dashed"
|
||||
? getDashArrayDashed(element.strokeWidth)
|
||||
@@ -320,7 +422,6 @@ const getArrowheadShapes = (
|
||||
|
||||
export const generateLinearCollisionShape = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const generator = new RoughGenerator();
|
||||
const options: Options = {
|
||||
@@ -331,7 +432,18 @@ export const generateLinearCollisionShape = (
|
||||
preserveVertices: true,
|
||||
};
|
||||
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) {
|
||||
@@ -491,7 +603,7 @@ export const generateLinearCollisionShape = (
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export const _generateElementShape = (
|
||||
const generateElementShape = (
|
||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
generator: RoughGenerator,
|
||||
{
|
||||
@@ -792,3 +904,103 @@ const generateElbowArrowShape = (
|
||||
|
||||
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,
|
||||
hashElementsVersion,
|
||||
hashString,
|
||||
isInitializedImageElement,
|
||||
isImageElement,
|
||||
} from "./index";
|
||||
|
||||
import type { ApplyToOptions } from "./delta";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
@@ -72,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
|
||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||
*/
|
||||
export class Store {
|
||||
// internally used by history
|
||||
// for internal use by history
|
||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||
// for public use as part of onIncrement API
|
||||
public readonly onStoreIncrementEmitter = new Emitter<
|
||||
[DurableIncrement | EphemeralIncrement]
|
||||
>();
|
||||
@@ -137,6 +142,8 @@ export class Store {
|
||||
} else {
|
||||
// immediately create an immutable change of the scheduled updates,
|
||||
// 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(
|
||||
this.app.scene.getElementsMapIncludingDeleted(),
|
||||
this.app.state,
|
||||
@@ -233,7 +240,6 @@ export class Store {
|
||||
if (!storeDelta.isEmpty()) {
|
||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||
|
||||
// Notify listeners with the increment
|
||||
this.onDurableIncrementEmitter.trigger(increment);
|
||||
this.onStoreIncrementEmitter.trigger(increment);
|
||||
}
|
||||
@@ -546,10 +552,26 @@ export class StoreDelta {
|
||||
public static load({
|
||||
id,
|
||||
elements: { added, removed, updated },
|
||||
appState: { delta: appStateDelta },
|
||||
}: DTO<StoreDelta>) {
|
||||
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,
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
options?: ApplyToOptions,
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] =
|
||||
delta.elements.applyTo(elements);
|
||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||
elements,
|
||||
StoreSnapshot.empty().elements,
|
||||
options,
|
||||
);
|
||||
|
||||
const [nextAppState, appStateContainsVisibleChange] =
|
||||
delta.appState.applyTo(appState, nextElements);
|
||||
@@ -601,6 +627,10 @@ export class StoreDelta {
|
||||
);
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
||||
}
|
||||
|
||||
public 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(
|
||||
nextElements: SceneElementsMap,
|
||||
@@ -904,6 +934,14 @@ export class StoreSnapshot {
|
||||
!prevElement || // element was added
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -958,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
croppingElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
@@ -978,14 +1015,12 @@ export const getObservedAppState = (
|
||||
croppingElementId: appState.croppingElementId,
|
||||
activeLockedId: appState.activeLockedId,
|
||||
lockedMultiSelections: appState.lockedMultiSelections,
|
||||
editingLinearElementId:
|
||||
(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
|
||||
null,
|
||||
selectedLinearElementId:
|
||||
(appState as AppState).selectedLinearElement?.elementId ??
|
||||
(appState as ObservedAppState).selectedLinearElementId ??
|
||||
null,
|
||||
selectedLinearElement: appState.selectedLinearElement
|
||||
? {
|
||||
elementId: appState.selectedLinearElement.elementId,
|
||||
isEditing: !!appState.selectedLinearElement.isEditing,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||
|
||||
@@ -326,10 +326,7 @@ export const getContainerCenter = (
|
||||
if (!midSegmentMidpoint) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
|
||||
@@ -330,7 +330,7 @@ export const shouldShowBoundingBox = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
return false;
|
||||
}
|
||||
if (elements.length > 1) {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
lineSegment,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromArray,
|
||||
rectangle,
|
||||
@@ -11,14 +19,13 @@ import {
|
||||
|
||||
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 { generateLinearCollisionShape } from "./Shape";
|
||||
import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
@@ -83,9 +90,14 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
shapes.set(offset, shape);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the **rotated** components of freedraw, line or arrow elements.
|
||||
*
|
||||
* @param element The linear element to deconstruct
|
||||
* @returns The rotated in components.
|
||||
*/
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
@@ -93,7 +105,7 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const ops = generateLinearCollisionShape(element, elementsMap) as {
|
||||
const ops = generateLinearCollisionShape(element) as {
|
||||
op: string;
|
||||
data: number[];
|
||||
}[];
|
||||
@@ -165,11 +177,11 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
|
||||
/**
|
||||
* 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 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(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
@@ -304,12 +316,12 @@ export function deconstructRectanguloidElement(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a diamond element in the form of
|
||||
* line segments and curves as a tuple, in this order.
|
||||
* Get the **unrotated** building components of a diamond element
|
||||
* in the form of line segments and curves as a tuple, in this order.
|
||||
*
|
||||
* @param element The element to deconstruct
|
||||
* @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(
|
||||
element: ExcalidrawDiamondElement,
|
||||
@@ -428,3 +440,44 @@ export function deconstructDiamondElement(
|
||||
|
||||
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()[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
|
||||
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
||||
mouse.reset();
|
||||
expect(h.state.editingLinearElement).not.toBe(null);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
mouse.down(0, 0);
|
||||
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");
|
||||
mouse.up();
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("check rotated elements can be hit:", () => {
|
||||
//const p = [120, -211];
|
||||
//const p = [0, 13];
|
||||
const hit = hitElementItself({
|
||||
point: pointFrom<GlobalPoint>(87, -68),
|
||||
point: pointFrom<GlobalPoint>(88, -68),
|
||||
element: window.h.elements[0],
|
||||
threshold: 10,
|
||||
elementsMap: window.h.scene.getNonDeletedElementsMap(),
|
||||
|
||||
@@ -1,13 +1,328 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
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 create removed delta 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();
|
||||
|
||||
const delta = ElementsDelta.calculate(prevElements, nextElements);
|
||||
|
||||
expect(delta.isEmpty()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not create added delta 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]]);
|
||||
|
||||
const delta = ElementsDelta.calculate(prevElements, nextElements);
|
||||
|
||||
expect(delta.isEmpty()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not create updated delta 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.isEmpty()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
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("ensure stable delta properties order", () => {
|
||||
it("should maintain stable order for root properties", () => {
|
||||
const name = "untitled scene";
|
||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||
const selectedLinearElement = {
|
||||
elementId: "id1" as LinearElementEditor["elementId"],
|
||||
isEditing: false,
|
||||
};
|
||||
|
||||
const commonAppState = {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
@@ -16,6 +331,7 @@ describe("AppStateDelta", () => {
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
};
|
||||
@@ -23,23 +339,23 @@ describe("AppStateDelta", () => {
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name: "",
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name,
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
name: "",
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
name,
|
||||
...commonAppState,
|
||||
};
|
||||
@@ -57,8 +373,7 @@ describe("AppStateDelta", () => {
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@@ -104,8 +419,7 @@ describe("AppStateDelta", () => {
|
||||
selectedElementIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@@ -146,4 +460,97 @@ describe("AppStateDelta", () => {
|
||||
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.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) => {
|
||||
@@ -253,75 +254,82 @@ describe("Test Linear Elements", () => {
|
||||
});
|
||||
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)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(h.state.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)
|
||||
it("should enter line editor via ctrl+enter (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
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)", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
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)", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.doubleClick();
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor on ctrl+dblclick (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
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)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
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 () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.doubleClick();
|
||||
expect(h.state.editingLinearElement).toEqual(null);
|
||||
expect(h.state.selectedLinearElement).toBe(null);
|
||||
await getTextEditor();
|
||||
});
|
||||
|
||||
@@ -330,10 +338,12 @@ describe("Test Linear Elements", () => {
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
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();
|
||||
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(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
@@ -367,7 +377,7 @@ describe("Test Linear Elements", () => {
|
||||
// drag line from midpoint
|
||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -423,12 +433,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
"54.27552",
|
||||
"46.16120",
|
||||
],
|
||||
[
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
"76.95494",
|
||||
"44.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -469,7 +479,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(startPoint, endPoint);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -488,12 +498,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"105.96978",
|
||||
"67.44233",
|
||||
"104.27552",
|
||||
"66.16120",
|
||||
],
|
||||
[
|
||||
"126.08587",
|
||||
"63.29417",
|
||||
"126.95494",
|
||||
"64.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -537,7 +547,7 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -588,7 +598,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -629,7 +639,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -677,7 +687,7 @@ describe("Test Linear Elements", () => {
|
||||
deletePoint(points[2]);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`18`,
|
||||
`17`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -735,7 +745,7 @@ describe("Test Linear Elements", () => {
|
||||
),
|
||||
);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(line.points.length).toEqual(5);
|
||||
@@ -804,12 +814,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"31.88408",
|
||||
"23.13276",
|
||||
"29.28349",
|
||||
"20.91105",
|
||||
],
|
||||
[
|
||||
"77.74793",
|
||||
"44.57841",
|
||||
"78.86048",
|
||||
"46.12277",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -833,7 +843,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -893,12 +903,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
"54.27552",
|
||||
"46.16120",
|
||||
],
|
||||
[
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
"76.95494",
|
||||
"44.56052",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -1060,8 +1070,8 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
expect(position).toMatchInlineSnapshot(`
|
||||
{
|
||||
"x": "85.82202",
|
||||
"y": "75.63461",
|
||||
"x": "86.17305",
|
||||
"y": "76.11251",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { alignElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Alignment } from "@excalidraw/element";
|
||||
@@ -38,7 +40,11 @@ export const alignActionsPredicate = (
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState as Readonly<AppState>,
|
||||
).length > 1 &&
|
||||
// TODO enable aligning frames when implemented properly
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
@@ -52,7 +58,12 @@ const alignSelectedElements = (
|
||||
) => {
|
||||
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);
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ export const actionClearCanvas = register({
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
? { ...appState.activeTool, type: app.defaultSelectionTool }
|
||||
: appState.activeTool,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -494,13 +494,13 @@ export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
label: "toolBar.eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
@@ -530,6 +530,9 @@ export const actionToggleLassoTool = register({
|
||||
label: "toolBar.lasso",
|
||||
icon: LassoIcon,
|
||||
trackEvent: { category: "toolbar" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return app.defaultSelectionTool !== "lasso";
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
|
||||
@@ -205,16 +205,19 @@ export const actionDeleteSelected = register({
|
||||
icon: TrashIcon,
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
const {
|
||||
elementId,
|
||||
selectedPointsIndices,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
} = appState.selectedLinearElement;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
const linearElement = LinearElementEditor.getElement(
|
||||
elementId,
|
||||
elementsMap,
|
||||
);
|
||||
if (!linearElement) {
|
||||
return false;
|
||||
}
|
||||
// case: no point selected → do nothing, as deleting the whole element
|
||||
@@ -225,10 +228,10 @@ export const actionDeleteSelected = register({
|
||||
return false;
|
||||
}
|
||||
|
||||
// case: deleting last remaining point
|
||||
if (element.points.length < 2) {
|
||||
// case: deleting all points
|
||||
if (selectedPointsIndices.length >= linearElement.points.length) {
|
||||
const nextElements = elements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
if (el.id === linearElement.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
@@ -239,7 +242,7 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
editingLinearElement: null,
|
||||
selectedLinearElement: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@@ -252,20 +255,24 @@ export const actionDeleteSelected = register({
|
||||
? null
|
||||
: startBindingElement,
|
||||
endBindingElement: selectedPointsIndices?.includes(
|
||||
element.points.length - 1,
|
||||
linearElement.points.length - 1,
|
||||
)
|
||||
? null
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
|
||||
LinearElementEditor.deletePoints(
|
||||
linearElement,
|
||||
app,
|
||||
selectedPointsIndices,
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
selectedLinearElement: {
|
||||
...appState.selectedLinearElement,
|
||||
...binding,
|
||||
selectedPointsIndices:
|
||||
selectedPointsIndices?.[0] > 0
|
||||
@@ -291,7 +298,9 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
selectedLinearElement: null,
|
||||
|
||||
@@ -10,6 +10,8 @@ import { distributeElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Distribution } from "@excalidraw/element";
|
||||
@@ -31,7 +33,11 @@ import type { AppClassProperties, AppState } from "../types";
|
||||
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState as Readonly<AppState>,
|
||||
).length > 2 &&
|
||||
// TODO enable distributing frames when implemented properly
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
@@ -49,6 +55,7 @@ const distributeSelectedElements = (
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
distribution,
|
||||
appState,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
@@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({
|
||||
}
|
||||
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||
try {
|
||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
isBindingEnabled,
|
||||
} from "@excalidraw/element/binding";
|
||||
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
||||
import {
|
||||
isValidPolygon,
|
||||
LinearElementEditor,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
isBindingElement,
|
||||
@@ -14,7 +18,12 @@ import {
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
||||
import {
|
||||
KEYS,
|
||||
arrayToMap,
|
||||
tupleToCoors,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
import { isPathALoop } from "@excalidraw/element";
|
||||
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
@@ -43,12 +52,16 @@ export const actionFinalize = register({
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, data, app) => {
|
||||
const { interactiveCanvas, focusContainer, scene } = app;
|
||||
|
||||
const { event, sceneCoords } =
|
||||
(data as {
|
||||
event?: PointerEvent;
|
||||
sceneCoords?: { x: number; y: number };
|
||||
}) ?? {};
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (data?.event && appState.selectedLinearElement) {
|
||||
if (event && appState.selectedLinearElement) {
|
||||
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||
data.event,
|
||||
event,
|
||||
appState.selectedLinearElement,
|
||||
appState,
|
||||
app.scene,
|
||||
@@ -69,7 +82,14 @@ export const actionFinalize = register({
|
||||
let newElements = elements;
|
||||
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
|
||||
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 {
|
||||
elements: newElements,
|
||||
@@ -85,9 +105,9 @@ export const actionFinalize = register({
|
||||
}
|
||||
}
|
||||
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
appState.selectedLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
if (element) {
|
||||
@@ -108,12 +128,21 @@ export const actionFinalize = register({
|
||||
return {
|
||||
elements:
|
||||
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,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
selectedLinearElement: new LinearElementEditor(
|
||||
element,
|
||||
arrayToMap(elementsMap),
|
||||
false, // exit editing mode
|
||||
),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@@ -145,11 +174,7 @@ export const actionFinalize = register({
|
||||
|
||||
if (element) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
appState.multiElement &&
|
||||
element.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
if (appState.multiElement && element.type !== "freedraw") {
|
||||
const { points, lastCommittedPoint } = element;
|
||||
if (
|
||||
!lastCommittedPoint ||
|
||||
@@ -163,7 +188,12 @@ export const actionFinalize = register({
|
||||
|
||||
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
|
||||
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)) {
|
||||
@@ -204,12 +234,17 @@ export const actionFinalize = register({
|
||||
element.points.length > 1 &&
|
||||
isBindingEnabled(appState)
|
||||
) {
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(element, appState, { x, y }, scene);
|
||||
const coords =
|
||||
sceneCoords ??
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
),
|
||||
);
|
||||
|
||||
maybeBindLinearElement(element, appState, coords, scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,13 +261,13 @@ export const actionFinalize = register({
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,7 +310,7 @@ export const actionFinalize = register({
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
(event.key === KEYS.ESCAPE &&
|
||||
(appState.editingLinearElement !== null ||
|
||||
(appState.selectedLinearElement?.isEditing ||
|
||||
(!appState.newElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import {
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
} 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 {
|
||||
ExcalidrawLinearElement,
|
||||
@@ -22,8 +24,6 @@ import { ButtonIcon } from "../components/ButtonIcon";
|
||||
|
||||
import { newElementWith } from "../../element/src/mutateElement";
|
||||
|
||||
import { toggleLinePolygonState } from "../../element/src/shapes";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
@@ -45,7 +45,7 @@ export const actionToggleLinearEditor = register({
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
selectedElements.length === 1 &&
|
||||
isLinearElement(selectedElements[0]) &&
|
||||
!isElbowArrow(selectedElements[0])
|
||||
@@ -60,14 +60,25 @@ export const actionToggleLinearEditor = register({
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? null
|
||||
: new LinearElementEditor(selectedElement, arrayToMap(elements));
|
||||
invariant(selectedElement, "No selected element found");
|
||||
invariant(
|
||||
appState.selectedLinearElement,
|
||||
"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 {
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement,
|
||||
selectedLinearElement,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
|
||||
@@ -52,9 +52,11 @@ import {
|
||||
|
||||
import { hasStrokeColor } from "@excalidraw/element";
|
||||
|
||||
import { updateElbowArrowPoints } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import {
|
||||
updateElbowArrowPoints,
|
||||
CaptureUpdateAction,
|
||||
toggleLinePolygonState,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
@@ -135,8 +137,6 @@ import {
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
|
||||
import { toggleLinePolygonState } from "../../element/src/shapes";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
|
||||
@@ -21,7 +21,7 @@ export const actionSelectAll = register({
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ export const getDefaultAppState = (): Omit<
|
||||
newElement: null,
|
||||
editingTextElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
activeTool: {
|
||||
type: "selection",
|
||||
customType: null,
|
||||
@@ -175,7 +174,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
newElement: { browser: false, export: false, server: false },
|
||||
editingTextElement: { browser: false, 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 },
|
||||
penMode: { browser: true, export: false, server: false },
|
||||
penDetected: { browser: true, export: false, server: false },
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
.undo-redo-buttons {
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
// box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
backdrop-filter: blur(40px);
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
}
|
||||
|
||||
.zoom-button,
|
||||
@@ -13,7 +12,6 @@
|
||||
font-size: 0.875rem !important;
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
backdrop-filter: blur(40px);
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size) !important;
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
hasStrokeWidth,
|
||||
} from "../scene";
|
||||
|
||||
import { SHAPES } from "./shapes";
|
||||
import { getToolbarTools } from "./shapes";
|
||||
|
||||
import "./Actions.scss";
|
||||
|
||||
@@ -140,7 +140,7 @@ export const SelectedShapeActions = ({
|
||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||
|
||||
const showLineEditorAction =
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
targetElements.length === 1 &&
|
||||
isLinearElement(targetElements[0]) &&
|
||||
!isElbowArrow(targetElements[0]);
|
||||
@@ -295,7 +295,8 @@ export const ShapesSwitcher = ({
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
const lassoToolSelected = activeTool.type === "lasso";
|
||||
const lassoToolSelected =
|
||||
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
|
||||
|
||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||
|
||||
@@ -303,63 +304,68 @@ export const ShapesSwitcher = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
if (
|
||||
UIOptions.tools?.[
|
||||
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
|
||||
] === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
{getToolbarTools(app).map(
|
||||
({ value, icon, key, numericKey, fillable }, index) => {
|
||||
if (
|
||||
UIOptions.tools?.[
|
||||
value as Extract<
|
||||
typeof value,
|
||||
keyof AppProps["UIOptions"]["tools"]
|
||||
>
|
||||
] === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
|
||||
if (value === "selection") {
|
||||
if (appState.activeTool.type === "selection") {
|
||||
app.setActiveTool({ type: "lasso" });
|
||||
} else {
|
||||
app.setActiveTool({ type: "selection" });
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
if (value === "selection") {
|
||||
if (appState.activeTool.type === "selection") {
|
||||
app.setActiveTool({ type: "lasso" });
|
||||
} else {
|
||||
app.setActiveTool({ type: "selection" });
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
@@ -418,14 +424,16 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||
icon={LassoIcon}
|
||||
data-testid="toolbar-lasso"
|
||||
selected={lassoToolSelected}
|
||||
>
|
||||
{t("toolBar.lasso")}
|
||||
</DropdownMenu.Item>
|
||||
{app.defaultSelectionTool !== "lasso" && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||
icon={LassoIcon}
|
||||
data-testid="toolbar-lasso"
|
||||
selected={lassoToolSelected}
|
||||
>
|
||||
{t("toolBar.lasso")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||
Generate
|
||||
</div>
|
||||
@@ -505,15 +513,3 @@ export const ExitZenModeAction = ({
|
||||
{t("buttons.exitZenMode")}
|
||||
</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
@@ -108,6 +108,7 @@ $verticalBreakpoint: 861px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import { useStable } from "../../hooks/useStable";
|
||||
|
||||
import { Ellipsify } from "../Ellipsify";
|
||||
|
||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||
|
||||
import "./CommandPalette.scss";
|
||||
@@ -964,7 +966,7 @@ const CommandItem = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{command.label}
|
||||
<Ellipsify>{command.label}</Ellipsify>
|
||||
</div>
|
||||
{showShortcut && 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>
|
||||
);
|
||||
};
|
||||
@@ -115,7 +115,7 @@ const getHints = ({
|
||||
appState.selectionElement &&
|
||||
!selectedElements.length &&
|
||||
!appState.editingTextElement &&
|
||||
!appState.editingLinearElement
|
||||
!appState.selectedLinearElement?.isEditing
|
||||
) {
|
||||
return [t("hints.deepBoxSelect")];
|
||||
}
|
||||
@@ -130,8 +130,8 @@ const getHints = ({
|
||||
|
||||
if (selectedElements.length === 1) {
|
||||
if (isLinearElement(selectedElements[0])) {
|
||||
if (appState.editingLinearElement) {
|
||||
return appState.editingLinearElement.selectedPointsIndices
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
return appState.selectedLinearElement.selectedPointsIndices
|
||||
? t("hints.lineEditor_pointSelected")
|
||||
: t("hints.lineEditor_nothingSelected");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
|
||||
display: "inline-block",
|
||||
lineHeight: 0,
|
||||
verticalAlign: "middle",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
--padding: 0;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--island-bg-color);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
} from "@excalidraw/element";
|
||||
import { resizeSingleElement } 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";
|
||||
|
||||
@@ -15,7 +18,10 @@ import type { Scene } from "@excalidraw/element";
|
||||
import DragInput from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type {
|
||||
DragFinishedCallbackType,
|
||||
DragInputCallbackType,
|
||||
} from "./DragInput";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface DimensionDragInputProps {
|
||||
@@ -43,6 +49,8 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
originalAppState,
|
||||
instantChange,
|
||||
scene,
|
||||
app,
|
||||
setAppState,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements[0];
|
||||
@@ -153,6 +161,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
return;
|
||||
}
|
||||
|
||||
// User types in a value to stats then presses Enter
|
||||
if (nextValue !== undefined) {
|
||||
const nextWidth = Math.max(
|
||||
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;
|
||||
}
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
|
||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||
if (property === "width") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||
} else {
|
||||
nextWidth = Math.round(nextWidth);
|
||||
}
|
||||
}
|
||||
// Stats slider is dragged
|
||||
{
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
|
||||
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) {
|
||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||
if (property === "width") {
|
||||
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||
} else {
|
||||
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||
if (shouldChangeByStepSize) {
|
||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||
} 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);
|
||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||
const handleDragFinished: DragFinishedCallbackType = ({
|
||||
setAppState,
|
||||
app,
|
||||
originalElements,
|
||||
originalAppState,
|
||||
}) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements?.[0];
|
||||
const latestElement = origElement && elementsMap.get(origElement.id);
|
||||
|
||||
resizeSingleElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
// Handle frame membership update for resized frames
|
||||
if (latestElement && isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
origElement,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
property === "width" ? "e" : "s",
|
||||
{
|
||||
shouldMaintainAspectRatio: keepAspectRatio,
|
||||
},
|
||||
originalAppState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
app.scene.replaceAllElements(updatedElements);
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,6 +349,7 @@ const DimensionDragInput = ({
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
property={property}
|
||||
dragFinishedCallback={handleDragFinished}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import { useApp } from "../App";
|
||||
import { useApp, useExcalidrawSetAppState } from "../App";
|
||||
import { InlineIcon } from "../InlineIcon";
|
||||
|
||||
import { SMALLEST_DELTA } from "./utils";
|
||||
@@ -36,6 +36,15 @@ export type DragInputCallbackType<
|
||||
property: P;
|
||||
originalAppState: AppState;
|
||||
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;
|
||||
|
||||
interface StatsDragInputProps<
|
||||
@@ -54,6 +63,7 @@ interface StatsDragInputProps<
|
||||
appState: AppState;
|
||||
/** how many px you need to drag to get 1 unit change */
|
||||
sensitivity?: number;
|
||||
dragFinishedCallback?: DragFinishedCallbackType;
|
||||
}
|
||||
|
||||
const StatsDragInput = <
|
||||
@@ -71,8 +81,10 @@ const StatsDragInput = <
|
||||
scene,
|
||||
appState,
|
||||
sensitivity = 1,
|
||||
dragFinishedCallback,
|
||||
}: StatsDragInputProps<T, E>) => {
|
||||
const app = useApp();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -137,6 +149,8 @@ const StatsDragInput = <
|
||||
property,
|
||||
originalAppState: appState,
|
||||
setInputValue: (value) => setInputValue(String(value)),
|
||||
app,
|
||||
setAppState,
|
||||
});
|
||||
app.syncActionResult({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -263,6 +277,8 @@ const StatsDragInput = <
|
||||
scene,
|
||||
originalAppState,
|
||||
setInputValue: (value) => setInputValue(String(value)),
|
||||
app,
|
||||
setAppState,
|
||||
});
|
||||
|
||||
stepChange = 0;
|
||||
@@ -287,6 +303,14 @@ const StatsDragInput = <
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
// Notify implementors
|
||||
dragFinishedCallback?.({
|
||||
app,
|
||||
setAppState,
|
||||
originalElements,
|
||||
originalAppState,
|
||||
});
|
||||
|
||||
lastPointer = null;
|
||||
accumulatedChange = 0;
|
||||
stepChange = 0;
|
||||
|
||||
@@ -2,7 +2,12 @@ import { pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
|
||||
import { updateBoundElements } from "@excalidraw/element";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
isFrameLikeElement,
|
||||
replaceAllElementsInFrame,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
import {
|
||||
rescalePointsInElement,
|
||||
resizeSingleElement,
|
||||
@@ -25,7 +30,10 @@ import DragInput from "./DragInput";
|
||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { getElementsInAtomicUnit } from "./utils";
|
||||
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type {
|
||||
DragFinishedCallbackType,
|
||||
DragInputCallbackType,
|
||||
} from "./DragInput";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
@@ -153,6 +161,8 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
nextValue,
|
||||
scene,
|
||||
property,
|
||||
setAppState,
|
||||
app,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
||||
@@ -239,6 +249,25 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
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 changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
const elementsToHighlight: ExcalidrawElement[] = [];
|
||||
|
||||
for (const atomicUnit of atomicUnits) {
|
||||
const elementsInUnit = getElementsInAtomicUnit(
|
||||
@@ -342,13 +372,63 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
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();
|
||||
};
|
||||
|
||||
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 = ({
|
||||
property,
|
||||
elements,
|
||||
@@ -396,6 +476,7 @@ const MultiDimension = ({
|
||||
appState={appState}
|
||||
property={property}
|
||||
scene={scene}
|
||||
dragFinishedCallback={handleDragFinished}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -401,11 +401,23 @@ describe("stats for a non-generic element", () => {
|
||||
UI.updateInput(input, "36");
|
||||
expect(text.fontSize).toBe(36);
|
||||
|
||||
// cannot change width or height
|
||||
const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
|
||||
expect(width).toBeUndefined();
|
||||
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
|
||||
expect(height).toBeUndefined();
|
||||
// can change width or height
|
||||
const width = UI.queryStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
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
|
||||
UI.updateInput(input, "0");
|
||||
@@ -627,12 +639,11 @@ describe("stats for multiple elements", () => {
|
||||
) as HTMLInputElement;
|
||||
expect(fontSize).toBeDefined();
|
||||
|
||||
// changing width does not affect text
|
||||
UI.updateInput(width, "200");
|
||||
|
||||
expect(rectangle?.width).toBe(200);
|
||||
expect(frame.width).toBe(200);
|
||||
expect(text?.width).not.toBe(200);
|
||||
expect(text?.width).toBe(200);
|
||||
|
||||
UI.updateInput(angle, "40");
|
||||
|
||||
@@ -726,3 +737,196 @@ describe("stats for multiple elements", () => {
|
||||
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 { getBoundTextElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement, isTextElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
@@ -41,12 +41,6 @@ export const isPropertyEditable = (
|
||||
element: ExcalidrawElement,
|
||||
property: keyof ExcalidrawElement,
|
||||
) => {
|
||||
if (property === "height" && isTextElement(element)) {
|
||||
return false;
|
||||
}
|
||||
if (property === "width" && isTextElement(element)) {
|
||||
return false;
|
||||
}
|
||||
if (property === "angle" && isFrameLikeElement(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -192,7 +192,6 @@ const getRelevantAppStateProps = (
|
||||
viewModeEnabled: appState.viewModeEnabled,
|
||||
openDialog: appState.openDialog,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
editingLinearElement: appState.editingLinearElement,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
frameToHighlight: appState.frameToHighlight,
|
||||
offsetLeft: appState.offsetLeft,
|
||||
|
||||
@@ -34,6 +34,13 @@ const StaticCanvas = (props: StaticCanvasProps) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
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(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) {
|
||||
@@ -49,26 +56,6 @@ const StaticCanvas = (props: StaticCanvasProps) => {
|
||||
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(
|
||||
{
|
||||
canvas,
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.zen-mode {
|
||||
box-shadow: none;
|
||||
@@ -100,6 +102,7 @@
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-md);
|
||||
flex: 1 0 auto;
|
||||
|
||||
@media screen and (min-width: 1921px) {
|
||||
height: 2.25rem;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useDevice } from "../App";
|
||||
|
||||
import { Ellipsify } from "../Ellipsify";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
const MenuItemContent = ({
|
||||
@@ -18,7 +20,7 @@ const MenuItemContent = ({
|
||||
<>
|
||||
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||
<div style={textStyle} className="dropdown-menu-item__text">
|
||||
{children}
|
||||
<Ellipsify>{children}</Ellipsify>
|
||||
</div>
|
||||
{shortcut && !device.editor.isMobile && (
|
||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||
|
||||
@@ -2,13 +2,7 @@ import clsx from "clsx";
|
||||
|
||||
import { actionShortcuts } from "../../actions";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import {
|
||||
ExitZenModeAction,
|
||||
FinalizeAction,
|
||||
UndoRedoActions,
|
||||
ZoomActions,
|
||||
} from "../Actions";
|
||||
import { useDevice } from "../App";
|
||||
import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions";
|
||||
import { HelpButton } from "../HelpButton";
|
||||
import { Section } from "../Section";
|
||||
import Stack from "../Stack";
|
||||
@@ -29,10 +23,6 @@ const Footer = ({
|
||||
}) => {
|
||||
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
|
||||
|
||||
const device = useDevice();
|
||||
const showFinalize =
|
||||
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
|
||||
|
||||
return (
|
||||
<footer
|
||||
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>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
EraserIcon,
|
||||
} from "./icons";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
export const SHAPES = [
|
||||
{
|
||||
icon: SelectionIcon,
|
||||
@@ -86,8 +88,23 @@ export const SHAPES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const findShapeByKey = (key: string) => {
|
||||
const shape = SHAPES.find((shape, index) => {
|
||||
export const getToolbarTools = (app: AppClassProperties) => {
|
||||
return app.defaultSelectionTool === "lasso"
|
||||
? ([
|
||||
{
|
||||
value: "lasso",
|
||||
icon: SelectionIcon,
|
||||
key: KEYS.V,
|
||||
numericKey: KEYS["1"],
|
||||
fillable: true,
|
||||
},
|
||||
...SHAPES.slice(1),
|
||||
] as const)
|
||||
: SHAPES;
|
||||
};
|
||||
|
||||
export const findShapeByKey = (key: string, app: AppClassProperties) => {
|
||||
const shape = getToolbarTools(app).find((shape, index) => {
|
||||
return (
|
||||
(shape.numericKey != null && key === shape.numericKey.toString()) ||
|
||||
(shape.key &&
|
||||
|
||||
@@ -386,9 +386,6 @@ body.excalidraw-cursor-resize * {
|
||||
width: 12.5rem;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
background: transparent !important;
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
}
|
||||
|
||||
.dropdown-select {
|
||||
@@ -651,16 +648,14 @@ body.excalidraw-cursor-resize * {
|
||||
.App-mobile-menu,
|
||||
.App-menu__left {
|
||||
--button-border: transparent;
|
||||
// --button-bg: var(--color-surface-mid);
|
||||
--button-bg: rgba(255, 255, 255, 0.3);
|
||||
--button-bg: var(--color-surface-mid);
|
||||
}
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
.App-mobile-menu,
|
||||
.App-menu__left {
|
||||
--button-hover-bg: #363541;
|
||||
// --button-bg: var(--color-surface-high);
|
||||
--button-bg: rgba(0, 0, 0, 0.15);
|
||||
--button-bg: var(--color-surface-high);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
--input-border-color: #{$oc-gray-4};
|
||||
--input-hover-bg-color: #{$oc-gray-1};
|
||||
--input-label-color: #{$oc-gray-7};
|
||||
--island-bg-color: transparent !important;
|
||||
--island-bg-color: #ffffff;
|
||||
--keybinding-color: var(--color-gray-40);
|
||||
--link-color: #{$oc-blue-7};
|
||||
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
|
||||
@@ -149,16 +149,16 @@
|
||||
|
||||
--color-surface-high: #f1f0ff;
|
||||
--color-surface-mid: #f6f6f9;
|
||||
--color-surface-low: rgba(236, 236, 244, 0.1);
|
||||
--color-surface-low: #ececf4;
|
||||
--color-surface-lowest: #ffffff;
|
||||
--color-on-surface: #1b1b1f;
|
||||
--color-brand-hover: #5753d0;
|
||||
--color-on-primary-container: #030064;
|
||||
--color-surface-primary-container: rgba(224, 223, 255, 0.7);
|
||||
--color-surface-primary-container: #e0dfff;
|
||||
--color-brand-active: #4440bf;
|
||||
--color-border-outline: #767680;
|
||||
--color-border-outline-variant: #c5c5d0;
|
||||
// --color-surface-primary-container: #e0dfff;
|
||||
--color-surface-primary-container: #e0dfff;
|
||||
|
||||
--color-badge: #0b6513;
|
||||
--background-color-badge: #d3ffd2;
|
||||
@@ -253,15 +253,16 @@
|
||||
--color-logo-text: #e2dfff;
|
||||
|
||||
--color-surface-high: #2e2d39;
|
||||
--color-surface-low: rgba(35, 35, 41, 0.1);
|
||||
--color-surface-low: hsl(240, 8%, 15%);
|
||||
--color-surface-mid: hsl(240 6% 10%);
|
||||
--color-surface-lowest: hsl(0, 0%, 7%);
|
||||
--color-on-surface: #e3e3e8;
|
||||
--color-brand-hover: #bbb8ff;
|
||||
--color-on-primary-container: #e0dfff;
|
||||
--color-surface-primary-container: rgba(64, 62, 106, 0.7);
|
||||
--color-surface-primary-container: #403e6a;
|
||||
--color-brand-active: #d0ccff;
|
||||
--color-border-outline: #8e8d9c;
|
||||
--color-border-outline-variant: #46464f;
|
||||
--color-surface-primary-container: #403e6a;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +79,6 @@
|
||||
color: var(--button-color, var(--color-on-surface));
|
||||
font-family: var(--ui-font);
|
||||
|
||||
// background: transparent !important;
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
|
||||
svg {
|
||||
width: var(--button-width, var(--lg-icon-size));
|
||||
height: var(--button-height, var(--lg-icon-size));
|
||||
@@ -187,8 +183,6 @@
|
||||
border: none;
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
background-color: var(--color-surface-low);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 0 0 1px var(--color-brand-active);
|
||||
|
||||
@@ -1791,7 +1791,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 120,
|
||||
"x": 187.7545,
|
||||
"x": 187.75450000000004,
|
||||
"y": 44.5,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -170,7 +170,11 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
{
|
||||
repairBindings: true,
|
||||
refreshDimensions: false,
|
||||
deleteInvisibleElements: true,
|
||||
},
|
||||
),
|
||||
};
|
||||
} else if (isValidLibrary(data)) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
|
||||
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
|
||||
MakeBrand<"RemoteExcalidrawElement">;
|
||||
|
||||
const shouldDiscardRemoteElement = (
|
||||
export const shouldDiscardRemoteElement = (
|
||||
localAppState: AppState,
|
||||
local: OrderedExcalidrawElement | undefined,
|
||||
remote: RemoteExcalidrawElement,
|
||||
@@ -30,7 +30,7 @@ const shouldDiscardRemoteElement = (
|
||||
// local element is being edited
|
||||
(local.id === localAppState.editingTextElement?.id ||
|
||||
local.id === localAppState.resizingElement?.id ||
|
||||
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
|
||||
local.id === localAppState.newElement?.id ||
|
||||
// local element is newer
|
||||
local.version > remote.version ||
|
||||
// resolve conflicting edits deterministically by taking the one with
|
||||
|
||||
@@ -241,8 +241,9 @@ const restoreElementWithProperties = <
|
||||
return ret;
|
||||
};
|
||||
|
||||
const restoreElement = (
|
||||
export const restoreElement = (
|
||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
opts?: { deleteInvisibleElements?: boolean },
|
||||
): typeof element | null => {
|
||||
element = { ...element };
|
||||
|
||||
@@ -290,7 +291,8 @@ const restoreElement = (
|
||||
|
||||
// if empty text, mark as deleted. We keep in array
|
||||
// for data integrity purposes (collab etc.)
|
||||
if (!text && !element.isDeleted) {
|
||||
if (opts?.deleteInvisibleElements && !text && !element.isDeleted) {
|
||||
// TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded)
|
||||
element = { ...element, originalText: text, isDeleted: true };
|
||||
element = bumpVersion(element);
|
||||
}
|
||||
@@ -523,7 +525,13 @@ export const restoreElements = (
|
||||
elements: ImportedDataState["elements"],
|
||||
/** NOTE doesn't serve for reconciliation */
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
||||
opts?:
|
||||
| {
|
||||
refreshDimensions?: boolean;
|
||||
repairBindings?: boolean;
|
||||
deleteInvisibleElements?: boolean;
|
||||
}
|
||||
| undefined,
|
||||
): OrderedExcalidrawElement[] => {
|
||||
// used to detect duplicate top-level element ids
|
||||
const existingIds = new Set<string>();
|
||||
@@ -532,24 +540,38 @@ export const restoreElements = (
|
||||
(elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
// and causing issues if retained
|
||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.get(element.id);
|
||||
if (localElement && localElement.version > migratedElement.version) {
|
||||
migratedElement = bumpVersion(
|
||||
migratedElement,
|
||||
localElement.version,
|
||||
);
|
||||
}
|
||||
if (existingIds.has(migratedElement.id)) {
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
if (element.type === "selection") {
|
||||
return elements;
|
||||
}
|
||||
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element, {
|
||||
deleteInvisibleElements: opts?.deleteInvisibleElements,
|
||||
});
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.get(element.id);
|
||||
|
||||
const shouldMarkAsDeleted =
|
||||
opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
|
||||
|
||||
if (
|
||||
shouldMarkAsDeleted ||
|
||||
(localElement && localElement.version > migratedElement.version)
|
||||
) {
|
||||
migratedElement = bumpVersion(migratedElement, localElement?.version);
|
||||
}
|
||||
|
||||
if (shouldMarkAsDeleted) {
|
||||
migratedElement = { ...migratedElement, isDeleted: true };
|
||||
}
|
||||
|
||||
if (existingIds.has(migratedElement.id)) {
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}, [] as ExcalidrawElement[]),
|
||||
);
|
||||
@@ -790,7 +812,11 @@ export const restore = (
|
||||
*/
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
|
||||
elementsConfig?: {
|
||||
refreshDimensions?: boolean;
|
||||
repairBindings?: boolean;
|
||||
deleteInvisibleElements?: boolean;
|
||||
},
|
||||
): RestoredDataState => {
|
||||
return {
|
||||
elements: restoreElements(data?.elements, localElements, elementsConfig),
|
||||
|
||||
@@ -175,7 +175,7 @@ export class History {
|
||||
let nextAppState = appState;
|
||||
let containsVisibleChange = false;
|
||||
|
||||
// iterate through the history entries in case ;they result in no visible changes
|
||||
// iterate through the history entries in case they result in no visible changes
|
||||
while (historyDelta) {
|
||||
try {
|
||||
[nextElements, nextAppState, containsVisibleChange] =
|
||||
|
||||
@@ -229,6 +229,7 @@ export { defaultLang, useI18n, languages } from "./i18n";
|
||||
export {
|
||||
restore,
|
||||
restoreAppState,
|
||||
restoreElement,
|
||||
restoreElements,
|
||||
restoreLibraryItems,
|
||||
} from "./data/restore";
|
||||
@@ -281,6 +282,7 @@ export { Sidebar } from "./components/Sidebar/Sidebar";
|
||||
export { Button } from "./components/Button";
|
||||
export { Footer };
|
||||
export { MainMenu };
|
||||
export { Ellipsify } from "./components/Ellipsify";
|
||||
export { useDevice } from "./components/App";
|
||||
export { WelcomeScreen };
|
||||
export { LiveCollaborationTrigger };
|
||||
|
||||
@@ -66,12 +66,22 @@
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2 || ^18.2.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/common": "0.18.0",
|
||||
"@excalidraw/element": "0.18.0",
|
||||
"@excalidraw/math": "0.18.0",
|
||||
"@excalidraw/laser-pointer": "1.3.1",
|
||||
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
|
||||
"@excalidraw/random-username": "1.1.0",
|
||||
@@ -124,12 +134,5 @@
|
||||
"harfbuzzjs": "0.3.6",
|
||||
"jest-diff": "29.7.0",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { elementCenterPoint, THEME, THEME_FILTER } from "@excalidraw/common";
|
||||
import { THEME, THEME_FILTER } from "@excalidraw/common";
|
||||
|
||||
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element";
|
||||
import { getDiamondPoints } from "@excalidraw/element";
|
||||
import { getCornerRadius } from "@excalidraw/element";
|
||||
import { elementCenterPoint, getCornerRadius } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
curve,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "@excalidraw/element/types";
|
||||
@@ -128,14 +129,12 @@ function drawCatmullRomCubicApprox(
|
||||
export const drawHighlightForRectWithRotation = (
|
||||
context: CanvasRenderingContext2D,
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
elementsMap: ElementsMap,
|
||||
padding: number,
|
||||
) => {
|
||||
const [x, y] = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y),
|
||||
elementCenterPoint(
|
||||
element,
|
||||
window.h.app.scene.getElementsMapIncludingDeleted(),
|
||||
),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
@@ -289,13 +288,11 @@ export const drawHighlightForDiamondWithRotation = (
|
||||
context: CanvasRenderingContext2D,
|
||||
padding: number,
|
||||
element: ExcalidrawDiamondElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const [x, y] = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y),
|
||||
elementCenterPoint(
|
||||
element,
|
||||
window.h.app.scene.getElementsMapIncludingDeleted(),
|
||||
),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
element.angle,
|
||||
);
|
||||
context.save();
|
||||
|
||||
@@ -118,7 +118,8 @@ const renderLinearElementPointHighlight = (
|
||||
) => {
|
||||
const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
|
||||
if (
|
||||
appState.editingLinearElement?.selectedPointsIndices?.includes(
|
||||
appState.selectedLinearElement?.isEditing &&
|
||||
appState.selectedLinearElement?.selectedPointsIndices?.includes(
|
||||
hoverPointIndex,
|
||||
)
|
||||
) {
|
||||
@@ -180,7 +181,7 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||
point[0],
|
||||
point[1],
|
||||
(isOverlappingPoint
|
||||
? radius * (appState.editingLinearElement ? 1.5 : 2)
|
||||
? radius * (appState.selectedLinearElement?.isEditing ? 1.5 : 2)
|
||||
: radius) / appState.zoom.value,
|
||||
!isPhantomPoint,
|
||||
!isOverlappingPoint || isSelected,
|
||||
@@ -205,10 +206,15 @@ const renderBindingHighlightForBindableElement = (
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
drawHighlightForRectWithRotation(context, element, padding);
|
||||
drawHighlightForRectWithRotation(context, element, elementsMap, padding);
|
||||
break;
|
||||
case "diamond":
|
||||
drawHighlightForDiamondWithRotation(context, padding, element);
|
||||
drawHighlightForDiamondWithRotation(
|
||||
context,
|
||||
padding,
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
break;
|
||||
case "ellipse": {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
@@ -443,7 +449,7 @@ const renderLinearPointHandles = (
|
||||
);
|
||||
|
||||
const { POINT_HANDLE_SIZE } = LinearElementEditor;
|
||||
const radius = appState.editingLinearElement
|
||||
const radius = appState.selectedLinearElement?.isEditing
|
||||
? POINT_HANDLE_SIZE
|
||||
: POINT_HANDLE_SIZE / 2;
|
||||
|
||||
@@ -465,7 +471,8 @@ const renderLinearPointHandles = (
|
||||
);
|
||||
|
||||
let isSelected =
|
||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
|
||||
!!appState.selectedLinearElement?.isEditing &&
|
||||
!!appState.selectedLinearElement?.selectedPointsIndices?.includes(idx);
|
||||
// when element is a polygon, highlight the last point as well if first
|
||||
// point is selected since they overlap and the last point tends to be
|
||||
// rendered on top
|
||||
@@ -474,7 +481,8 @@ const renderLinearPointHandles = (
|
||||
element.polygon &&
|
||||
!isSelected &&
|
||||
idx === element.points.length - 1 &&
|
||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(0)
|
||||
!!appState.selectedLinearElement?.isEditing &&
|
||||
!!appState.selectedLinearElement?.selectedPointsIndices?.includes(0)
|
||||
) {
|
||||
isSelected = true;
|
||||
}
|
||||
@@ -530,7 +538,7 @@ const renderLinearPointHandles = (
|
||||
);
|
||||
|
||||
midPoints.forEach((segmentMidPoint) => {
|
||||
if (appState.editingLinearElement || points.length === 2) {
|
||||
if (appState.selectedLinearElement?.isEditing || points.length === 2) {
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
@@ -755,7 +763,10 @@ const _renderInteractiveScene = ({
|
||||
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
||||
// ShapeCache returns empty hence making sure that we get the
|
||||
// correct element from visible elements
|
||||
if (appState.editingLinearElement?.elementId === element.id) {
|
||||
if (
|
||||
appState.selectedLinearElement?.isEditing &&
|
||||
appState.selectedLinearElement.elementId === element.id
|
||||
) {
|
||||
if (element) {
|
||||
editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>;
|
||||
}
|
||||
@@ -848,7 +859,8 @@ const _renderInteractiveScene = ({
|
||||
// correct element from visible elements
|
||||
if (
|
||||
selectedElements.length === 1 &&
|
||||
appState.editingLinearElement?.elementId === selectedElements[0].id
|
||||
appState.selectedLinearElement?.isEditing &&
|
||||
appState.selectedLinearElement.elementId === selectedElements[0].id
|
||||
) {
|
||||
renderLinearPointHandles(
|
||||
context,
|
||||
@@ -879,7 +891,7 @@ const _renderInteractiveScene = ({
|
||||
}
|
||||
|
||||
// Paint selected elements
|
||||
if (!appState.multiElement && !appState.editingLinearElement) {
|
||||
if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) {
|
||||
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
|
||||
|
||||
const isSingleLinearElementSelected =
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { throttleRAF } from "@excalidraw/common";
|
||||
|
||||
import { renderElement } from "@excalidraw/element";
|
||||
import {
|
||||
getTargetFrame,
|
||||
isInvisiblySmallElement,
|
||||
renderElement,
|
||||
shouldApplyFrameClip,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
|
||||
|
||||
import { frameClip } from "./staticScene";
|
||||
|
||||
import type { NewElementSceneRenderConfig } from "../scene/types";
|
||||
|
||||
const _renderNewElementScene = ({
|
||||
@@ -29,11 +36,37 @@ const _renderNewElementScene = ({
|
||||
normalizedHeight,
|
||||
});
|
||||
|
||||
// Apply zoom
|
||||
context.save();
|
||||
|
||||
// Apply zoom
|
||||
context.scale(appState.zoom.value, appState.zoom.value);
|
||||
|
||||
if (newElement && newElement.type !== "selection") {
|
||||
// e.g. when creating arrows and we're still below the arrow drag distance
|
||||
// threshold
|
||||
// (for now we skip render only with elements while we're creating to be
|
||||
// safe)
|
||||
if (isInvisiblySmallElement(newElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameId = newElement.frameId || appState.frameToHighlight?.id;
|
||||
|
||||
if (
|
||||
frameId &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip
|
||||
) {
|
||||
const frame = getTargetFrame(newElement, elementsMap, appState);
|
||||
|
||||
if (
|
||||
frame &&
|
||||
shouldApplyFrameClip(newElement, frame, appState, elementsMap)
|
||||
) {
|
||||
frameClip(frame, context, renderConfig, appState);
|
||||
}
|
||||
}
|
||||
|
||||
renderElement(
|
||||
newElement,
|
||||
elementsMap,
|
||||
@@ -46,6 +79,8 @@ const _renderNewElementScene = ({
|
||||
} else {
|
||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user