Compare commits

...

17 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
41 changed files with 2984 additions and 749 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/
+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,
+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",
+10 -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;
+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();
+377 -147
View File
@@ -2,7 +2,6 @@ import {
arrayToMap, arrayToMap,
arrayToObject, arrayToObject,
assertNever, assertNever,
invariant,
isDevEnv, isDevEnv,
isShallowEqual, isShallowEqual,
isTestEnv, isTestEnv,
@@ -56,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";
@@ -151,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 };
@@ -497,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.
*/ */
@@ -504,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,
@@ -535,76 +557,137 @@ 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 = {},
selectedLinearElementIsEditing, 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,
); );
let selectedLinearElement = appState.selectedLinearElement; const mergedLockedMultiSelections = Delta.mergeObjects(
appState.lockedMultiSelections,
insertedLockedMultiSelections,
deletedLockedMultiSelections,
);
if (selectedLinearElementId === null) { const selectedLinearElement =
// Unselect linear element (visible change) insertedSelectedLinearElement &&
selectedLinearElement = null; nextElements.has(insertedSelectedLinearElement.elementId)
} else if ( ? new LinearElementEditor(
selectedLinearElementId && nextElements.get(
nextElements.has(selectedLinearElementId) insertedSelectedLinearElement.elementId,
) { ) as NonDeleted<ExcalidrawLinearElement>,
selectedLinearElement = new LinearElementEditor( nextElements,
nextElements.get( insertedSelectedLinearElement.isEditing,
selectedLinearElementId, )
) as NonDeleted<ExcalidrawLinearElement>, : null;
nextElements,
selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false
);
}
if (
// Value being 'null' is equivaluent to unknown in this case because it only gets set
// to null when 'selectedLinearElementId' is set to null
selectedLinearElementIsEditing != null
) {
invariant(
selectedLinearElement,
`selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`,
);
selectedLinearElement = {
...selectedLinearElement,
isEditing: selectedLinearElementIsEditing,
};
}
const nextAppState = { const nextAppState = {
...appState, ...appState,
...directlyApplicablePartial, ...directlyApplicablePartial,
selectedElementIds: mergedSelectedElementIds, selectedElementIds: mergedSelectedElementIds,
selectedGroupIds: mergedSelectedGroupIds, selectedGroupIds: mergedSelectedGroupIds,
selectedLinearElement, lockedMultiSelections: mergedLockedMultiSelections,
selectedLinearElement:
typeof insertedSelectedLinearElement !== "undefined"
? selectedLinearElement
: appState.selectedLinearElement,
}; };
const constainsVisibleChanges = this.filterInvisibleChanges( const constainsVisibleChanges = this.filterInvisibleChanges(
@@ -733,64 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
break; break;
case "selectedLinearElementId": { case "selectedLinearElement":
const appStateKey = AppStateDelta.convertToAppStateKey(key); const nextLinearElement = nextAppState[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 "selectedLinearElementIsEditing": {
// Changes in editing state are always visible
const prevIsEditing =
prevAppState.selectedLinearElement?.isEditing ?? false;
const nextIsEditing =
nextAppState.selectedLinearElement?.isEditing ?? false;
if (prevIsEditing !== nextIsEditing) {
visibleDifferenceFlag.value = true;
}
break;
}
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,
); );
}
} }
} }
} }
@@ -798,15 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return visibleDifferenceFlag.value; return visibleDifferenceFlag.value;
} }
private static convertToAppStateKey(
key: keyof Pick<ObservedElementsAppState, "selectedLinearElementId">,
): keyof Pick<AppState, "selectedLinearElement"> {
switch (key) {
case "selectedLinearElementId":
return "selectedLinearElement";
}
}
private static filterSelectedElements( private static filterSelectedElements(
selectedElementIds: AppState["selectedElementIds"], selectedElementIds: AppState["selectedElementIds"],
elements: SceneElementsMap, elements: SceneElementsMap,
@@ -871,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
editingGroupId, editingGroupId,
selectedGroupIds, selectedGroupIds,
selectedElementIds, selectedElementIds,
selectedLinearElementId, selectedLinearElement,
selectedLinearElementIsEditing,
croppingElementId, croppingElementId,
lockedMultiSelections, lockedMultiSelections,
activeLockedId, activeLockedId,
@@ -926,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.`);
@@ -960,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;
}; };
/** /**
@@ -1066,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",
@@ -1074,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(
@@ -1110,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,
@@ -1124,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;
}
} }
} }
@@ -1140,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(
@@ -1149,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;
} }
@@ -1178,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;
} }
} }
@@ -1196,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;
@@ -1295,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;
} }
} }
@@ -1316,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>;
@@ -1326,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([
@@ -1365,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`,
@@ -1395,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(
@@ -1413,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;
@@ -1466,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> = {};
@@ -1481,7 +1674,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
if (options.excludedProperties.has(key)) { if (options?.excludedProperties?.has(key)) {
continue; continue;
} }
@@ -1521,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);
} }
/** /**
@@ -1561,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 = (
@@ -1572,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);
@@ -1624,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;
} }
@@ -1704,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>,
@@ -1758,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,
}); });
@@ -1853,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;
}
} }
+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)
+31 -16
View File
@@ -76,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]
>(); >();
@@ -239,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);
} }
@@ -552,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;
} }
/** /**
@@ -572,9 +588,7 @@ export class StoreDelta {
delta: StoreDelta, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
options: ApplyToOptions = { options?: ApplyToOptions,
excludedProperties: new Set(),
},
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements, elements,
@@ -613,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();
} }
@@ -978,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => {
viewBackgroundColor: COLOR_PALETTE.white, viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
selectedLinearElementId: null, selectedLinearElement: null,
selectedLinearElementIsEditing: null,
croppingElementId: null, croppingElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
@@ -998,14 +1015,12 @@ export const getObservedAppState = (
croppingElementId: appState.croppingElementId, croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId, activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections, lockedMultiSelections: appState.lockedMultiSelections,
selectedLinearElementId: selectedLinearElement: appState.selectedLinearElement
(appState as AppState).selectedLinearElement?.elementId ?? ? {
(appState as ObservedAppState).selectedLinearElementId ?? elementId: appState.selectedLinearElement.elementId,
null, isEditing: !!appState.selectedLinearElement.isEditing,
selectedLinearElementIsEditing: }
(appState as AppState).selectedLinearElement?.isEditing ?? : null,
(appState as ObservedAppState).selectedLinearElementIsEditing ??
null,
}; };
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
+416 -12
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",
@@ -24,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,
}; };
@@ -58,9 +373,7 @@ describe("AppStateDelta", () => {
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElementId: null, selectedLinearElement: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -106,9 +419,7 @@ describe("AppStateDelta", () => {
selectedElementIds: {}, selectedElementIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElementId: null, selectedLinearElement: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -149,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 },
},
),
);
});
});
}); });
+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"];
@@ -298,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,
+27 -6
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,
@@ -117,7 +128,12 @@ 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,
@@ -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,
}); });
} }
+72 -64
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";
@@ -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>
+265 -47
View File
@@ -100,6 +100,7 @@ import {
randomInteger, randomInteger,
CLASSES, CLASSES,
Emitter, Emitter,
isMobile,
MINIMUM_ARROW_SIZE, MINIMUM_ARROW_SIZE,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -233,6 +234,8 @@ import {
hitElementBoundingBox, hitElementBoundingBox,
isLineElement, isLineElement,
isSimpleArrow, isSimpleArrow,
StoreDelta,
type ApplyToOptions,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -259,6 +262,7 @@ import type {
MagicGenerationData, MagicGenerationData,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
SceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -650,9 +654,14 @@ class App extends React.Component<AppProps, AppState> {
>(); >();
onRemoveEventListenersEmitter = new Emitter<[]>(); onRemoveEventListenersEmitter = new Emitter<[]>();
defaultSelectionTool: "selection" | "lasso" = "selection";
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
this.defaultSelectionTool = this.isMobileOrTablet()
? ("lasso" as const)
: ("selection" as const);
const { const {
excalidrawAPI, excalidrawAPI,
viewModeEnabled = false, viewModeEnabled = false,
@@ -697,6 +706,7 @@ class App extends React.Component<AppProps, AppState> {
if (excalidrawAPI) { if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = { const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene, updateScene: this.updateScene,
applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement, mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary, updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles, addFiles: this.addFiles,
@@ -1602,7 +1612,8 @@ class App extends React.Component<AppProps, AppState> {
renderWelcomeScreen={ renderWelcomeScreen={
!this.state.isLoading && !this.state.isLoading &&
this.state.showWelcomeScreen && this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" && this.state.activeTool.type ===
this.defaultSelectionTool &&
!this.state.zenModeEnabled && !this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length !this.scene.getElementsIncludingDeleted().length
} }
@@ -2342,7 +2353,11 @@ class App extends React.Component<AppProps, AppState> {
}, },
}; };
} }
const scene = restore(initialData, null, null, { repairBindings: true }); const scene = restore(initialData, null, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
const activeTool = scene.appState.activeTool;
scene.appState = { scene.appState = {
...scene.appState, ...scene.appState,
theme: this.props.theme || scene.appState.theme, theme: this.props.theme || scene.appState.theme,
@@ -2352,8 +2367,13 @@ class App extends React.Component<AppProps, AppState> {
// with a library install link, which should auto-open the library) // with a library install link, which should auto-open the library)
openSidebar: scene.appState?.openSidebar || this.state.openSidebar, openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
activeTool: activeTool:
scene.appState.activeTool.type === "image" activeTool.type === "image" ||
? { ...scene.appState.activeTool, type: "selection" } activeTool.type === "lasso" ||
activeTool.type === "selection"
? {
...activeTool,
type: this.defaultSelectionTool,
}
: scene.appState.activeTool, : scene.appState.activeTool,
isLoading: false, isLoading: false,
toast: this.state.toast, toast: this.state.toast,
@@ -2392,6 +2412,16 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private isMobileOrTablet = (): boolean => {
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
const hasCoarsePointer =
"matchMedia" in window &&
window?.matchMedia("(pointer: coarse)")?.matches;
const isTouchMobile = hasTouch && hasCoarsePointer;
return isMobile || isTouchMobile;
};
private isMobileBreakpoint = (width: number, height: number) => { private isMobileBreakpoint = (width: number, height: number) => {
return ( return (
width < MQ_MAX_WIDTH_PORTRAIT || width < MQ_MAX_WIDTH_PORTRAIT ||
@@ -3110,7 +3140,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
files: data.files || null, files: data.files || null,
position: "cursor", position: this.isMobileOrTablet() ? "center" : "cursor",
retainSeed: isPlainPaste, retainSeed: isPlainPaste,
}); });
} else if (data.text) { } else if (data.text) {
@@ -3128,7 +3158,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
files, files,
position: "cursor", position: this.isMobileOrTablet() ? "center" : "cursor",
}); });
return; return;
@@ -3188,7 +3218,7 @@ class App extends React.Component<AppProps, AppState> {
} }
this.addTextFromPaste(data.text, isPlainPaste); this.addTextFromPaste(data.text, isPlainPaste);
} }
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: this.defaultSelectionTool }, true);
event?.preventDefault(); event?.preventDefault();
}, },
); );
@@ -3200,7 +3230,9 @@ class App extends React.Component<AppProps, AppState> {
retainSeed?: boolean; retainSeed?: boolean;
fitToContent?: boolean; fitToContent?: boolean;
}) => { }) => {
const elements = restoreElements(opts.elements, null, undefined); const elements = restoreElements(opts.elements, null, {
deleteInvisibleElements: true,
});
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
@@ -3332,7 +3364,7 @@ class App extends React.Component<AppProps, AppState> {
} }
}, },
); );
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: this.defaultSelectionTool }, true);
if (opts.fitToContent) { if (opts.fitToContent) {
this.scrollToContent(duplicatedElements, { this.scrollToContent(duplicatedElements, {
@@ -3578,7 +3610,7 @@ class App extends React.Component<AppProps, AppState> {
...updateActiveTool( ...updateActiveTool(
this.state, this.state,
prevState.activeTool.locked prevState.activeTool.locked
? { type: "selection" } ? { type: this.defaultSelectionTool }
: prevState.activeTool, : prevState.activeTool,
), ),
locked: !prevState.activeTool.locked, locked: !prevState.activeTool.locked,
@@ -3933,6 +3965,27 @@ class App extends React.Component<AppProps, AppState> {
}, },
); );
public applyDeltas = (
deltas: StoreDelta[],
options?: ApplyToOptions,
): [SceneElementsMap, AppState, boolean] => {
// squash all deltas together, starting with a fresh new delta instance
const aggregatedDelta = StoreDelta.squash(...deltas);
// create new instance of elements map & appState, so we don't accidentaly mutate existing ones
const nextAppState = { ...this.state };
const nextElements = new Map(
this.scene.getElementsMapIncludingDeleted(),
) as SceneElementsMap;
return StoreDelta.applyTo(
aggregatedDelta,
nextElements,
nextAppState,
options,
);
};
public mutateElement = <TElement extends Mutable<ExcalidrawElement>>( public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement, element: TElement,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
@@ -4470,7 +4523,7 @@ class App extends React.Component<AppProps, AppState> {
!this.state.selectionElement && !this.state.selectionElement &&
!this.state.selectedElementsAreBeingDragged !this.state.selectedElementsAreBeingDragged
) { ) {
const shape = findShapeByKey(event.key); const shape = findShapeByKey(event.key, this);
if (shape) { if (shape) {
if (this.state.activeTool.type !== shape) { if (this.state.activeTool.type !== shape) {
trackEvent( trackEvent(
@@ -4563,7 +4616,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "laser") { if (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: this.defaultSelectionTool });
} else { } else {
this.setActiveTool({ type: "laser" }); this.setActiveTool({ type: "laser" });
} }
@@ -4927,17 +4980,8 @@ class App extends React.Component<AppProps, AppState> {
}), }),
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
const isDeleted = !nextOriginalText.trim(); const isDeleted = !nextOriginalText.trim();
updateElement(nextOriginalText, isDeleted);
if (isDeleted && !isExistingElement) {
// let's just remove the element from the scene, as it's an empty just created text element
this.scene.replaceAllElements(
this.scene
.getElementsIncludingDeleted()
.filter((x) => x.id !== element.id),
);
} else {
updateElement(nextOriginalText, isDeleted);
}
// select the created text element only if submitting via keyboard // select the created text element only if submitting via keyboard
// (when submitting via click it should act as signal to deselect) // (when submitting via click it should act as signal to deselect)
if (!isDeleted && viaKeyboard) { if (!isDeleted && viaKeyboard) {
@@ -4961,15 +5005,16 @@ class App extends React.Component<AppProps, AppState> {
})); }));
}); });
} }
if (isDeleted) { if (isDeleted) {
fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [ fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [
element, element,
]); ]);
} }
// we need to record either way, whether the text element was added or removed if (!isDeleted || isExistingElement) {
// since we need to sync this delta to other clients, otherwise it would end up with inconsistencies this.store.scheduleCapture();
this.store.scheduleCapture(); }
flushSync(() => { flushSync(() => {
this.setState({ this.setState({
@@ -5416,7 +5461,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
// we should only be able to double click when mode is selection // we should only be able to double click when mode is selection
if (this.state.activeTool.type !== "selection") { if (this.state.activeTool.type !== this.defaultSelectionTool) {
return; return;
} }
@@ -5708,8 +5753,9 @@ class App extends React.Component<AppProps, AppState> {
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
const frames = this.scene const frames = this.scene
.getNonDeletedFramesLikes() .getNonDeletedFramesLikes()
.filter((frame): frame is ExcalidrawFrameLikeElement => .filter(
isCursorInFrame(sceneCoords, frame, elementsMap), (frame): frame is ExcalidrawFrameLikeElement =>
!frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap),
); );
return frames.length ? frames[frames.length - 1] : null; return frames.length ? frames[frames.length - 1] : null;
@@ -6027,6 +6073,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
hasDeselectedButton || hasDeselectedButton ||
(this.state.activeTool.type !== "selection" && (this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" && this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser") this.state.activeTool.type !== "eraser")
) { ) {
@@ -6189,7 +6236,12 @@ class App extends React.Component<AppProps, AppState> {
!isElbowArrow(hitElement) || !isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding) !(hitElement.startBinding || hitElement.endBinding)
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); if (
this.state.activeTool.type !== "lasso" ||
selectedElements.length > 0
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
if (this.state.activeEmbeddable?.state === "hover") { if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null }); this.setState({ activeEmbeddable: null });
} }
@@ -6306,7 +6358,12 @@ class App extends React.Component<AppProps, AppState> {
!isElbowArrow(element) || !isElbowArrow(element) ||
!(element.startBinding || element.endBinding) !(element.startBinding || element.endBinding)
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); if (
this.state.activeTool.type !== "lasso" ||
Object.keys(this.state.selectedElementIds).length > 0
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
} }
} }
} else if (this.hitElement(scenePointerX, scenePointerY, element)) { } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
@@ -6315,7 +6372,12 @@ class App extends React.Component<AppProps, AppState> {
!isElbowArrow(element) || !isElbowArrow(element) ||
!(element.startBinding || element.endBinding) !(element.startBinding || element.endBinding)
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); if (
this.state.activeTool.type !== "lasso" ||
Object.keys(this.state.selectedElementIds).length > 0
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
} }
} }
@@ -6577,11 +6639,119 @@ class App extends React.Component<AppProps, AppState> {
} }
if (this.state.activeTool.type === "lasso") { if (this.state.activeTool.type === "lasso") {
this.lassoTrail.startPath( const hitSelectedElement =
pointerDownState.origin.x, pointerDownState.hit.element &&
pointerDownState.origin.y, this.isASelectedElement(pointerDownState.hit.element);
event.shiftKey,
); const isMobileOrTablet = this.isMobileOrTablet();
if (
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement
) {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
// block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately)
pointerDownState.drag.blockDragging = !isMobileOrTablet;
}
// only for mobile or tablet, if we hit an element, select it immediately like normal selection
if (
isMobileOrTablet &&
pointerDownState.hit.element &&
!hitSelectedElement
) {
this.setState((prevState) => {
const nextSelectedElementIds: { [id: string]: true } = {
...prevState.selectedElementIds,
[pointerDownState.hit.element!.id]: true,
};
const previouslySelectedElements: ExcalidrawElement[] = [];
Object.keys(prevState.selectedElementIds).forEach((id) => {
const element = this.scene.getElement(id);
element && previouslySelectedElements.push(element);
});
const hitElement = pointerDownState.hit.element!;
// if hitElement is frame-like, deselect all of its elements
// if they are selected
if (isFrameLikeElement(hitElement)) {
getFrameChildren(previouslySelectedElements, hitElement.id).forEach(
(element) => {
delete nextSelectedElementIds[element.id];
},
);
} else if (hitElement.frameId) {
// if hitElement is in a frame and its frame has been selected
// disable selection for the given element
if (nextSelectedElementIds[hitElement.frameId]) {
delete nextSelectedElementIds[hitElement.id];
}
} else {
// hitElement is neither a frame nor an element in a frame
// but since hitElement could be in a group with some frames
// this means selecting hitElement will have the frames selected as well
// because we want to keep the invariant:
// - frames and their elements are not selected at the same time
// we deselect elements in those frames that were previously selected
const groupIds = hitElement.groupIds;
const framesInGroups = new Set(
groupIds
.flatMap((gid) =>
getElementsInGroup(this.scene.getNonDeletedElements(), gid),
)
.filter((element) => isFrameLikeElement(element))
.map((frame) => frame.id),
);
if (framesInGroups.size > 0) {
previouslySelectedElements.forEach((element) => {
if (element.frameId && framesInGroups.has(element.frameId)) {
// deselect element and groups containing the element
delete nextSelectedElementIds[element.id];
element.groupIds
.flatMap((gid) =>
getElementsInGroup(
this.scene.getNonDeletedElements(),
gid,
),
)
.forEach((element) => {
delete nextSelectedElementIds[element.id];
});
}
});
}
}
return {
...selectGroupsForSelectedElements(
{
editingGroupId: prevState.editingGroupId,
selectedElementIds: nextSelectedElementIds,
},
this.scene.getNonDeletedElements(),
prevState,
this,
),
showHyperlinkPopup:
hitElement.link || isEmbeddableElement(hitElement)
? "info"
: false,
};
});
pointerDownState.hit.wasAddedToSelection = true;
}
} else if (this.state.activeTool.type === "text") { } else if (this.state.activeTool.type === "text") {
this.handleTextOnPointerDown(event, pointerDownState); this.handleTextOnPointerDown(event, pointerDownState);
} else if ( } else if (
@@ -6961,6 +7131,7 @@ class App extends React.Component<AppProps, AppState> {
hasOccurred: false, hasOccurred: false,
offset: null, offset: null,
origin: { ...origin }, origin: { ...origin },
blockDragging: false,
}, },
eventListeners: { eventListeners: {
onMove: null, onMove: null,
@@ -7036,7 +7207,10 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
): boolean => { ): boolean => {
if (this.state.activeTool.type === "selection") { if (
this.state.activeTool.type === "selection" ||
this.state.activeTool.type === "lasso"
) {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
@@ -7243,7 +7417,18 @@ class App extends React.Component<AppProps, AppState> {
// on CMD/CTRL, drill down to hit element regardless of groups etc. // on CMD/CTRL, drill down to hit element regardless of groups etc.
if (event[KEYS.CTRL_OR_CMD]) { if (event[KEYS.CTRL_OR_CMD]) {
if (event.altKey) { if (event.altKey) {
// ctrl + alt means we're lasso selecting // ctrl + alt means we're lasso selecting - start lasso trail and switch to lasso tool
// Close any open dialogs that might interfere with lasso selection
if (this.state.openDialog?.name === "elementLinkSelector") {
this.setOpenDialog(null);
}
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
this.setActiveTool({ type: "lasso", fromSelection: true });
return false; return false;
} }
if (!this.state.selectedElementIds[hitElement.id]) { if (!this.state.selectedElementIds[hitElement.id]) {
@@ -7464,7 +7649,9 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
if (!this.state.activeTool.locked) { if (!this.state.activeTool.locked) {
this.setState({ this.setState({
activeTool: updateActiveTool(this.state, { type: "selection" }), activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
}); });
} }
}; };
@@ -8248,15 +8435,18 @@ class App extends React.Component<AppProps, AppState> {
event.shiftKey && event.shiftKey &&
this.state.selectedLinearElement.elementId === this.state.selectedLinearElement.elementId ===
pointerDownState.hit.element?.id; pointerDownState.hit.element?.id;
if ( if (
(hasHitASelectedElement || (hasHitASelectedElement ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) && pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
!isSelectingPointsInLineEditor && !isSelectingPointsInLineEditor &&
this.state.activeTool.type !== "lasso" !pointerDownState.drag.blockDragging
) { ) {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
if (
if (selectedElements.every((element) => element.locked)) { selectedElements.length > 0 &&
selectedElements.every((element) => element.locked)
) {
return; return;
} }
@@ -8277,6 +8467,29 @@ class App extends React.Component<AppProps, AppState> {
// if elements should be deselected on pointerup // if elements should be deselected on pointerup
pointerDownState.drag.hasOccurred = true; pointerDownState.drag.hasOccurred = true;
// prevent immediate dragging during lasso selection to avoid element displacement
// only allow dragging if we're not in the middle of lasso selection
// (on mobile, allow dragging if we hit an element)
if (
this.state.activeTool.type === "lasso" &&
this.lassoTrail.hasCurrentTrail &&
!(this.isMobileOrTablet() && pointerDownState.hit.element) &&
!this.state.activeTool.fromSelection
) {
return;
}
// Clear lasso trail when starting to drag selected elements with lasso tool
// Only clear if we're actually dragging (not during lasso selection)
if (
this.state.activeTool.type === "lasso" &&
selectedElements.length > 0 &&
pointerDownState.drag.hasOccurred &&
!this.state.activeTool.fromSelection
) {
this.lassoTrail.endPath();
}
// prevent dragging even if we're no longer holding cmd/ctrl otherwise // prevent dragging even if we're no longer holding cmd/ctrl otherwise
// it would have weird results (stuff jumping all over the screen) // it would have weird results (stuff jumping all over the screen)
// Checking for editingTextElement to avoid jump while editing on mobile #6503 // Checking for editingTextElement to avoid jump while editing on mobile #6503
@@ -8871,6 +9084,7 @@ class App extends React.Component<AppProps, AppState> {
): (event: PointerEvent) => void { ): (event: PointerEvent) => void {
return withBatchedUpdates((childEvent: PointerEvent) => { return withBatchedUpdates((childEvent: PointerEvent) => {
this.removePointer(childEvent); this.removePointer(childEvent);
pointerDownState.drag.blockDragging = false;
if (pointerDownState.eventListeners.onMove) { if (pointerDownState.eventListeners.onMove) {
pointerDownState.eventListeners.onMove.flush(); pointerDownState.eventListeners.onMove.flush();
} }
@@ -9159,7 +9373,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevState) => ({ this.setState((prevState) => ({
newElement: null, newElement: null,
activeTool: updateActiveTool(this.state, { activeTool: updateActiveTool(this.state, {
type: "selection", type: this.defaultSelectionTool,
}), }),
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
{ {
@@ -9775,7 +9989,9 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
newElement: null, newElement: null,
suggestedBindings: [], suggestedBindings: [],
activeTool: updateActiveTool(this.state, { type: "selection" }), activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
}); });
} else { } else {
this.setState({ this.setState({
@@ -10069,7 +10285,9 @@ class App extends React.Component<AppProps, AppState> {
this.setState( this.setState(
{ {
newElement: null, newElement: null,
activeTool: updateActiveTool(this.state, { type: "selection" }), activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
}, },
() => { () => {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
@@ -10121,7 +10339,7 @@ class App extends React.Component<AppProps, AppState> {
if (erroredFiles.size) { if (erroredFiles.size) {
this.store.scheduleAction(CaptureUpdateAction.NEVER); this.store.scheduleAction(CaptureUpdateAction.NEVER);
this.scene.replaceAllElements( this.scene.replaceAllElements(
elements.map((element) => { this.scene.getElementsIncludingDeleted().map((element) => {
if ( if (
isInitializedImageElement(element) && isInitializedImageElement(element) &&
erroredFiles.has(element.fileId) erroredFiles.has(element.fileId)
@@ -10442,7 +10660,7 @@ class App extends React.Component<AppProps, AppState> {
event.nativeEvent.pointerType === "pen" && event.nativeEvent.pointerType === "pen" &&
// always allow if user uses a pen secondary button // always allow if user uses a pen secondary button
event.button !== POINTER_BUTTON.SECONDARY)) && event.button !== POINTER_BUTTON.SECONDARY)) &&
this.state.activeTool.type !== "selection" this.state.activeTool.type !== this.defaultSelectionTool
) { ) {
return; return;
} }
+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] =
+1
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";
+7 -1
View File
@@ -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] &&
@@ -3682,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,
@@ -3714,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,
File diff suppressed because it is too large Load Diff
@@ -2216,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",
}, },
@@ -6394,15 +6385,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"selectedElementIds": { "selectedElementIds": {
"id9": true, "id9": true,
}, },
"selectedLinearElementId": "id9", "selectedLinearElement": {
"selectedLinearElementIsEditing": false, "elementId": "id9",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedElementIds": { "selectedElementIds": {
"id6": true, "id6": true,
}, },
"selectedLinearElementId": null, "selectedLinearElement": null,
"selectedLinearElementIsEditing": null,
}, },
}, },
}, },
@@ -6470,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,
},
}, },
}, },
}, },
@@ -6542,15 +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,
"selectedLinearElementIsEditing": null,
}, },
"inserted": { "inserted": {
"selectedElementIds": { "selectedElementIds": {
"id12": true, "id12": true,
}, },
"selectedLinearElementId": "id12", "selectedLinearElement": {
"selectedLinearElementIsEditing": false, "elementId": "id12",
"isEditing": false,
},
}, },
}, },
}, },
@@ -6677,12 +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": {
"selectedLinearElementIsEditing": false, "elementId": "id15",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedLinearElementId": null, "selectedLinearElement": null,
"selectedLinearElementIsEditing": null,
}, },
}, },
}, },
@@ -6700,15 +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,
"selectedLinearElementIsEditing": null,
}, },
"inserted": { "inserted": {
"selectedElementIds": { "selectedElementIds": {
"id15": true, "id15": true,
}, },
"selectedLinearElementId": "id15", "selectedLinearElement": {
"selectedLinearElementIsEditing": false, "elementId": "id15",
"isEditing": false,
},
}, },
}, },
}, },
@@ -6833,12 +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": {
"selectedLinearElementIsEditing": false, "elementId": "id22",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedLinearElementId": null, "selectedLinearElement": null,
"selectedLinearElementIsEditing": null,
}, },
}, },
}, },
@@ -6873,12 +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,
"selectedLinearElementIsEditing": null,
}, },
"inserted": { "inserted": {
"selectedLinearElementId": "id22", "selectedLinearElement": {
"selectedLinearElementIsEditing": false, "elementId": "id22",
"isEditing": false,
},
}, },
}, },
}, },
@@ -8719,13 +8722,14 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
"selectedLinearElementId": "id0", "selectedLinearElement": {
"selectedLinearElementIsEditing": false, "elementId": "id0",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedElementIds": {}, "selectedElementIds": {},
"selectedLinearElementId": null, "selectedLinearElement": null,
"selectedLinearElementIsEditing": null,
}, },
}, },
}, },
@@ -8946,13 +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": {
"selectedLinearElementIsEditing": false, "elementId": "id0",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedElementIds": {}, "selectedElementIds": {},
"selectedLinearElementId": null, "selectedLinearElement": null,
"selectedLinearElementIsEditing": null,
}, },
}, },
}, },
@@ -9365,13 +9370,14 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
"selectedLinearElementId": "id0", "selectedLinearElement": {
"selectedLinearElementIsEditing": false, "elementId": "id0",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedElementIds": {}, "selectedElementIds": {},
"selectedLinearElementId": null, "selectedLinearElement": null,
"selectedLinearElementIsEditing": null,
}, },
}, },
}, },
@@ -9770,13 +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": {
"selectedLinearElementIsEditing": false, "elementId": "id0",
"isEditing": false,
},
}, },
"inserted": { "inserted": {
"selectedElementIds": {}, "selectedElementIds": {},
"selectedLinearElementId": null, "selectedLinearElement": null,
"selectedLinearElementIsEditing": null,
}, },
}, },
}, },
@@ -10885,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",
}, },
@@ -14494,12 +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,
"selectedLinearElementIsEditing": null,
}, },
"inserted": { "inserted": {
"selectedLinearElementId": "id6", "selectedLinearElement": {
"selectedLinearElementIsEditing": false, "elementId": "id6",
"isEditing": false,
},
}, },
}, },
}, },
+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,
}),
]);
}); });
}); });
}); });
+6 -7
View File
@@ -3043,15 +3043,14 @@ describe("history", () => {
}); });
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(3); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(4);
expect(h.state.selectedLinearElement).not.toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
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.selectedLinearElement).not.toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
}); });
@@ -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,
}), }),
]); ]);
+11 -2
View File
@@ -248,8 +248,10 @@ export type ObservedElementsAppState = {
editingGroupId: AppState["editingGroupId"]; editingGroupId: AppState["editingGroupId"];
selectedElementIds: AppState["selectedElementIds"]; selectedElementIds: AppState["selectedElementIds"];
selectedGroupIds: AppState["selectedGroupIds"]; selectedGroupIds: AppState["selectedGroupIds"];
selectedLinearElementId: LinearElementEditor["elementId"] | null; selectedLinearElement: {
selectedLinearElementIsEditing: boolean | null; elementId: LinearElementEditor["elementId"];
isEditing: boolean;
} | null;
croppingElementId: AppState["croppingElementId"]; croppingElementId: AppState["croppingElementId"];
lockedMultiSelections: AppState["lockedMultiSelections"]; lockedMultiSelections: AppState["lockedMultiSelections"];
activeLockedId: AppState["activeLockedId"]; activeLockedId: AppState["activeLockedId"];
@@ -729,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<{
@@ -778,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: {
@@ -799,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"];
@@ -704,7 +704,7 @@ describe("textWysiwyg", () => {
rectangle.x + rectangle.width / 2, rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
); );
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(3);
text = h.elements[1] as ExcalidrawTextElementWithContainer; text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text"); expect(text.type).toBe("text");
@@ -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]).toBeUndefined(); 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 = {
+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"