Compare 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
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
zsviczianandGitHub c6f8ef9ad2 fix: Scene deleted after pica image resize failure (#9879)
Revert change in private updateImageCache
2025-08-18 11:45:05 +02:00
Marcel MrazandGitHub 2535d73054 feat: apply deltas API (#9869) 2025-08-15 15:25:56 +02:00
David LuzarandGitHub dda3affcb0 fix: do not strip invisible elements from array (#9844) 2025-08-12 11:56:11 +02:00
Marcel MrazandGitHub 54c148f390 fix: text restore & deletion issues (#9853) 2025-08-12 09:27:04 +02:00
zsviczianandGitHub 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 MrazandGitHub 9036812b6d fix: editing linear element (#9839) 2025-08-08 09:30:11 +02:00
Marcel MrazandGitHub df25de7e68 feat: fix delta apply to issues (#9830) 2025-08-07 15:38:58 +02:00
David LuzarandGitHub a3763648fe chore: update title (#9814)
* chore: update title

* update meta tag

* lint
2025-08-01 17:17:42 +02:00
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 DiandGitHub cb33de25f4 feat: allow a frame to snap to its children (#9795) 2025-07-31 13:58:29 +02:00
Omar BrikaaandGitHub 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
d6a934ed19 chore: Remove editingLinearElement (#9771)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-24 17:02:21 +02:00
416da62138 fix: multiple line editor bugs (#9760)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-07-24 09:11:04 +02:00
Omar BrikaaandGitHub 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
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 TangonanandGitHub 8492b144b0 test: added test file for distribute (#9754) 2025-07-17 19:52:16 +02:00
Marcel MrazandGitHub 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 LuzarandGitHub 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 TangonanandGitHub 0cfa53b764 fix: aligning and distributing elements and nested groups while editing a group (#9721) 2025-07-15 12:43:42 +02:00
David LuzarandGitHub cde46793f8 feat: support timestamps for youtube video emebds (#9737) 2025-07-13 19:19:10 +02:00
Aakansha DoshiandGitHub 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
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
77 changed files with 4353 additions and 1206 deletions
+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
View File
@@ -27,3 +27,9 @@ dev-dist
html html
meta*.json meta*.json
.claude .claude
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
@@ -33,6 +33,7 @@ const ExcalidrawScope = {
initialData, initialData,
useI18n: ExcalidrawComp.useI18n, useI18n: ExcalidrawComp.useI18n,
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements, convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
}; };
export default ExcalidrawScope; export default ExcalidrawScope;
+1
View File
@@ -74,6 +74,7 @@ import type {
import type { ResolutionType } from "@excalidraw/common/utility-types"; import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils"; import type { ResolvablePromise } from "@excalidraw/common/utils";
import "./record";
import CustomStats from "./CustomStats"; import CustomStats from "./CustomStats";
import { import {
Provider, Provider,
+8 -16
View File
@@ -9,7 +9,7 @@ import {
} from "@excalidraw/excalidraw/renderer/helpers"; } from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types"; import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/common"; import { throttleRAF } from "@excalidraw/common";
import { useCallback, useImperativeHandle, useRef } from "react"; import { useCallback } from "react";
import { import {
isLineSegment, isLineSegment,
@@ -18,6 +18,8 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve"; import { isCurve } from "@excalidraw/math/curve";
import React from "react";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/utils/visualdebug"; import type { DebugElement } from "@excalidraw/utils/visualdebug";
@@ -113,10 +115,6 @@ const _debugRenderer = (
scale, scale,
); );
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({ const context = bootstrapCanvas({
canvas, canvas,
scale, scale,
@@ -314,19 +312,12 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
interface DebugCanvasProps { interface DebugCanvasProps {
appState: AppState; appState: AppState;
scale: number; scale: number;
ref?: React.Ref<HTMLCanvasElement>;
} }
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => { const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => {
const { width, height } = appState; const { width, height } = appState;
const canvasRef = useRef<HTMLCanvasElement>(null);
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
ref,
() => canvasRef.current,
[canvasRef],
);
return ( return (
<canvas <canvas
style={{ style={{
@@ -338,11 +329,12 @@ const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
}} }}
width={width * scale} width={width * scale}
height={height * scale} height={height * scale}
ref={canvasRef} ref={ref}
> >
Debug Canvas Debug Canvas
</canvas> </canvas>
); );
}; },
);
export default DebugCanvas; export default DebugCanvas;
+3 -1
View File
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
} }
const storedScene = docSnap.data() as FirebaseStoredScene; const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements( const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null), restoreElements(await decryptElements(storedScene, roomKey), null, {
deleteInvisibleElements: true,
}),
); );
if (socket) { if (socket) {
+6 -1
View File
@@ -258,11 +258,16 @@ export const loadScene = async (
await importFromBackend(id, privateKey), await importFromBackend(id, privateKey),
localDataState?.appState, localDataState?.appState,
localDataState?.elements, localDataState?.elements,
{ repairBindings: true, refreshDimensions: false }, {
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
); );
} else { } else {
data = restore(localDataState || null, null, null, { data = restore(localDataState || null, null, null, {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true,
}); });
} }
+4 -2
View File
@@ -2,7 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title> <title>
Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw
</title>
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
@@ -14,7 +16,7 @@
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<meta <meta
name="title" name="title"
content="Excalidraw — Collaborative whiteboarding made easy" content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
/> />
<meta <meta
name="description" name="description"
+1
View File
@@ -46,6 +46,7 @@
"build:version": "node ../scripts/build-version.js", "build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"start": "yarn && vite", "start": "yarn && vite",
"start:test": "yarn && vite --mode test",
"start:production": "yarn build && yarn serve", "start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o", "serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000" "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

+3
View File
@@ -11,9 +11,11 @@
"@babel/preset-env": "7.26.9", "@babel/preset-env": "7.26.9",
"@excalidraw/eslint-config": "1.0.3", "@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
"@playwright/test": "1.55.0",
"@types/chai": "4.3.0", "@types/chai": "4.3.0",
"@types/jest": "27.4.0", "@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7", "@types/lodash.throttle": "4.1.7",
"@types/node": "24.3.0",
"@types/react": "19.0.10", "@types/react": "19.0.10",
"@types/react-dom": "19.0.4", "@types/react-dom": "19.0.4",
"@types/socket.io-client": "3.0.0", "@types/socket.io-client": "3.0.0",
@@ -62,6 +64,7 @@
"build:preview": "yarn --cwd ./excalidraw-app build:preview", "build:preview": "yarn --cwd ./excalidraw-app build:preview",
"start": "yarn --cwd ./excalidraw-app start", "start": "yarn --cwd ./excalidraw-app start",
"start:production": "yarn --cwd ./excalidraw-app start:production", "start:production": "yarn --cwd ./excalidraw-app start:production",
"start:test": "yarn --cwd ./excalidraw-app start:test",
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start", "start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest", "test:app": "vitest",
+11 -1
View File
@@ -18,13 +18,22 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari = export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1; !isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS = export const isIOS =
/iPad|iPhone/.test(navigator.platform) || /iPad|iPhone/i.test(navigator.platform) ||
// iPadOS 13+ // iPadOS 13+
(navigator.userAgent.includes("Mac") && "ontouchend" in document); (navigator.userAgent.includes("Mac") && "ontouchend" in document);
// keeping function so it can be mocked in test // keeping function so it can be mocked in test
export const isBrave = () => export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave"; (navigator as any).brave?.isBrave?.name === "isBrave";
export const isMobile =
isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent.toLowerCase(),
) ||
/android|ios|ipod|blackberry|windows phone/i.test(
navigator.platform.toLowerCase(),
);
export const supportsResizeObserver = export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window; 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) // (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px export const DRAGGING_THRESHOLD = 10; // px
export const MINIMUM_ARROW_SIZE = 20; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1; export const ELEMENT_TRANSLATE_AMOUNT = 1;
+15 -3
View File
@@ -164,9 +164,14 @@ export class Scene {
return this.frames; return this.frames;
} }
constructor(elements: ElementsMapOrArray | null = null) { constructor(
elements: ElementsMapOrArray | null = null,
options?: {
skipValidation?: true;
},
) {
if (elements) { if (elements) {
this.replaceAllElements(elements); this.replaceAllElements(elements, options);
} }
} }
@@ -263,12 +268,19 @@ export class Scene {
return didChange; return didChange;
} }
replaceAllElements(nextElements: ElementsMapOrArray) { replaceAllElements(
nextElements: ElementsMapOrArray,
options?: {
skipValidation?: true;
},
) {
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices // we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
const _nextElements = toArray(nextElements); const _nextElements = toArray(nextElements);
const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
if (!options?.skipValidation) {
validateIndicesThrottled(_nextElements); validateIndicesThrottled(_nextElements);
}
this.elements = syncInvalidIndices(_nextElements); this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear(); this.elementsMap.clear();
+7 -4
View File
@@ -1,6 +1,8 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding"; import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds"; import { getCommonBoundingBox } from "./bounds";
import { getMaximumGroups } from "./groups"; import { getSelectedElementsByGroup } from "./groups";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
@@ -16,11 +18,12 @@ export const alignElements = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
alignment: Alignment, alignment: Alignment,
scene: Scene, scene: Scene,
appState: Readonly<AppState>,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const elementsMap = scene.getNonDeletedElementsMap(); const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements, selectedElements,
elementsMap, scene.getNonDeletedElementsMap(),
appState,
); );
const selectionBoundingBox = getCommonBoundingBox(selectedElements); const selectionBoundingBox = getCommonBoundingBox(selectedElements);
+367 -122
View File
@@ -55,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { Scene } from "./Scene";
import { StoreSnapshot } from "./store"; import { StoreSnapshot } from "./store";
import { Scene } from "./Scene";
import type { BindableProp, BindingProp } from "./binding"; import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@@ -150,13 +150,27 @@ export class Delta<T> {
); );
} }
/**
* Merges two deltas into a new one.
*/
public static merge<T>(
delta1: Delta<T>,
delta2: Delta<T>,
delta3: Delta<T> = Delta.empty(),
) {
return Delta.create(
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
);
}
/** /**
* Merges deleted and inserted object partials. * Merges deleted and inserted object partials.
*/ */
public static mergeObjects<T extends { [key: string]: unknown }>( public static mergeObjects<T extends { [key: string]: unknown }>(
prev: T, prev: T,
added: T, added: T,
removed: T, removed: T = {} as T,
) { ) {
const cloned = { ...prev }; const cloned = { ...prev };
@@ -496,6 +510,11 @@ export interface DeltaContainer<T> {
*/ */
applyTo(previous: T, ...options: unknown[]): [T, boolean]; applyTo(previous: T, ...options: unknown[]): [T, boolean];
/**
* Squashes the current delta with the given one.
*/
squash(delta: DeltaContainer<T>): this;
/** /**
* Checks whether all `Delta`s are empty. * Checks whether all `Delta`s are empty.
*/ */
@@ -503,7 +522,11 @@ export interface DeltaContainer<T> {
} }
export class AppStateDelta implements DeltaContainer<AppState> { export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public readonly delta: Delta<ObservedAppState>) {} private constructor(public delta: Delta<ObservedAppState>) {}
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
return new AppStateDelta(delta);
}
public static calculate<T extends ObservedAppState>( public static calculate<T extends ObservedAppState>(
prevAppState: T, prevAppState: T,
@@ -534,53 +557,124 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return new AppStateDelta(inversedDelta); return new AppStateDelta(inversedDelta);
} }
public squash(delta: AppStateDelta): this {
if (delta.isEmpty()) {
return this;
}
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
this.delta.deleted.selectedElementIds ?? {},
delta.delta.deleted.selectedElementIds ?? {},
);
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
this.delta.inserted.selectedElementIds ?? {},
delta.delta.inserted.selectedElementIds ?? {},
);
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
this.delta.deleted.selectedGroupIds ?? {},
delta.delta.deleted.selectedGroupIds ?? {},
);
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
this.delta.inserted.selectedGroupIds ?? {},
delta.delta.inserted.selectedGroupIds ?? {},
);
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
this.delta.deleted.lockedMultiSelections ?? {},
delta.delta.deleted.lockedMultiSelections ?? {},
);
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
this.delta.inserted.lockedMultiSelections ?? {},
delta.delta.inserted.lockedMultiSelections ?? {},
);
const mergedInserted: Partial<ObservedAppState> = {};
const mergedDeleted: Partial<ObservedAppState> = {};
if (
Object.keys(mergedDeletedSelectedElementIds).length ||
Object.keys(mergedInsertedSelectedElementIds).length
) {
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
}
if (
Object.keys(mergedDeletedSelectedGroupIds).length ||
Object.keys(mergedInsertedSelectedGroupIds).length
) {
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
}
if (
Object.keys(mergedDeletedLockedMultiSelections).length ||
Object.keys(mergedInsertedLockedMultiSelections).length
) {
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
mergedInserted.lockedMultiSelections =
mergedInsertedLockedMultiSelections;
}
this.delta = Delta.merge(
this.delta,
delta.delta,
Delta.create(mergedDeleted, mergedInserted),
);
return this;
}
public applyTo( public applyTo(
appState: AppState, appState: AppState,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
): [AppState, boolean] { ): [AppState, boolean] {
try { try {
const { const {
selectedElementIds: removedSelectedElementIds = {}, selectedElementIds: deletedSelectedElementIds = {},
selectedGroupIds: removedSelectedGroupIds = {}, selectedGroupIds: deletedSelectedGroupIds = {},
lockedMultiSelections: deletedLockedMultiSelections = {},
} = this.delta.deleted; } = this.delta.deleted;
const { const {
selectedElementIds: addedSelectedElementIds = {}, selectedElementIds: insertedSelectedElementIds = {},
selectedGroupIds: addedSelectedGroupIds = {}, selectedGroupIds: insertedSelectedGroupIds = {},
selectedLinearElementId, lockedMultiSelections: insertedLockedMultiSelections = {},
editingLinearElementId, selectedLinearElement: insertedSelectedLinearElement,
...directlyApplicablePartial ...directlyApplicablePartial
} = this.delta.inserted; } = this.delta.inserted;
const mergedSelectedElementIds = Delta.mergeObjects( const mergedSelectedElementIds = Delta.mergeObjects(
appState.selectedElementIds, appState.selectedElementIds,
addedSelectedElementIds, insertedSelectedElementIds,
removedSelectedElementIds, deletedSelectedElementIds,
); );
const mergedSelectedGroupIds = Delta.mergeObjects( const mergedSelectedGroupIds = Delta.mergeObjects(
appState.selectedGroupIds, appState.selectedGroupIds,
addedSelectedGroupIds, insertedSelectedGroupIds,
removedSelectedGroupIds, deletedSelectedGroupIds,
);
const mergedLockedMultiSelections = Delta.mergeObjects(
appState.lockedMultiSelections,
insertedLockedMultiSelections,
deletedLockedMultiSelections,
); );
const selectedLinearElement = const selectedLinearElement =
selectedLinearElementId && nextElements.has(selectedLinearElementId) insertedSelectedLinearElement &&
nextElements.has(insertedSelectedLinearElement.elementId)
? new LinearElementEditor( ? new LinearElementEditor(
nextElements.get( nextElements.get(
selectedLinearElementId, insertedSelectedLinearElement.elementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
)
: null;
const editingLinearElement =
editingLinearElementId && nextElements.has(editingLinearElementId)
? new LinearElementEditor(
nextElements.get(
editingLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>, ) as NonDeleted<ExcalidrawLinearElement>,
nextElements, nextElements,
insertedSelectedLinearElement.isEditing,
) )
: null; : null;
@@ -589,14 +683,11 @@ export class AppStateDelta implements DeltaContainer<AppState> {
...directlyApplicablePartial, ...directlyApplicablePartial,
selectedElementIds: mergedSelectedElementIds, selectedElementIds: mergedSelectedElementIds,
selectedGroupIds: mergedSelectedGroupIds, selectedGroupIds: mergedSelectedGroupIds,
lockedMultiSelections: mergedLockedMultiSelections,
selectedLinearElement: selectedLinearElement:
typeof selectedLinearElementId !== "undefined" typeof insertedSelectedLinearElement !== "undefined"
? selectedLinearElement // element was either inserted or deleted ? selectedLinearElement
: appState.selectedLinearElement, // otherwise assign what we had before : appState.selectedLinearElement,
editingLinearElement:
typeof editingLinearElementId !== "undefined"
? editingLinearElement // element was either inserted or deleted
: appState.editingLinearElement, // otherwise assign what we had before
}; };
const constainsVisibleChanges = this.filterInvisibleChanges( const constainsVisibleChanges = this.filterInvisibleChanges(
@@ -725,46 +816,48 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
break; break;
case "selectedLinearElementId": case "selectedLinearElement":
case "editingLinearElementId": const nextLinearElement = nextAppState[key];
const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
if (!linearElement) { if (!nextLinearElement) {
// previously there was a linear element (assuming visible), now there is none // previously there was a linear element (assuming visible), now there is none
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
const element = nextElements.get(linearElement.elementId); const element = nextElements.get(nextLinearElement.elementId);
if (element && !element.isDeleted) { if (element && !element.isDeleted) {
// previously there wasn't a linear element, now there is one which is visible // previously there wasn't a linear element, now there is one which is visible
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
// there was assigned a linear element now, but it's deleted // there was assigned a linear element now, but it's deleted
nextAppState[appStateKey] = null; nextAppState[key] = null;
} }
} }
break; break;
case "lockedMultiSelections": { case "lockedMultiSelections":
const prevLockedUnits = prevAppState[key] || {}; const prevLockedUnits = prevAppState[key] || {};
const nextLockedUnits = nextAppState[key] || {}; const nextLockedUnits = nextAppState[key] || {};
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) { if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} }
break; break;
} case "activeLockedId":
case "activeLockedId": {
const prevHitLockedId = prevAppState[key] || null; const prevHitLockedId = prevAppState[key] || null;
const nextHitLockedId = nextAppState[key] || null; const nextHitLockedId = nextAppState[key] || null;
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (prevHitLockedId !== nextHitLockedId) { if (prevHitLockedId !== nextHitLockedId) {
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} }
break; break;
} default:
default: {
assertNever( assertNever(
key, key,
`Unknown ObservedElementsAppState's key "${key}"`, `Unknown ObservedElementsAppState's key "${key}"`,
@@ -773,25 +866,10 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
} }
} }
}
return visibleDifferenceFlag.value; return visibleDifferenceFlag.value;
} }
private static convertToAppStateKey(
key: keyof Pick<
ObservedElementsAppState,
"selectedLinearElementId" | "editingLinearElementId"
>,
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
switch (key) {
case "selectedLinearElementId":
return "selectedLinearElement";
case "editingLinearElementId":
return "editingLinearElement";
}
}
private static filterSelectedElements( private static filterSelectedElements(
selectedElementIds: AppState["selectedElementIds"], selectedElementIds: AppState["selectedElementIds"],
elements: SceneElementsMap, elements: SceneElementsMap,
@@ -856,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
editingGroupId, editingGroupId,
selectedGroupIds, selectedGroupIds,
selectedElementIds, selectedElementIds,
editingLinearElementId, selectedLinearElement,
selectedLinearElementId,
croppingElementId, croppingElementId,
lockedMultiSelections, lockedMultiSelections,
activeLockedId, activeLockedId,
@@ -911,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
"lockedMultiSelections", "lockedMultiSelections",
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>, (prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
); );
Delta.diffObjects(
deleted,
inserted,
"activeLockedId",
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
);
} catch (e) { } catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess appstate change deltas.`); console.error(`Couldn't postprocess appstate change deltas.`);
@@ -945,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">; Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
export type ApplyToOptions = { export type ApplyToOptions = {
excludedProperties: Set<keyof ElementPartial>; excludedProperties?: Set<keyof ElementPartial>;
}; };
type ApplyToFlags = { type ApplyToFlags = {
containsVisibleDifference: boolean; containsVisibleDifference: boolean;
containsZindexDifference: boolean; containsZindexDifference: boolean;
applyDirection: "forward" | "backward" | undefined;
}; };
/** /**
@@ -1051,6 +1123,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
deleted.version !== inserted.version deleted.version !== inserted.version
); );
private static satisfiesUniqueInvariants = (
elementsDelta: ElementsDelta,
id: string,
) => {
const { added, removed, updated } = elementsDelta;
// it's required that there is only one unique delta type per element
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
};
private static validate( private static validate(
elementsDelta: ElementsDelta, elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated", type: "added" | "removed" | "updated",
@@ -1059,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
for (const [id, delta] of Object.entries(elementsDelta[type])) { for (const [id, delta] of Object.entries(elementsDelta[type])) {
if ( if (
!this.satisfiesCommmonInvariants(delta) || !this.satisfiesCommmonInvariants(delta) ||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
!satifiesSpecialInvariants(delta) !satifiesSpecialInvariants(delta)
) { ) {
console.error( console.error(
@@ -1095,7 +1177,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const nextElement = nextElements.get(prevElement.id); const nextElement = nextElements.get(prevElement.id);
if (!nextElement) { if (!nextElement) {
const deleted = { ...prevElement, isDeleted: false } as ElementPartial; const deleted = { ...prevElement } as ElementPartial;
const inserted = { const inserted = {
isDeleted: true, isDeleted: true,
@@ -1109,9 +1191,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
// ignore updates which would "delete" already deleted element
if (!prevElement.isDeleted) {
removed[prevElement.id] = delta; removed[prevElement.id] = delta;
} }
} }
}
for (const nextElement of nextElements.values()) { for (const nextElement of nextElements.values()) {
const prevElement = prevElements.get(nextElement.id); const prevElement = prevElements.get(nextElement.id);
@@ -1125,7 +1210,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inserted = { const inserted = {
...nextElement, ...nextElement,
isDeleted: false,
} as ElementPartial; } as ElementPartial;
const delta = Delta.create( const delta = Delta.create(
@@ -1134,7 +1218,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
// ignore updates which would "delete" already deleted element
if (!nextElement.isDeleted) {
added[nextElement.id] = delta; added[nextElement.id] = delta;
}
continue; continue;
} }
@@ -1163,8 +1250,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
// making sure there are at least some changes const strippedDeleted = ElementsDelta.stripVersionProps(delta.deleted);
if (!Delta.isEmpty(delta)) { 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; updated[nextElement.id] = delta;
} }
} }
@@ -1181,8 +1273,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => { const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {}; const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) { for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted); inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
} }
return inversedDeltas; return inversedDeltas;
@@ -1280,8 +1372,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
latestDelta = delta; 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 // 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; modifiedDeltas[id] = latestDelta;
} }
} }
@@ -1301,9 +1400,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo( public applyTo(
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements, snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
options: ApplyToOptions = { options?: ApplyToOptions,
excludedProperties: new Set(),
},
): [SceneElementsMap, boolean] { ): [SceneElementsMap, boolean] {
let nextElements = new Map(elements) as SceneElementsMap; let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>; let changedElements: Map<string, OrderedExcalidrawElement>;
@@ -1311,22 +1408,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const flags: ApplyToFlags = { const flags: ApplyToFlags = {
containsVisibleDifference: false, containsVisibleDifference: false,
containsZindexDifference: false, containsZindexDifference: false,
applyDirection: undefined,
}; };
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
try { try {
const applyDeltas = ElementsDelta.createApplier( const applyDeltas = ElementsDelta.createApplier(
elements,
nextElements, nextElements,
snapshot, snapshot,
options,
flags, flags,
options,
); );
const addedElements = applyDeltas(this.added); const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas(this.removed); const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas(this.updated); const updatedElements = applyDeltas(this.updated);
const affectedElements = this.resolveConflicts(elements, nextElements); const affectedElements = this.resolveConflicts(
elements,
nextElements,
flags.applyDirection,
);
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues // TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
changedElements = new Map([ changedElements = new Map([
@@ -1350,22 +1453,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
} }
try { try {
// the following reorder performs also mutations, but only on new instances of changed elements // the following reorder performs mutations, but only on new instances of changed elements,
// (unless something goes really bad and it fallbacks to fixing all invalid indices) // unless something goes really bad and it fallbacks to fixing all invalid indices
nextElements = ElementsDelta.reorderElements( nextElements = ElementsDelta.reorderElements(
nextElements, nextElements,
changedElements, changedElements,
flags, flags,
); );
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry ElementsDelta.redrawElements(nextElements, changedElements);
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements);
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// Need ordered nextElements to avoid z-index binding issues
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) { } catch (e) {
console.error( console.error(
`Couldn't mutate elements after applying elements change`, `Couldn't mutate elements after applying elements change`,
@@ -1380,12 +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 = private static createApplier =
( (
prevElements: SceneElementsMap,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
snapshot: StoreSnapshot["elements"], snapshot: StoreSnapshot["elements"],
options: ApplyToOptions,
flags: ApplyToFlags, flags: ApplyToFlags,
options?: ApplyToOptions,
) => ) =>
(deltas: Record<string, Delta<ElementPartial>>) => { (deltas: Record<string, Delta<ElementPartial>>) => {
const getElement = ElementsDelta.createGetter( const getElement = ElementsDelta.createGetter(
@@ -1398,15 +1595,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const element = getElement(id, delta.inserted); const element = getElement(id, delta.inserted);
if (element) { if (element) {
const newElement = ElementsDelta.applyDelta( const nextElement = ElementsDelta.applyDelta(
element, element,
delta, delta,
options,
flags, flags,
options,
); );
nextElements.set(newElement.id, newElement); nextElements.set(nextElement.id, nextElement);
acc.set(newElement.id, newElement); acc.set(nextElement.id, nextElement);
if (!flags.applyDirection) {
const prevElement = prevElements.get(id);
if (prevElement) {
flags.applyDirection =
prevElement.version > nextElement.version
? "backward"
: "forward";
}
}
} }
return acc; return acc;
@@ -1451,8 +1659,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static applyDelta( private static applyDelta(
element: OrderedExcalidrawElement, element: OrderedExcalidrawElement,
delta: Delta<ElementPartial>, delta: Delta<ElementPartial>,
options: ApplyToOptions,
flags: ApplyToFlags, flags: ApplyToFlags,
options?: ApplyToOptions,
) { ) {
const directlyApplicablePartial: Mutable<ElementPartial> = {}; const directlyApplicablePartial: Mutable<ElementPartial> = {};
@@ -1466,7 +1674,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
if (options.excludedProperties.has(key)) { if (options?.excludedProperties?.has(key)) {
continue; continue;
} }
@@ -1506,7 +1714,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
delta.deleted.index !== delta.inserted.index; delta.deleted.index !== delta.inserted.index;
} }
return newElementWith(element, directlyApplicablePartial); return newElementWith(element, directlyApplicablePartial, true);
} }
/** /**
@@ -1546,6 +1754,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private resolveConflicts( private resolveConflicts(
prevElements: SceneElementsMap, prevElements: SceneElementsMap,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
applyDirection: "forward" | "backward" = "forward",
) { ) {
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>(); const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
const updater = ( const updater = (
@@ -1557,21 +1766,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return; return;
} }
const prevElement = prevElements.get(element.id);
const nextVersion =
applyDirection === "forward"
? nextElement.version + 1
: nextElement.version - 1;
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
let affectedElement: OrderedExcalidrawElement; let affectedElement: OrderedExcalidrawElement;
if (prevElements.get(element.id) === nextElement) { if (prevElement === nextElement) {
// create the new element instance in case we didn't modify the element yet // create the new element instance in case we didn't modify the element yet
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations // so that we won't end up in an incosistent state in case we would fail in the middle of mutations
affectedElement = newElementWith( affectedElement = newElementWith(
nextElement, nextElement,
updates as ElementUpdate<OrderedExcalidrawElement>, {
...elementUpdates,
version: nextVersion,
},
true,
); );
} else { } else {
affectedElement = mutateElement( affectedElement = mutateElement(nextElement, nextElements, {
nextElement, ...elementUpdates,
nextElements, // don't modify the version further, if it's already different
updates as ElementUpdate<OrderedExcalidrawElement>, version:
); prevElement?.version !== nextElement.version
? nextElement.version
: nextVersion,
});
} }
nextAffectedElements.set(affectedElement.id, affectedElement); nextAffectedElements.set(affectedElement.id, affectedElement);
@@ -1609,25 +1833,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)), Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
); );
// calculate complete deltas for affected elements, and assign them back to all the deltas // calculate complete deltas for affected elements, and squash them back to the current deltas
this.squash(
// technically we could do better here if perf. would become an issue // technically we could do better here if perf. would become an issue
const { added, removed, updated } = ElementsDelta.calculate( ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
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; return nextAffectedElements;
} }
@@ -1689,6 +1900,31 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
BindableElement.rebindAffected(nextElements, nextElement(), updater); BindableElement.rebindAffected(nextElements, nextElement(), updater);
} }
public static redrawElements(
nextElements: SceneElementsMap,
changedElements: Map<string, OrderedExcalidrawElement>,
) {
try {
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements, { skipValidation: true });
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// needs ordered nextElements to avoid z-index binding issues
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) {
console.error(`Couldn't redraw elements`, e);
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return nextElements;
}
}
private static redrawTextBoundingBoxes( private static redrawTextBoundingBoxes(
scene: Scene, scene: Scene,
changed: Map<string, OrderedExcalidrawElement>, changed: Map<string, OrderedExcalidrawElement>,
@@ -1743,6 +1979,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
) { ) {
for (const element of changed.values()) { for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) { if (!element.isDeleted && isBindableElement(element)) {
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
updateBoundElements(element, scene, { updateBoundElements(element, scene, {
changedElements: changed, changedElements: changed,
}); });
@@ -1838,4 +2075,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return strippedPartial; return strippedPartial;
} }
private static stripVersionProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { version, versionNonce, ...strippedPartial } = partial;
return strippedPartial;
}
} }
+9 -2
View File
@@ -1,7 +1,9 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { getCommonBoundingBox } from "./bounds"; import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement"; import { newElementWith } from "./mutateElement";
import { getMaximumGroups } from "./groups"; import { getSelectedElementsByGroup } from "./groups";
import type { ElementsMap, ExcalidrawElement } from "./types"; import type { ElementsMap, ExcalidrawElement } from "./types";
@@ -14,6 +16,7 @@ export const distributeElements = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: ElementsMap,
distribution: Distribution, distribution: Distribution,
appState: Readonly<AppState>,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const [start, mid, end, extent] = const [start, mid, end, extent] =
distribution.axis === "x" distribution.axis === "x"
@@ -21,7 +24,11 @@ export const distributeElements = (
: (["minY", "midY", "maxY", "height"] as const); : (["minY", "midY", "maxY", "height"] as const);
const bounds = getCommonBoundingBox(selectedElements); const bounds = getCommonBoundingBox(selectedElements);
const groups = getMaximumGroups(selectedElements, elementsMap) const groups = getSelectedElementsByGroup(
selectedElements,
elementsMap,
appState,
)
.map((group) => [group, getCommonBoundingBox(group)] as const) .map((group) => [group, getCommonBoundingBox(group)] as const)
.sort((a, b) => a[1][mid] - b[1][mid]); .sort((a, b) => a[1][mid] - b[1][mid]);
+32 -2
View File
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>(); const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
const RE_YOUTUBE = const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/; /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
const RE_VIMEO = const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/; /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
@@ -56,6 +56,35 @@ const RE_REDDIT =
const RE_REDDIT_EMBED = const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i; /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const parseYouTubeTimestamp = (url: string): number => {
let timeParam: string | null | undefined;
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
timeParam =
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
} catch (error) {
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
timeParam = timeMatch?.[1];
}
if (!timeParam) {
return 0;
}
if (/^\d+$/.test(timeParam)) {
return parseInt(timeParam, 10);
}
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (!timeMatch) {
return 0;
}
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
};
const ALLOWED_DOMAINS = new Set([ const ALLOWED_DOMAINS = new Set([
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
@@ -113,7 +142,8 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 }; let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE); const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) { if (ytLink?.[2]) {
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``; const startTime = parseYouTubeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts"); const isPortrait = link.includes("shorts");
type = "video"; type = "video";
switch (ytLink[1]) { switch (ytLink[1]) {
+77
View File
@@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { isBoundToContainer } from "./typeChecks";
import { makeNextSelectedElementIds, getSelectedElements } from "./selection"; import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
import type { import type {
@@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = (
return copy; return copy;
}; };
// given a list of selected elements, return the element grouped by their immediate group selected state
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
export const getSelectedElementsByGroup = (
selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
appState: Readonly<AppState>,
): ExcalidrawElement[][] => {
const selectedGroupIds = getSelectedGroupIds(appState);
const unboundElements = selectedElements.filter(
(element) => !isBoundToContainer(element),
);
const groups: Map<string, ExcalidrawElement[]> = new Map();
const elements: Map<string, ExcalidrawElement[]> = new Map();
// helper function to add an element to the elements map
const addToElementsMap = (element: ExcalidrawElement) => {
// elements
const currentElementMembers = elements.get(element.id) || [];
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentElementMembers.push(boundTextElement);
}
elements.set(element.id, [...currentElementMembers, element]);
};
// helper function to add an element to the groups map
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
// groups
const currentGroupMembers = groups.get(groupId) || [];
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentGroupMembers.push(boundTextElement);
}
groups.set(groupId, [...currentGroupMembers, element]);
};
// helper function to handle the case where a single group is selected
// and all elements selected are within the group, it will respect group hierarchy in accordance to
// their nested grouping order
const handleSingleSelectedGroupCase = (
element: ExcalidrawElement,
selectedGroupId: GroupId,
) => {
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
const nestedGroupCount = element.groupIds.slice(
0,
indexOfSelectedGroupId,
).length;
return nestedGroupCount > 0
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
: addToElementsMap(element);
};
const isAllInSameGroup = selectedElements.every((element) =>
isSelectedViaGroup(appState, element),
);
unboundElements.forEach((element) => {
const selectedGroupId = getSelectedGroupIdForElement(
element,
appState.selectedGroupIds,
);
if (!selectedGroupId) {
addToElementsMap(element);
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
handleSingleSelectedGroupCase(element, selectedGroupId);
} else {
addToGroupsMap(element, selectedGroupId);
}
});
return Array.from(groups.values()).concat(Array.from(elements.values()));
};
+33 -26
View File
@@ -149,10 +149,12 @@ export class LinearElementEditor {
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean; public readonly elbowed: boolean;
public readonly customLineAngle: number | null; public readonly customLineAngle: number | null;
public readonly isEditing: boolean;
constructor( constructor(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
isEditing: boolean = false,
) { ) {
this.elementId = element.id as string & { this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
@@ -187,6 +189,7 @@ export class LinearElementEditor {
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed; this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null; this.customLineAngle = null;
this.isEditing = isEditing;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -194,6 +197,7 @@ export class LinearElementEditor {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static POINT_HANDLE_SIZE = 10; static POINT_HANDLE_SIZE = 10;
/** /**
* @param id the `elementId` from the instance of this class (so that we can * @param id the `elementId` from the instance of this class (so that we can
* statically guarantee this method returns an ExcalidrawLinearElement) * statically guarantee this method returns an ExcalidrawLinearElement)
@@ -215,11 +219,14 @@ export class LinearElementEditor {
setState: React.Component<any, AppState>["setState"], setState: React.Component<any, AppState>["setState"],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
) { ) {
if (!appState.editingLinearElement || !appState.selectionElement) { if (
!appState.selectedLinearElement?.isEditing ||
!appState.selectionElement
) {
return false; return false;
} }
const { editingLinearElement } = appState; const { selectedLinearElement } = appState;
const { selectedPointsIndices, elementId } = editingLinearElement; const { selectedPointsIndices, elementId } = selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
@@ -260,8 +267,8 @@ export class LinearElementEditor {
}); });
setState({ setState({
editingLinearElement: { selectedLinearElement: {
...editingLinearElement, ...selectedLinearElement,
selectedPointsIndices: nextSelectedPoints.length selectedPointsIndices: nextSelectedPoints.length
? nextSelectedPoints ? nextSelectedPoints
: null, : null,
@@ -479,9 +486,6 @@ export class LinearElementEditor {
return { return {
...app.state, ...app.state,
editingLinearElement: app.state.editingLinearElement
? newLinearElementEditor
: null,
selectedLinearElement: newLinearElementEditor, selectedLinearElement: newLinearElementEditor,
suggestedBindings, suggestedBindings,
}; };
@@ -618,7 +622,7 @@ export class LinearElementEditor {
// Since its not needed outside editor unless 2 pointer lines or bound text // Since its not needed outside editor unless 2 pointer lines or bound text
if ( if (
!isElbowArrow(element) && !isElbowArrow(element) &&
!appState.editingLinearElement && !appState.selectedLinearElement?.isEditing &&
element.points.length > 2 && element.points.length > 2 &&
!boundText !boundText
) { ) {
@@ -684,7 +688,7 @@ export class LinearElementEditor {
); );
if ( if (
points.length >= 3 && points.length >= 3 &&
!appState.editingLinearElement && !appState.selectedLinearElement?.isEditing &&
!isElbowArrow(element) !isElbowArrow(element)
) { ) {
return null; return null;
@@ -881,7 +885,7 @@ export class LinearElementEditor {
segmentMidpoint, segmentMidpoint,
elementsMap, elementsMap,
); );
} else if (event.altKey && appState.editingLinearElement) { } else if (event.altKey && appState.selectedLinearElement?.isEditing) {
if (linearElementEditor.lastUncommittedPoint == null) { if (linearElementEditor.lastUncommittedPoint == null) {
scene.mutateElement(element, { scene.mutateElement(element, {
points: [ points: [
@@ -1023,14 +1027,14 @@ export class LinearElementEditor {
app: AppClassProperties, app: AppClassProperties,
): LinearElementEditor | null { ): LinearElementEditor | null {
const appState = app.state; const appState = app.state;
if (!appState.editingLinearElement) { if (!appState.selectedLinearElement?.isEditing) {
return null; return null;
} }
const { elementId, lastUncommittedPoint } = appState.editingLinearElement; const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
return appState.editingLinearElement; return appState.selectedLinearElement;
} }
const { points } = element; const { points } = element;
@@ -1040,10 +1044,12 @@ export class LinearElementEditor {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, app, [points.length - 1]); LinearElementEditor.deletePoints(element, app, [points.length - 1]);
} }
return { return appState.selectedLinearElement?.lastUncommittedPoint
...appState.editingLinearElement, ? {
...appState.selectedLinearElement,
lastUncommittedPoint: null, lastUncommittedPoint: null,
}; }
: appState.selectedLinearElement;
} }
let newPoint: LocalPoint; let newPoint: LocalPoint;
@@ -1067,8 +1073,8 @@ export class LinearElementEditor {
newPoint = LinearElementEditor.createPointAt( newPoint = LinearElementEditor.createPointAt(
element, element,
elementsMap, elementsMap,
scenePointerX - appState.editingLinearElement.pointerOffset.x, scenePointerX - appState.selectedLinearElement.pointerOffset.x,
scenePointerY - appState.editingLinearElement.pointerOffset.y, scenePointerY - appState.selectedLinearElement.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null ? null
: app.getEffectiveGridSize(), : app.getEffectiveGridSize(),
@@ -1092,7 +1098,7 @@ export class LinearElementEditor {
LinearElementEditor.addPoints(element, app.scene, [newPoint]); LinearElementEditor.addPoints(element, app.scene, [newPoint]);
} }
return { return {
...appState.editingLinearElement, ...appState.selectedLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1], lastUncommittedPoint: element.points[element.points.length - 1],
}; };
} }
@@ -1251,12 +1257,12 @@ export class LinearElementEditor {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState { static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
invariant( invariant(
appState.editingLinearElement, appState.selectedLinearElement?.isEditing,
"Not currently editing a linear element", "Not currently editing a linear element",
); );
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const { selectedPointsIndices, elementId } = appState.editingLinearElement; const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
invariant( invariant(
@@ -1318,8 +1324,8 @@ export class LinearElementEditor {
return { return {
...appState, ...appState,
editingLinearElement: { selectedLinearElement: {
...appState.editingLinearElement, ...appState.selectedLinearElement,
selectedPointsIndices: nextSelectedIndices, selectedPointsIndices: nextSelectedIndices,
}, },
}; };
@@ -1331,7 +1337,8 @@ export class LinearElementEditor {
pointIndices: readonly number[], pointIndices: readonly number[],
) { ) {
const isUncommittedPoint = const isUncommittedPoint =
app.state.editingLinearElement?.lastUncommittedPoint === app.state.selectedLinearElement?.isEditing &&
app.state.selectedLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1]; element.points[element.points.length - 1];
const nextPoints = element.points.filter((_, idx) => { const nextPoints = element.points.filter((_, idx) => {
@@ -1505,7 +1512,7 @@ export class LinearElementEditor {
pointFrom(pointerCoords.x, pointerCoords.y), pointFrom(pointerCoords.x, pointerCoords.y),
); );
if ( if (
!appState.editingLinearElement && !appState.selectedLinearElement?.isEditing &&
dist < DRAGGING_THRESHOLD / appState.zoom.value dist < DRAGGING_THRESHOLD / appState.zoom.value
) { ) {
return false; return false;
+5
View File
@@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
return element.strokeWidth * 12; return element.strokeWidth * 12;
case "text": case "text":
return element.fontSize / 2; return element.fontSize / 2;
case "arrow":
if (element.endArrowhead || element.endArrowhead) {
return 40;
}
return 20;
default: default:
return 20; return 20;
} }
+2 -1
View File
@@ -21,6 +21,7 @@ import {
assertNever, assertNever,
COLOR_PALETTE, COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE, LINE_POLYGON_POINT_MERGE_DISTANCE,
isTestEnv,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator"; import { RoughGenerator } from "roughjs/bin/generator";
@@ -182,7 +183,7 @@ export const generateRoughOptions = (
continuousPath = false, continuousPath = false,
): Options => { ): Options => {
const options: Options = { const options: Options = {
seed: element.seed, seed: isTestEnv() ? 1 : element.seed,
strokeLineDash: strokeLineDash:
element.strokeStyle === "dashed" element.strokeStyle === "dashed"
? getDashArrayDashed(element.strokeWidth) ? getDashArrayDashed(element.strokeWidth)
+38 -15
View File
@@ -27,6 +27,8 @@ import {
isImageElement, isImageElement,
} from "./index"; } from "./index";
import type { ApplyToOptions } from "./delta";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
OrderedExcalidrawElement, OrderedExcalidrawElement,
@@ -74,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
* Store which captures the observed changes and emits them as `StoreIncrement` events. * Store which captures the observed changes and emits them as `StoreIncrement` events.
*/ */
export class Store { export class Store {
// internally used by history // for internal use by history
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>(); public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
// for public use as part of onIncrement API
public readonly onStoreIncrementEmitter = new Emitter< public readonly onStoreIncrementEmitter = new Emitter<
[DurableIncrement | EphemeralIncrement] [DurableIncrement | EphemeralIncrement]
>(); >();
@@ -237,7 +240,6 @@ export class Store {
if (!storeDelta.isEmpty()) { if (!storeDelta.isEmpty()) {
const increment = new DurableIncrement(storeChange, storeDelta); const increment = new DurableIncrement(storeChange, storeDelta);
// Notify listeners with the increment
this.onDurableIncrementEmitter.trigger(increment); this.onDurableIncrementEmitter.trigger(increment);
this.onStoreIncrementEmitter.trigger(increment); this.onStoreIncrementEmitter.trigger(increment);
} }
@@ -550,10 +552,26 @@ export class StoreDelta {
public static load({ public static load({
id, id,
elements: { added, removed, updated }, elements: { added, removed, updated },
appState: { delta: appStateDelta },
}: DTO<StoreDelta>) { }: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated); const elements = ElementsDelta.create(added, removed, updated);
const appState = AppStateDelta.create(appStateDelta);
return new this(id, elements, AppStateDelta.empty()); return new this(id, elements, appState);
}
/**
* Squash the passed deltas into the aggregated delta instance.
*/
public static squash(...deltas: StoreDelta[]) {
const aggregatedDelta = StoreDelta.empty();
for (const delta of deltas) {
aggregatedDelta.elements.squash(delta.elements);
aggregatedDelta.appState.squash(delta.appState);
}
return aggregatedDelta;
} }
/** /**
@@ -570,9 +588,13 @@ export class StoreDelta {
delta: StoreDelta, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
options?: ApplyToOptions,
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
delta.elements.applyTo(elements); elements,
StoreSnapshot.empty().elements,
options,
);
const [nextAppState, appStateContainsVisibleChange] = const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements); delta.appState.applyTo(appState, nextElements);
@@ -605,6 +627,10 @@ export class StoreDelta {
); );
} }
public static empty() {
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
}
public isEmpty() { public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty(); return this.elements.isEmpty() && this.appState.isEmpty();
} }
@@ -970,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => {
viewBackgroundColor: COLOR_PALETTE.white, viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
editingLinearElementId: null, selectedLinearElement: null,
selectedLinearElementId: null,
croppingElementId: null, croppingElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
@@ -990,14 +1015,12 @@ export const getObservedAppState = (
croppingElementId: appState.croppingElementId, croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId, activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections, lockedMultiSelections: appState.lockedMultiSelections,
editingLinearElementId: selectedLinearElement: appState.selectedLinearElement
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer ? {
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot elementId: appState.selectedLinearElement.elementId,
null, isEditing: !!appState.selectedLinearElement.isEditing,
selectedLinearElementId: }
(appState as AppState).selectedLinearElement?.elementId ?? : null,
(appState as ObservedAppState).selectedLinearElementId ??
null,
}; };
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
+1 -1
View File
@@ -330,7 +330,7 @@ export const shouldShowBoundingBox = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
) => { ) => {
if (appState.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
return false; return false;
} }
if (elements.length > 1) { if (elements.length > 1) {
+420
View File
@@ -589,4 +589,424 @@ describe("aligning", () => {
expect(API.getSelectedElements()[2].x).toEqual(250); expect(API.getSelectedElements()[2].x).toEqual(250);
expect(API.getSelectedElements()[3].x).toEqual(150); expect(API.getSelectedElements()[3].x).toEqual(150);
}); });
const createGroupAndSelectInEditGroupMode = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
mouse.moveTo(10, 0);
mouse.doubleClick();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
mouse.moveTo(100, 100);
mouse.click();
});
};
it("aligns elements within a group while in group edit mode correctly to the top", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
});
it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(100);
});
it("aligns elements within a group while in group edit mode correctly to the left", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
});
it("aligns elements within a group while in group edit mode correctly to the right", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(100);
});
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(50);
});
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(50);
});
const createNestedGroupAndSelectInEditGroupMode = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
mouse.moveTo(200, 200);
// create third element
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// third element is already selected, select the initial group and group together
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
// double click to enter edit mode
mouse.doubleClick();
// select nested group and other element within the group
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(200, 200);
mouse.click();
});
};
it("aligns element and nested group while in group edit mode correctly to the top", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
});
it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
it("aligns element and nested group while in group edit mode correctly to the left", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
});
it("aligns element and nested group while in group edit mode correctly to the right", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
});
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
});
const createAndSelectSingleGroup = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
};
it("aligns elements within a single-selected group correctly to the top", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
});
it("aligns elements within a single-selected group correctly to the bottom", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(100);
});
it("aligns elements within a single-selected group correctly to the left", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
});
it("aligns elements within a single-selected group correctly to the right", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(100);
});
it("aligns elements within a single-selected group correctly to the vertical center", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(50);
});
it("aligns elements within a single-selected group correctly to the horizontal center", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(50);
});
const createAndSelectSingleGroupWithNestedGroup = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
// Add group to current selection
mouse.restorePosition(10, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
// Create the nested group
API.executeAction(actionGroup);
};
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
});
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
});
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
});
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
});
}); });
+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 // NOTE this mouse down/up + await needs to be done in order to repro
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740 // the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
mouse.reset(); mouse.reset();
expect(h.state.editingLinearElement).not.toBe(null); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
mouse.down(0, 0); mouse.down(0, 0);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
expect(h.state.editingLinearElement).toBe(null); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(API.getSelectedElement().type).toBe("rectangle"); expect(API.getSelectedElement().type).toBe("rectangle");
mouse.up(); mouse.up();
expect(API.getSelectedElement().type).toBe("rectangle"); expect(API.getSelectedElement().type).toBe("rectangle");
+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 { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element"; import type { LinearElementEditor } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { AppStateDelta } from "../src/delta"; import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
describe("ElementsDelta", () => {
describe("elements delta calculation", () => {
it("should not 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("AppStateDelta", () => {
describe("ensure stable delta properties order", () => { describe("ensure stable delta properties order", () => {
it("should maintain stable order for root properties", () => { it("should maintain stable order for root properties", () => {
const name = "untitled scene"; const name = "untitled scene";
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"]; const selectedLinearElement = {
elementId: "id1" as LinearElementEditor["elementId"],
isEditing: false,
};
const commonAppState = { const commonAppState = {
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
@@ -16,6 +331,7 @@ describe("AppStateDelta", () => {
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
editingLinearElementId: null, editingLinearElementId: null,
selectedLinearElementIsEditing: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
}; };
@@ -23,23 +339,23 @@ describe("AppStateDelta", () => {
const prevAppState1: ObservedAppState = { const prevAppState1: ObservedAppState = {
...commonAppState, ...commonAppState,
name: "", name: "",
selectedLinearElementId: null, selectedLinearElement: null,
}; };
const nextAppState1: ObservedAppState = { const nextAppState1: ObservedAppState = {
...commonAppState, ...commonAppState,
name, name,
selectedLinearElementId, selectedLinearElement,
}; };
const prevAppState2: ObservedAppState = { const prevAppState2: ObservedAppState = {
selectedLinearElementId: null, selectedLinearElement: null,
name: "", name: "",
...commonAppState, ...commonAppState,
}; };
const nextAppState2: ObservedAppState = { const nextAppState2: ObservedAppState = {
selectedLinearElementId, selectedLinearElement,
name, name,
...commonAppState, ...commonAppState,
}; };
@@ -57,8 +373,7 @@ describe("AppStateDelta", () => {
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElementId: null, selectedLinearElement: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -104,8 +419,7 @@ describe("AppStateDelta", () => {
selectedElementIds: {}, selectedElementIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElementId: null, selectedLinearElement: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -146,4 +460,97 @@ describe("AppStateDelta", () => {
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2)); expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
}); });
}); });
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const delta = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const appStateDelta1 = AppStateDelta.create(delta);
const appStateDelta2 = AppStateDelta.empty();
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toBe(delta);
});
it("should squash exclusive properties", () => {
const delta1 = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const delta2 = Delta.create(
{ viewBackgroundColor: "#ffffff" },
{ viewBackgroundColor: "#000000" },
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create(
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
{ name: "titled scene", viewBackgroundColor: "#000000" },
),
);
});
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
const delta1 = Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true },
selectedGroupIds: {},
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
);
const delta2 = Delta.create<Partial<ObservedAppState>>(
{
selectedElementIds: { id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
{
selectedElementIds: { id2: true },
selectedGroupIds: { g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true, id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true, g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
),
);
});
});
}); });
+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.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
}); });
expect(h.state.editingLinearElement?.elementId).toEqual(line.id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
}; };
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => { const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
@@ -253,75 +254,82 @@ describe("Test Linear Elements", () => {
}); });
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!); fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor via enter (line)", () => { it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]); mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
// ctrl+enter alias (to align with arrows) // ctrl+enter alias (to align with arrows)
it("should enter line editor via ctrl+enter (line)", () => { it("should enter line editor via ctrl+enter (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]); mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
}); });
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor via ctrl+enter (arrow)", () => { it("should enter line editor via ctrl+enter (arrow)", () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]); mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
}); });
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on ctrl+dblclick (simple arrow)", () => { it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick(); mouse.doubleClick();
}); });
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on ctrl+dblclick (line)", () => { it("should enter line editor on ctrl+dblclick (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick(); mouse.doubleClick();
}); });
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on dblclick (line)", () => { it("should enter line editor on dblclick (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.doubleClick(); mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should not enter line editor on dblclick (arrow)", async () => { it("should not enter line editor on dblclick (arrow)", async () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.doubleClick(); mouse.doubleClick();
expect(h.state.editingLinearElement).toEqual(null); expect(h.state.selectedLinearElement).toBe(null);
await getTextEditor(); await getTextEditor();
}); });
@@ -330,10 +338,12 @@ describe("Test Linear Elements", () => {
const arrow = h.elements[0] as ExcalidrawLinearElement; const arrow = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(arrow); enterLineEditingMode(arrow);
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
mouse.doubleClick(); mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
@@ -367,7 +377,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -469,7 +479,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint); drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -537,7 +547,7 @@ describe("Test Linear Elements", () => {
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -588,7 +598,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta)); drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -629,7 +639,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -677,7 +687,7 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]); deletePoint(points[2]);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`18`, `17`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -735,7 +745,7 @@ describe("Test Linear Elements", () => {
), ),
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@@ -833,7 +843,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
+13 -2
View File
@@ -10,6 +10,8 @@ import { alignElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElementsByGroup } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element"; import type { Alignment } from "@excalidraw/element";
@@ -38,7 +40,11 @@ export const alignActionsPredicate = (
) => { ) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
return ( return (
selectedElements.length > 1 && getSelectedElementsByGroup(
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState as Readonly<AppState>,
).length > 1 &&
// TODO enable aligning frames when implemented properly // TODO enable aligning frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el)) !selectedElements.some((el) => isFrameLikeElement(el))
); );
@@ -52,7 +58,12 @@ const alignSelectedElements = (
) => { ) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
const updatedElements = alignElements(selectedElements, alignment, app.scene); const updatedElements = alignElements(
selectedElements,
alignment,
app.scene,
appState,
);
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
+6 -3
View File
@@ -121,7 +121,7 @@ export const actionClearCanvas = register({
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool: activeTool:
appState.activeTool.type === "image" appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" } ? { ...appState.activeTool, type: app.defaultSelectionTool }
: appState.activeTool, : appState.activeTool,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@@ -494,13 +494,13 @@ export const actionToggleEraserTool = register({
name: "toggleEraserTool", name: "toggleEraserTool",
label: "toolBar.eraser", label: "toolBar.eraser",
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) { if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: "selection", type: app.defaultSelectionTool,
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
@@ -530,6 +530,9 @@ export const actionToggleLassoTool = register({
label: "toolBar.lasso", label: "toolBar.lasso",
icon: LassoIcon, icon: LassoIcon,
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
predicate: (elements, appState, props, app) => {
return app.defaultSelectionTool !== "lasso";
},
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];
@@ -205,16 +205,19 @@ export const actionDeleteSelected = register({
icon: TrashIcon, icon: TrashIcon,
trackEvent: { category: "element", action: "delete" }, trackEvent: { category: "element", action: "delete" },
perform: (elements, appState, formData, app) => { perform: (elements, appState, formData, app) => {
if (appState.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
const { const {
elementId, elementId,
selectedPointsIndices, selectedPointsIndices,
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
} = appState.editingLinearElement; } = appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const linearElement = LinearElementEditor.getElement(
if (!element) { elementId,
elementsMap,
);
if (!linearElement) {
return false; return false;
} }
// case: no point selected → do nothing, as deleting the whole element // case: no point selected → do nothing, as deleting the whole element
@@ -225,10 +228,10 @@ export const actionDeleteSelected = register({
return false; return false;
} }
// case: deleting last remaining point // case: deleting all points
if (element.points.length < 2) { if (selectedPointsIndices.length >= linearElement.points.length) {
const nextElements = elements.map((el) => { const nextElements = elements.map((el) => {
if (el.id === element.id) { if (el.id === linearElement.id) {
return newElementWith(el, { isDeleted: true }); return newElementWith(el, { isDeleted: true });
} }
return el; return el;
@@ -239,7 +242,7 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
editingLinearElement: null, selectedLinearElement: null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -252,20 +255,24 @@ export const actionDeleteSelected = register({
? null ? null
: startBindingElement, : startBindingElement,
endBindingElement: selectedPointsIndices?.includes( endBindingElement: selectedPointsIndices?.includes(
element.points.length - 1, linearElement.points.length - 1,
) )
? null ? null
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints(element, app, selectedPointsIndices); LinearElementEditor.deletePoints(
linearElement,
app,
selectedPointsIndices,
);
return { return {
elements, elements,
appState: { appState: {
...appState, ...appState,
editingLinearElement: { selectedLinearElement: {
...appState.editingLinearElement, ...appState.selectedLinearElement,
...binding, ...binding,
selectedPointsIndices: selectedPointsIndices:
selectedPointsIndices?.[0] > 0 selectedPointsIndices?.[0] > 0
@@ -291,7 +298,9 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }), activeTool: updateActiveTool(appState, {
type: app.defaultSelectionTool,
}),
multiElement: null, multiElement: null,
activeEmbeddable: null, activeEmbeddable: null,
selectedLinearElement: null, selectedLinearElement: null,
@@ -10,6 +10,8 @@ import { distributeElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElementsByGroup } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Distribution } from "@excalidraw/element"; import type { Distribution } from "@excalidraw/element";
@@ -31,7 +33,11 @@ import type { AppClassProperties, AppState } from "../types";
const enableActionGroup = (appState: AppState, app: AppClassProperties) => { const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
return ( return (
selectedElements.length > 1 && getSelectedElementsByGroup(
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState as Readonly<AppState>,
).length > 2 &&
// TODO enable distributing frames when implemented properly // TODO enable distributing frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el)) !selectedElements.some((el) => isFrameLikeElement(el))
); );
@@ -49,6 +55,7 @@ const distributeSelectedElements = (
selectedElements, selectedElements,
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
distribution, distribution,
appState,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
@@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({
} }
// duplicate selected point(s) if editing a line // duplicate selected point(s) if editing a line
if (appState.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
// TODO: Invariants should be checked here instead of duplicateSelectedPoints() // TODO: Invariants should be checked here instead of duplicateSelectedPoints()
try { try {
const newAppState = LinearElementEditor.duplicateSelectedPoints( const newAppState = LinearElementEditor.duplicateSelectedPoints(
+36 -15
View File
@@ -5,7 +5,11 @@ import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
isBindingEnabled, isBindingEnabled,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element"; import {
isValidPolygon,
LinearElementEditor,
newElementWith,
} from "@excalidraw/element";
import { import {
isBindingElement, isBindingElement,
@@ -78,7 +82,14 @@ export const actionFinalize = register({
let newElements = elements; let newElements = elements;
if (element && isInvisiblySmallElement(element)) { if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter((el) => el.id !== element!.id); newElements = newElements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, {
isDeleted: true,
});
}
return el;
});
} }
return { return {
elements: newElements, elements: newElements,
@@ -94,9 +105,9 @@ export const actionFinalize = register({
} }
} }
if (appState.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
const { elementId, startBindingElement, endBindingElement } = const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement; appState.selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (element) { if (element) {
@@ -117,12 +128,21 @@ export const actionFinalize = register({
return { return {
elements: elements:
element.points.length < 2 || isInvisiblySmallElement(element) element.points.length < 2 || isInvisiblySmallElement(element)
? elements.filter((el) => el.id !== element.id) ? elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
})
: undefined, : undefined,
appState: { appState: {
...appState, ...appState,
cursorButton: "up", cursorButton: "up",
editingLinearElement: null, selectedLinearElement: new LinearElementEditor(
element,
arrayToMap(elementsMap),
false, // exit editing mode
),
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -154,11 +174,7 @@ export const actionFinalize = register({
if (element) { if (element) {
// pen and mouse have hover // pen and mouse have hover
if ( if (appState.multiElement && element.type !== "freedraw") {
appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points, lastCommittedPoint } = element; const { points, lastCommittedPoint } = element;
if ( if (
!lastCommittedPoint || !lastCommittedPoint ||
@@ -172,7 +188,12 @@ export const actionFinalize = register({
if (element && isInvisiblySmallElement(element)) { if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter((el) => el.id !== element!.id); newElements = newElements.map((el) => {
if (el.id === element?.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
} }
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
@@ -240,13 +261,13 @@ export const actionFinalize = register({
if (appState.activeTool.type === "eraser") { if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: "selection", type: app.defaultSelectionTool,
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
} else { } else {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
type: "selection", type: app.defaultSelectionTool,
}); });
} }
@@ -289,7 +310,7 @@ export const actionFinalize = register({
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && (event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null || (appState.selectedLinearElement?.isEditing ||
(!appState.newElement && appState.multiElement === null))) || (!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
@@ -1,10 +1,9 @@
import { LinearElementEditor } from "@excalidraw/element";
import { import {
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap, invariant } from "@excalidraw/common";
import { import {
toggleLinePolygonState, toggleLinePolygonState,
@@ -46,7 +45,7 @@ export const actionToggleLinearEditor = register({
predicate: (elements, appState, _, app) => { predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
if ( if (
!appState.editingLinearElement && !appState.selectedLinearElement?.isEditing &&
selectedElements.length === 1 && selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) && isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0]) !isElbowArrow(selectedElements[0])
@@ -61,14 +60,25 @@ export const actionToggleLinearEditor = register({
includeBoundTextElement: true, includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement; })[0] as ExcalidrawLinearElement;
const editingLinearElement = invariant(selectedElement, "No selected element found");
appState.editingLinearElement?.elementId === selectedElement.id invariant(
? null appState.selectedLinearElement,
: new LinearElementEditor(selectedElement, arrayToMap(elements)); "No selected linear element found",
);
invariant(
selectedElement.id === appState.selectedLinearElement.elementId,
"Selected element ID and linear editor elementId does not match",
);
const selectedLinearElement = {
...appState.selectedLinearElement,
isEditing: !appState.selectedLinearElement.isEditing,
};
return { return {
appState: { appState: {
...appState, ...appState,
editingLinearElement, selectedLinearElement,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -21,7 +21,7 @@ export const actionSelectAll = register({
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
viewMode: false, viewMode: false,
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
if (appState.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
return false; return false;
} }
-2
View File
@@ -48,7 +48,6 @@ export const getDefaultAppState = (): Omit<
newElement: null, newElement: null,
editingTextElement: null, editingTextElement: null,
editingGroupId: null, editingGroupId: null,
editingLinearElement: null,
activeTool: { activeTool: {
type: "selection", type: "selection",
customType: null, customType: null,
@@ -175,7 +174,6 @@ const APP_STATE_STORAGE_CONF = (<
newElement: { browser: false, export: false, server: false }, newElement: { browser: false, export: false, server: false },
editingTextElement: { browser: false, export: false, server: false }, editingTextElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false }, penDetected: { browser: true, export: false, server: false },
+14 -18
View File
@@ -46,7 +46,7 @@ import {
hasStrokeWidth, hasStrokeWidth,
} from "../scene"; } from "../scene";
import { SHAPES } from "./shapes"; import { getToolbarTools } from "./shapes";
import "./Actions.scss"; import "./Actions.scss";
@@ -140,7 +140,7 @@ export const SelectedShapeActions = ({
targetElements.length === 1 || isSingleElementBoundContainer; targetElements.length === 1 || isSingleElementBoundContainer;
const showLineEditorAction = const showLineEditorAction =
!appState.editingLinearElement && !appState.selectedLinearElement?.isEditing &&
targetElements.length === 1 && targetElements.length === 1 &&
isLinearElement(targetElements[0]) && isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]); !isElbowArrow(targetElements[0]);
@@ -295,7 +295,8 @@ export const ShapesSwitcher = ({
const frameToolSelected = activeTool.type === "frame"; const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser"; const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected = activeTool.type === "lasso"; const lassoToolSelected =
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
const embeddableToolSelected = activeTool.type === "embeddable"; const embeddableToolSelected = activeTool.type === "embeddable";
@@ -303,10 +304,14 @@ export const ShapesSwitcher = ({
return ( return (
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {getToolbarTools(app).map(
({ value, icon, key, numericKey, fillable }, index) => {
if ( if (
UIOptions.tools?.[ UIOptions.tools?.[
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]> value as Extract<
typeof value,
keyof AppProps["UIOptions"]["tools"]
>
] === false ] === false
) { ) {
return null; return null;
@@ -359,7 +364,8 @@ export const ShapesSwitcher = ({
}} }}
/> />
); );
})} },
)}
<div className="App-toolbar__divider" /> <div className="App-toolbar__divider" />
<DropdownMenu open={isExtraToolsMenuOpen}> <DropdownMenu open={isExtraToolsMenuOpen}>
@@ -418,6 +424,7 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.laser")} {t("toolBar.laser")}
</DropdownMenu.Item> </DropdownMenu.Item>
{app.defaultSelectionTool !== "lasso" && (
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })} onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon} icon={LassoIcon}
@@ -426,6 +433,7 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.lasso")} {t("toolBar.lasso")}
</DropdownMenu.Item> </DropdownMenu.Item>
)}
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}> <div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate Generate
</div> </div>
@@ -505,15 +513,3 @@ export const ExitZenModeAction = ({
{t("buttons.exitZenMode")} {t("buttons.exitZenMode")}
</button> </button>
); );
export const FinalizeAction = ({
renderAction,
className,
}: {
renderAction: ActionManager["renderAction"];
className?: string;
}) => (
<div className={`finalize-button ${className}`}>
{renderAction("finalize", { size: "small" })}
</div>
);
+367 -117
View File
@@ -100,6 +100,8 @@ import {
randomInteger, randomInteger,
CLASSES, CLASSES,
Emitter, Emitter,
isMobile,
MINIMUM_ARROW_SIZE,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -232,6 +234,8 @@ import {
hitElementBoundingBox, hitElementBoundingBox,
isLineElement, isLineElement,
isSimpleArrow, isSimpleArrow,
StoreDelta,
type ApplyToOptions,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -258,6 +262,7 @@ import type {
MagicGenerationData, MagicGenerationData,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
SceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -593,6 +598,10 @@ class App extends React.Component<AppProps, AppState> {
* insert to DOM before user initially scrolls to them) */ * insert to DOM before user initially scrolls to them) */
private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>(); private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
private handleToastClose = () => {
this.setToast(null);
};
private elementsPendingErasure: ElementsPendingErasure = new Set(); private elementsPendingErasure: ElementsPendingErasure = new Set();
public flowChartCreator: FlowChartCreator = new FlowChartCreator(); public flowChartCreator: FlowChartCreator = new FlowChartCreator();
@@ -645,9 +654,14 @@ class App extends React.Component<AppProps, AppState> {
>(); >();
onRemoveEventListenersEmitter = new Emitter<[]>(); onRemoveEventListenersEmitter = new Emitter<[]>();
defaultSelectionTool: "selection" | "lasso" = "selection";
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
this.defaultSelectionTool = this.isMobileOrTablet()
? ("lasso" as const)
: ("selection" as const);
const { const {
excalidrawAPI, excalidrawAPI,
viewModeEnabled = false, viewModeEnabled = false,
@@ -692,6 +706,7 @@ class App extends React.Component<AppProps, AppState> {
if (excalidrawAPI) { if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = { const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene, updateScene: this.updateScene,
applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement, mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary, updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles, addFiles: this.addFiles,
@@ -1597,7 +1612,8 @@ class App extends React.Component<AppProps, AppState> {
renderWelcomeScreen={ renderWelcomeScreen={
!this.state.isLoading && !this.state.isLoading &&
this.state.showWelcomeScreen && this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" && this.state.activeTool.type ===
this.defaultSelectionTool &&
!this.state.zenModeEnabled && !this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length !this.scene.getElementsIncludingDeleted().length
} }
@@ -1707,14 +1723,16 @@ class App extends React.Component<AppProps, AppState> {
/> />
</ElementCanvasButtons> </ElementCanvasButtons>
)} )}
{this.state.toast !== null && ( {this.state.toast !== null && (
<Toast <Toast
message={this.state.toast.message} message={this.state.toast.message}
onClose={() => this.setToast(null)} onClose={this.handleToastClose}
duration={this.state.toast.duration} duration={this.state.toast.duration}
closable={this.state.toast.closable} closable={this.state.toast.closable}
/> />
)} )}
{this.state.contextMenu && ( {this.state.contextMenu && (
<ContextMenu <ContextMenu
items={this.state.contextMenu.items} items={this.state.contextMenu.items}
@@ -2151,9 +2169,14 @@ class App extends React.Component<AppProps, AppState> {
public dismissLinearEditor = () => { public dismissLinearEditor = () => {
setTimeout(() => { setTimeout(() => {
if (this.state.selectedLinearElement?.isEditing) {
this.setState({ this.setState({
editingLinearElement: null, selectedLinearElement: {
...this.state.selectedLinearElement,
isEditing: false,
},
}); });
}
}); });
}; };
@@ -2330,7 +2353,11 @@ class App extends React.Component<AppProps, AppState> {
}, },
}; };
} }
const scene = restore(initialData, null, null, { repairBindings: true }); const scene = restore(initialData, null, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
const activeTool = scene.appState.activeTool;
scene.appState = { scene.appState = {
...scene.appState, ...scene.appState,
theme: this.props.theme || scene.appState.theme, theme: this.props.theme || scene.appState.theme,
@@ -2340,8 +2367,13 @@ class App extends React.Component<AppProps, AppState> {
// with a library install link, which should auto-open the library) // with a library install link, which should auto-open the library)
openSidebar: scene.appState?.openSidebar || this.state.openSidebar, openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
activeTool: activeTool:
scene.appState.activeTool.type === "image" activeTool.type === "image" ||
? { ...scene.appState.activeTool, type: "selection" } activeTool.type === "lasso" ||
activeTool.type === "selection"
? {
...activeTool,
type: this.defaultSelectionTool,
}
: scene.appState.activeTool, : scene.appState.activeTool,
isLoading: false, isLoading: false,
toast: this.state.toast, toast: this.state.toast,
@@ -2380,6 +2412,16 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private isMobileOrTablet = (): boolean => {
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
const hasCoarsePointer =
"matchMedia" in window &&
window?.matchMedia("(pointer: coarse)")?.matches;
const isTouchMobile = hasTouch && hasCoarsePointer;
return isMobile || isTouchMobile;
};
private isMobileBreakpoint = (width: number, height: number) => { private isMobileBreakpoint = (width: number, height: number) => {
return ( return (
width < MQ_MAX_WIDTH_PORTRAIT || width < MQ_MAX_WIDTH_PORTRAIT ||
@@ -2849,15 +2891,15 @@ class App extends React.Component<AppProps, AppState> {
); );
if ( if (
this.state.editingLinearElement && this.state.selectedLinearElement?.isEditing &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId] !this.state.selectedElementIds[this.state.selectedLinearElement.elementId]
) { ) {
// defer so that the scheduleCapture flag isn't reset via current update // defer so that the scheduleCapture flag isn't reset via current update
setTimeout(() => { setTimeout(() => {
// execute only if the condition still holds when the deferred callback // execute only if the condition still holds when the deferred callback
// executes (it can be scheduled multiple times depending on how // executes (it can be scheduled multiple times depending on how
// many times the component renders) // many times the component renders)
this.state.editingLinearElement && this.state.selectedLinearElement?.isEditing &&
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
}); });
} }
@@ -3098,7 +3140,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
files: data.files || null, files: data.files || null,
position: "cursor", position: this.isMobileOrTablet() ? "center" : "cursor",
retainSeed: isPlainPaste, retainSeed: isPlainPaste,
}); });
} else if (data.text) { } else if (data.text) {
@@ -3116,7 +3158,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
files, files,
position: "cursor", position: this.isMobileOrTablet() ? "center" : "cursor",
}); });
return; return;
@@ -3176,7 +3218,7 @@ class App extends React.Component<AppProps, AppState> {
} }
this.addTextFromPaste(data.text, isPlainPaste); this.addTextFromPaste(data.text, isPlainPaste);
} }
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: this.defaultSelectionTool }, true);
event?.preventDefault(); event?.preventDefault();
}, },
); );
@@ -3188,7 +3230,9 @@ class App extends React.Component<AppProps, AppState> {
retainSeed?: boolean; retainSeed?: boolean;
fitToContent?: boolean; fitToContent?: boolean;
}) => { }) => {
const elements = restoreElements(opts.elements, null, undefined); const elements = restoreElements(opts.elements, null, {
deleteInvisibleElements: true,
});
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
@@ -3320,7 +3364,7 @@ class App extends React.Component<AppProps, AppState> {
} }
}, },
); );
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: this.defaultSelectionTool }, true);
if (opts.fitToContent) { if (opts.fitToContent) {
this.scrollToContent(duplicatedElements, { this.scrollToContent(duplicatedElements, {
@@ -3566,7 +3610,7 @@ class App extends React.Component<AppProps, AppState> {
...updateActiveTool( ...updateActiveTool(
this.state, this.state,
prevState.activeTool.locked prevState.activeTool.locked
? { type: "selection" } ? { type: this.defaultSelectionTool }
: prevState.activeTool, : prevState.activeTool,
), ),
locked: !prevState.activeTool.locked, locked: !prevState.activeTool.locked,
@@ -3921,6 +3965,27 @@ class App extends React.Component<AppProps, AppState> {
}, },
); );
public applyDeltas = (
deltas: StoreDelta[],
options?: ApplyToOptions,
): [SceneElementsMap, AppState, boolean] => {
// squash all deltas together, starting with a fresh new delta instance
const aggregatedDelta = StoreDelta.squash(...deltas);
// create new instance of elements map & appState, so we don't accidentaly mutate existing ones
const nextAppState = { ...this.state };
const nextElements = new Map(
this.scene.getElementsMapIncludingDeleted(),
) as SceneElementsMap;
return StoreDelta.applyTo(
aggregatedDelta,
nextElements,
nextAppState,
options,
);
};
public mutateElement = <TElement extends Mutable<ExcalidrawElement>>( public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement, element: TElement,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
@@ -4412,17 +4477,13 @@ class App extends React.Component<AppProps, AppState> {
if (event[KEYS.CTRL_OR_CMD] || isLineElement(selectedElement)) { if (event[KEYS.CTRL_OR_CMD] || isLineElement(selectedElement)) {
if (isLinearElement(selectedElement)) { if (isLinearElement(selectedElement)) {
if ( if (
!this.state.editingLinearElement || !this.state.selectedLinearElement?.isEditing ||
this.state.editingLinearElement.elementId !== selectedElement.id this.state.selectedLinearElement.elementId !==
selectedElement.id
) { ) {
this.store.scheduleCapture(); this.store.scheduleCapture();
if (!isElbowArrow(selectedElement)) { if (!isElbowArrow(selectedElement)) {
this.setState({ this.actionManager.executeAction(actionToggleLinearEditor);
editingLinearElement: new LinearElementEditor(
selectedElement,
this.scene.getNonDeletedElementsMap(),
),
});
} }
} }
} }
@@ -4462,7 +4523,7 @@ class App extends React.Component<AppProps, AppState> {
!this.state.selectionElement && !this.state.selectionElement &&
!this.state.selectedElementsAreBeingDragged !this.state.selectedElementsAreBeingDragged
) { ) {
const shape = findShapeByKey(event.key); const shape = findShapeByKey(event.key, this);
if (shape) { if (shape) {
if (this.state.activeTool.type !== shape) { if (this.state.activeTool.type !== shape) {
trackEvent( trackEvent(
@@ -4555,7 +4616,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "laser") { if (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: this.defaultSelectionTool });
} else { } else {
this.setActiveTool({ type: "laser" }); this.setActiveTool({ type: "laser" });
} }
@@ -4920,6 +4981,7 @@ class App extends React.Component<AppProps, AppState> {
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
const isDeleted = !nextOriginalText.trim(); const isDeleted = !nextOriginalText.trim();
updateElement(nextOriginalText, isDeleted); updateElement(nextOriginalText, isDeleted);
// select the created text element only if submitting via keyboard // select the created text element only if submitting via keyboard
// (when submitting via click it should act as signal to deselect) // (when submitting via click it should act as signal to deselect)
if (!isDeleted && viaKeyboard) { if (!isDeleted && viaKeyboard) {
@@ -4943,11 +5005,13 @@ class App extends React.Component<AppProps, AppState> {
})); }));
}); });
} }
if (isDeleted) { if (isDeleted) {
fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [ fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [
element, element,
]); ]);
} }
if (!isDeleted || isExistingElement) { if (!isDeleted || isExistingElement) {
this.store.scheduleCapture(); this.store.scheduleCapture();
} }
@@ -5397,7 +5461,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
// we should only be able to double click when mode is selection // we should only be able to double click when mode is selection
if (this.state.activeTool.type !== "selection") { if (this.state.activeTool.type !== this.defaultSelectionTool) {
return; return;
} }
@@ -5414,15 +5478,12 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
((event[KEYS.CTRL_OR_CMD] && isSimpleArrow(selectedLinearElement)) || ((event[KEYS.CTRL_OR_CMD] && isSimpleArrow(selectedLinearElement)) ||
isLineElement(selectedLinearElement)) && isLineElement(selectedLinearElement)) &&
this.state.editingLinearElement?.elementId !== selectedLinearElement.id (!this.state.selectedLinearElement?.isEditing ||
this.state.selectedLinearElement.elementId !==
selectedLinearElement.id)
) { ) {
this.store.scheduleCapture(); // Use the proper action to ensure immediate history capture
this.setState({ this.actionManager.executeAction(actionToggleLinearEditor);
editingLinearElement: new LinearElementEditor(
selectedLinearElement,
this.scene.getNonDeletedElementsMap(),
),
});
return; return;
} else if ( } else if (
this.state.selectedLinearElement && this.state.selectedLinearElement &&
@@ -5487,8 +5548,8 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
} else if ( } else if (
this.state.editingLinearElement && this.state.selectedLinearElement?.isEditing &&
this.state.editingLinearElement.elementId === this.state.selectedLinearElement.elementId ===
selectedLinearElement.id && selectedLinearElement.id &&
isLineElement(selectedLinearElement) isLineElement(selectedLinearElement)
) { ) {
@@ -5543,7 +5604,7 @@ class App extends React.Component<AppProps, AppState> {
// shouldn't edit/create text when inside line editor (often false positive) // shouldn't edit/create text when inside line editor (often false positive)
if (!this.state.editingLinearElement) { if (!this.state.selectedLinearElement?.isEditing) {
const container = this.getTextBindableContainerAtPosition( const container = this.getTextBindableContainerAtPosition(
sceneX, sceneX,
sceneY, sceneY,
@@ -5692,8 +5753,9 @@ class App extends React.Component<AppProps, AppState> {
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
const frames = this.scene const frames = this.scene
.getNonDeletedFramesLikes() .getNonDeletedFramesLikes()
.filter((frame): frame is ExcalidrawFrameLikeElement => .filter(
isCursorInFrame(sceneCoords, frame, elementsMap), (frame): frame is ExcalidrawFrameLikeElement =>
!frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap),
); );
return frames.length ? frames[frames.length - 1] : null; return frames.length ? frames[frames.length - 1] : null;
@@ -5841,8 +5903,8 @@ class App extends React.Component<AppProps, AppState> {
} }
if ( if (
this.state.editingLinearElement && this.state.selectedLinearElement?.isEditing &&
!this.state.editingLinearElement.isDragging !this.state.selectedLinearElement.isDragging
) { ) {
const editingLinearElement = LinearElementEditor.handlePointerMove( const editingLinearElement = LinearElementEditor.handlePointerMove(
event, event,
@@ -5850,30 +5912,34 @@ class App extends React.Component<AppProps, AppState> {
scenePointerY, scenePointerY,
this, this,
); );
const linearElement = editingLinearElement
? this.scene.getElement(editingLinearElement.elementId)
: null;
if ( if (
editingLinearElement && editingLinearElement &&
editingLinearElement !== this.state.editingLinearElement editingLinearElement !== this.state.selectedLinearElement
) { ) {
// Since we are reading from previous state which is not possible with // Since we are reading from previous state which is not possible with
// automatic batching in React 18 hence using flush sync to synchronously // automatic batching in React 18 hence using flush sync to synchronously
// update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details. // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
flushSync(() => { flushSync(() => {
this.setState({ this.setState({
editingLinearElement, selectedLinearElement: editingLinearElement,
}); });
}); });
} }
if (editingLinearElement?.lastUncommittedPoint != null) { if (
editingLinearElement?.lastUncommittedPoint != null &&
linearElement &&
isBindingElementType(linearElement.type)
) {
this.maybeSuggestBindingAtCursor( this.maybeSuggestBindingAtCursor(
scenePointer, scenePointer,
editingLinearElement.elbowed, editingLinearElement.elbowed,
); );
} else { } else if (this.state.suggestedBindings.length) {
// causes stack overflow if not sync
flushSync(() => {
this.setState({ suggestedBindings: [] }); this.setState({ suggestedBindings: [] });
});
} }
} }
@@ -6007,6 +6073,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
hasDeselectedButton || hasDeselectedButton ||
(this.state.activeTool.type !== "selection" && (this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" && this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser") this.state.activeTool.type !== "eraser")
) { ) {
@@ -6019,7 +6086,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
selectedElements.length === 1 && selectedElements.length === 1 &&
!isOverScrollBar && !isOverScrollBar &&
!this.state.editingLinearElement !this.state.selectedLinearElement?.isEditing
) { ) {
// for linear elements, we'd like to prioritize point dragging over edge resizing // for linear elements, we'd like to prioritize point dragging over edge resizing
// therefore, we update and check hovered point index first // therefore, we update and check hovered point index first
@@ -6137,15 +6204,6 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if (isOverScrollBar) { } else if (isOverScrollBar) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if (
this.state.selectedLinearElement &&
hitElement?.id === this.state.selectedLinearElement.elementId
) {
this.handleHoverSelectedLinearElement(
this.state.selectedLinearElement,
scenePointerX,
scenePointerY,
);
} else if ( } else if (
// if using cmd/ctrl, we're not dragging // if using cmd/ctrl, we're not dragging
!event[KEYS.CTRL_OR_CMD] !event[KEYS.CTRL_OR_CMD]
@@ -6177,8 +6235,13 @@ class App extends React.Component<AppProps, AppState> {
// Ebow arrows can only be moved when unconnected // Ebow arrows can only be moved when unconnected
!isElbowArrow(hitElement) || !isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding) !(hitElement.startBinding || hitElement.endBinding)
) {
if (
this.state.activeTool.type !== "lasso" ||
selectedElements.length > 0
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
if (this.state.activeEmbeddable?.state === "hover") { if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null }); this.setState({ activeEmbeddable: null });
} }
@@ -6187,6 +6250,14 @@ class App extends React.Component<AppProps, AppState> {
} else { } else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} }
if (this.state.selectedLinearElement) {
this.handleHoverSelectedLinearElement(
this.state.selectedLinearElement,
scenePointerX,
scenePointerY,
);
}
} }
if (this.state.openDialog?.name === "elementLinkSelector" && hitElement) { if (this.state.openDialog?.name === "elementLinkSelector" && hitElement) {
@@ -6286,19 +6357,29 @@ class App extends React.Component<AppProps, AppState> {
// Ebow arrows can only be moved when unconnected // Ebow arrows can only be moved when unconnected
!isElbowArrow(element) || !isElbowArrow(element) ||
!(element.startBinding || element.endBinding) !(element.startBinding || element.endBinding)
) {
if (
this.state.activeTool.type !== "lasso" ||
Object.keys(this.state.selectedElementIds).length > 0
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
} }
}
} else if (this.hitElement(scenePointerX, scenePointerY, element)) { } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
if ( if (
// Ebow arrows can only be moved when unconnected // Ebow arrows can only be moved when unconnected
!isElbowArrow(element) || !isElbowArrow(element) ||
!(element.startBinding || element.endBinding) !(element.startBinding || element.endBinding)
) {
if (
this.state.activeTool.type !== "lasso" ||
Object.keys(this.state.selectedElementIds).length > 0
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
} }
}
if ( if (
this.state.selectedLinearElement.hoverPointIndex !== hoverPointIndex this.state.selectedLinearElement.hoverPointIndex !== hoverPointIndex
@@ -6558,11 +6639,119 @@ class App extends React.Component<AppProps, AppState> {
} }
if (this.state.activeTool.type === "lasso") { if (this.state.activeTool.type === "lasso") {
const hitSelectedElement =
pointerDownState.hit.element &&
this.isASelectedElement(pointerDownState.hit.element);
const isMobileOrTablet = this.isMobileOrTablet();
if (
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement
) {
this.lassoTrail.startPath( this.lassoTrail.startPath(
pointerDownState.origin.x, pointerDownState.origin.x,
pointerDownState.origin.y, pointerDownState.origin.y,
event.shiftKey, event.shiftKey,
); );
// block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately)
pointerDownState.drag.blockDragging = !isMobileOrTablet;
}
// only for mobile or tablet, if we hit an element, select it immediately like normal selection
if (
isMobileOrTablet &&
pointerDownState.hit.element &&
!hitSelectedElement
) {
this.setState((prevState) => {
const nextSelectedElementIds: { [id: string]: true } = {
...prevState.selectedElementIds,
[pointerDownState.hit.element!.id]: true,
};
const previouslySelectedElements: ExcalidrawElement[] = [];
Object.keys(prevState.selectedElementIds).forEach((id) => {
const element = this.scene.getElement(id);
element && previouslySelectedElements.push(element);
});
const hitElement = pointerDownState.hit.element!;
// if hitElement is frame-like, deselect all of its elements
// if they are selected
if (isFrameLikeElement(hitElement)) {
getFrameChildren(previouslySelectedElements, hitElement.id).forEach(
(element) => {
delete nextSelectedElementIds[element.id];
},
);
} else if (hitElement.frameId) {
// if hitElement is in a frame and its frame has been selected
// disable selection for the given element
if (nextSelectedElementIds[hitElement.frameId]) {
delete nextSelectedElementIds[hitElement.id];
}
} else {
// hitElement is neither a frame nor an element in a frame
// but since hitElement could be in a group with some frames
// this means selecting hitElement will have the frames selected as well
// because we want to keep the invariant:
// - frames and their elements are not selected at the same time
// we deselect elements in those frames that were previously selected
const groupIds = hitElement.groupIds;
const framesInGroups = new Set(
groupIds
.flatMap((gid) =>
getElementsInGroup(this.scene.getNonDeletedElements(), gid),
)
.filter((element) => isFrameLikeElement(element))
.map((frame) => frame.id),
);
if (framesInGroups.size > 0) {
previouslySelectedElements.forEach((element) => {
if (element.frameId && framesInGroups.has(element.frameId)) {
// deselect element and groups containing the element
delete nextSelectedElementIds[element.id];
element.groupIds
.flatMap((gid) =>
getElementsInGroup(
this.scene.getNonDeletedElements(),
gid,
),
)
.forEach((element) => {
delete nextSelectedElementIds[element.id];
});
}
});
}
}
return {
...selectGroupsForSelectedElements(
{
editingGroupId: prevState.editingGroupId,
selectedElementIds: nextSelectedElementIds,
},
this.scene.getNonDeletedElements(),
prevState,
this,
),
showHyperlinkPopup:
hitElement.link || isEmbeddableElement(hitElement)
? "info"
: false,
};
});
pointerDownState.hit.wasAddedToSelection = true;
}
} else if (this.state.activeTool.type === "text") { } else if (this.state.activeTool.type === "text") {
this.handleTextOnPointerDown(event, pointerDownState); this.handleTextOnPointerDown(event, pointerDownState);
} else if ( } else if (
@@ -6942,6 +7131,7 @@ class App extends React.Component<AppProps, AppState> {
hasOccurred: false, hasOccurred: false,
offset: null, offset: null,
origin: { ...origin }, origin: { ...origin },
blockDragging: false,
}, },
eventListeners: { eventListeners: {
onMove: null, onMove: null,
@@ -7017,14 +7207,17 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
): boolean => { ): boolean => {
if (this.state.activeTool.type === "selection") { if (
this.state.activeTool.type === "selection" ||
this.state.activeTool.type === "lasso"
) {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
if ( if (
selectedElements.length === 1 && selectedElements.length === 1 &&
!this.state.editingLinearElement && !this.state.selectedLinearElement?.isEditing &&
!isElbowArrow(selectedElements[0]) && !isElbowArrow(selectedElements[0]) &&
!( !(
this.state.selectedLinearElement && this.state.selectedLinearElement &&
@@ -7095,8 +7288,7 @@ class App extends React.Component<AppProps, AppState> {
} }
} else { } else {
if (this.state.selectedLinearElement) { if (this.state.selectedLinearElement) {
const linearElementEditor = const linearElementEditor = this.state.selectedLinearElement;
this.state.editingLinearElement || this.state.selectedLinearElement;
const ret = LinearElementEditor.handlePointerDown( const ret = LinearElementEditor.handlePointerDown(
event, event,
this, this,
@@ -7110,10 +7302,6 @@ class App extends React.Component<AppProps, AppState> {
} }
if (ret.linearElementEditor) { if (ret.linearElementEditor) {
this.setState({ selectedLinearElement: ret.linearElementEditor }); this.setState({ selectedLinearElement: ret.linearElementEditor });
if (this.state.editingLinearElement) {
this.setState({ editingLinearElement: ret.linearElementEditor });
}
} }
if (ret.didAddPoint) { if (ret.didAddPoint) {
return true; return true;
@@ -7214,11 +7402,11 @@ class App extends React.Component<AppProps, AppState> {
this.clearSelection(hitElement); this.clearSelection(hitElement);
} }
if (this.state.editingLinearElement) { if (this.state.selectedLinearElement?.isEditing) {
this.setState({ this.setState({
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
{ {
[this.state.editingLinearElement.elementId]: true, [this.state.selectedLinearElement.elementId]: true,
}, },
this.state, this.state,
), ),
@@ -7229,7 +7417,18 @@ class App extends React.Component<AppProps, AppState> {
// on CMD/CTRL, drill down to hit element regardless of groups etc. // on CMD/CTRL, drill down to hit element regardless of groups etc.
if (event[KEYS.CTRL_OR_CMD]) { if (event[KEYS.CTRL_OR_CMD]) {
if (event.altKey) { if (event.altKey) {
// ctrl + alt means we're lasso selecting // ctrl + alt means we're lasso selecting - start lasso trail and switch to lasso tool
// Close any open dialogs that might interfere with lasso selection
if (this.state.openDialog?.name === "elementLinkSelector") {
this.setOpenDialog(null);
}
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
this.setActiveTool({ type: "lasso", fromSelection: true });
return false; return false;
} }
if (!this.state.selectedElementIds[hitElement.id]) { if (!this.state.selectedElementIds[hitElement.id]) {
@@ -7450,7 +7649,9 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
if (!this.state.activeTool.locked) { if (!this.state.activeTool.locked) {
this.setState({ this.setState({
activeTool: updateActiveTool(this.state, { type: "selection" }), activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
}); });
} }
}; };
@@ -8077,8 +8278,6 @@ class App extends React.Component<AppProps, AppState> {
this.scene, this.scene,
); );
flushSync(() => {
if (this.state.selectedLinearElement) {
this.setState({ this.setState({
selectedLinearElement: { selectedLinearElement: {
...this.state.selectedLinearElement, ...this.state.selectedLinearElement,
@@ -8086,8 +8285,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState: ret.pointerDownState, pointerDownState: ret.pointerDownState,
}, },
}); });
}
});
return; return;
} }
@@ -8145,7 +8342,9 @@ class App extends React.Component<AppProps, AppState> {
pointDistance( pointDistance(
pointFrom(pointerCoords.x, pointerCoords.y), pointFrom(pointerCoords.x, pointerCoords.y),
pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
) < DRAGGING_THRESHOLD ) *
this.state.zoom.value <
MINIMUM_ARROW_SIZE
) { ) {
return; return;
} }
@@ -8163,8 +8362,7 @@ class App extends React.Component<AppProps, AppState> {
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
if (this.state.selectedLinearElement) { if (this.state.selectedLinearElement) {
const linearElementEditor = const linearElementEditor = this.state.selectedLinearElement;
this.state.editingLinearElement || this.state.selectedLinearElement;
if ( if (
LinearElementEditor.shouldAddMidpoint( LinearElementEditor.shouldAddMidpoint(
@@ -8200,16 +8398,6 @@ class App extends React.Component<AppProps, AppState> {
}, },
}); });
} }
if (this.state.editingLinearElement) {
this.setState({
editingLinearElement: {
...this.state.editingLinearElement,
pointerDownState: ret.pointerDownState,
selectedPointsIndices: ret.selectedPointsIndices,
segmentMidPointHoveredCoords: null,
},
});
}
}); });
return; return;
@@ -8243,19 +8431,22 @@ class App extends React.Component<AppProps, AppState> {
); );
const isSelectingPointsInLineEditor = const isSelectingPointsInLineEditor =
this.state.editingLinearElement && this.state.selectedLinearElement?.isEditing &&
event.shiftKey && event.shiftKey &&
this.state.editingLinearElement.elementId === this.state.selectedLinearElement.elementId ===
pointerDownState.hit.element?.id; pointerDownState.hit.element?.id;
if ( if (
(hasHitASelectedElement || (hasHitASelectedElement ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) && pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
!isSelectingPointsInLineEditor && !isSelectingPointsInLineEditor &&
this.state.activeTool.type !== "lasso" !pointerDownState.drag.blockDragging
) { ) {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
if (
if (selectedElements.every((element) => element.locked)) { selectedElements.length > 0 &&
selectedElements.every((element) => element.locked)
) {
return; return;
} }
@@ -8276,6 +8467,29 @@ class App extends React.Component<AppProps, AppState> {
// if elements should be deselected on pointerup // if elements should be deselected on pointerup
pointerDownState.drag.hasOccurred = true; pointerDownState.drag.hasOccurred = true;
// prevent immediate dragging during lasso selection to avoid element displacement
// only allow dragging if we're not in the middle of lasso selection
// (on mobile, allow dragging if we hit an element)
if (
this.state.activeTool.type === "lasso" &&
this.lassoTrail.hasCurrentTrail &&
!(this.isMobileOrTablet() && pointerDownState.hit.element) &&
!this.state.activeTool.fromSelection
) {
return;
}
// Clear lasso trail when starting to drag selected elements with lasso tool
// Only clear if we're actually dragging (not during lasso selection)
if (
this.state.activeTool.type === "lasso" &&
selectedElements.length > 0 &&
pointerDownState.drag.hasOccurred &&
!this.state.activeTool.fromSelection
) {
this.lassoTrail.endPath();
}
// prevent dragging even if we're no longer holding cmd/ctrl otherwise // prevent dragging even if we're no longer holding cmd/ctrl otherwise
// it would have weird results (stuff jumping all over the screen) // it would have weird results (stuff jumping all over the screen)
// Checking for editingTextElement to avoid jump while editing on mobile #6503 // Checking for editingTextElement to avoid jump while editing on mobile #6503
@@ -8598,7 +8812,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.lastCoords.y = pointerCoords.y;
if (event.altKey) { if (event.altKey) {
flushSync(() => {
this.setActiveTool( this.setActiveTool(
{ type: "lasso", fromSelection: true }, { type: "lasso", fromSelection: true },
event.shiftKey, event.shiftKey,
@@ -8611,10 +8824,9 @@ class App extends React.Component<AppProps, AppState> {
this.setAppState({ this.setAppState({
selectionElement: null, selectionElement: null,
}); });
}); return;
} else {
this.maybeDragNewGenericElement(pointerDownState, event);
} }
this.maybeDragNewGenericElement(pointerDownState, event);
} else if (this.state.activeTool.type === "lasso") { } else if (this.state.activeTool.type === "lasso") {
if (!event.altKey && this.state.activeTool.fromSelection) { if (!event.altKey && this.state.activeTool.fromSelection) {
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: "selection" });
@@ -8734,7 +8946,7 @@ class App extends React.Component<AppProps, AppState> {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
// box-select line editor points // box-select line editor points
if (this.state.editingLinearElement) { if (this.state.selectedLinearElement?.isEditing) {
LinearElementEditor.handleBoxSelection( LinearElementEditor.handleBoxSelection(
event, event,
this.state, this.state,
@@ -8872,6 +9084,7 @@ class App extends React.Component<AppProps, AppState> {
): (event: PointerEvent) => void { ): (event: PointerEvent) => void {
return withBatchedUpdates((childEvent: PointerEvent) => { return withBatchedUpdates((childEvent: PointerEvent) => {
this.removePointer(childEvent); this.removePointer(childEvent);
pointerDownState.drag.blockDragging = false;
if (pointerDownState.eventListeners.onMove) { if (pointerDownState.eventListeners.onMove) {
pointerDownState.eventListeners.onMove.flush(); pointerDownState.eventListeners.onMove.flush();
} }
@@ -8977,23 +9190,23 @@ class App extends React.Component<AppProps, AppState> {
// Handle end of dragging a point of a linear element, might close a loop // Handle end of dragging a point of a linear element, might close a loop
// and sets binding element // and sets binding element
if (this.state.editingLinearElement) { if (this.state.selectedLinearElement?.isEditing) {
if ( if (
!pointerDownState.boxSelection.hasOccurred && !pointerDownState.boxSelection.hasOccurred &&
pointerDownState.hit?.element?.id !== pointerDownState.hit?.element?.id !==
this.state.editingLinearElement.elementId this.state.selectedLinearElement.elementId
) { ) {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
} else { } else {
const editingLinearElement = LinearElementEditor.handlePointerUp( const editingLinearElement = LinearElementEditor.handlePointerUp(
childEvent, childEvent,
this.state.editingLinearElement, this.state.selectedLinearElement,
this.state, this.state,
this.scene, this.scene,
); );
if (editingLinearElement !== this.state.editingLinearElement) { if (editingLinearElement !== this.state.selectedLinearElement) {
this.setState({ this.setState({
editingLinearElement, selectedLinearElement: editingLinearElement,
suggestedBindings: [], suggestedBindings: [],
}); });
} }
@@ -9096,25 +9309,54 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
); );
if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) { const dragDistance =
pointDistance(
pointFrom(pointerCoords.x, pointerCoords.y),
pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
) * this.state.zoom.value;
if (
(!pointerDownState.drag.hasOccurred ||
dragDistance < MINIMUM_ARROW_SIZE) &&
newElement &&
!multiElement
) {
if (this.device.isTouchScreen) {
const FIXED_DELTA_X = Math.min(
(this.state.width * 0.7) / this.state.zoom.value,
100,
);
this.scene.mutateElement( this.scene.mutateElement(
newElement, newElement,
{ {
x: newElement.x - FIXED_DELTA_X / 2,
points: [ points: [
...newElement.points, pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>( pointFrom<LocalPoint>(FIXED_DELTA_X, 0),
pointerCoords.x - newElement.x,
pointerCoords.y - newElement.y,
),
], ],
}, },
{ informMutation: false, isDragging: false }, { informMutation: false, isDragging: false },
); );
this.actionManager.executeAction(actionFinalize);
} else {
const dx = pointerCoords.x - newElement.x;
const dy = pointerCoords.y - newElement.y;
this.scene.mutateElement(
newElement,
{
points: [...newElement.points, pointFrom<LocalPoint>(dx, dy)],
},
{ informMutation: false, isDragging: false },
);
this.setState({ this.setState({
multiElement: newElement, multiElement: newElement,
newElement, newElement,
}); });
}
} else if (pointerDownState.drag.hasOccurred && !multiElement) { } else if (pointerDownState.drag.hasOccurred && !multiElement) {
if ( if (
isBindingEnabled(this.state) && isBindingEnabled(this.state) &&
@@ -9131,7 +9373,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevState) => ({ this.setState((prevState) => ({
newElement: null, newElement: null,
activeTool: updateActiveTool(this.state, { activeTool: updateActiveTool(this.state, {
type: "selection", type: this.defaultSelectionTool,
}), }),
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
{ {
@@ -9477,14 +9719,17 @@ class App extends React.Component<AppProps, AppState> {
!pointerDownState.hit.wasAddedToSelection && !pointerDownState.hit.wasAddedToSelection &&
// if we're editing a line, pointerup shouldn't switch selection if // if we're editing a line, pointerup shouldn't switch selection if
// box selected // box selected
(!this.state.editingLinearElement || (!this.state.selectedLinearElement?.isEditing ||
!pointerDownState.boxSelection.hasOccurred) && !pointerDownState.boxSelection.hasOccurred) &&
// hitElement can be set when alt + ctrl to toggle lasso and we will // hitElement can be set when alt + ctrl to toggle lasso and we will
// just respect the selected elements from lasso instead // just respect the selected elements from lasso instead
this.state.activeTool.type !== "lasso" this.state.activeTool.type !== "lasso"
) { ) {
// when inside line editor, shift selects points instead // when inside line editor, shift selects points instead
if (childEvent.shiftKey && !this.state.editingLinearElement) { if (
childEvent.shiftKey &&
!this.state.selectedLinearElement?.isEditing
) {
if (this.state.selectedElementIds[hitElement.id]) { if (this.state.selectedElementIds[hitElement.id]) {
if (isSelectedViaGroup(this.state, hitElement)) { if (isSelectedViaGroup(this.state, hitElement)) {
this.setState((_prevState) => { this.setState((_prevState) => {
@@ -9662,8 +9907,9 @@ class App extends React.Component<AppProps, AppState> {
(!hitElement && (!hitElement &&
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements)) pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
) { ) {
if (this.state.editingLinearElement) { if (this.state.selectedLinearElement?.isEditing) {
this.setState({ editingLinearElement: null }); // Exit editing mode but keep the element selected
this.actionManager.executeAction(actionToggleLinearEditor);
} else { } else {
// Deselect selected elements // Deselect selected elements
this.setState({ this.setState({
@@ -9743,7 +9989,9 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
newElement: null, newElement: null,
suggestedBindings: [], suggestedBindings: [],
activeTool: updateActiveTool(this.state, { type: "selection" }), activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
}); });
} else { } else {
this.setState({ this.setState({
@@ -10037,7 +10285,9 @@ class App extends React.Component<AppProps, AppState> {
this.setState( this.setState(
{ {
newElement: null, newElement: null,
activeTool: updateActiveTool(this.state, { type: "selection" }), activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
}, },
() => { () => {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
@@ -10089,7 +10339,7 @@ class App extends React.Component<AppProps, AppState> {
if (erroredFiles.size) { if (erroredFiles.size) {
this.store.scheduleAction(CaptureUpdateAction.NEVER); this.store.scheduleAction(CaptureUpdateAction.NEVER);
this.scene.replaceAllElements( this.scene.replaceAllElements(
elements.map((element) => { this.scene.getElementsIncludingDeleted().map((element) => {
if ( if (
isInitializedImageElement(element) && isInitializedImageElement(element) &&
erroredFiles.has(element.fileId) erroredFiles.has(element.fileId)
@@ -10410,7 +10660,7 @@ class App extends React.Component<AppProps, AppState> {
event.nativeEvent.pointerType === "pen" && event.nativeEvent.pointerType === "pen" &&
// always allow if user uses a pen secondary button // always allow if user uses a pen secondary button
event.button !== POINTER_BUTTON.SECONDARY)) && event.button !== POINTER_BUTTON.SECONDARY)) &&
this.state.activeTool.type !== "selection" this.state.activeTool.type !== this.defaultSelectionTool
) { ) {
return; return;
} }
@@ -108,6 +108,7 @@ $verticalBreakpoint: 861px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
overflow: hidden;
} }
} }
@@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { useStable } from "../../hooks/useStable"; import { useStable } from "../../hooks/useStable";
import { Ellipsify } from "../Ellipsify";
import * as defaultItems from "./defaultCommandPaletteItems"; import * as defaultItems from "./defaultCommandPaletteItems";
import "./CommandPalette.scss"; import "./CommandPalette.scss";
@@ -964,7 +966,7 @@ const CommandItem = ({
} }
/> />
)} )}
{command.label} <Ellipsify>{command.label}</Ellipsify>
</div> </div>
{showShortcut && command.shortcut && ( {showShortcut && command.shortcut && (
<CommandShortcutHint shortcut={command.shortcut} /> <CommandShortcutHint shortcut={command.shortcut} />
@@ -0,0 +1,18 @@
export const Ellipsify = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
{...rest}
style={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
...rest.style,
}}
>
{children}
</span>
);
};
@@ -115,7 +115,7 @@ const getHints = ({
appState.selectionElement && appState.selectionElement &&
!selectedElements.length && !selectedElements.length &&
!appState.editingTextElement && !appState.editingTextElement &&
!appState.editingLinearElement !appState.selectedLinearElement?.isEditing
) { ) {
return [t("hints.deepBoxSelect")]; return [t("hints.deepBoxSelect")];
} }
@@ -130,8 +130,8 @@ const getHints = ({
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
if (isLinearElement(selectedElements[0])) { if (isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
return appState.editingLinearElement.selectedPointsIndices return appState.selectedLinearElement.selectedPointsIndices
? t("hints.lineEditor_pointSelected") ? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected"); : t("hints.lineEditor_nothingSelected");
} }
@@ -7,6 +7,7 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
display: "inline-block", display: "inline-block",
lineHeight: 0, lineHeight: 0,
verticalAlign: "middle", verticalAlign: "middle",
flex: "0 0 auto",
}} }}
> >
{icon} {icon}
@@ -192,7 +192,6 @@ const getRelevantAppStateProps = (
viewModeEnabled: appState.viewModeEnabled, viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog, openDialog: appState.openDialog,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
editingLinearElement: appState.editingLinearElement,
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight, frameToHighlight: appState.frameToHighlight,
offsetLeft: appState.offsetLeft, offsetLeft: appState.offsetLeft,
@@ -34,6 +34,13 @@ const StaticCanvas = (props: StaticCanvasProps) => {
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
useEffect(() => {
props.canvas.style.width = `${props.appState.width}px`;
props.canvas.style.height = `${props.appState.height}px`;
props.canvas.width = props.appState.width * props.scale;
props.canvas.height = props.appState.height * props.scale;
}, [props.appState.height, props.appState.width, props.canvas, props.scale]);
useEffect(() => { useEffect(() => {
const wrapper = wrapperRef.current; const wrapper = wrapperRef.current;
if (!wrapper) { if (!wrapper) {
@@ -49,26 +56,6 @@ const StaticCanvas = (props: StaticCanvasProps) => {
canvas.classList.add("excalidraw__canvas", "static"); canvas.classList.add("excalidraw__canvas", "static");
} }
const widthString = `${props.appState.width}px`;
const heightString = `${props.appState.height}px`;
if (canvas.style.width !== widthString) {
canvas.style.width = widthString;
}
if (canvas.style.height !== heightString) {
canvas.style.height = heightString;
}
const scaledWidth = props.appState.width * props.scale;
const scaledHeight = props.appState.height * props.scale;
// setting width/height resets the canvas even if dimensions not changed,
// which would cause flicker when we skip frame (due to throttling)
if (canvas.width !== scaledWidth) {
canvas.width = scaledWidth;
}
if (canvas.height !== scaledHeight) {
canvas.height = scaledHeight;
}
renderStaticScene( renderStaticScene(
{ {
canvas, canvas,
@@ -19,6 +19,8 @@
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
position: relative; position: relative;
transition: box-shadow 0.5s ease-in-out; transition: box-shadow 0.5s ease-in-out;
display: flex;
flex-direction: column;
&.zen-mode { &.zen-mode {
box-shadow: none; box-shadow: none;
@@ -100,6 +102,7 @@
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
flex: 1 0 auto;
@media screen and (min-width: 1921px) { @media screen and (min-width: 1921px) {
height: 2.25rem; height: 2.25rem;
@@ -1,5 +1,7 @@
import { useDevice } from "../App"; import { useDevice } from "../App";
import { Ellipsify } from "../Ellipsify";
import type { JSX } from "react"; import type { JSX } from "react";
const MenuItemContent = ({ const MenuItemContent = ({
@@ -18,7 +20,7 @@ const MenuItemContent = ({
<> <>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>} {icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text"> <div style={textStyle} className="dropdown-menu-item__text">
{children} <Ellipsify>{children}</Ellipsify>
</div> </div>
{shortcut && !device.editor.isMobile && ( {shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div> <div className="dropdown-menu-item__shortcut">{shortcut}</div>
@@ -2,13 +2,7 @@ import clsx from "clsx";
import { actionShortcuts } from "../../actions"; import { actionShortcuts } from "../../actions";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions";
ExitZenModeAction,
FinalizeAction,
UndoRedoActions,
ZoomActions,
} from "../Actions";
import { useDevice } from "../App";
import { HelpButton } from "../HelpButton"; import { HelpButton } from "../HelpButton";
import { Section } from "../Section"; import { Section } from "../Section";
import Stack from "../Stack"; import Stack from "../Stack";
@@ -29,10 +23,6 @@ const Footer = ({
}) => { }) => {
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels(); const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
const device = useDevice();
const showFinalize =
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
return ( return (
<footer <footer
role="contentinfo" role="contentinfo"
@@ -60,15 +50,6 @@ const Footer = ({
})} })}
/> />
)} )}
{showFinalize && (
<FinalizeAction
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
/>
)}
</Section> </Section>
</Stack.Col> </Stack.Col>
</div> </div>
+19 -2
View File
@@ -13,6 +13,8 @@ import {
EraserIcon, EraserIcon,
} from "./icons"; } from "./icons";
import type { AppClassProperties } from "../types";
export const SHAPES = [ export const SHAPES = [
{ {
icon: SelectionIcon, icon: SelectionIcon,
@@ -86,8 +88,23 @@ export const SHAPES = [
}, },
] as const; ] as const;
export const findShapeByKey = (key: string) => { export const getToolbarTools = (app: AppClassProperties) => {
const shape = SHAPES.find((shape, index) => { 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 ( return (
(shape.numericKey != null && key === shape.numericKey.toString()) || (shape.numericKey != null && key === shape.numericKey.toString()) ||
(shape.key && (shape.key &&
+5 -1
View File
@@ -170,7 +170,11 @@ export const loadSceneOrLibraryFromBlob = async (
}, },
localAppState, localAppState,
localElements, localElements,
{ repairBindings: true, refreshDimensions: false }, {
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
), ),
}; };
} else if (isValidLibrary(data)) { } else if (isValidLibrary(data)) {
+2 -2
View File
@@ -20,7 +20,7 @@ export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
export type RemoteExcalidrawElement = OrderedExcalidrawElement & export type RemoteExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"RemoteExcalidrawElement">; MakeBrand<"RemoteExcalidrawElement">;
const shouldDiscardRemoteElement = ( export const shouldDiscardRemoteElement = (
localAppState: AppState, localAppState: AppState,
local: OrderedExcalidrawElement | undefined, local: OrderedExcalidrawElement | undefined,
remote: RemoteExcalidrawElement, remote: RemoteExcalidrawElement,
@@ -30,7 +30,7 @@ const shouldDiscardRemoteElement = (
// local element is being edited // local element is being edited
(local.id === localAppState.editingTextElement?.id || (local.id === localAppState.editingTextElement?.id ||
local.id === localAppState.resizingElement?.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 element is newer
local.version > remote.version || local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with // resolve conflicting edits deterministically by taking the one with
+38 -12
View File
@@ -241,8 +241,9 @@ const restoreElementWithProperties = <
return ret; return ret;
}; };
const restoreElement = ( export const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
opts?: { deleteInvisibleElements?: boolean },
): typeof element | null => { ): typeof element | null => {
element = { ...element }; element = { ...element };
@@ -290,7 +291,8 @@ const restoreElement = (
// if empty text, mark as deleted. We keep in array // if empty text, mark as deleted. We keep in array
// for data integrity purposes (collab etc.) // 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 = { ...element, originalText: text, isDeleted: true };
element = bumpVersion(element); element = bumpVersion(element);
} }
@@ -523,7 +525,13 @@ export const restoreElements = (
elements: ImportedDataState["elements"], elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */ /** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined, opts?:
| {
refreshDimensions?: boolean;
repairBindings?: boolean;
deleteInvisibleElements?: boolean;
}
| undefined,
): OrderedExcalidrawElement[] => { ): OrderedExcalidrawElement[] => {
// used to detect duplicate top-level element ids // used to detect duplicate top-level element ids
const existingIds = new Set<string>(); const existingIds = new Set<string>();
@@ -532,16 +540,30 @@ export const restoreElements = (
(elements || []).reduce((elements, element) => { (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements, // filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained // and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) { if (element.type === "selection") {
let migratedElement: ExcalidrawElement | null = restoreElement(element); return elements;
}
let migratedElement: ExcalidrawElement | null = restoreElement(element, {
deleteInvisibleElements: opts?.deleteInvisibleElements,
});
if (migratedElement) { if (migratedElement) {
const localElement = localElementsMap?.get(element.id); const localElement = localElementsMap?.get(element.id);
if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion( const shouldMarkAsDeleted =
migratedElement, opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
localElement.version,
); if (
shouldMarkAsDeleted ||
(localElement && localElement.version > migratedElement.version)
) {
migratedElement = bumpVersion(migratedElement, localElement?.version);
} }
if (shouldMarkAsDeleted) {
migratedElement = { ...migratedElement, isDeleted: true };
}
if (existingIds.has(migratedElement.id)) { if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() }; migratedElement = { ...migratedElement, id: randomId() };
} }
@@ -549,7 +571,7 @@ export const restoreElements = (
elements.push(migratedElement); elements.push(migratedElement);
} }
}
return elements; return elements;
}, [] as ExcalidrawElement[]), }, [] as ExcalidrawElement[]),
); );
@@ -790,7 +812,11 @@ export const restore = (
*/ */
localAppState: Partial<AppState> | null | undefined, localAppState: Partial<AppState> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean }, elementsConfig?: {
refreshDimensions?: boolean;
repairBindings?: boolean;
deleteInvisibleElements?: boolean;
},
): RestoredDataState => { ): RestoredDataState => {
return { return {
elements: restoreElements(data?.elements, localElements, elementsConfig), elements: restoreElements(data?.elements, localElements, elementsConfig),
+1 -1
View File
@@ -175,7 +175,7 @@ export class History {
let nextAppState = appState; let nextAppState = appState;
let containsVisibleChange = false; 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) { while (historyDelta) {
try { try {
[nextElements, nextAppState, containsVisibleChange] = [nextElements, nextAppState, containsVisibleChange] =
+2
View File
@@ -229,6 +229,7 @@ export { defaultLang, useI18n, languages } from "./i18n";
export { export {
restore, restore,
restoreAppState, restoreAppState,
restoreElement,
restoreElements, restoreElements,
restoreLibraryItems, restoreLibraryItems,
} from "./data/restore"; } from "./data/restore";
@@ -281,6 +282,7 @@ export { Sidebar } from "./components/Sidebar/Sidebar";
export { Button } from "./components/Button"; export { Button } from "./components/Button";
export { Footer }; export { Footer };
export { MainMenu }; export { MainMenu };
export { Ellipsify } from "./components/Ellipsify";
export { useDevice } from "./components/App"; export { useDevice } from "./components/App";
export { WelcomeScreen }; export { WelcomeScreen };
export { LiveCollaborationTrigger }; export { LiveCollaborationTrigger };
@@ -118,7 +118,8 @@ const renderLinearElementPointHighlight = (
) => { ) => {
const { elementId, hoverPointIndex } = appState.selectedLinearElement!; const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
if ( if (
appState.editingLinearElement?.selectedPointsIndices?.includes( appState.selectedLinearElement?.isEditing &&
appState.selectedLinearElement?.selectedPointsIndices?.includes(
hoverPointIndex, hoverPointIndex,
) )
) { ) {
@@ -180,7 +181,7 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
point[0], point[0],
point[1], point[1],
(isOverlappingPoint (isOverlappingPoint
? radius * (appState.editingLinearElement ? 1.5 : 2) ? radius * (appState.selectedLinearElement?.isEditing ? 1.5 : 2)
: radius) / appState.zoom.value, : radius) / appState.zoom.value,
!isPhantomPoint, !isPhantomPoint,
!isOverlappingPoint || isSelected, !isOverlappingPoint || isSelected,
@@ -448,7 +449,7 @@ const renderLinearPointHandles = (
); );
const { POINT_HANDLE_SIZE } = LinearElementEditor; const { POINT_HANDLE_SIZE } = LinearElementEditor;
const radius = appState.editingLinearElement const radius = appState.selectedLinearElement?.isEditing
? POINT_HANDLE_SIZE ? POINT_HANDLE_SIZE
: POINT_HANDLE_SIZE / 2; : POINT_HANDLE_SIZE / 2;
@@ -470,7 +471,8 @@ const renderLinearPointHandles = (
); );
let isSelected = 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 // 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 // point is selected since they overlap and the last point tends to be
// rendered on top // rendered on top
@@ -479,7 +481,8 @@ const renderLinearPointHandles = (
element.polygon && element.polygon &&
!isSelected && !isSelected &&
idx === element.points.length - 1 && idx === element.points.length - 1 &&
!!appState.editingLinearElement?.selectedPointsIndices?.includes(0) !!appState.selectedLinearElement?.isEditing &&
!!appState.selectedLinearElement?.selectedPointsIndices?.includes(0)
) { ) {
isSelected = true; isSelected = true;
} }
@@ -535,7 +538,7 @@ const renderLinearPointHandles = (
); );
midPoints.forEach((segmentMidPoint) => { midPoints.forEach((segmentMidPoint) => {
if (appState.editingLinearElement || points.length === 2) { if (appState.selectedLinearElement?.isEditing || points.length === 2) {
renderSingleLinearPoint( renderSingleLinearPoint(
context, context,
appState, appState,
@@ -760,7 +763,10 @@ const _renderInteractiveScene = ({
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to // 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 // ShapeCache returns empty hence making sure that we get the
// correct element from visible elements // correct element from visible elements
if (appState.editingLinearElement?.elementId === element.id) { if (
appState.selectedLinearElement?.isEditing &&
appState.selectedLinearElement.elementId === element.id
) {
if (element) { if (element) {
editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>; editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>;
} }
@@ -853,7 +859,8 @@ const _renderInteractiveScene = ({
// correct element from visible elements // correct element from visible elements
if ( if (
selectedElements.length === 1 && selectedElements.length === 1 &&
appState.editingLinearElement?.elementId === selectedElements[0].id appState.selectedLinearElement?.isEditing &&
appState.selectedLinearElement.elementId === selectedElements[0].id
) { ) {
renderLinearPointHandles( renderLinearPointHandles(
context, context,
@@ -884,7 +891,7 @@ const _renderInteractiveScene = ({
} }
// Paint selected elements // Paint selected elements
if (!appState.multiElement && !appState.editingLinearElement) { if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) {
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState); const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
const isSingleLinearElementSelected = const isSingleLinearElementSelected =
@@ -1,9 +1,16 @@
import { throttleRAF } from "@excalidraw/common"; import { throttleRAF } from "@excalidraw/common";
import { renderElement } from "@excalidraw/element"; import {
getTargetFrame,
isInvisiblySmallElement,
renderElement,
shouldApplyFrameClip,
} from "@excalidraw/element";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers"; import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { frameClip } from "./staticScene";
import type { NewElementSceneRenderConfig } from "../scene/types"; import type { NewElementSceneRenderConfig } from "../scene/types";
const _renderNewElementScene = ({ const _renderNewElementScene = ({
@@ -29,11 +36,37 @@ const _renderNewElementScene = ({
normalizedHeight, normalizedHeight,
}); });
// Apply zoom
context.save(); context.save();
// Apply zoom
context.scale(appState.zoom.value, appState.zoom.value); context.scale(appState.zoom.value, appState.zoom.value);
if (newElement && newElement.type !== "selection") { 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( renderElement(
newElement, newElement,
elementsMap, elementsMap,
@@ -46,6 +79,8 @@ const _renderNewElementScene = ({
} else { } else {
context.clearRect(0, 0, normalizedWidth, normalizedHeight); context.clearRect(0, 0, normalizedWidth, normalizedHeight);
} }
context.restore();
} }
}; };
+1 -1
View File
@@ -113,7 +113,7 @@ const strokeGrid = (
context.restore(); context.restore();
}; };
const frameClip = ( export const frameClip = (
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
+10 -11
View File
@@ -13,7 +13,7 @@ import {
getDraggedElementsBounds, getDraggedElementsBounds,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { isBoundToContainer, isFrameLikeElement } from "@excalidraw/element"; import { isBoundToContainer } from "@excalidraw/element";
import { getMaximumGroups } from "@excalidraw/element"; import { getMaximumGroups } from "@excalidraw/element";
@@ -169,8 +169,14 @@ export const isSnappingEnabled = ({
selectedElements: NonDeletedExcalidrawElement[]; selectedElements: NonDeletedExcalidrawElement[];
}) => { }) => {
if (event) { 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 ( 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]) ||
(!app.state.objectsSnapModeEnabled && (!app.state.objectsSnapModeEnabled &&
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
@@ -311,20 +317,13 @@ const getReferenceElements = (
selectedElements: NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) =>
const selectedFrames = selectedElements getVisibleAndNonSelectedElements(
.filter((element) => isFrameLikeElement(element))
.map((frame) => frame.id);
return getVisibleAndNonSelectedElements(
elements, elements,
selectedElements, selectedElements,
appState, appState,
elementsMap, elementsMap,
).filter(
(element) => !(element.frameId && selectedFrames.includes(element.frameId)),
); );
};
export const getVisibleGaps = ( export const getVisibleGaps = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -908,7 +908,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -1106,7 +1105,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -1319,7 +1317,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -1649,7 +1646,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -1979,7 +1975,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -2192,7 +2187,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -2432,7 +2426,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -2729,7 +2722,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -3100,7 +3092,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -3592,7 +3583,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -3692,14 +3682,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1116226695, "seed": 400692809,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 23633383, "versionNonce": 81784553,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -3724,14 +3714,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1278240551, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 401146281, "versionNonce": 1150084233,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -3914,7 +3904,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -4236,7 +4225,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -5520,7 +5508,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -6736,7 +6723,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -7670,7 +7656,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -8669,7 +8654,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -9659,7 +9643,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -14,8 +14,12 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
> >
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Click me Click me
</span>
</div> </div>
</button> </button>
<a <a
@@ -26,8 +30,12 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
> >
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Excalidraw blog Excalidraw blog
</span>
</div> </div>
</a> </a>
<div <div
@@ -87,8 +95,12 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
</div> </div>
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Help Help
</span>
</div> </div>
<div <div
class="dropdown-menu-item__shortcut" class="dropdown-menu-item__shortcut"
@@ -137,8 +149,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div> </div>
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Open Open
</span>
</div> </div>
<div <div
class="dropdown-menu-item__shortcut" class="dropdown-menu-item__shortcut"
@@ -174,8 +190,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div> </div>
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Save to... Save to...
</span>
</div> </div>
</button> </button>
<button <button
@@ -230,8 +250,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div> </div>
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Export image... Export image...
</span>
</div> </div>
<div <div
class="dropdown-menu-item__shortcut" class="dropdown-menu-item__shortcut"
@@ -279,8 +303,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div> </div>
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Find on canvas Find on canvas
</span>
</div> </div>
<div <div
class="dropdown-menu-item__shortcut" class="dropdown-menu-item__shortcut"
@@ -336,8 +364,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div> </div>
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Help Help
</span>
</div> </div>
<div <div
class="dropdown-menu-item__shortcut" class="dropdown-menu-item__shortcut"
@@ -373,8 +405,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div> </div>
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Reset the canvas Reset the canvas
</span>
</div> </div>
</button> </button>
<div <div
@@ -418,8 +454,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div> </div>
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
GitHub GitHub
</span>
</div> </div>
</a> </a>
<a <a
@@ -464,8 +504,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div> </div>
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Follow us Follow us
</span>
</div> </div>
</a> </a>
<a <a
@@ -504,8 +548,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div> </div>
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Discord chat Discord chat
</span>
</div> </div>
</a> </a>
</div> </div>
@@ -541,8 +589,12 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</div> </div>
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
> >
Dark mode Dark mode
</span>
</div> </div>
<div <div
class="dropdown-menu-item__shortcut" class="dropdown-menu-item__shortcut"
File diff suppressed because it is too large Load Diff
@@ -34,7 +34,6 @@ exports[`given element A and group of elements B and given both are selected whe
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -459,7 +458,6 @@ exports[`given element A and group of elements B and given both are selected whe
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -874,7 +872,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": "id28", "editingGroupId": "id28",
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -1439,7 +1436,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -1645,7 +1641,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -2028,7 +2023,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -2222,16 +2216,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] undo
}, },
}, },
}, },
"updated": { "updated": {},
"id0": {
"deleted": {
"version": 5,
},
"inserted": {
"version": 3,
},
},
},
}, },
"id": "id6", "id": "id6",
}, },
@@ -2272,7 +2257,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -2451,7 +2435,6 @@ exports[`regression tests > can drag element that covers another element, while
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -2775,7 +2758,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -3029,7 +3011,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -3269,7 +3250,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -3504,7 +3484,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -3761,7 +3740,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -4074,7 +4052,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -4509,7 +4486,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -4791,7 +4767,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -5066,7 +5041,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -5273,7 +5247,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -5472,7 +5445,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": "id11", "editingGroupId": "id11",
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -5864,7 +5836,6 @@ exports[`regression tests > drags selected elements from point inside common bou
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -6160,7 +6131,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -6415,13 +6385,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"selectedElementIds": { "selectedElementIds": {
"id9": true, "id9": true,
}, },
"selectedLinearElementId": "id9", "selectedLinearElement": {
"elementId": "id9",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedElementIds": { "selectedElementIds": {
"id6": true, "id6": true,
}, },
"selectedLinearElementId": null, "selectedLinearElement": null,
}, },
}, },
}, },
@@ -6489,13 +6462,19 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"selectedElementIds": { "selectedElementIds": {
"id12": true, "id12": true,
}, },
"selectedLinearElementId": "id12", "selectedLinearElement": {
"elementId": "id12",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedElementIds": { "selectedElementIds": {
"id9": true, "id9": true,
}, },
"selectedLinearElementId": "id9", "selectedLinearElement": {
"elementId": "id9",
"isEditing": false,
},
}, },
}, },
}, },
@@ -6561,13 +6540,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"selectedElementIds": { "selectedElementIds": {
"id15": true, "id15": true,
}, },
"selectedLinearElementId": null, "selectedLinearElement": null,
}, },
"inserted": { "inserted": {
"selectedElementIds": { "selectedElementIds": {
"id12": true, "id12": true,
}, },
"selectedLinearElementId": "id12", "selectedLinearElement": {
"elementId": "id12",
"isEditing": false,
},
}, },
}, },
}, },
@@ -6694,10 +6676,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"appState": AppStateDelta { "appState": AppStateDelta {
"delta": Delta { "delta": Delta {
"deleted": { "deleted": {
"selectedLinearElementId": "id15", "selectedLinearElement": {
"elementId": "id15",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedLinearElementId": null, "selectedLinearElement": null,
}, },
}, },
}, },
@@ -6715,13 +6700,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"selectedElementIds": { "selectedElementIds": {
"id22": true, "id22": true,
}, },
"selectedLinearElementId": null, "selectedLinearElement": null,
}, },
"inserted": { "inserted": {
"selectedElementIds": { "selectedElementIds": {
"id15": true, "id15": true,
}, },
"selectedLinearElementId": "id15", "selectedLinearElement": {
"elementId": "id15",
"isEditing": false,
},
}, },
}, },
}, },
@@ -6846,10 +6834,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"appState": AppStateDelta { "appState": AppStateDelta {
"delta": Delta { "delta": Delta {
"deleted": { "deleted": {
"selectedLinearElementId": "id22", "selectedLinearElement": {
"elementId": "id22",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedLinearElementId": null, "selectedLinearElement": null,
}, },
}, },
}, },
@@ -6884,10 +6875,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"appState": AppStateDelta { "appState": AppStateDelta {
"delta": Delta { "delta": Delta {
"deleted": { "deleted": {
"selectedLinearElementId": null, "selectedLinearElement": null,
}, },
"inserted": { "inserted": {
"selectedLinearElementId": "id22", "selectedLinearElement": {
"elementId": "id22",
"isEditing": false,
},
}, },
}, },
}, },
@@ -6991,7 +6985,6 @@ exports[`regression tests > given a group of selected elements with an element t
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -7324,7 +7317,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -7602,7 +7594,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -7836,7 +7827,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -8075,7 +8065,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -8190,7 +8179,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] undo st
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -8203,7 +8192,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] undo st
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -8254,7 +8243,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -8369,7 +8357,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] undo stac
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -8382,7 +8370,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] undo stac
"strokeWidth": 2, "strokeWidth": 2,
"type": "diamond", "type": "diamond",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -8433,7 +8421,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -8548,7 +8535,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] undo stac
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -8561,7 +8548,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] undo stac
"strokeWidth": 2, "strokeWidth": 2,
"type": "ellipse", "type": "ellipse",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -8612,7 +8599,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -8676,6 +8662,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
"isDragging": false, "isDragging": false,
"isEditing": false,
"lastUncommittedPoint": null, "lastUncommittedPoint": null,
"pointerDownState": { "pointerDownState": {
"lastClickedIsEndPoint": false, "lastClickedIsEndPoint": false,
@@ -8735,11 +8722,14 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
"selectedLinearElementId": "id0", "selectedLinearElement": {
"elementId": "id0",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedElementIds": {}, "selectedElementIds": {},
"selectedLinearElementId": null, "selectedLinearElement": null,
}, },
}, },
}, },
@@ -8758,7 +8748,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@@ -8771,8 +8761,8 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -8786,7 +8776,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -8837,7 +8827,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -8901,6 +8890,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
"isDragging": false, "isDragging": false,
"isEditing": false,
"lastUncommittedPoint": null, "lastUncommittedPoint": null,
"pointerDownState": { "pointerDownState": {
"lastClickedIsEndPoint": false, "lastClickedIsEndPoint": false,
@@ -8960,11 +8950,14 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
"selectedLinearElementId": "id0", "selectedLinearElement": {
"elementId": "id0",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedElementIds": {}, "selectedElementIds": {},
"selectedLinearElementId": null, "selectedLinearElement": null,
}, },
}, },
}, },
@@ -8982,7 +8975,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@@ -8995,8 +8988,8 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"polygon": false, "polygon": false,
@@ -9009,7 +9002,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
"strokeWidth": 2, "strokeWidth": 2,
"type": "line", "type": "line",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -9060,7 +9053,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -9167,12 +9159,12 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": [ "lastCommittedPoint": [
10, 30,
10, 30,
], ],
"link": null, "link": null,
"locked": false, "locked": false,
@@ -9183,12 +9175,12 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"pressures": [ "pressures": [
@@ -9204,7 +9196,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
"strokeWidth": 2, "strokeWidth": 2,
"type": "freedraw", "type": "freedraw",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -9255,7 +9247,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -9319,6 +9310,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
"isDragging": false, "isDragging": false,
"isEditing": false,
"lastUncommittedPoint": null, "lastUncommittedPoint": null,
"pointerDownState": { "pointerDownState": {
"lastClickedIsEndPoint": false, "lastClickedIsEndPoint": false,
@@ -9378,11 +9370,14 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
"selectedLinearElementId": "id0", "selectedLinearElement": {
"elementId": "id0",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedElementIds": {}, "selectedElementIds": {},
"selectedLinearElementId": null, "selectedLinearElement": null,
}, },
}, },
}, },
@@ -9401,7 +9396,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@@ -9414,8 +9409,8 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -9429,7 +9424,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -9480,7 +9475,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -9595,7 +9589,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] undo stac
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -9608,7 +9602,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] undo stac
"strokeWidth": 2, "strokeWidth": 2,
"type": "diamond", "type": "diamond",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -9659,7 +9653,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -9723,6 +9716,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
"isDragging": false, "isDragging": false,
"isEditing": false,
"lastUncommittedPoint": null, "lastUncommittedPoint": null,
"pointerDownState": { "pointerDownState": {
"lastClickedIsEndPoint": false, "lastClickedIsEndPoint": false,
@@ -9782,11 +9776,14 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
"selectedLinearElementId": "id0", "selectedLinearElement": {
"elementId": "id0",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedElementIds": {}, "selectedElementIds": {},
"selectedLinearElementId": null, "selectedLinearElement": null,
}, },
}, },
}, },
@@ -9804,7 +9801,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@@ -9817,8 +9814,8 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"polygon": false, "polygon": false,
@@ -9831,7 +9828,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
"strokeWidth": 2, "strokeWidth": 2,
"type": "line", "type": "line",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -9882,7 +9879,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -9997,7 +9993,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] undo stac
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -10010,7 +10006,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] undo stac
"strokeWidth": 2, "strokeWidth": 2,
"type": "ellipse", "type": "ellipse",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -10061,7 +10057,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -10168,12 +10163,12 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": [ "lastCommittedPoint": [
10, 30,
10, 30,
], ],
"link": null, "link": null,
"locked": false, "locked": false,
@@ -10184,12 +10179,12 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"pressures": [ "pressures": [
@@ -10205,7 +10200,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
"strokeWidth": 2, "strokeWidth": 2,
"type": "freedraw", "type": "freedraw",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -10256,7 +10251,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -10371,7 +10365,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] undo st
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -10384,7 +10378,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] undo st
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@@ -10435,7 +10429,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -10899,32 +10892,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] undo s
}, },
}, },
}, },
"updated": { "updated": {},
"id0": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id3": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id6": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
},
}, },
"id": "id21", "id": "id21",
}, },
@@ -10965,7 +10933,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -11244,7 +11211,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -11366,7 +11332,6 @@ exports[`regression tests > shift click on selected element should deselect it o
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -11565,7 +11530,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -11883,7 +11847,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -12311,7 +12274,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -12950,7 +12912,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -13075,7 +13036,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": "id11", "editingGroupId": "id11",
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -13705,7 +13665,6 @@ exports[`regression tests > switches from group of selected elements to another
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -14043,7 +14002,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -14306,7 +14264,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -14428,7 +14385,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -14520,10 +14476,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
"appState": AppStateDelta { "appState": AppStateDelta {
"delta": Delta { "delta": Delta {
"deleted": { "deleted": {
"selectedLinearElementId": null, "selectedLinearElement": null,
}, },
"inserted": { "inserted": {
"selectedLinearElementId": "id6", "selectedLinearElement": {
"elementId": "id6",
"isEditing": false,
},
}, },
}, },
}, },
@@ -14816,7 +14775,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
@@ -14938,7 +14896,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
+42 -8
View File
@@ -60,7 +60,11 @@ describe("restoreElements", () => {
const rectElement = API.createElement({ type: "rectangle" }); const rectElement = API.createElement({ type: "rectangle" });
mockSizeHelper.mockImplementation(() => true); mockSizeHelper.mockImplementation(() => true);
expect(restore.restoreElements([rectElement], null).length).toBe(0); expect(
restore.restoreElements([rectElement], null, {
deleteInvisibleElements: true,
}),
).toEqual([expect.objectContaining({ isDeleted: true })]);
}); });
it("should restore text element correctly passing value for each attribute", () => { it("should restore text element correctly passing value for each attribute", () => {
@@ -85,6 +89,23 @@ describe("restoreElements", () => {
}); });
}); });
it("should not delete empty text element when opts.deleteInvisibleElements is not defined", () => {
const textElement = API.createElement({
type: "text",
text: "",
isDeleted: false,
});
const restoredElements = restore.restoreElements([textElement], null);
expect(restoredElements).toEqual([
expect.objectContaining({
id: textElement.id,
isDeleted: false,
}),
]);
});
it("should restore text element correctly with unknown font family, null text and undefined alignment", () => { it("should restore text element correctly with unknown font family, null text and undefined alignment", () => {
const textElement: any = API.createElement({ const textElement: any = API.createElement({
type: "text", type: "text",
@@ -97,10 +118,9 @@ describe("restoreElements", () => {
textElement.font = "10 unknown"; textElement.font = "10 unknown";
expect(textElement.isDeleted).toBe(false); expect(textElement.isDeleted).toBe(false);
const restoredText = restore.restoreElements( const restoredText = restore.restoreElements([textElement], null, {
[textElement], deleteInvisibleElements: true,
null, })[0] as ExcalidrawTextElement;
)[0] as ExcalidrawTextElement;
expect(restoredText.isDeleted).toBe(true); expect(restoredText.isDeleted).toBe(true);
expect(restoredText).toMatchSnapshot({ expect(restoredText).toMatchSnapshot({
seed: expect.any(Number), seed: expect.any(Number),
@@ -177,13 +197,16 @@ describe("restoreElements", () => {
y: 0, y: 0,
}); });
const restoredElements = restore.restoreElements([arrowElement], null); const restoredElements = restore.restoreElements([arrowElement], null, {
deleteInvisibleElements: true,
});
const restoredArrow = restoredElements[0] as const restoredArrow = restoredElements[0] as
| ExcalidrawArrowElement | ExcalidrawArrowElement
| undefined; | undefined;
expect(restoredArrow).toBeUndefined(); expect(restoredArrow).not.toBeUndefined();
expect(restoredArrow?.isDeleted).toBe(true);
}); });
it("should keep 'imperceptibly' small freedraw/line elements", () => { it("should keep 'imperceptibly' small freedraw/line elements", () => {
@@ -848,6 +871,7 @@ describe("repairing bindings", () => {
let restoredElements = restore.restoreElements( let restoredElements = restore.restoreElements(
[container, invisibleBoundElement, boundElement], [container, invisibleBoundElement, boundElement],
null, null,
{ deleteInvisibleElements: true },
); );
expect(restoredElements).toEqual([ expect(restoredElements).toEqual([
@@ -855,6 +879,11 @@ describe("repairing bindings", () => {
id: container.id, id: container.id,
boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding], boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding],
}), }),
expect.objectContaining({
id: invisibleBoundElement.id,
containerId: container.id,
isDeleted: true,
}),
expect.objectContaining({ expect.objectContaining({
id: boundElement.id, id: boundElement.id,
containerId: container.id, containerId: container.id,
@@ -864,7 +893,7 @@ describe("repairing bindings", () => {
restoredElements = restore.restoreElements( restoredElements = restore.restoreElements(
[container, invisibleBoundElement, boundElement], [container, invisibleBoundElement, boundElement],
null, null,
{ repairBindings: true }, { repairBindings: true, deleteInvisibleElements: true },
); );
expect(restoredElements).toEqual([ expect(restoredElements).toEqual([
@@ -872,6 +901,11 @@ describe("repairing bindings", () => {
id: container.id, id: container.id,
boundElements: [], boundElements: [],
}), }),
expect.objectContaining({
id: invisibleBoundElement.id,
containerId: container.id,
isDeleted: true,
}),
expect.objectContaining({ expect.objectContaining({
id: boundElement.id, id: boundElement.id,
containerId: container.id, containerId: container.id,
+12 -2
View File
@@ -315,7 +315,12 @@ describe("Test dragCreate", () => {
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements).toEqual([
expect.objectContaining({
type: "arrow",
isDeleted: true,
}),
]);
}); });
it("line", async () => { it("line", async () => {
@@ -344,7 +349,12 @@ describe("Test dragCreate", () => {
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements).toEqual([
expect.objectContaining({
type: "line",
isDeleted: true,
}),
]);
}); });
}); });
}); });
+19 -20
View File
@@ -1073,7 +1073,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(6); expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement).not.toBeNull(); expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1090,7 +1090,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(5); expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1114,7 +1114,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(4); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(2);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1131,7 +1131,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(3); expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(3);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); // undo `open editor` expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor`
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1148,7 +1148,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(4);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1165,7 +1165,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(5);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1181,7 +1181,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(6); expect(API.getRedoStack().length).toBe(6);
expect(API.getSelectedElements().length).toBe(0); expect(API.getSelectedElements().length).toBe(0);
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1197,7 +1197,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(5);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1213,7 +1213,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(4);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1230,7 +1230,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(3); expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(3);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); // undo `open editor` expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor`
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1247,7 +1247,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(4); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(2);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1264,7 +1264,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(5); expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -1281,7 +1281,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(6); expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement).not.toBeNull(); expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -3029,8 +3029,8 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(4); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).not.toBeNull(); expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
// Simulate remote update // Simulate remote update
API.updateScene({ API.updateScene({
@@ -3043,16 +3043,15 @@ describe("history", () => {
}); });
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(4);
expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(4); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
}); });
it("should iterate through the history when z-index changes do not produce visible change and we synced changed indices", async () => { it("should iterate through the history when z-index changes do not produce visible change and we synced changed indices", async () => {
@@ -4056,7 +4055,7 @@ describe("history", () => {
expect.objectContaining({ expect.objectContaining({
id: container.id, id: container.id,
boundElements: [{ id: remoteText.id, type: "text" }], boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false, // isDeleted got remotely updated to false isDeleted: true,
}), }),
expect.objectContaining({ expect.objectContaining({
id: text.id, id: text.id,
@@ -4066,7 +4065,7 @@ describe("history", () => {
expect.objectContaining({ expect.objectContaining({
id: remoteText.id, id: remoteText.id,
// unbound // unbound
containerId: container.id, containerId: null,
isDeleted: false, isDeleted: false,
}), }),
]); ]);
@@ -150,7 +150,7 @@ describe("regression tests", () => {
expect(h.state.activeTool.type).toBe(shape); expect(h.state.activeTool.type).toBe(shape);
mouse.down(10, 10); mouse.down(10, 10);
mouse.up(10, 10); mouse.up(30, 30);
if (shouldSelect) { if (shouldSelect) {
expect(API.getSelectedElement().type).toBe(shape); expect(API.getSelectedElement().type).toBe(shape);
+11 -6
View File
@@ -213,7 +213,6 @@ export type InteractiveCanvasAppState = Readonly<
_CommonCanvasAppState & { _CommonCanvasAppState & {
// renderInteractiveScene // renderInteractiveScene
activeEmbeddable: AppState["activeEmbeddable"]; activeEmbeddable: AppState["activeEmbeddable"];
editingLinearElement: AppState["editingLinearElement"];
selectionElement: AppState["selectionElement"]; selectionElement: AppState["selectionElement"];
selectedGroupIds: AppState["selectedGroupIds"]; selectedGroupIds: AppState["selectedGroupIds"];
selectedLinearElement: AppState["selectedLinearElement"]; selectedLinearElement: AppState["selectedLinearElement"];
@@ -249,10 +248,10 @@ export type ObservedElementsAppState = {
editingGroupId: AppState["editingGroupId"]; editingGroupId: AppState["editingGroupId"];
selectedElementIds: AppState["selectedElementIds"]; selectedElementIds: AppState["selectedElementIds"];
selectedGroupIds: AppState["selectedGroupIds"]; selectedGroupIds: AppState["selectedGroupIds"];
// Avoiding storing whole instance, as it could lead into state incosistencies, empty undos/redos and etc. selectedLinearElement: {
editingLinearElementId: LinearElementEditor["elementId"] | null; elementId: LinearElementEditor["elementId"];
// Right now it's coupled to `editingLinearElement`, ideally it should not be really needed as we already have selectedElementIds & editingLinearElementId isEditing: boolean;
selectedLinearElementId: LinearElementEditor["elementId"] | null; } | null;
croppingElementId: AppState["croppingElementId"]; croppingElementId: AppState["croppingElementId"];
lockedMultiSelections: AppState["lockedMultiSelections"]; lockedMultiSelections: AppState["lockedMultiSelections"];
activeLockedId: AppState["activeLockedId"]; activeLockedId: AppState["activeLockedId"];
@@ -307,7 +306,6 @@ export interface AppState {
* set when a new text is created or when an existing text is being edited * set when a new text is created or when an existing text is being edited
*/ */
editingTextElement: NonDeletedExcalidrawElement | null; editingTextElement: NonDeletedExcalidrawElement | null;
editingLinearElement: LinearElementEditor | null;
activeTool: { activeTool: {
/** /**
* indicates a previous tool we should revert back to if we deselect the * indicates a previous tool we should revert back to if we deselect the
@@ -733,6 +731,8 @@ export type AppClassProperties = {
onPointerUpEmitter: App["onPointerUpEmitter"]; onPointerUpEmitter: App["onPointerUpEmitter"];
updateEditorAtom: App["updateEditorAtom"]; updateEditorAtom: App["updateEditorAtom"];
defaultSelectionTool: "selection" | "lasso";
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{
@@ -782,6 +782,10 @@ export type PointerDownState = Readonly<{
// by default same as PointerDownState.origin. On alt-duplication, reset // by default same as PointerDownState.origin. On alt-duplication, reset
// to current pointer position at time of duplication. // to current pointer position at time of duplication.
origin: { x: number; y: number }; origin: { x: number; y: number };
// Whether to block drag after lasso selection
// this is meant to be used to block dragging after lasso selection on PCs
// until the next pointer down
blockDragging: boolean;
}; };
// We need to have these in the state so that we can unsubscribe them // We need to have these in the state so that we can unsubscribe them
eventListeners: { eventListeners: {
@@ -803,6 +807,7 @@ export type UnsubscribeCallback = () => void;
export interface ExcalidrawImperativeAPI { export interface ExcalidrawImperativeAPI {
updateScene: InstanceType<typeof App>["updateScene"]; updateScene: InstanceType<typeof App>["updateScene"];
applyDeltas: InstanceType<typeof App>["applyDeltas"];
mutateElement: InstanceType<typeof App>["mutateElement"]; mutateElement: InstanceType<typeof App>["mutateElement"];
updateLibrary: InstanceType<typeof Library>["updateLibrary"]; updateLibrary: InstanceType<typeof Library>["updateLibrary"];
resetScene: InstanceType<typeof App>["resetScene"]; resetScene: InstanceType<typeof App>["resetScene"];
@@ -1198,7 +1198,11 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, " "); updateTextEditor(editor, " ");
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
expect(rectangle.boundElements).toStrictEqual([]); expect(rectangle.boundElements).toStrictEqual([]);
expect(h.elements[1].isDeleted).toBe(true); expect(h.elements[1]).toEqual(
expect.objectContaining({
isDeleted: true,
}),
);
}); });
it("should restore original container height and clear cache once text is unbind", async () => { it("should restore original container height and clear cache once text is unbind", async () => {
+2
View File
@@ -49,6 +49,7 @@ export const exportToCanvas = ({
{ elements, appState }, { elements, appState },
null, null,
null, null,
{ deleteInvisibleElements: true },
); );
const { exportBackground, viewBackgroundColor } = restoredAppState; const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas( return _exportToCanvas(
@@ -179,6 +180,7 @@ export const exportToSvg = async ({
{ elements, appState }, { elements, appState },
null, null,
null, null,
{ deleteInvisibleElements: true },
); );
const exportAppState = { const exportAppState = {
@@ -34,7 +34,6 @@ exports[`exportToSvg > with default arguments 1`] = `
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
+89
View File
@@ -0,0 +1,89 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./excalidraw-app/tests/regression",
snapshotPathTemplate:
"{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 1,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
headless: true,
},
timeout: 1200000,
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
launchOptions: {
args: ["--disable-font-subpixel-positioning", "--disable-gpu"],
},
},
},
// {
// name: "firefox",
// use: { ...devices["Desktop Firefox"] },
// },
// {
// name: "webkit",
// use: { ...devices["Desktop Safari"] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: "yarn start --no-open",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
+2 -2
View File
@@ -144,7 +144,7 @@ const askToCommit = (tag, nextVersion) => {
}); });
rl.question( rl.question(
"Do you want to commit these changes to git? (Y/n): ", "Would you like to commit these changes to git? (Y/n): ",
(answer) => { (answer) => {
rl.close(); rl.close();
@@ -189,7 +189,7 @@ const askToPublish = (tag, version) => {
}); });
rl.question( rl.question(
"Do you want to publish these changes to npm? (Y/n): ", "Would you like to publish these changes to npm? (Y/n): ",
(answer) => { (answer) => {
rl.close(); rl.close();
+38
View File
@@ -2162,6 +2162,13 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@playwright/test@1.55.0":
version "1.55.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.55.0.tgz#080fa6d9ee6d749ff523b1c18259572d0268b963"
integrity sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==
dependencies:
playwright "1.55.0"
"@polka/url@^1.0.0-next.24": "@polka/url@^1.0.0-next.24":
version "1.0.0-next.28" version "1.0.0-next.28"
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.28.tgz#d45e01c4a56f143ee69c54dd6b12eade9e270a73" resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.28.tgz#d45e01c4a56f143ee69c54dd6b12eade9e270a73"
@@ -2945,6 +2952,13 @@
dependencies: dependencies:
undici-types "~6.20.0" undici-types "~6.20.0"
"@types/node@24.3.0":
version "24.3.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.3.0.tgz#89b09f45cb9a8ee69466f18ee5864e4c3eb84dec"
integrity sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==
dependencies:
undici-types "~7.10.0"
"@types/node@>=12.12.47", "@types/node@>=13.7.0": "@types/node@>=12.12.47", "@types/node@>=13.7.0":
version "22.13.8" version "22.13.8"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.8.tgz#57e2450295b33a6518d6fd4f65f47236d3e41d8d" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.8.tgz#57e2450295b33a6518d6fd4f65f47236d3e41d8d"
@@ -5826,6 +5840,11 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
fsevents@~2.3.2, fsevents@~2.3.3: fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3" version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
@@ -7838,6 +7857,20 @@ pkg-dir@4.2.0:
dependencies: dependencies:
find-up "^4.0.0" find-up "^4.0.0"
playwright-core@1.55.0:
version "1.55.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.55.0.tgz#ec8a9f8ef118afb3e86e0f46f1393e3bea32adf4"
integrity sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==
playwright@1.55.0:
version "1.55.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.55.0.tgz#7aca7ac3ffd9e083a8ad8b2514d6f9ba401cc78b"
integrity sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==
dependencies:
playwright-core "1.55.0"
optionalDependencies:
fsevents "2.3.2"
png-chunk-text@1.0.0: png-chunk-text@1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/png-chunk-text/-/png-chunk-text-1.0.0.tgz#1c6006d8e34ba471d38e1c9c54b3f53e1085e18f" resolved "https://registry.yarnpkg.com/png-chunk-text/-/png-chunk-text-1.0.0.tgz#1c6006d8e34ba471d38e1c9c54b3f53e1085e18f"
@@ -9328,6 +9361,11 @@ undici-types@~6.20.0:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433"
integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==
undici-types@~7.10.0:
version "7.10.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.10.0.tgz#4ac2e058ce56b462b056e629cc6a02393d3ff350"
integrity sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==
unicode-canonical-property-names-ecmascript@^2.0.0: unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2"