Compare commits

...

31 Commits

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

* alternative ux: drag with lasso right away

* fix: lasso dragging should snap too

* fix: alt+cmd getting stuck

* test: snapshots

* alternatvie: keep lasso drag to only mobile

* alternative: drag after selection on PCs

* improve mobile dection

* add mobile lasso icon

* add default selection tool

* render according to default selection tool

* return to default selection tool after deletion

* reset to default tool after clearing out the canvas

* return to default tool after eraser toggle

* if default lasso, close lasso toggle

* finalize to default selection tool

* toggle between laser and default selection

* return to default selection tool after creation

* double click to add text when using default selection tool

* set to default selection tool after unlocking tool

* paste to center on touch screen

* switch to default selection tool after pasting

* lint

* fix tests

* show welcome screen when using default selection tool

* fix tests

* fix snapshots

* fix context menu not opening

* prevent potential displacement issue

* prevent element jumping during lasso selection

* fix dragging on mobile

* use same selection icon

* fix alt+cmd lasso getting cut off

* fix: shortcut handling

* lint

---------

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

* lint

* lint

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

* update meta tag

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

* cleanup save/restore

---------

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

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

* Remove selectedLinearElement flushSync

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

* feat: move finalize to next to last point

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

* fix: make default adaptive to zoom

* fix: increase padding to avoid cutoffs

* refactor: simplify

* fix: only use bigger padding when needed

* center arrow horizontally on pointer

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

* do not render 0-width arrow while creating

* dead code

* fix tests

* fix: remove redundant code

* do not enter line editor on creation

---------

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

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

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

* fix: lint

---------

Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2025-07-03 17:07:26 +10:00
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,
+24 -32
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,35 +312,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
interface DebugCanvasProps { interface DebugCanvasProps {
appState: AppState; appState: AppState;
scale: number; scale: number;
ref?: React.Ref<HTMLCanvasElement>;
} }
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => { const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
const { width, height } = appState; ({ appState, scale }, ref) => {
const { width, height } = appState;
const canvasRef = useRef<HTMLCanvasElement>(null); return (
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>( <canvas
ref, style={{
() => canvasRef.current, width,
[canvasRef], height,
); position: "absolute",
zIndex: 2,
return ( pointerEvents: "none",
<canvas }}
style={{ width={width * scale}
width, height={height * scale}
height, ref={ref}
position: "absolute", >
zIndex: 2, Debug Canvas
pointerEvents: "none", </canvas>
}} );
width={width * scale} },
height={height * scale} );
ref={canvasRef}
>
Debug Canvas
</canvas>
);
};
export default DebugCanvas; export default DebugCanvas;
+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;
+16 -4
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[] = [];
validateIndicesThrottled(_nextElements); if (!options?.skipValidation) {
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);
+370 -125
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,52 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
break; break;
case "selectedLinearElementId": case "selectedLinearElement":
case "editingLinearElementId": const nextLinearElement = nextAppState[key];
const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
if (!linearElement) { if (!nextLinearElement) {
// previously there was a linear element (assuming visible), now there is none // previously there was a linear element (assuming visible), now there is none
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
const element = nextElements.get(linearElement.elementId); const element = nextElements.get(nextLinearElement.elementId);
if (element && !element.isDeleted) { if (element && !element.isDeleted) {
// previously there wasn't a linear element, now there is one which is visible // previously there wasn't a linear element, now there is one which is visible
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
// there was assigned a linear element now, but it's deleted // there was assigned a linear element now, but it's deleted
nextAppState[appStateKey] = null; nextAppState[key] = null;
} }
} }
break; break;
case "lockedMultiSelections": { case "lockedMultiSelections":
const prevLockedUnits = prevAppState[key] || {}; const prevLockedUnits = prevAppState[key] || {};
const nextLockedUnits = nextAppState[key] || {}; const nextLockedUnits = nextAppState[key] || {};
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) { if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} }
break; break;
} case "activeLockedId":
case "activeLockedId": {
const prevHitLockedId = prevAppState[key] || null; const prevHitLockedId = prevAppState[key] || null;
const nextHitLockedId = nextAppState[key] || null; const nextHitLockedId = nextAppState[key] || null;
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (prevHitLockedId !== nextHitLockedId) { if (prevHitLockedId !== nextHitLockedId) {
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} }
break; break;
} default:
default: {
assertNever( assertNever(
key, key,
`Unknown ObservedElementsAppState's key "${key}"`, `Unknown ObservedElementsAppState's key "${key}"`,
true, true,
); );
}
} }
} }
} }
@@ -778,20 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return visibleDifferenceFlag.value; return visibleDifferenceFlag.value;
} }
private static convertToAppStateKey(
key: keyof Pick<
ObservedElementsAppState,
"selectedLinearElementId" | "editingLinearElementId"
>,
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
switch (key) {
case "selectedLinearElementId":
return "selectedLinearElement";
case "editingLinearElementId":
return "editingLinearElement";
}
}
private static filterSelectedElements( private static filterSelectedElements(
selectedElementIds: AppState["selectedElementIds"], selectedElementIds: AppState["selectedElementIds"],
elements: SceneElementsMap, elements: SceneElementsMap,
@@ -856,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
editingGroupId, editingGroupId,
selectedGroupIds, selectedGroupIds,
selectedElementIds, selectedElementIds,
editingLinearElementId, selectedLinearElement,
selectedLinearElementId,
croppingElementId, croppingElementId,
lockedMultiSelections, lockedMultiSelections,
activeLockedId, activeLockedId,
@@ -911,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
"lockedMultiSelections", "lockedMultiSelections",
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>, (prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
); );
Delta.diffObjects(
deleted,
inserted,
"activeLockedId",
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
);
} catch (e) { } catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess appstate change deltas.`); console.error(`Couldn't postprocess appstate change deltas.`);
@@ -945,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">; Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
export type ApplyToOptions = { export type ApplyToOptions = {
excludedProperties: Set<keyof ElementPartial>; excludedProperties?: Set<keyof ElementPartial>;
}; };
type ApplyToFlags = { type ApplyToFlags = {
containsVisibleDifference: boolean; containsVisibleDifference: boolean;
containsZindexDifference: boolean; containsZindexDifference: boolean;
applyDirection: "forward" | "backward" | undefined;
}; };
/** /**
@@ -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,7 +1191,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
removed[prevElement.id] = delta; // ignore updates which would "delete" already deleted element
if (!prevElement.isDeleted) {
removed[prevElement.id] = delta;
}
} }
} }
@@ -1125,7 +1210,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inserted = { 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,
); );
added[nextElement.id] = delta; // ignore updates which would "delete" already deleted element
if (!nextElement.isDeleted) {
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
// technically we could do better here if perf. would become an issue this.squash(
const { added, removed, updated } = ElementsDelta.calculate( // technically we could do better here if perf. would become an issue
prevAffectedElements, ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
nextAffectedElements,
); );
for (const [id, delta] of Object.entries(added)) {
this.added[id] = delta;
}
for (const [id, delta] of Object.entries(removed)) {
this.removed[id] = delta;
}
for (const [id, delta] of Object.entries(updated)) {
this.updated[id] = delta;
}
return nextAffectedElements; return nextAffectedElements;
} }
@@ -1689,6 +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()));
};
+35 -28
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, ? {
lastUncommittedPoint: null, ...appState.selectedLinearElement,
}; 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,8 +1337,9 @@ export class LinearElementEditor {
pointIndices: readonly number[], pointIndices: readonly number[],
) { ) {
const isUncommittedPoint = const isUncommittedPoint =
app.state.editingLinearElement?.lastUncommittedPoint === app.state.selectedLinearElement?.isEditing &&
element.points[element.points.length - 1]; app.state.selectedLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
const nextPoints = element.points.filter((_, idx) => { const nextPoints = element.points.filter((_, idx) => {
return !pointIndices.includes(idx); return !pointIndices.includes(idx);
@@ -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 },
+73 -77
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,63 +304,68 @@ export const ShapesSwitcher = ({
return ( return (
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {getToolbarTools(app).map(
if ( ({ value, icon, key, numericKey, fillable }, index) => {
UIOptions.tools?.[ if (
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]> UIOptions.tools?.[
] === false value as Extract<
) { typeof value,
return null; keyof AppProps["UIOptions"]["tools"]
} >
] === false
) {
return null;
}
const label = t(`toolBar.${value}`); const label = t(`toolBar.${value}`);
const letter = const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]); key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}` ? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`; : `${numericKey}`;
return ( return (
<ToolButton <ToolButton
className={clsx("Shape", { fillable })} className={clsx("Shape", { fillable })}
key={value} key={value}
type="radio" type="radio"
icon={icon} icon={icon}
checked={activeTool.type === value} checked={activeTool.type === value}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`} title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter} keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)} aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`} data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => { onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") { if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true); app.togglePenMode(true);
}
if (value === "selection") {
if (appState.activeTool.type === "selection") {
app.setActiveTool({ type: "lasso" });
} else {
app.setActiveTool({ type: "selection" });
} }
}
}} if (value === "selection") {
onChange={({ pointerType }) => { if (appState.activeTool.type === "selection") {
if (appState.activeTool.type !== value) { app.setActiveTool({ type: "lasso" });
trackEvent("toolbar", value, "ui"); } else {
} app.setActiveTool({ type: "selection" });
if (value === "image") { }
app.setActiveTool({ }
type: value, }}
}); onChange={({ pointerType }) => {
} else { if (appState.activeTool.type !== value) {
app.setActiveTool({ type: value }); trackEvent("toolbar", value, "ui");
} }
}} if (value === "image") {
/> app.setActiveTool({
); type: value,
})} });
} else {
app.setActiveTool({ type: value });
}
}}
/>
);
},
)}
<div className="App-toolbar__divider" /> <div className="App-toolbar__divider" />
<DropdownMenu open={isExtraToolsMenuOpen}> <DropdownMenu open={isExtraToolsMenuOpen}>
@@ -418,14 +424,16 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.laser")} {t("toolBar.laser")}
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item {app.defaultSelectionTool !== "lasso" && (
onSelect={() => app.setActiveTool({ type: "lasso" })} <DropdownMenu.Item
icon={LassoIcon} onSelect={() => app.setActiveTool({ type: "lasso" })}
data-testid="toolbar-lasso" icon={LassoIcon}
selected={lassoToolSelected} data-testid="toolbar-lasso"
> selected={lassoToolSelected}
{t("toolBar.lasso")} >
</DropdownMenu.Item> {t("toolBar.lasso")}
</DropdownMenu.Item>
)}
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}> <div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate Generate
</div> </div>
@@ -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>
);
File diff suppressed because it is too large Load Diff
@@ -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
+47 -21
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,24 +540,38 @@ 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;
if (migratedElement) {
const localElement = localElementsMap?.get(element.id);
if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(
migratedElement,
localElement.version,
);
}
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement);
}
} }
let migratedElement: ExcalidrawElement | null = restoreElement(element, {
deleteInvisibleElements: opts?.deleteInvisibleElements,
});
if (migratedElement) {
const localElement = localElementsMap?.get(element.id);
const shouldMarkAsDeleted =
opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
if (
shouldMarkAsDeleted ||
(localElement && localElement.version > migratedElement.version)
) {
migratedElement = bumpVersion(migratedElement, localElement?.version);
}
if (shouldMarkAsDeleted) {
migratedElement = { ...migratedElement, isDeleted: true };
}
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement);
}
return elements; 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,
@@ -15,7 +15,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Click me <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Click me
</span>
</div> </div>
</button> </button>
<a <a
@@ -27,7 +31,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Excalidraw blog <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Excalidraw blog
</span>
</div> </div>
</a> </a>
<div <div
@@ -88,7 +96,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Help <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Help
</span>
</div> </div>
<div <div
class="dropdown-menu-item__shortcut" class="dropdown-menu-item__shortcut"
@@ -138,7 +150,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Open <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Open
</span>
</div> </div>
<div <div
class="dropdown-menu-item__shortcut" class="dropdown-menu-item__shortcut"
@@ -175,7 +191,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Save to... <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Save to...
</span>
</div> </div>
</button> </button>
<button <button
@@ -231,7 +251,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Export image... <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Export image...
</span>
</div> </div>
<div <div
class="dropdown-menu-item__shortcut" class="dropdown-menu-item__shortcut"
@@ -280,7 +304,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Find on canvas <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Find on canvas
</span>
</div> </div>
<div <div
class="dropdown-menu-item__shortcut" class="dropdown-menu-item__shortcut"
@@ -337,7 +365,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Help <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Help
</span>
</div> </div>
<div <div
class="dropdown-menu-item__shortcut" class="dropdown-menu-item__shortcut"
@@ -374,7 +406,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Reset the canvas <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Reset the canvas
</span>
</div> </div>
</button> </button>
<div <div
@@ -419,7 +455,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
GitHub <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
GitHub
</span>
</div> </div>
</a> </a>
<a <a
@@ -465,7 +505,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Follow us <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Follow us
</span>
</div> </div>
</a> </a>
<a <a
@@ -505,7 +549,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Discord chat <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Discord chat
</span>
</div> </div>
</a> </a>
</div> </div>
@@ -542,7 +590,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
class="dropdown-menu-item__text" class="dropdown-menu-item__text"
> >
Dark mode <span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
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"