Compare commits

...

46 Commits

Author SHA1 Message Date
Mark Tolmacs 2ed78d4895 fix: Improve rendering stability 2025-09-05 12:58:22 +02:00
Mark Tolmacs 5ba2798306 Iterating on arrows 2025-08-26 14:55:40 +02:00
Mark Tolmacs b8aae34e32 Fixes and handling 2025-08-25 14:46:07 +02:00
Mark Tolmacs 6317ac16ee Working screenshot diff 2025-08-25 11:20:38 +02:00
Mark Tolmacs 9ad75b8375 Direct function generation 2025-08-25 10:42:47 +02:00
Mark Tolmacs 4c9ad1a22f Implementation of one test 2025-08-20 17:53:20 +02:00
Mark Tolmacs 023895b49b Can generate and play back 2025-08-20 15:17:49 +02:00
Mark Tolmacs eb37be953a Install playwright 2025-08-20 15:17:35 +02:00
Ryan Di b4903a7eab feat: drag, resize, and rotate after selecting in lasso (#9732)
* feat: drag, resize, and rotate after selecting in lasso

* alternative ux: drag with lasso right away

* fix: lasso dragging should snap too

* fix: alt+cmd getting stuck

* test: snapshots

* alternatvie: keep lasso drag to only mobile

* alternative: drag after selection on PCs

* improve mobile dection

* add mobile lasso icon

* add default selection tool

* render according to default selection tool

* return to default selection tool after deletion

* reset to default tool after clearing out the canvas

* return to default tool after eraser toggle

* if default lasso, close lasso toggle

* finalize to default selection tool

* toggle between laser and default selection

* return to default selection tool after creation

* double click to add text when using default selection tool

* set to default selection tool after unlocking tool

* paste to center on touch screen

* switch to default selection tool after pasting

* lint

* fix tests

* show welcome screen when using default selection tool

* fix tests

* fix snapshots

* fix context menu not opening

* prevent potential displacement issue

* prevent element jumping during lasso selection

* fix dragging on mobile

* use same selection icon

* fix alt+cmd lasso getting cut off

* fix: shortcut handling

* lint

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-08-20 00:03:02 +02:00
zsviczian c6f8ef9ad2 fix: Scene deleted after pica image resize failure (#9879)
Revert change in private updateImageCache
2025-08-18 11:45:05 +02:00
Marcel Mraz 2535d73054 feat: apply deltas API (#9869) 2025-08-15 15:25:56 +02:00
David Luzar dda3affcb0 fix: do not strip invisible elements from array (#9844) 2025-08-12 11:56:11 +02:00
Marcel Mraz 54c148f390 fix: text restore & deletion issues (#9853) 2025-08-12 09:27:04 +02:00
zsviczian cc8e490c75 fix: do not auto-add elements to locked frame (#9851)
* Do not return locked frames when filtering for top level frame

* lint

* lint

* lint
2025-08-11 11:52:44 +02:00
Marcel Mraz 9036812b6d fix: editing linear element (#9839) 2025-08-08 09:30:11 +02:00
Marcel Mraz df25de7e68 feat: fix delta apply to issues (#9830) 2025-08-07 15:38:58 +02:00
David Luzar a3763648fe chore: update title (#9814)
* chore: update title

* update meta tag

* lint
2025-08-01 17:17:42 +02:00
Ryan Di 178eca5828 fix: add frame clipping to new element canvas (#9794)
* fix: add frame clipping to new element canvas

* cleanup save/restore

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-31 12:10:59 +00:00
Ryan Di cb33de25f4 feat: allow a frame to snap to its children (#9795) 2025-07-31 13:58:29 +02:00
Omar Brikaa 37ad85cbaf fix: Fix the root cause of flushSync flickering (#9791)
* More reliable width and height change detection

* Put the dimensions useEffect before the scene render one, just in case
2025-07-27 23:52:07 +02:00
Márk Tolmács d6a934ed19 chore: Remove editingLinearElement (#9771)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-24 17:02:21 +02:00
Omar Brikaa 416da62138 fix: multiple line editor bugs (#9760)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-07-24 09:11:04 +02:00
Omar Brikaa f38f381989 fix: Remove flushSync from alt-lasso and elbow dragging (#9734)
* Remove lasso flushSync

* Remove selectedLinearElement flushSync

* Early return
2025-07-23 23:39:16 +02:00
Ryan Di e5e07260c6 fix: improve line creation ux on touch screens (#9740)
* fix: awkward point adding and removing on touch device

* feat: move finalize to next to last point

* feat: on touch screen, click would create a default line/arrow

* fix: make default adaptive to zoom

* fix: increase padding to avoid cutoffs

* refactor: simplify

* fix: only use bigger padding when needed

* center arrow horizontally on pointer

* increase min drag distance before we start 2-point-arrow-drag-creating

* do not render 0-width arrow while creating

* dead code

* fix tests

* fix: remove redundant code

* do not enter line editor on creation

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-23 18:49:56 +10:00
Christopher Tangonan 8492b144b0 test: added test file for distribute (#9754) 2025-07-17 19:52:16 +02:00
Marcel Mraz e46f038132 feat: expose applyTo options, don't commit empty text element (#9744)
* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements
2025-07-17 15:22:32 +02:00
David Luzar 678dff25ed fix: ellipsify MainMenu and CommandPalette items (#9743)
* fix: ellipsify MainMenu and CommandPalette items

* fix lint
2025-07-15 12:59:55 +02:00
Christopher Tangonan 0cfa53b764 fix: aligning and distributing elements and nested groups while editing a group (#9721) 2025-07-15 12:43:42 +02:00
David Luzar cde46793f8 feat: support timestamps for youtube video emebds (#9737) 2025-07-13 19:19:10 +02:00
Aakansha Doshi 2d127f8c22 docs: fix broken update scene button example in docs (#9726)
fix: update scene example in docs
2025-07-08 19:29:44 +05:30
Soham Kulkarni 4eadb891f8 fix(toast): prevent toast from re-rendering and resetting timeout Fixes #9714 (#9715)
* Update App.tsx

* fix: lint

---------

Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2025-07-03 17:07:26 +10:00
Marcel Mraz 258605d1d5 chore: release multiple packages (#9698) 2025-06-30 12:19:15 +02:00
Márk Tolmács c141500400 chore: Relocate visualdebug so ESLint doesn't complain (#9668) 2025-06-18 14:45:51 +02:00
Márk Tolmács 8e27de2cdc fix: Frame dimensions change by stats don't include new elements (#9568) 2025-06-16 14:07:03 +02:00
Márk Tolmács 0a19c93509 fix: Bindings at partially overlapping binding areas (#9536) 2025-06-16 12:30:59 +02:00
Márk Tolmács 958597dfaa chore: Refactor doBoundsIntersect (#9657) 2025-06-16 12:30:42 +02:00
Marcel Mraz 058918f8e5 feat: capture images after they initialize (#9643)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-15 23:43:14 +02:00
Spawn 3f194918e6 feat: add mulitplatform Docker image support (#9594) 2025-06-15 20:11:37 +02:00
Ryan Di 93c92d13e9 feat: wrap texts from stats panel (#9552) 2025-06-14 13:05:24 +02:00
zsviczian 84e96e9393 fix: move doBoundsIntersect from element/src/bounds.ts to common/math/src/utils.ts (#9650)
move doBoundsIntersect to math/utils

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-14 11:01:30 +00:00
zsviczian 320af405e9 fix: move elementCenterPoint from common/src/utils.ts to element/src/bounds.ts (#9647)
move elementCenterPoint from utils to bounds.ts
2025-06-14 12:49:22 +02:00
Marcel Mraz 60512f13d5 Fix broken history when eleemnt in update scene are optional 2025-06-14 12:29:58 +02:00
Márk Tolmács f0458cc216 fix: Mid-point for rounded linears are not precisely centered (#9544) 2025-06-12 21:08:37 +02:00
Márk Tolmács 9f3fdf5505 fix: Test hook usage in production code (#9645) 2025-06-12 10:39:50 +02:00
Márk Tolmács f42e1ab64e perf: Improve elbow arrow indirect binding logic (#9624) 2025-06-11 19:15:48 +02:00
Ashwin Temkar 18808481fd fix: set cursor to auto when not hovering a point on linear element (#9642)
* fix: set cursor to auto when not hovering a point on linear element #9628

* Simplify hover test for cursor

* Add back comment

* Fix test for hit testing

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-06-11 16:52:02 +02:00
124 changed files with 6460 additions and 2775 deletions
+1 -1
View File
@@ -24,4 +24,4 @@ jobs:
- name: Auto release
run: |
yarn add @actions/core -W
yarn autorelease
yarn release --tag=next --non-interactive
-55
View File
@@ -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 }}"
+27
View File
@@ -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
+6 -1
View File
@@ -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
+6
View File
@@ -27,3 +27,9 @@ dev-dist
html
meta*.json
.claude
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+5 -4
View File
@@ -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;
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"outputDirectory": "dist",
"installCommand": "yarn install",
"buildCommand": "yarn build:package && yarn build"
"buildCommand": "yarn build:packages && yarn build"
}
+1
View File
@@ -74,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,
+25 -33
View File
@@ -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;
+3 -1
View File
@@ -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) {
+6 -1
View File
@@ -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,
});
}
+4 -2
View File
@@ -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"
+1
View File
@@ -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"
+270
View File
@@ -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
View File
@@ -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"
+5 -2
View File
@@ -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": [
+11 -1
View File
@@ -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 -15
View File
@@ -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);
+9 -2
View File
@@ -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"
}
}
+16 -4
View File
@@ -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();
-95
View File
@@ -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;
};
}
+7 -4
View File
@@ -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);
+45 -4
View File
@@ -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>,
+80 -4
View File
@@ -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);
};
+129 -67
View File
@@ -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;
};
+1 -2
View File
@@ -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
View File
@@ -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;
}
}
+4 -8
View File
@@ -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)),
+9 -2
View File
@@ -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]);
+5 -12
View File
@@ -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;
+32 -2
View File
@@ -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]) {
+1 -1
View File
@@ -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,
+77
View File
@@ -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()));
};
+1 -3
View File
@@ -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";
+146 -112
View File
@@ -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;
+1 -1
View File
@@ -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";
+7 -2
View File
@@ -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;
}
+56 -191
View File
@@ -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;
};
-447
View File
@@ -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;
};
+51 -16
View File
@@ -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, {
-3
View File
@@ -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] };
+1 -1
View File
@@ -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) {
+63 -10
View File
@@ -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;
};
+420
View File
@@ -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);
});
});
+2 -2
View File
@@ -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");
+1 -1
View File
@@ -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(),
+417 -10
View File
@@ -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 },
},
),
);
});
});
});
+128
View File
@@ -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);
});
});
+153
View File
@@ -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",
}
`);
});
+13 -2
View File
@@ -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);
+6 -3
View File
@@ -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(
+60 -25
View File
@@ -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;
}
-2
View File
@@ -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 },
+73 -77
View File
@@ -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}
@@ -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>
+19 -2
View File
@@ -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 &&
@@ -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,
}
`;
+5 -1
View File
@@ -170,7 +170,11 @@ export const loadSceneOrLibraryFromBlob = async (
},
localAppState,
localElements,
{ repairBindings: true, refreshDimensions: false },
{
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
),
};
} else if (isValidLibrary(data)) {
+2 -2
View File
@@ -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
+47 -21
View File
@@ -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),
+1 -1
View File
@@ -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] =
+2
View File
@@ -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 };
+10 -7
View File
@@ -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"
}
}
+7 -10
View File
@@ -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();
}
};
+1 -1
View File
@@ -113,7 +113,7 @@ const strokeGrid = (
context.restore();
};
const frameClip = (
export const frameClip = (
frame: ExcalidrawFrameLikeElement,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
+10 -11
View File
@@ -13,7 +13,7 @@ import {
getDraggedElementsBounds,
getElementAbsoluteCoords,
} from "@excalidraw/element";
import { isBoundToContainer, isFrameLikeElement } from "@excalidraw/element";
import { isBoundToContainer } from "@excalidraw/element";
import { getMaximumGroups } from "@excalidraw/element";
@@ -169,8 +169,14 @@ export const isSnappingEnabled = ({
selectedElements: NonDeletedExcalidrawElement[];
}) => {
if (event) {
// Allow snapping for lasso tool when dragging selected elements
// but not during lasso selection phase
const isLassoDragging =
app.state.activeTool.type === "lasso" &&
app.state.selectedElementsAreBeingDragged;
return (
app.state.activeTool.type !== "lasso" &&
(app.state.activeTool.type !== "lasso" || isLassoDragging) &&
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
(!app.state.objectsSnapModeEnabled &&
event[KEYS.CTRL_OR_CMD] &&
@@ -311,20 +317,13 @@ const getReferenceElements = (
selectedElements: NonDeletedExcalidrawElement[],
appState: AppState,
elementsMap: ElementsMap,
) => {
const selectedFrames = selectedElements
.filter((element) => isFrameLikeElement(element))
.map((frame) => frame.id);
return getVisibleAndNonSelectedElements(
) =>
getVisibleAndNonSelectedElements(
elements,
selectedElements,
appState,
elementsMap,
).filter(
(element) => !(element.frameId && selectedFrames.includes(element.frameId)),
);
};
export const getVisibleGaps = (
elements: readonly NonDeletedExcalidrawElement[],
@@ -908,7 +908,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -1106,7 +1105,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -1319,7 +1317,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -1649,7 +1646,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -1979,7 +1975,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -2192,7 +2187,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -2432,7 +2426,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -2729,7 +2722,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -3100,7 +3092,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -3592,7 +3583,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -3692,14 +3682,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1116226695,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 23633383,
"versionNonce": 81784553,
"width": 20,
"x": 20,
"y": 30,
@@ -3724,14 +3714,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"y": 0,
@@ -3914,7 +3904,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -4236,7 +4225,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -5520,7 +5508,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -6736,7 +6723,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -7670,7 +7656,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -8669,7 +8654,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -9659,7 +9643,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@@ -15,7 +15,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div
class="dropdown-menu-item__text"
>
Click me
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Click me
</span>
</div>
</button>
<a
@@ -27,7 +31,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div
class="dropdown-menu-item__text"
>
Excalidraw blog
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Excalidraw blog
</span>
</div>
</a>
<div
@@ -88,7 +96,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div
class="dropdown-menu-item__text"
>
Help
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Help
</span>
</div>
<div
class="dropdown-menu-item__shortcut"
@@ -138,7 +150,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Open
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Open
</span>
</div>
<div
class="dropdown-menu-item__shortcut"
@@ -175,7 +191,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Save to...
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Save to...
</span>
</div>
</button>
<button
@@ -231,7 +251,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Export image...
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Export image...
</span>
</div>
<div
class="dropdown-menu-item__shortcut"
@@ -280,7 +304,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Find on canvas
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Find on canvas
</span>
</div>
<div
class="dropdown-menu-item__shortcut"
@@ -337,7 +365,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Help
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Help
</span>
</div>
<div
class="dropdown-menu-item__shortcut"
@@ -374,7 +406,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Reset the canvas
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Reset the canvas
</span>
</div>
</button>
<div
@@ -419,7 +455,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
GitHub
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
GitHub
</span>
</div>
</a>
<a
@@ -465,7 +505,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Follow us
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Follow us
</span>
</div>
</a>
<a
@@ -505,7 +549,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Discord chat
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Discord chat
</span>
</div>
</a>
</div>
@@ -542,7 +590,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Dark mode
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Dark mode
</span>
</div>
<div
class="dropdown-menu-item__shortcut"
File diff suppressed because it is too large Load Diff

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