Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ed78d4895 | |||
| 5ba2798306 | |||
| b8aae34e32 | |||
| 6317ac16ee | |||
| 9ad75b8375 | |||
| 4c9ad1a22f | |||
| 023895b49b | |||
| eb37be953a | |||
| b4903a7eab | |||
| c6f8ef9ad2 | |||
| 2535d73054 | |||
| dda3affcb0 | |||
| 54c148f390 | |||
| cc8e490c75 | |||
| 9036812b6d | |||
| df25de7e68 | |||
| a3763648fe |
@@ -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
|
||||
@@ -27,3 +27,9 @@ dev-dist
|
||||
html
|
||||
meta*.json
|
||||
.claude
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
@@ -74,6 +74,7 @@ import type {
|
||||
import type { ResolutionType } from "@excalidraw/common/utility-types";
|
||||
import type { ResolvablePromise } from "@excalidraw/common/utils";
|
||||
|
||||
import "./record";
|
||||
import CustomStats from "./CustomStats";
|
||||
import {
|
||||
Provider,
|
||||
|
||||
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
|
||||
}
|
||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
|
||||
@@ -258,11 +258,16 @@ export const loadScene = async (
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
{
|
||||
repairBindings: true,
|
||||
refreshDimensions: false,
|
||||
deleteInvisibleElements: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||
<title>
|
||||
Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw
|
||||
</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||
@@ -14,7 +16,7 @@
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta
|
||||
name="title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"start": "yarn && vite",
|
||||
"start:test": "yarn && vite --mode test",
|
||||
"start:production": "yarn build && yarn serve",
|
||||
"serve": "npx http-server build -a localhost -p 5001 -o",
|
||||
"build:preview": "yarn build && vite preview --port 5000"
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { isDevEnv } from "@excalidraw/common";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
record: typeof Record;
|
||||
}
|
||||
}
|
||||
|
||||
export class Record {
|
||||
private static recording: boolean = false;
|
||||
private static events: string = "";
|
||||
private static timestamp: number = 0;
|
||||
|
||||
public static get isRecording() {
|
||||
return Record.recording;
|
||||
}
|
||||
|
||||
private static header() {
|
||||
Record.events += " await page.addInitScript(() => {\n";
|
||||
Record.events += " Math.random = () => 0.42;\n\n";
|
||||
|
||||
// Capture LocalStorage, which is essential to re-establish state
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key != null) {
|
||||
const value = JSON.stringify(localStorage.getItem(key));
|
||||
Record.events += ` localStorage.getItem("${key}");\n`;
|
||||
Record.events += ` localStorage.setItem("${key}", ${value});\n`;
|
||||
}
|
||||
}
|
||||
Record.events += " });\n";
|
||||
Record.events += ` await page.setViewportSize({ width: ${window.innerWidth}, height: ${window.innerHeight} });\n`;
|
||||
Record.events += ` await page.goto("http://localhost:3000");\n`;
|
||||
Record.events += ` await page.waitForLoadState("load");\n`;
|
||||
}
|
||||
|
||||
public static restart() {
|
||||
if (!Record.recording) {
|
||||
Record.start();
|
||||
return;
|
||||
}
|
||||
|
||||
Record.events += `});\n\n`;
|
||||
Record.events += `test("${
|
||||
Date.now() + Math.floor(Math.random() * Date.now()).toString(36)
|
||||
}", async ({ page }) => {\n`;
|
||||
|
||||
Record.header();
|
||||
}
|
||||
|
||||
public static start() {
|
||||
Record.recording = true;
|
||||
|
||||
// Record header
|
||||
this.header();
|
||||
|
||||
// Set up the events
|
||||
Record.timestamp = performance.now();
|
||||
|
||||
window.addEventListener("mousemove", this.onMouseMove);
|
||||
window.addEventListener("mousedown", this.onMouseDown);
|
||||
window.addEventListener("mouseup", this.onMouseUp);
|
||||
window.addEventListener("keydown", this.onKeyDown);
|
||||
window.addEventListener("keyup", this.onKeyUp);
|
||||
}
|
||||
|
||||
public static stop() {
|
||||
window.removeEventListener("mousemove", this.onMouseMove);
|
||||
window.removeEventListener("mousedown", this.onMouseDown);
|
||||
window.removeEventListener("mouseup", this.onMouseUp);
|
||||
window.removeEventListener("keydown", this.onKeyDown);
|
||||
window.removeEventListener("keyup", this.onKeyUp);
|
||||
Record.recording = false;
|
||||
}
|
||||
|
||||
/// Displays a window as an absolutely positioned DIV with the generated
|
||||
/// events within <pre> tags as formatted JSON, so it can be copied easily.
|
||||
public static showGeneratedEvents() {
|
||||
if (Record.recording) {
|
||||
Record.stop();
|
||||
}
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.style.position = "absolute";
|
||||
div.style.top = "10px";
|
||||
div.style.right = "10px";
|
||||
div.style.left = "10px";
|
||||
div.style.height = "60vh";
|
||||
div.style.backgroundColor = "gray";
|
||||
div.style.padding = "10px";
|
||||
div.style.zIndex = "10000";
|
||||
|
||||
const pre = document.createElement("pre");
|
||||
|
||||
let textContent = `import { expect, test } from "@playwright/test";\n\n`;
|
||||
textContent += `test("${
|
||||
Date.now() + Math.floor(Math.random() * Date.now()).toString(36)
|
||||
}", async ({ page }) => {\n`;
|
||||
textContent += Record.events;
|
||||
textContent += `});\n`;
|
||||
|
||||
pre.textContent = textContent;
|
||||
//pre.textContent = Record.events;
|
||||
|
||||
pre.style.marginTop = "18px";
|
||||
pre.style.maxHeight = "60vh";
|
||||
pre.style.overflow = "auto";
|
||||
div.appendChild(pre);
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.textContent = "Copy";
|
||||
copyBtn.title = "Copy generated events to clipboard";
|
||||
copyBtn.setAttribute("aria-label", "Copy generated events to clipboard");
|
||||
copyBtn.style.position = "absolute";
|
||||
copyBtn.style.top = "4px";
|
||||
copyBtn.style.left = "4px";
|
||||
copyBtn.style.border = "none";
|
||||
copyBtn.style.background = "transparent";
|
||||
copyBtn.style.fontSize = "12px";
|
||||
copyBtn.style.lineHeight = "1";
|
||||
copyBtn.style.cursor = "pointer";
|
||||
copyBtn.style.padding = "4px 8px";
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
const text = pre.textContent ?? "";
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
const orig = copyBtn.textContent;
|
||||
copyBtn.textContent = "Copied";
|
||||
setTimeout(() => (copyBtn.textContent = orig), 1000);
|
||||
} catch {}
|
||||
});
|
||||
div.appendChild(copyBtn);
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.textContent = "×";
|
||||
closeBtn.title = "Close";
|
||||
closeBtn.style.position = "absolute";
|
||||
closeBtn.style.top = "4px";
|
||||
closeBtn.style.right = "4px";
|
||||
closeBtn.style.border = "none";
|
||||
closeBtn.style.background = "transparent";
|
||||
closeBtn.style.fontSize = "18px";
|
||||
closeBtn.style.lineHeight = "1";
|
||||
closeBtn.style.cursor = "pointer";
|
||||
closeBtn.addEventListener("click", () => {
|
||||
// remove the dialog from DOM
|
||||
if (div.parentNode) {
|
||||
div.parentNode.removeChild(div);
|
||||
}
|
||||
});
|
||||
div.appendChild(closeBtn);
|
||||
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
private static onMouseMove(event: MouseEvent) {
|
||||
if (
|
||||
event.clientX < 0 ||
|
||||
event.clientX > window.innerWidth ||
|
||||
event.clientY < 0 ||
|
||||
event.clientY > window.innerHeight
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
Record.events += ` await page.mouse.move(${event.clientX}, ${event.clientY});\n`;
|
||||
}
|
||||
|
||||
private static onMouseDown(event: MouseEvent) {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
const button =
|
||||
event.button === 0 ? "left" : event.button === 1 ? "middle" : "right";
|
||||
Record.events += ` await page.mouse.down({ button: "${button}" });\n`;
|
||||
}
|
||||
|
||||
private static onMouseUp(event: MouseEvent) {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
const button =
|
||||
event.button === 0 ? "left" : event.button === 1 ? "middle" : "right";
|
||||
Record.events += ` await page.mouse.up({ button: "${button}" });\n`;
|
||||
|
||||
Record.events += " await expect(page).toHaveScreenshot({\n";
|
||||
Record.events += " maxDiffPixels: 100,\n";
|
||||
Record.events += " maxDiffPixelRatio: 0.01,\n";
|
||||
Record.events += " });\n";
|
||||
}
|
||||
|
||||
private static onKeyDown(event: KeyboardEvent) {
|
||||
// Only record if the recording key is not pressed
|
||||
if (event.key !== "F2") {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
Record.events += ` await page.keyboard.down("${event.key}");\n`;
|
||||
}
|
||||
}
|
||||
|
||||
private static onKeyUp(event: KeyboardEvent) {
|
||||
// Only record if the recording key is not pressed
|
||||
if (event.key !== "F2") {
|
||||
const now = event.timeStamp || performance.now();
|
||||
const delay = now - Record.timestamp;
|
||||
Record.timestamp = now;
|
||||
|
||||
if (delay > 0) {
|
||||
Record.events += ` await page.waitForTimeout(${delay});\n`;
|
||||
}
|
||||
Record.events += ` await page.keyboard.up("${event.key}");\n`;
|
||||
|
||||
Record.events += " await expect(page).toHaveScreenshot({\n";
|
||||
Record.events += " maxDiffPixels: 100,\n";
|
||||
Record.events += " maxDiffPixelRatio: 0.01,\n";
|
||||
Record.events += " });\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDevEnv()) {
|
||||
window.record = Record;
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "F2") {
|
||||
if (Record.isRecording) {
|
||||
if (event.ctrlKey) {
|
||||
console.info("Stopping Playwright recording");
|
||||
Record.stop();
|
||||
} else {
|
||||
Record.restart();
|
||||
}
|
||||
} else {
|
||||
console.info("Starting Playwright recording");
|
||||
Record.start();
|
||||
}
|
||||
} else if (event.key === "Enter" && event.ctrlKey) {
|
||||
Record.showGeneratedEvents();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("17562123239901g67cqde", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Math.random = () => 0.42;
|
||||
|
||||
localStorage.getItem("i18nextLng");
|
||||
localStorage.setItem("i18nextLng", "en");
|
||||
localStorage.getItem("excalidraw-collab");
|
||||
localStorage.setItem("excalidraw-collab", '{"username":""}');
|
||||
localStorage.getItem("excalidraw-debug");
|
||||
localStorage.setItem("excalidraw-debug", '{"enabled":true}');
|
||||
localStorage.getItem("excalidraw-theme");
|
||||
localStorage.setItem("excalidraw-theme", "dark");
|
||||
localStorage.getItem("version-files");
|
||||
localStorage.setItem("version-files", "1756212319038");
|
||||
localStorage.getItem("version-dataState");
|
||||
localStorage.setItem("version-dataState", "1756212319038");
|
||||
localStorage.getItem("excalidraw-state");
|
||||
localStorage.setItem(
|
||||
"excalidraw-state",
|
||||
'{"showWelcomeScreen":true,"theme":"dark","currentChartType":"bar","currentItemBackgroundColor":"#a5d8ff","currentItemEndArrowhead":"arrow","currentItemFillStyle":"solid","currentItemFontFamily":5,"currentItemFontSize":20,"currentItemOpacity":100,"currentItemRoughness":2,"currentItemStartArrowhead":null,"currentItemStrokeColor":"#1e1e1e","currentItemRoundness":"round","currentItemArrowType":"round","currentItemStrokeStyle":"solid","currentItemStrokeWidth":2,"currentItemTextAlign":"left","cursorButton":"up","editingGroupId":null,"activeTool":{"type":"arrow","customType":null,"locked":false,"fromSelection":false,"lastActiveTool":null},"penMode":false,"penDetected":false,"exportBackground":true,"exportScale":1,"exportEmbedScene":false,"exportWithDarkMode":false,"gridSize":20,"gridStep":5,"gridModeEnabled":false,"defaultSidebarDockedPreference":false,"lastPointerDownWith":"mouse","name":"Untitled-2025-07-28-1603","openMenu":null,"openSidebar":null,"previousSelectedElementIds":{},"scrolledOutside":false,"scrollX":688.1079394457738,"scrollY":349.585883261872,"selectedElementIds":{},"selectedGroupIds":{},"shouldCacheIgnoreZoom":false,"stats":{"open":true,"panels":3},"viewBackgroundColor":"#ffffff","zenModeEnabled":false,"zoom":{"value":1.331666},"selectedLinearElement":null,"objectsSnapModeEnabled":false,"lockedMultiSelections":{}}',
|
||||
);
|
||||
localStorage.getItem("excalidraw");
|
||||
localStorage.setItem(
|
||||
"excalidraw",
|
||||
'[{"id":"hJuTw4QcwTsFtadNNnkLj","type":"rectangle","x":-100,"y":-100,"width":200,"height":200,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#a5d8ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a1","roundness":{"type":3},"seed":43277494,"version":839,"versionNonce":1923298088,"isDeleted":false,"boundElements":[{"id":"0xSZCPMN8RzKiJOpvGaKB","type":"arrow"}],"updated":1756212298830,"link":null,"locked":false},{"id":"qXw5KqKvAjHRr5uwPi9B-","type":"rectangle","x":-523.1841597523046,"y":-129.52451989693097,"width":200,"height":200,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#a5d8ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a2","roundness":{"type":3},"seed":392633439,"version":963,"versionNonce":210394408,"isDeleted":false,"boundElements":[{"id":"0xSZCPMN8RzKiJOpvGaKB","type":"arrow"}],"updated":1756212298830,"link":null,"locked":false}]',
|
||||
);
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 1001 });
|
||||
await page.goto("http://localhost:3000");
|
||||
await page.waitForLoadState("load");
|
||||
await page.waitForTimeout(3.599999999627471);
|
||||
await page.mouse.move(425, 390);
|
||||
await page.waitForTimeout(9.900000000372529);
|
||||
await page.mouse.move(424, 390);
|
||||
await page.waitForTimeout(51.09999999962747);
|
||||
await page.mouse.move(423, 390);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(423, 392);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(422, 392);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(420, 393);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(420, 395);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(418, 395);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(417, 396);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(416, 396);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(416, 397);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(414, 397);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(414, 399);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(413, 399);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(413, 401);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(413, 402);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(411, 402);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(411, 403);
|
||||
await page.waitForTimeout(32);
|
||||
await page.mouse.move(412, 403);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(412, 403);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(412, 402);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(413, 402);
|
||||
await page.waitForTimeout(0.900000000372529);
|
||||
await page.mouse.move(413, 400);
|
||||
await page.waitForTimeout(1.099999999627471);
|
||||
await page.mouse.move(415, 400);
|
||||
await page.waitForTimeout(7);
|
||||
await page.mouse.move(416, 400);
|
||||
await page.waitForTimeout(71);
|
||||
await page.mouse.move(417, 400);
|
||||
await page.waitForTimeout(450);
|
||||
await page.mouse.move(417, 399);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(418, 399);
|
||||
await page.waitForTimeout(6.1000000005587935);
|
||||
await page.mouse.move(419, 399);
|
||||
await page.mouse.down({ button: "left" });
|
||||
await page.waitForTimeout(11.899999999441206);
|
||||
await page.mouse.move(420, 399);
|
||||
await page.waitForTimeout(69);
|
||||
await page.mouse.move(421, 399);
|
||||
await page.waitForTimeout(13);
|
||||
await page.mouse.move(422, 399);
|
||||
await page.waitForTimeout(7.1000000005587935);
|
||||
await page.mouse.move(423, 399);
|
||||
await page.waitForTimeout(5.3999999994412065);
|
||||
await page.mouse.move(425, 399);
|
||||
await page.waitForTimeout(0.5);
|
||||
await page.mouse.move(427, 401);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(428, 401);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(428, 403);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(430, 403);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(432, 403);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(432, 404);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(433, 404);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(434, 404);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(436, 406);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(437, 406);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(438, 407);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(440, 407);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(442, 407);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(443, 409);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(445, 409);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(447, 411);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(451, 411);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(455, 411);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(457, 413);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(459, 413);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(461, 415);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(463, 417);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(467, 419);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(469, 419);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(471, 420);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(473, 424);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(477, 424);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(477, 426);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(478, 426);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(482, 428);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(484, 430);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(486, 430);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(488, 430);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(488, 432);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(489, 432);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(491, 432);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(493, 434);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(495, 434);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(499, 436);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(501, 436);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(502, 436);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(504, 438);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(506, 440);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(508, 440);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(510, 442);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(512, 444);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(514, 444);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(514, 445);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(515, 445);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(517, 445);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(517, 447);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(519, 448);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(521, 448);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(522, 450);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(524, 450);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(525, 450);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(527, 450);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(529, 450);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(530, 450);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(532, 450);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(533, 451);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(534, 451);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(535, 451);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(535, 453);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(537, 453);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(538, 453);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(540, 453);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(540, 454);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(541, 454);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(542, 454);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(543, 454);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(544, 454);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(544, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(546, 455);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(547, 455);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(548, 455);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(549, 455);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(551, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(552, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(554, 455);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(555, 455);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(557, 455);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(559, 457);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(560, 457);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(562, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(564, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(566, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(567, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(569, 459);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(570, 459);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(574, 460);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(575, 460);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(575, 462);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(577, 464);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(578, 464);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(580, 464);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(582, 465);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(584, 465);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(585, 465);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(586, 465);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(588, 467);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(590, 467);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(591, 467);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(591, 468);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(594, 468);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(595, 468);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(597, 468);
|
||||
await page.waitForTimeout(2.7000000001862645);
|
||||
await page.mouse.move(601, 470);
|
||||
await page.waitForTimeout(1.400000000372529);
|
||||
await page.mouse.move(602, 470);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(603, 470);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(605, 470);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(607, 470);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(608, 470);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(610, 470);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(612, 470);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(613, 470);
|
||||
await page.waitForTimeout(1.2000000001862645);
|
||||
await page.mouse.move(613, 471);
|
||||
await page.waitForTimeout(1.7999999998137355);
|
||||
await page.mouse.move(614, 471);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(616, 473);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(618, 473);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(621, 473);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(623, 473);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(625, 473);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(626, 473);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(628, 473);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(629, 473);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(631, 473);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(634, 475);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(636, 475);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(638, 475);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(639, 475);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(640, 475);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(642, 475);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(644, 475);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(647, 475);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(649, 475);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(650, 475);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(652, 475);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(653, 475);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(655, 475);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(656, 476);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(658, 476);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(659, 476);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(660, 476);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(662, 476);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(663, 476);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(664, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(666, 474);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(668, 474);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(670, 474);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(671, 474);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(673, 476);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(675, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(678, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(680, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(682, 476);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(683, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(684, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(686, 476);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(690, 476);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(694, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(696, 478);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(698, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(699, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(703, 478);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(705, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(707, 478);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(708, 478);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(711, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(713, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(714, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(716, 478);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(717, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(719, 478);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(722, 478);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(724, 478);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(725, 478);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(726, 478);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(727, 478);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(733, 480);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(734, 480);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(735, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(737, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(739, 480);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(740, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(742, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(744, 480);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(744, 482);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(745, 482);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(747, 482);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(748, 482);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(749, 482);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(751, 482);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(753, 482);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(755, 482);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(758, 482);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(760, 482);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(761, 483);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(763, 484);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(765, 484);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(766, 485);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(767, 485);
|
||||
await page.waitForTimeout(11);
|
||||
await page.mouse.move(768, 485);
|
||||
await page.waitForTimeout(12.100000000558794);
|
||||
await page.mouse.move(769, 485);
|
||||
await page.waitForTimeout(51.89999999944121);
|
||||
await page.mouse.move(770, 485);
|
||||
await page.waitForTimeout(9);
|
||||
await page.mouse.move(771, 485);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(772, 485);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(773, 485);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(774, 485);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(776, 487);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(777, 487);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(778, 487);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(779, 487);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(780, 488);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(781, 488);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(782, 488);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(784, 488);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(785, 488);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(786, 488);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(788, 488);
|
||||
await page.waitForTimeout(8.899999999441206);
|
||||
await page.mouse.move(789, 488);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(790, 488);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(791, 488);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(792, 488);
|
||||
await page.waitForTimeout(6.1000000005587935);
|
||||
await page.mouse.move(793, 488);
|
||||
await page.waitForTimeout(3.8999999994412065);
|
||||
await page.mouse.move(793, 489);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(794, 489);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(795, 489);
|
||||
await page.waitForTimeout(7.599999999627471);
|
||||
await page.mouse.move(796, 489);
|
||||
await page.waitForTimeout(3.400000000372529);
|
||||
await page.mouse.move(797, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(798, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(799, 489);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(800, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(801, 489);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(802, 489);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(803, 489);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(804, 489);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(805, 489);
|
||||
await page.waitForTimeout(5.8999999994412065);
|
||||
await page.mouse.move(806, 490);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(807, 490);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(809, 490);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(810, 490);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(812, 490);
|
||||
await page.waitForTimeout(1.8999999994412065);
|
||||
await page.mouse.move(813, 490);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(815, 490);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(816, 492);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(818, 492);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(819, 492);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(820, 492);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(822, 492);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(823, 492);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(825, 492);
|
||||
await page.waitForTimeout(2.8999999994412065);
|
||||
await page.mouse.move(826, 492);
|
||||
await page.waitForTimeout(1.1000000005587935);
|
||||
await page.mouse.move(827, 492);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(828, 492);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(830, 492);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(831, 492);
|
||||
await page.waitForTimeout(2.1000000005587935);
|
||||
await page.mouse.move(832, 492);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(833, 493);
|
||||
await page.waitForTimeout(6.1000000005587935);
|
||||
await page.mouse.move(835, 493);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(836, 493);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(838, 493);
|
||||
await page.waitForTimeout(0.8999999994412065);
|
||||
await page.mouse.move(839, 493);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(841, 493);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(842, 493);
|
||||
await page.waitForTimeout(3.8999999994412065);
|
||||
await page.mouse.move(843, 493);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(843, 492);
|
||||
await page.waitForTimeout(4.1000000005587935);
|
||||
await page.mouse.move(844, 492);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(844, 493);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(845, 493);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(846, 493);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(847, 494);
|
||||
await page.waitForTimeout(24.899999999441206);
|
||||
await page.mouse.move(847, 495);
|
||||
await page.waitForTimeout(29.100000000558794);
|
||||
await page.mouse.move(848, 495);
|
||||
await page.waitForTimeout(297.8999999994412);
|
||||
await page.mouse.move(848, 495);
|
||||
await page.waitForTimeout(5.7000000001862645);
|
||||
await page.mouse.up({ button: "left" });
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
maxDiffPixelRatio: 0.01,
|
||||
});
|
||||
await page.waitForTimeout(11.299999999813735);
|
||||
await page.mouse.move(847, 495);
|
||||
await page.waitForTimeout(199.1000000005588);
|
||||
await page.mouse.move(847, 495);
|
||||
await page.waitForTimeout(249.29999999981374);
|
||||
await page.mouse.move(846, 495);
|
||||
await page.waitForTimeout(5.7000000001862645);
|
||||
await page.mouse.move(846, 496);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(845, 496);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(845, 497);
|
||||
await page.waitForTimeout(9);
|
||||
await page.mouse.move(845, 498);
|
||||
await page.waitForTimeout(3.8999999994412065);
|
||||
await page.mouse.move(844, 498);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(844, 500);
|
||||
await page.waitForTimeout(8);
|
||||
await page.mouse.move(844, 501);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(843, 501);
|
||||
await page.waitForTimeout(137);
|
||||
await page.mouse.move(844, 501);
|
||||
await page.waitForTimeout(68.09999999962747);
|
||||
await page.mouse.move(845, 501);
|
||||
await page.waitForTimeout(7.7999999998137355);
|
||||
await page.mouse.move(845, 500);
|
||||
await page.waitForTimeout(1);
|
||||
await page.mouse.move(846, 500);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(847, 500);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(848, 500);
|
||||
await page.waitForTimeout(3.1000000005587935);
|
||||
await page.mouse.move(849, 500);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(850, 500);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(851, 500);
|
||||
await page.waitForTimeout(2);
|
||||
await page.mouse.move(852, 499);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(854, 499);
|
||||
await page.waitForTimeout(5);
|
||||
await page.mouse.move(855, 499);
|
||||
await page.waitForTimeout(3);
|
||||
await page.mouse.move(856, 499);
|
||||
await page.waitForTimeout(6);
|
||||
await page.mouse.move(857, 499);
|
||||
await page.waitForTimeout(12);
|
||||
await page.mouse.move(858, 499);
|
||||
await page.waitForTimeout(24);
|
||||
await page.mouse.move(858, 498);
|
||||
await page.waitForTimeout(4);
|
||||
await page.mouse.move(858, 498);
|
||||
await page.waitForTimeout(59.200000000186265);
|
||||
await page.keyboard.down("Control");
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -11,9 +11,11 @@
|
||||
"@babel/preset-env": "7.26.9",
|
||||
"@excalidraw/eslint-config": "1.0.3",
|
||||
"@excalidraw/prettier-config": "1.0.2",
|
||||
"@playwright/test": "1.55.0",
|
||||
"@types/chai": "4.3.0",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/lodash.throttle": "4.1.7",
|
||||
"@types/node": "24.3.0",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/socket.io-client": "3.0.0",
|
||||
@@ -62,6 +64,7 @@
|
||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||
"start": "yarn --cwd ./excalidraw-app start",
|
||||
"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",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||
"test:app": "vitest",
|
||||
|
||||
@@ -18,13 +18,22 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
export const isIOS =
|
||||
/iPad|iPhone/.test(navigator.platform) ||
|
||||
/iPad|iPhone/i.test(navigator.platform) ||
|
||||
// iPadOS 13+
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||
// keeping function so it can be mocked in test
|
||||
export const isBrave = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
export const isMobile =
|
||||
isIOS ||
|
||||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
||||
navigator.userAgent.toLowerCase(),
|
||||
) ||
|
||||
/android|ios|ipod|blackberry|windows phone/i.test(
|
||||
navigator.platform.toLowerCase(),
|
||||
);
|
||||
|
||||
export const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
|
||||
@@ -164,9 +164,14 @@ export class Scene {
|
||||
return this.frames;
|
||||
}
|
||||
|
||||
constructor(elements: ElementsMapOrArray | null = null) {
|
||||
constructor(
|
||||
elements: ElementsMapOrArray | null = null,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
if (elements) {
|
||||
this.replaceAllElements(elements);
|
||||
this.replaceAllElements(elements, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,12 +268,19 @@ export class Scene {
|
||||
return didChange;
|
||||
}
|
||||
|
||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||
replaceAllElements(
|
||||
nextElements: ElementsMapOrArray,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||
const _nextElements = toArray(nextElements);
|
||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
|
||||
validateIndicesThrottled(_nextElements);
|
||||
if (!options?.skipValidation) {
|
||||
validateIndicesThrottled(_nextElements);
|
||||
}
|
||||
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
this.elementsMap.clear();
|
||||
|
||||
+377
-147
@@ -2,7 +2,6 @@ import {
|
||||
arrayToMap,
|
||||
arrayToObject,
|
||||
assertNever,
|
||||
invariant,
|
||||
isDevEnv,
|
||||
isShallowEqual,
|
||||
isTestEnv,
|
||||
@@ -56,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
|
||||
|
||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import { StoreSnapshot } from "./store";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import type { BindableProp, BindingProp } from "./binding";
|
||||
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
@@ -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.
|
||||
*/
|
||||
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||
prev: T,
|
||||
added: T,
|
||||
removed: T,
|
||||
removed: T = {} as T,
|
||||
) {
|
||||
const cloned = { ...prev };
|
||||
|
||||
@@ -497,6 +510,11 @@ export interface DeltaContainer<T> {
|
||||
*/
|
||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||
|
||||
/**
|
||||
* Squashes the current delta with the given one.
|
||||
*/
|
||||
squash(delta: DeltaContainer<T>): this;
|
||||
|
||||
/**
|
||||
* Checks whether all `Delta`s are empty.
|
||||
*/
|
||||
@@ -504,7 +522,11 @@ export interface DeltaContainer<T> {
|
||||
}
|
||||
|
||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||
private constructor(public delta: Delta<ObservedAppState>) {}
|
||||
|
||||
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
|
||||
return new AppStateDelta(delta);
|
||||
}
|
||||
|
||||
public static calculate<T extends ObservedAppState>(
|
||||
prevAppState: T,
|
||||
@@ -535,76 +557,137 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
return new AppStateDelta(inversedDelta);
|
||||
}
|
||||
|
||||
public squash(delta: AppStateDelta): this {
|
||||
if (delta.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
|
||||
this.delta.deleted.selectedElementIds ?? {},
|
||||
delta.delta.deleted.selectedElementIds ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
|
||||
this.delta.inserted.selectedElementIds ?? {},
|
||||
delta.delta.inserted.selectedElementIds ?? {},
|
||||
);
|
||||
|
||||
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
|
||||
this.delta.deleted.selectedGroupIds ?? {},
|
||||
delta.delta.deleted.selectedGroupIds ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
|
||||
this.delta.inserted.selectedGroupIds ?? {},
|
||||
delta.delta.inserted.selectedGroupIds ?? {},
|
||||
);
|
||||
|
||||
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
|
||||
this.delta.deleted.lockedMultiSelections ?? {},
|
||||
delta.delta.deleted.lockedMultiSelections ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
|
||||
this.delta.inserted.lockedMultiSelections ?? {},
|
||||
delta.delta.inserted.lockedMultiSelections ?? {},
|
||||
);
|
||||
|
||||
const mergedInserted: Partial<ObservedAppState> = {};
|
||||
const mergedDeleted: Partial<ObservedAppState> = {};
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedSelectedElementIds).length ||
|
||||
Object.keys(mergedInsertedSelectedElementIds).length
|
||||
) {
|
||||
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
|
||||
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedSelectedGroupIds).length ||
|
||||
Object.keys(mergedInsertedSelectedGroupIds).length
|
||||
) {
|
||||
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
|
||||
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedLockedMultiSelections).length ||
|
||||
Object.keys(mergedInsertedLockedMultiSelections).length
|
||||
) {
|
||||
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
|
||||
mergedInserted.lockedMultiSelections =
|
||||
mergedInsertedLockedMultiSelections;
|
||||
}
|
||||
|
||||
this.delta = Delta.merge(
|
||||
this.delta,
|
||||
delta.delta,
|
||||
Delta.create(mergedDeleted, mergedInserted),
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public applyTo(
|
||||
appState: AppState,
|
||||
nextElements: SceneElementsMap,
|
||||
): [AppState, boolean] {
|
||||
try {
|
||||
const {
|
||||
selectedElementIds: removedSelectedElementIds = {},
|
||||
selectedGroupIds: removedSelectedGroupIds = {},
|
||||
selectedElementIds: deletedSelectedElementIds = {},
|
||||
selectedGroupIds: deletedSelectedGroupIds = {},
|
||||
lockedMultiSelections: deletedLockedMultiSelections = {},
|
||||
} = this.delta.deleted;
|
||||
|
||||
const {
|
||||
selectedElementIds: addedSelectedElementIds = {},
|
||||
selectedGroupIds: addedSelectedGroupIds = {},
|
||||
selectedLinearElementId,
|
||||
selectedLinearElementIsEditing,
|
||||
selectedElementIds: insertedSelectedElementIds = {},
|
||||
selectedGroupIds: insertedSelectedGroupIds = {},
|
||||
lockedMultiSelections: insertedLockedMultiSelections = {},
|
||||
selectedLinearElement: insertedSelectedLinearElement,
|
||||
...directlyApplicablePartial
|
||||
} = this.delta.inserted;
|
||||
|
||||
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||
appState.selectedElementIds,
|
||||
addedSelectedElementIds,
|
||||
removedSelectedElementIds,
|
||||
insertedSelectedElementIds,
|
||||
deletedSelectedElementIds,
|
||||
);
|
||||
|
||||
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||
appState.selectedGroupIds,
|
||||
addedSelectedGroupIds,
|
||||
removedSelectedGroupIds,
|
||||
insertedSelectedGroupIds,
|
||||
deletedSelectedGroupIds,
|
||||
);
|
||||
|
||||
let selectedLinearElement = appState.selectedLinearElement;
|
||||
const mergedLockedMultiSelections = Delta.mergeObjects(
|
||||
appState.lockedMultiSelections,
|
||||
insertedLockedMultiSelections,
|
||||
deletedLockedMultiSelections,
|
||||
);
|
||||
|
||||
if (selectedLinearElementId === null) {
|
||||
// Unselect linear element (visible change)
|
||||
selectedLinearElement = null;
|
||||
} else if (
|
||||
selectedLinearElementId &&
|
||||
nextElements.has(selectedLinearElementId)
|
||||
) {
|
||||
selectedLinearElement = new LinearElementEditor(
|
||||
nextElements.get(
|
||||
selectedLinearElementId,
|
||||
) as NonDeleted<ExcalidrawLinearElement>,
|
||||
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 selectedLinearElement =
|
||||
insertedSelectedLinearElement &&
|
||||
nextElements.has(insertedSelectedLinearElement.elementId)
|
||||
? new LinearElementEditor(
|
||||
nextElements.get(
|
||||
insertedSelectedLinearElement.elementId,
|
||||
) as NonDeleted<ExcalidrawLinearElement>,
|
||||
nextElements,
|
||||
insertedSelectedLinearElement.isEditing,
|
||||
)
|
||||
: null;
|
||||
|
||||
const nextAppState = {
|
||||
...appState,
|
||||
...directlyApplicablePartial,
|
||||
selectedElementIds: mergedSelectedElementIds,
|
||||
selectedGroupIds: mergedSelectedGroupIds,
|
||||
selectedLinearElement,
|
||||
lockedMultiSelections: mergedLockedMultiSelections,
|
||||
selectedLinearElement:
|
||||
typeof insertedSelectedLinearElement !== "undefined"
|
||||
? selectedLinearElement
|
||||
: appState.selectedLinearElement,
|
||||
};
|
||||
|
||||
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||
@@ -733,64 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
}
|
||||
|
||||
break;
|
||||
case "selectedLinearElementId": {
|
||||
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||
const linearElement = nextAppState[appStateKey];
|
||||
case "selectedLinearElement":
|
||||
const nextLinearElement = nextAppState[key];
|
||||
|
||||
if (!linearElement) {
|
||||
if (!nextLinearElement) {
|
||||
// previously there was a linear element (assuming visible), now there is none
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
const element = nextElements.get(linearElement.elementId);
|
||||
const element = nextElements.get(nextLinearElement.elementId);
|
||||
|
||||
if (element && !element.isDeleted) {
|
||||
// previously there wasn't a linear element, now there is one which is visible
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
// there was assigned a linear element now, but it's deleted
|
||||
nextAppState[appStateKey] = null;
|
||||
nextAppState[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "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": {
|
||||
case "lockedMultiSelections":
|
||||
const prevLockedUnits = prevAppState[key] || {};
|
||||
const nextLockedUnits = nextAppState[key] || {};
|
||||
|
||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||
// hence instead we should check whether elements are actually visible,
|
||||
// so that once these changes are applied they actually result in a visible change to the user
|
||||
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "activeLockedId": {
|
||||
case "activeLockedId":
|
||||
const prevHitLockedId = prevAppState[key] || null;
|
||||
const nextHitLockedId = nextAppState[key] || null;
|
||||
|
||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||
// hence instead we should check whether elements are actually visible,
|
||||
// so that once these changes are applied they actually result in a visible change to the user
|
||||
if (prevHitLockedId !== nextHitLockedId) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
default:
|
||||
assertNever(
|
||||
key,
|
||||
`Unknown ObservedElementsAppState's key "${key}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -798,15 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
return visibleDifferenceFlag.value;
|
||||
}
|
||||
|
||||
private static convertToAppStateKey(
|
||||
key: keyof Pick<ObservedElementsAppState, "selectedLinearElementId">,
|
||||
): keyof Pick<AppState, "selectedLinearElement"> {
|
||||
switch (key) {
|
||||
case "selectedLinearElementId":
|
||||
return "selectedLinearElement";
|
||||
}
|
||||
}
|
||||
|
||||
private static filterSelectedElements(
|
||||
selectedElementIds: AppState["selectedElementIds"],
|
||||
elements: SceneElementsMap,
|
||||
@@ -871,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
editingGroupId,
|
||||
selectedGroupIds,
|
||||
selectedElementIds,
|
||||
selectedLinearElementId,
|
||||
selectedLinearElementIsEditing,
|
||||
selectedLinearElement,
|
||||
croppingElementId,
|
||||
lockedMultiSelections,
|
||||
activeLockedId,
|
||||
@@ -926,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
"lockedMultiSelections",
|
||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"activeLockedId",
|
||||
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
||||
);
|
||||
} catch (e) {
|
||||
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
||||
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||
@@ -960,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
||||
|
||||
export type ApplyToOptions = {
|
||||
excludedProperties: Set<keyof ElementPartial>;
|
||||
excludedProperties?: Set<keyof ElementPartial>;
|
||||
};
|
||||
|
||||
type ApplyToFlags = {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
applyDirection: "forward" | "backward" | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1066,6 +1123,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
deleted.version !== inserted.version
|
||||
);
|
||||
|
||||
private static satisfiesUniqueInvariants = (
|
||||
elementsDelta: ElementsDelta,
|
||||
id: string,
|
||||
) => {
|
||||
const { added, removed, updated } = elementsDelta;
|
||||
// it's required that there is only one unique delta type per element
|
||||
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
|
||||
};
|
||||
|
||||
private static validate(
|
||||
elementsDelta: ElementsDelta,
|
||||
type: "added" | "removed" | "updated",
|
||||
@@ -1074,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||
if (
|
||||
!this.satisfiesCommmonInvariants(delta) ||
|
||||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
|
||||
!satifiesSpecialInvariants(delta)
|
||||
) {
|
||||
console.error(
|
||||
@@ -1110,7 +1177,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const nextElement = nextElements.get(prevElement.id);
|
||||
|
||||
if (!nextElement) {
|
||||
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
||||
const deleted = { ...prevElement } as ElementPartial;
|
||||
|
||||
const inserted = {
|
||||
isDeleted: true,
|
||||
@@ -1124,7 +1191,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
removed[prevElement.id] = delta;
|
||||
// ignore updates which would "delete" already deleted element
|
||||
if (!prevElement.isDeleted) {
|
||||
removed[prevElement.id] = delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1140,7 +1210,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
const inserted = {
|
||||
...nextElement,
|
||||
isDeleted: false,
|
||||
} as ElementPartial;
|
||||
|
||||
const delta = Delta.create(
|
||||
@@ -1149,7 +1218,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
added[nextElement.id] = delta;
|
||||
// ignore updates which would "delete" already deleted element
|
||||
if (!nextElement.isDeleted) {
|
||||
added[nextElement.id] = delta;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -1178,8 +1250,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// making sure there are at least some changes
|
||||
if (!Delta.isEmpty(delta)) {
|
||||
const strippedDeleted = ElementsDelta.stripVersionProps(delta.deleted);
|
||||
const strippedInserted = ElementsDelta.stripVersionProps(
|
||||
delta.inserted,
|
||||
);
|
||||
|
||||
// making sure there are at least some changes and only changed version & versionNonce does not count!
|
||||
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted, true)) {
|
||||
updated[nextElement.id] = delta;
|
||||
}
|
||||
}
|
||||
@@ -1196,8 +1273,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||
|
||||
for (const [id, delta] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
|
||||
}
|
||||
|
||||
return inversedDeltas;
|
||||
@@ -1295,8 +1372,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
latestDelta = delta;
|
||||
}
|
||||
|
||||
const strippedDeleted = ElementsDelta.stripVersionProps(
|
||||
latestDelta.deleted,
|
||||
);
|
||||
const strippedInserted = ElementsDelta.stripVersionProps(
|
||||
latestDelta.inserted,
|
||||
);
|
||||
|
||||
// it might happen that after applying latest changes the delta itself does not contain any changes
|
||||
if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) {
|
||||
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted)) {
|
||||
modifiedDeltas[id] = latestDelta;
|
||||
}
|
||||
}
|
||||
@@ -1316,9 +1400,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
||||
options: ApplyToOptions = {
|
||||
excludedProperties: new Set(),
|
||||
},
|
||||
options?: ApplyToOptions,
|
||||
): [SceneElementsMap, boolean] {
|
||||
let nextElements = new Map(elements) as SceneElementsMap;
|
||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||
@@ -1326,22 +1408,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const flags: ApplyToFlags = {
|
||||
containsVisibleDifference: false,
|
||||
containsZindexDifference: false,
|
||||
applyDirection: undefined,
|
||||
};
|
||||
|
||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||
try {
|
||||
const applyDeltas = ElementsDelta.createApplier(
|
||||
elements,
|
||||
nextElements,
|
||||
snapshot,
|
||||
options,
|
||||
flags,
|
||||
options,
|
||||
);
|
||||
|
||||
const addedElements = applyDeltas(this.added);
|
||||
const removedElements = applyDeltas(this.removed);
|
||||
const updatedElements = applyDeltas(this.updated);
|
||||
|
||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||
const affectedElements = this.resolveConflicts(
|
||||
elements,
|
||||
nextElements,
|
||||
flags.applyDirection,
|
||||
);
|
||||
|
||||
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
||||
changedElements = new Map([
|
||||
@@ -1365,22 +1453,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
try {
|
||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||
// the following reorder performs mutations, but only on new instances of changed elements,
|
||||
// unless something goes really bad and it fallbacks to fixing all invalid indices
|
||||
nextElements = ElementsDelta.reorderElements(
|
||||
nextElements,
|
||||
changedElements,
|
||||
flags,
|
||||
);
|
||||
|
||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||
// we also don't have a scene on the server
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements);
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
// Need ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
ElementsDelta.redrawElements(nextElements, changedElements);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Couldn't mutate elements after applying elements change`,
|
||||
@@ -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 =
|
||||
(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"],
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) =>
|
||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const getElement = ElementsDelta.createGetter(
|
||||
@@ -1413,15 +1595,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const element = getElement(id, delta.inserted);
|
||||
|
||||
if (element) {
|
||||
const newElement = ElementsDelta.applyDelta(
|
||||
const nextElement = ElementsDelta.applyDelta(
|
||||
element,
|
||||
delta,
|
||||
options,
|
||||
flags,
|
||||
options,
|
||||
);
|
||||
|
||||
nextElements.set(newElement.id, newElement);
|
||||
acc.set(newElement.id, newElement);
|
||||
nextElements.set(nextElement.id, nextElement);
|
||||
acc.set(nextElement.id, nextElement);
|
||||
|
||||
if (!flags.applyDirection) {
|
||||
const prevElement = prevElements.get(id);
|
||||
|
||||
if (prevElement) {
|
||||
flags.applyDirection =
|
||||
prevElement.version > nextElement.version
|
||||
? "backward"
|
||||
: "forward";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
@@ -1466,8 +1659,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private static applyDelta(
|
||||
element: OrderedExcalidrawElement,
|
||||
delta: Delta<ElementPartial>,
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) {
|
||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
||||
|
||||
@@ -1481,7 +1674,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.excludedProperties.has(key)) {
|
||||
if (options?.excludedProperties?.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1521,7 +1714,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
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(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
applyDirection: "forward" | "backward" = "forward",
|
||||
) {
|
||||
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
const updater = (
|
||||
@@ -1572,21 +1766,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevElement = prevElements.get(element.id);
|
||||
const nextVersion =
|
||||
applyDirection === "forward"
|
||||
? nextElement.version + 1
|
||||
: nextElement.version - 1;
|
||||
|
||||
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
|
||||
|
||||
let affectedElement: OrderedExcalidrawElement;
|
||||
|
||||
if (prevElements.get(element.id) === nextElement) {
|
||||
if (prevElement === nextElement) {
|
||||
// create the new element instance in case we didn't modify the element yet
|
||||
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
||||
affectedElement = newElementWith(
|
||||
nextElement,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
{
|
||||
...elementUpdates,
|
||||
version: nextVersion,
|
||||
},
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
affectedElement = mutateElement(
|
||||
nextElement,
|
||||
nextElements,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
);
|
||||
affectedElement = mutateElement(nextElement, nextElements, {
|
||||
...elementUpdates,
|
||||
// don't modify the version further, if it's already different
|
||||
version:
|
||||
prevElement?.version !== nextElement.version
|
||||
? nextElement.version
|
||||
: nextVersion,
|
||||
});
|
||||
}
|
||||
|
||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||
@@ -1624,25 +1833,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||
);
|
||||
|
||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||
// technically we could do better here if perf. would become an issue
|
||||
const { added, removed, updated } = ElementsDelta.calculate(
|
||||
prevAffectedElements,
|
||||
nextAffectedElements,
|
||||
// calculate complete deltas for affected elements, and squash them back to the current deltas
|
||||
this.squash(
|
||||
// technically we could do better here if perf. would become an issue
|
||||
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
|
||||
);
|
||||
|
||||
for (const [id, delta] of Object.entries(added)) {
|
||||
this.added[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(removed)) {
|
||||
this.removed[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(updated)) {
|
||||
this.updated[id] = delta;
|
||||
}
|
||||
|
||||
return nextAffectedElements;
|
||||
}
|
||||
|
||||
@@ -1704,6 +1900,31 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
||||
}
|
||||
|
||||
public static redrawElements(
|
||||
nextElements: SceneElementsMap,
|
||||
changedElements: Map<string, OrderedExcalidrawElement>,
|
||||
) {
|
||||
try {
|
||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||
// we also don't have a scene on the server
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements, { skipValidation: true });
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
|
||||
// needs ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
} catch (e) {
|
||||
console.error(`Couldn't redraw elements`, e);
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
return nextElements;
|
||||
}
|
||||
}
|
||||
|
||||
private static redrawTextBoundingBoxes(
|
||||
scene: Scene,
|
||||
changed: Map<string, OrderedExcalidrawElement>,
|
||||
@@ -1758,6 +1979,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
) {
|
||||
for (const element of changed.values()) {
|
||||
if (!element.isDeleted && isBindableElement(element)) {
|
||||
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
|
||||
updateBoundElements(element, scene, {
|
||||
changedElements: changed,
|
||||
});
|
||||
@@ -1853,4 +2075,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
return strippedPartial;
|
||||
}
|
||||
|
||||
private static stripVersionProps(
|
||||
partial: Partial<OrderedExcalidrawElement>,
|
||||
): ElementPartial {
|
||||
const { version, versionNonce, ...strippedPartial } = partial;
|
||||
|
||||
return strippedPartial;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
assertNever,
|
||||
COLOR_PALETTE,
|
||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||
isTestEnv,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
@@ -182,7 +183,7 @@ export const generateRoughOptions = (
|
||||
continuousPath = false,
|
||||
): Options => {
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
seed: isTestEnv() ? 1 : element.seed,
|
||||
strokeLineDash:
|
||||
element.strokeStyle === "dashed"
|
||||
? getDashArrayDashed(element.strokeWidth)
|
||||
|
||||
@@ -76,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
|
||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||
*/
|
||||
export class Store {
|
||||
// internally used by history
|
||||
// for internal use by history
|
||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||
// for public use as part of onIncrement API
|
||||
public readonly onStoreIncrementEmitter = new Emitter<
|
||||
[DurableIncrement | EphemeralIncrement]
|
||||
>();
|
||||
@@ -239,7 +240,6 @@ export class Store {
|
||||
if (!storeDelta.isEmpty()) {
|
||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||
|
||||
// Notify listeners with the increment
|
||||
this.onDurableIncrementEmitter.trigger(increment);
|
||||
this.onStoreIncrementEmitter.trigger(increment);
|
||||
}
|
||||
@@ -552,10 +552,26 @@ export class StoreDelta {
|
||||
public static load({
|
||||
id,
|
||||
elements: { added, removed, updated },
|
||||
appState: { delta: appStateDelta },
|
||||
}: DTO<StoreDelta>) {
|
||||
const elements = ElementsDelta.create(added, removed, updated);
|
||||
const appState = AppStateDelta.create(appStateDelta);
|
||||
|
||||
return new this(id, elements, AppStateDelta.empty());
|
||||
return new this(id, elements, appState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Squash the passed deltas into the aggregated delta instance.
|
||||
*/
|
||||
public static squash(...deltas: StoreDelta[]) {
|
||||
const aggregatedDelta = StoreDelta.empty();
|
||||
|
||||
for (const delta of deltas) {
|
||||
aggregatedDelta.elements.squash(delta.elements);
|
||||
aggregatedDelta.appState.squash(delta.appState);
|
||||
}
|
||||
|
||||
return aggregatedDelta;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -572,9 +588,7 @@ export class StoreDelta {
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
options: ApplyToOptions = {
|
||||
excludedProperties: new Set(),
|
||||
},
|
||||
options?: ApplyToOptions,
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||
elements,
|
||||
@@ -613,6 +627,10 @@ export class StoreDelta {
|
||||
);
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
||||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||
}
|
||||
@@ -978,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
selectedLinearElement: null,
|
||||
croppingElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
@@ -998,14 +1015,12 @@ export const getObservedAppState = (
|
||||
croppingElementId: appState.croppingElementId,
|
||||
activeLockedId: appState.activeLockedId,
|
||||
lockedMultiSelections: appState.lockedMultiSelections,
|
||||
selectedLinearElementId:
|
||||
(appState as AppState).selectedLinearElement?.elementId ??
|
||||
(appState as ObservedAppState).selectedLinearElementId ??
|
||||
null,
|
||||
selectedLinearElementIsEditing:
|
||||
(appState as AppState).selectedLinearElement?.isEditing ??
|
||||
(appState as ObservedAppState).selectedLinearElementIsEditing ??
|
||||
null,
|
||||
selectedLinearElement: appState.selectedLinearElement
|
||||
? {
|
||||
elementId: appState.selectedLinearElement.elementId,
|
||||
isEditing: !!appState.selectedLinearElement.isEditing,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||
|
||||
@@ -1,13 +1,328 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
import type { LinearElementEditor } from "@excalidraw/element";
|
||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||
|
||||
import { AppStateDelta } from "../src/delta";
|
||||
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
||||
|
||||
describe("ElementsDelta", () => {
|
||||
describe("elements delta calculation", () => {
|
||||
it("should not create removed delta when element gets removed but was already deleted", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
const prevElements = new Map([[element.id, element]]);
|
||||
const nextElements = new Map();
|
||||
|
||||
const delta = ElementsDelta.calculate(prevElements, nextElements);
|
||||
|
||||
expect(delta.isEmpty()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not create added delta when adding element as already deleted", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
const prevElements = new Map();
|
||||
const nextElements = new Map([[element.id, element]]);
|
||||
|
||||
const delta = ElementsDelta.calculate(prevElements, nextElements);
|
||||
|
||||
expect(delta.isEmpty()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not create updated delta when there is only version and versionNonce change", () => {
|
||||
const baseElement = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
|
||||
const modifiedElement = {
|
||||
...baseElement,
|
||||
version: baseElement.version + 1,
|
||||
versionNonce: baseElement.versionNonce + 1,
|
||||
};
|
||||
|
||||
// Create maps for the delta calculation
|
||||
const prevElements = new Map([[baseElement.id, baseElement]]);
|
||||
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
|
||||
|
||||
// Calculate the delta
|
||||
const delta = ElementsDelta.calculate(
|
||||
prevElements as SceneElementsMap,
|
||||
nextElements as SceneElementsMap,
|
||||
);
|
||||
|
||||
expect(delta.isEmpty()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("squash", () => {
|
||||
it("should not squash when second delta is empty", () => {
|
||||
const updatedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
);
|
||||
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{ id1: updatedDelta },
|
||||
);
|
||||
const elementsDelta2 = ElementsDelta.empty();
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.updated.id1).toBe(updatedDelta);
|
||||
});
|
||||
|
||||
it("should squash mutually exclusive delta types", () => {
|
||||
const addedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||
);
|
||||
|
||||
const removedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
);
|
||||
|
||||
const updatedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
);
|
||||
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{ id1: addedDelta },
|
||||
{ id2: removedDelta },
|
||||
{},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{ id3: updatedDelta },
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added.id1).toBe(addedDelta);
|
||||
expect(elementsDelta.removed.id2).toBe(removedDelta);
|
||||
expect(elementsDelta.updated.id3).toBe(updatedDelta);
|
||||
});
|
||||
|
||||
it("should squash the same delta types", () => {
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2 },
|
||||
{ y: 200, version: 3, versionNonce: 3 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added.id1).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
);
|
||||
expect(elementsDelta.removed.id2).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
);
|
||||
expect(elementsDelta.updated.id3).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2 },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3 },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should squash different delta types ", () => {
|
||||
// id1: added -> updated => added
|
||||
// id2: removed -> added => added
|
||||
// id3: updated -> removed => removed
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ x: 300, version: 1, versionNonce: 1 },
|
||||
{ x: 301, version: 2, versionNonce: 2 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2 },
|
||||
{ y: 101, version: 3, versionNonce: 3 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added).toEqual({
|
||||
id1: Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
id2: Delta.create(
|
||||
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
});
|
||||
expect(elementsDelta.removed).toEqual({
|
||||
id3: Delta.create(
|
||||
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
});
|
||||
expect(elementsDelta.updated).toEqual({});
|
||||
});
|
||||
|
||||
it("should squash bound elements", () => {
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
boundElements: [{ id: "t1", type: "text" }],
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
versionNonce: 2,
|
||||
boundElements: [{ id: "t2", type: "text" }],
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{
|
||||
version: 2,
|
||||
versionNonce: 2,
|
||||
boundElements: [{ id: "a1", type: "arrow" }],
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
versionNonce: 3,
|
||||
boundElements: [{ id: "a2", type: "arrow" }],
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
|
||||
{ id: "t1", type: "text" },
|
||||
{ id: "a1", type: "arrow" },
|
||||
]);
|
||||
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
|
||||
{ id: "t2", type: "text" },
|
||||
{ id: "a2", type: "arrow" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppStateDelta", () => {
|
||||
describe("ensure stable delta properties order", () => {
|
||||
it("should maintain stable order for root properties", () => {
|
||||
const name = "untitled scene";
|
||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||
const selectedLinearElement = {
|
||||
elementId: "id1" as LinearElementEditor["elementId"],
|
||||
isEditing: false,
|
||||
};
|
||||
|
||||
const commonAppState = {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
@@ -24,23 +339,23 @@ describe("AppStateDelta", () => {
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name: "",
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name,
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
name: "",
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
name,
|
||||
...commonAppState,
|
||||
};
|
||||
@@ -58,9 +373,7 @@ describe("AppStateDelta", () => {
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@@ -106,9 +419,7 @@ describe("AppStateDelta", () => {
|
||||
selectedElementIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@@ -149,4 +460,97 @@ describe("AppStateDelta", () => {
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("squash", () => {
|
||||
it("should not squash when second delta is empty", () => {
|
||||
const delta = Delta.create(
|
||||
{ name: "untitled scene" },
|
||||
{ name: "titled scene" },
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta);
|
||||
const appStateDelta2 = AppStateDelta.empty();
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toBe(delta);
|
||||
});
|
||||
|
||||
it("should squash exclusive properties", () => {
|
||||
const delta1 = Delta.create(
|
||||
{ name: "untitled scene" },
|
||||
{ name: "titled scene" },
|
||||
);
|
||||
const delta2 = Delta.create(
|
||||
{ viewBackgroundColor: "#ffffff" },
|
||||
{ viewBackgroundColor: "#000000" },
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toEqual(
|
||||
Delta.create(
|
||||
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
|
||||
{ name: "titled scene", viewBackgroundColor: "#000000" },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
|
||||
const delta1 = Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
name: "untitled scene",
|
||||
selectedElementIds: { id1: true },
|
||||
selectedGroupIds: {},
|
||||
lockedMultiSelections: { g1: true },
|
||||
},
|
||||
{
|
||||
name: "titled scene",
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: {},
|
||||
},
|
||||
);
|
||||
const delta2 = Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
selectedElementIds: { id3: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: {},
|
||||
},
|
||||
{
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g2: true, g3: true },
|
||||
lockedMultiSelections: { g3: true },
|
||||
},
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toEqual(
|
||||
Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
name: "untitled scene",
|
||||
selectedElementIds: { id1: true, id3: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: { g1: true },
|
||||
},
|
||||
{
|
||||
name: "titled scene",
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g1: true, g2: true, g3: true },
|
||||
lockedMultiSelections: { g3: true },
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ export const actionClearCanvas = register({
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
? { ...appState.activeTool, type: app.defaultSelectionTool }
|
||||
: appState.activeTool,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -494,13 +494,13 @@ export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
label: "toolBar.eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
@@ -530,6 +530,9 @@ export const actionToggleLassoTool = register({
|
||||
label: "toolBar.lasso",
|
||||
icon: LassoIcon,
|
||||
trackEvent: { category: "toolbar" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return app.defaultSelectionTool !== "lasso";
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
|
||||
@@ -298,7 +298,9 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
selectedLinearElement: null,
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
isBindingEnabled,
|
||||
} from "@excalidraw/element/binding";
|
||||
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
||||
import {
|
||||
isValidPolygon,
|
||||
LinearElementEditor,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
isBindingElement,
|
||||
@@ -78,7 +82,14 @@ export const actionFinalize = register({
|
||||
let newElements = elements;
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
newElements = newElements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
return newElementWith(el, {
|
||||
isDeleted: true,
|
||||
});
|
||||
}
|
||||
return el;
|
||||
});
|
||||
}
|
||||
return {
|
||||
elements: newElements,
|
||||
@@ -117,7 +128,12 @@ export const actionFinalize = register({
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
? elements.filter((el) => el.id !== element.id)
|
||||
? elements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
})
|
||||
: undefined,
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -172,7 +188,12 @@ export const actionFinalize = register({
|
||||
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
newElements = newElements.map((el) => {
|
||||
if (el.id === element?.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
@@ -240,13 +261,13 @@ export const actionFinalize = register({
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
hasStrokeWidth,
|
||||
} from "../scene";
|
||||
|
||||
import { SHAPES } from "./shapes";
|
||||
import { getToolbarTools } from "./shapes";
|
||||
|
||||
import "./Actions.scss";
|
||||
|
||||
@@ -295,7 +295,8 @@ export const ShapesSwitcher = ({
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
const lassoToolSelected = activeTool.type === "lasso";
|
||||
const lassoToolSelected =
|
||||
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
|
||||
|
||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||
|
||||
@@ -303,63 +304,68 @@ export const ShapesSwitcher = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
if (
|
||||
UIOptions.tools?.[
|
||||
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
|
||||
] === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
{getToolbarTools(app).map(
|
||||
({ value, icon, key, numericKey, fillable }, index) => {
|
||||
if (
|
||||
UIOptions.tools?.[
|
||||
value as Extract<
|
||||
typeof value,
|
||||
keyof AppProps["UIOptions"]["tools"]
|
||||
>
|
||||
] === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
|
||||
if (value === "selection") {
|
||||
if (appState.activeTool.type === "selection") {
|
||||
app.setActiveTool({ type: "lasso" });
|
||||
} else {
|
||||
app.setActiveTool({ type: "selection" });
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
if (value === "selection") {
|
||||
if (appState.activeTool.type === "selection") {
|
||||
app.setActiveTool({ type: "lasso" });
|
||||
} else {
|
||||
app.setActiveTool({ type: "selection" });
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
@@ -418,14 +424,16 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||
icon={LassoIcon}
|
||||
data-testid="toolbar-lasso"
|
||||
selected={lassoToolSelected}
|
||||
>
|
||||
{t("toolBar.lasso")}
|
||||
</DropdownMenu.Item>
|
||||
{app.defaultSelectionTool !== "lasso" && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||
icon={LassoIcon}
|
||||
data-testid="toolbar-lasso"
|
||||
selected={lassoToolSelected}
|
||||
>
|
||||
{t("toolBar.lasso")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||
Generate
|
||||
</div>
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
randomInteger,
|
||||
CLASSES,
|
||||
Emitter,
|
||||
isMobile,
|
||||
MINIMUM_ARROW_SIZE,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
@@ -233,6 +234,8 @@ import {
|
||||
hitElementBoundingBox,
|
||||
isLineElement,
|
||||
isSimpleArrow,
|
||||
StoreDelta,
|
||||
type ApplyToOptions,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||
@@ -259,6 +262,7 @@ import type {
|
||||
MagicGenerationData,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
SceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
|
||||
@@ -650,9 +654,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
>();
|
||||
onRemoveEventListenersEmitter = new Emitter<[]>();
|
||||
|
||||
defaultSelectionTool: "selection" | "lasso" = "selection";
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
this.defaultSelectionTool = this.isMobileOrTablet()
|
||||
? ("lasso" as const)
|
||||
: ("selection" as const);
|
||||
const {
|
||||
excalidrawAPI,
|
||||
viewModeEnabled = false,
|
||||
@@ -697,6 +706,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (excalidrawAPI) {
|
||||
const api: ExcalidrawImperativeAPI = {
|
||||
updateScene: this.updateScene,
|
||||
applyDeltas: this.applyDeltas,
|
||||
mutateElement: this.mutateElement,
|
||||
updateLibrary: this.library.updateLibrary,
|
||||
addFiles: this.addFiles,
|
||||
@@ -1602,7 +1612,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
renderWelcomeScreen={
|
||||
!this.state.isLoading &&
|
||||
this.state.showWelcomeScreen &&
|
||||
this.state.activeTool.type === "selection" &&
|
||||
this.state.activeTool.type ===
|
||||
this.defaultSelectionTool &&
|
||||
!this.state.zenModeEnabled &&
|
||||
!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,
|
||||
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)
|
||||
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
|
||||
activeTool:
|
||||
scene.appState.activeTool.type === "image"
|
||||
? { ...scene.appState.activeTool, type: "selection" }
|
||||
activeTool.type === "image" ||
|
||||
activeTool.type === "lasso" ||
|
||||
activeTool.type === "selection"
|
||||
? {
|
||||
...activeTool,
|
||||
type: this.defaultSelectionTool,
|
||||
}
|
||||
: scene.appState.activeTool,
|
||||
isLoading: false,
|
||||
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) => {
|
||||
return (
|
||||
width < MQ_MAX_WIDTH_PORTRAIT ||
|
||||
@@ -3110,7 +3140,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
files: data.files || null,
|
||||
position: "cursor",
|
||||
position: this.isMobileOrTablet() ? "center" : "cursor",
|
||||
retainSeed: isPlainPaste,
|
||||
});
|
||||
} else if (data.text) {
|
||||
@@ -3128,7 +3158,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
files,
|
||||
position: "cursor",
|
||||
position: this.isMobileOrTablet() ? "center" : "cursor",
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -3188,7 +3218,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
this.addTextFromPaste(data.text, isPlainPaste);
|
||||
}
|
||||
this.setActiveTool({ type: "selection" });
|
||||
this.setActiveTool({ type: this.defaultSelectionTool }, true);
|
||||
event?.preventDefault();
|
||||
},
|
||||
);
|
||||
@@ -3200,7 +3230,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
retainSeed?: 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 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) {
|
||||
this.scrollToContent(duplicatedElements, {
|
||||
@@ -3578,7 +3610,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
...updateActiveTool(
|
||||
this.state,
|
||||
prevState.activeTool.locked
|
||||
? { type: "selection" }
|
||||
? { type: this.defaultSelectionTool }
|
||||
: prevState.activeTool,
|
||||
),
|
||||
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>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
@@ -4470,7 +4523,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!this.state.selectionElement &&
|
||||
!this.state.selectedElementsAreBeingDragged
|
||||
) {
|
||||
const shape = findShapeByKey(event.key);
|
||||
const shape = findShapeByKey(event.key, this);
|
||||
if (shape) {
|
||||
if (this.state.activeTool.type !== shape) {
|
||||
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 (this.state.activeTool.type === "laser") {
|
||||
this.setActiveTool({ type: "selection" });
|
||||
this.setActiveTool({ type: this.defaultSelectionTool });
|
||||
} else {
|
||||
this.setActiveTool({ type: "laser" });
|
||||
}
|
||||
@@ -4927,17 +4980,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}),
|
||||
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
|
||||
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
|
||||
// (when submitting via click it should act as signal to deselect)
|
||||
if (!isDeleted && viaKeyboard) {
|
||||
@@ -4961,15 +5005,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (isDeleted) {
|
||||
fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [
|
||||
element,
|
||||
]);
|
||||
}
|
||||
|
||||
// we need to record either way, whether the text element was added or removed
|
||||
// since we need to sync this delta to other clients, otherwise it would end up with inconsistencies
|
||||
this.store.scheduleCapture();
|
||||
if (!isDeleted || isExistingElement) {
|
||||
this.store.scheduleCapture();
|
||||
}
|
||||
|
||||
flushSync(() => {
|
||||
this.setState({
|
||||
@@ -5416,7 +5461,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -5708,8 +5753,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||
const frames = this.scene
|
||||
.getNonDeletedFramesLikes()
|
||||
.filter((frame): frame is ExcalidrawFrameLikeElement =>
|
||||
isCursorInFrame(sceneCoords, frame, elementsMap),
|
||||
.filter(
|
||||
(frame): frame is ExcalidrawFrameLikeElement =>
|
||||
!frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap),
|
||||
);
|
||||
|
||||
return frames.length ? frames[frames.length - 1] : null;
|
||||
@@ -6027,6 +6073,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (
|
||||
hasDeselectedButton ||
|
||||
(this.state.activeTool.type !== "selection" &&
|
||||
this.state.activeTool.type !== "lasso" &&
|
||||
this.state.activeTool.type !== "text" &&
|
||||
this.state.activeTool.type !== "eraser")
|
||||
) {
|
||||
@@ -6189,7 +6236,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isElbowArrow(hitElement) ||
|
||||
!(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") {
|
||||
this.setState({ activeEmbeddable: null });
|
||||
}
|
||||
@@ -6306,7 +6358,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isElbowArrow(element) ||
|
||||
!(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)) {
|
||||
@@ -6315,7 +6372,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isElbowArrow(element) ||
|
||||
!(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") {
|
||||
this.lassoTrail.startPath(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
event.shiftKey,
|
||||
);
|
||||
const hitSelectedElement =
|
||||
pointerDownState.hit.element &&
|
||||
this.isASelectedElement(pointerDownState.hit.element);
|
||||
|
||||
const isMobileOrTablet = this.isMobileOrTablet();
|
||||
|
||||
if (
|
||||
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
|
||||
!pointerDownState.resize.handleType &&
|
||||
!hitSelectedElement
|
||||
) {
|
||||
this.lassoTrail.startPath(
|
||||
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") {
|
||||
this.handleTextOnPointerDown(event, pointerDownState);
|
||||
} else if (
|
||||
@@ -6961,6 +7131,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
hasOccurred: false,
|
||||
offset: null,
|
||||
origin: { ...origin },
|
||||
blockDragging: false,
|
||||
},
|
||||
eventListeners: {
|
||||
onMove: null,
|
||||
@@ -7036,7 +7207,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
pointerDownState: PointerDownState,
|
||||
): boolean => {
|
||||
if (this.state.activeTool.type === "selection") {
|
||||
if (
|
||||
this.state.activeTool.type === "selection" ||
|
||||
this.state.activeTool.type === "lasso"
|
||||
) {
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||
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.
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
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;
|
||||
}
|
||||
if (!this.state.selectedElementIds[hitElement.id]) {
|
||||
@@ -7464,7 +7649,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
resetCursor(this.interactiveCanvas);
|
||||
if (!this.state.activeTool.locked) {
|
||||
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 &&
|
||||
this.state.selectedLinearElement.elementId ===
|
||||
pointerDownState.hit.element?.id;
|
||||
|
||||
if (
|
||||
(hasHitASelectedElement ||
|
||||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
|
||||
!isSelectingPointsInLineEditor &&
|
||||
this.state.activeTool.type !== "lasso"
|
||||
!pointerDownState.drag.blockDragging
|
||||
) {
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
|
||||
if (selectedElements.every((element) => element.locked)) {
|
||||
if (
|
||||
selectedElements.length > 0 &&
|
||||
selectedElements.every((element) => element.locked)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8277,6 +8467,29 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// if elements should be deselected on pointerup
|
||||
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
|
||||
// it would have weird results (stuff jumping all over the screen)
|
||||
// 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 {
|
||||
return withBatchedUpdates((childEvent: PointerEvent) => {
|
||||
this.removePointer(childEvent);
|
||||
pointerDownState.drag.blockDragging = false;
|
||||
if (pointerDownState.eventListeners.onMove) {
|
||||
pointerDownState.eventListeners.onMove.flush();
|
||||
}
|
||||
@@ -9159,7 +9373,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState((prevState) => ({
|
||||
newElement: null,
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: "selection",
|
||||
type: this.defaultSelectionTool,
|
||||
}),
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
@@ -9775,7 +9989,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({
|
||||
newElement: null,
|
||||
suggestedBindings: [],
|
||||
activeTool: updateActiveTool(this.state, { type: "selection" }),
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.defaultSelectionTool,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
@@ -10069,7 +10285,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState(
|
||||
{
|
||||
newElement: null,
|
||||
activeTool: updateActiveTool(this.state, { type: "selection" }),
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.defaultSelectionTool,
|
||||
}),
|
||||
},
|
||||
() => {
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
@@ -10121,7 +10339,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (erroredFiles.size) {
|
||||
this.store.scheduleAction(CaptureUpdateAction.NEVER);
|
||||
this.scene.replaceAllElements(
|
||||
elements.map((element) => {
|
||||
this.scene.getElementsIncludingDeleted().map((element) => {
|
||||
if (
|
||||
isInitializedImageElement(element) &&
|
||||
erroredFiles.has(element.fileId)
|
||||
@@ -10442,7 +10660,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event.nativeEvent.pointerType === "pen" &&
|
||||
// always allow if user uses a pen secondary button
|
||||
event.button !== POINTER_BUTTON.SECONDARY)) &&
|
||||
this.state.activeTool.type !== "selection"
|
||||
this.state.activeTool.type !== this.defaultSelectionTool
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
EraserIcon,
|
||||
} from "./icons";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
export const SHAPES = [
|
||||
{
|
||||
icon: SelectionIcon,
|
||||
@@ -86,8 +88,23 @@ export const SHAPES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const findShapeByKey = (key: string) => {
|
||||
const shape = SHAPES.find((shape, index) => {
|
||||
export const getToolbarTools = (app: AppClassProperties) => {
|
||||
return app.defaultSelectionTool === "lasso"
|
||||
? ([
|
||||
{
|
||||
value: "lasso",
|
||||
icon: SelectionIcon,
|
||||
key: KEYS.V,
|
||||
numericKey: KEYS["1"],
|
||||
fillable: true,
|
||||
},
|
||||
...SHAPES.slice(1),
|
||||
] as const)
|
||||
: SHAPES;
|
||||
};
|
||||
|
||||
export const findShapeByKey = (key: string, app: AppClassProperties) => {
|
||||
const shape = getToolbarTools(app).find((shape, index) => {
|
||||
return (
|
||||
(shape.numericKey != null && key === shape.numericKey.toString()) ||
|
||||
(shape.key &&
|
||||
|
||||
@@ -170,7 +170,11 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
{
|
||||
repairBindings: true,
|
||||
refreshDimensions: false,
|
||||
deleteInvisibleElements: true,
|
||||
},
|
||||
),
|
||||
};
|
||||
} else if (isValidLibrary(data)) {
|
||||
|
||||
@@ -4,13 +4,7 @@ import {
|
||||
supported as nativeFileSystemSupported,
|
||||
} from "browser-fs-access";
|
||||
|
||||
import {
|
||||
EVENT,
|
||||
MIME_TYPES,
|
||||
debounce,
|
||||
isIOS,
|
||||
isAndroid,
|
||||
} from "@excalidraw/common";
|
||||
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
|
||||
|
||||
import { AbortError } from "../errors";
|
||||
|
||||
@@ -19,8 +13,6 @@ import type { FileSystemHandle } from "browser-fs-access";
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
// increase timeout for mobile devices to give more time for file selection
|
||||
const MOBILE_INPUT_CHANGE_INTERVAL_MS = 2000;
|
||||
|
||||
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
extensions?: FILE_EXTENSION[];
|
||||
@@ -49,22 +41,13 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
mimeTypes,
|
||||
multiple: opts.multiple ?? false,
|
||||
legacySetup: (resolve, reject, input) => {
|
||||
const isMobile = isIOS || isAndroid;
|
||||
const intervalMs = isMobile
|
||||
? MOBILE_INPUT_CHANGE_INTERVAL_MS
|
||||
: INPUT_CHANGE_INTERVAL_MS;
|
||||
const scheduleRejection = debounce(reject, intervalMs);
|
||||
|
||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||
const focusHandler = () => {
|
||||
checkForFile();
|
||||
// on mobile, be less aggressive with rejection
|
||||
if (!isMobile) {
|
||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
scheduleRejection();
|
||||
}
|
||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
scheduleRejection();
|
||||
};
|
||||
|
||||
const checkForFile = () => {
|
||||
// this hack might not work when expecting multiple files
|
||||
if (input.files?.length) {
|
||||
@@ -72,15 +55,12 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
resolve(ret as RetType);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener(EVENT.FOCUS, focusHandler);
|
||||
});
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
checkForFile();
|
||||
}, intervalMs);
|
||||
|
||||
}, INPUT_CHANGE_INTERVAL_MS);
|
||||
return (rejectPromise) => {
|
||||
clearInterval(interval);
|
||||
scheduleRejection.cancel();
|
||||
@@ -89,9 +69,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
if (rejectPromise) {
|
||||
// so that something is shown in console if we need to debug this
|
||||
console.warn(
|
||||
"Opening the file was canceled (legacy-fs). This may happen on mobile devices.",
|
||||
);
|
||||
console.warn("Opening the file was canceled (legacy-fs).");
|
||||
rejectPromise(new AbortError());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
|
||||
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
|
||||
MakeBrand<"RemoteExcalidrawElement">;
|
||||
|
||||
const shouldDiscardRemoteElement = (
|
||||
export const shouldDiscardRemoteElement = (
|
||||
localAppState: AppState,
|
||||
local: OrderedExcalidrawElement | undefined,
|
||||
remote: RemoteExcalidrawElement,
|
||||
@@ -30,7 +30,7 @@ const shouldDiscardRemoteElement = (
|
||||
// local element is being edited
|
||||
(local.id === localAppState.editingTextElement?.id ||
|
||||
local.id === localAppState.resizingElement?.id ||
|
||||
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
|
||||
local.id === localAppState.newElement?.id ||
|
||||
// local element is newer
|
||||
local.version > remote.version ||
|
||||
// resolve conflicting edits deterministically by taking the one with
|
||||
|
||||
@@ -241,8 +241,9 @@ const restoreElementWithProperties = <
|
||||
return ret;
|
||||
};
|
||||
|
||||
const restoreElement = (
|
||||
export const restoreElement = (
|
||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
opts?: { deleteInvisibleElements?: boolean },
|
||||
): typeof element | null => {
|
||||
element = { ...element };
|
||||
|
||||
@@ -290,7 +291,8 @@ const restoreElement = (
|
||||
|
||||
// if empty text, mark as deleted. We keep in array
|
||||
// for data integrity purposes (collab etc.)
|
||||
if (!text && !element.isDeleted) {
|
||||
if (opts?.deleteInvisibleElements && !text && !element.isDeleted) {
|
||||
// TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded)
|
||||
element = { ...element, originalText: text, isDeleted: true };
|
||||
element = bumpVersion(element);
|
||||
}
|
||||
@@ -523,7 +525,13 @@ export const restoreElements = (
|
||||
elements: ImportedDataState["elements"],
|
||||
/** NOTE doesn't serve for reconciliation */
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
||||
opts?:
|
||||
| {
|
||||
refreshDimensions?: boolean;
|
||||
repairBindings?: boolean;
|
||||
deleteInvisibleElements?: boolean;
|
||||
}
|
||||
| undefined,
|
||||
): OrderedExcalidrawElement[] => {
|
||||
// used to detect duplicate top-level element ids
|
||||
const existingIds = new Set<string>();
|
||||
@@ -532,24 +540,38 @@ export const restoreElements = (
|
||||
(elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
// and causing issues if retained
|
||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.get(element.id);
|
||||
if (localElement && localElement.version > migratedElement.version) {
|
||||
migratedElement = bumpVersion(
|
||||
migratedElement,
|
||||
localElement.version,
|
||||
);
|
||||
}
|
||||
if (existingIds.has(migratedElement.id)) {
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
if (element.type === "selection") {
|
||||
return elements;
|
||||
}
|
||||
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element, {
|
||||
deleteInvisibleElements: opts?.deleteInvisibleElements,
|
||||
});
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.get(element.id);
|
||||
|
||||
const shouldMarkAsDeleted =
|
||||
opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
|
||||
|
||||
if (
|
||||
shouldMarkAsDeleted ||
|
||||
(localElement && localElement.version > migratedElement.version)
|
||||
) {
|
||||
migratedElement = bumpVersion(migratedElement, localElement?.version);
|
||||
}
|
||||
|
||||
if (shouldMarkAsDeleted) {
|
||||
migratedElement = { ...migratedElement, isDeleted: true };
|
||||
}
|
||||
|
||||
if (existingIds.has(migratedElement.id)) {
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}, [] as ExcalidrawElement[]),
|
||||
);
|
||||
@@ -790,7 +812,11 @@ export const restore = (
|
||||
*/
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
|
||||
elementsConfig?: {
|
||||
refreshDimensions?: boolean;
|
||||
repairBindings?: boolean;
|
||||
deleteInvisibleElements?: boolean;
|
||||
},
|
||||
): RestoredDataState => {
|
||||
return {
|
||||
elements: restoreElements(data?.elements, localElements, elementsConfig),
|
||||
|
||||
@@ -175,7 +175,7 @@ export class History {
|
||||
let nextAppState = appState;
|
||||
let containsVisibleChange = false;
|
||||
|
||||
// iterate through the history entries in case ;they result in no visible changes
|
||||
// iterate through the history entries in case they result in no visible changes
|
||||
while (historyDelta) {
|
||||
try {
|
||||
[nextElements, nextAppState, containsVisibleChange] =
|
||||
|
||||
@@ -229,6 +229,7 @@ export { defaultLang, useI18n, languages } from "./i18n";
|
||||
export {
|
||||
restore,
|
||||
restoreAppState,
|
||||
restoreElement,
|
||||
restoreElements,
|
||||
restoreLibraryItems,
|
||||
} from "./data/restore";
|
||||
|
||||
@@ -169,8 +169,14 @@ export const isSnappingEnabled = ({
|
||||
selectedElements: NonDeletedExcalidrawElement[];
|
||||
}) => {
|
||||
if (event) {
|
||||
// Allow snapping for lasso tool when dragging selected elements
|
||||
// but not during lasso selection phase
|
||||
const isLassoDragging =
|
||||
app.state.activeTool.type === "lasso" &&
|
||||
app.state.selectedElementsAreBeingDragged;
|
||||
|
||||
return (
|
||||
app.state.activeTool.type !== "lasso" &&
|
||||
(app.state.activeTool.type !== "lasso" || isLassoDragging) &&
|
||||
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
|
||||
(!app.state.objectsSnapModeEnabled &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
|
||||
@@ -3682,14 +3682,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1116226695,
|
||||
"seed": 400692809,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 23633383,
|
||||
"versionNonce": 81784553,
|
||||
"width": 20,
|
||||
"x": 20,
|
||||
"y": 30,
|
||||
@@ -3714,14 +3714,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"seed": 449462985,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"versionNonce": 1150084233,
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"y": 0,
|
||||
|
||||
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": {
|
||||
"id0": {
|
||||
"deleted": {
|
||||
"version": 5,
|
||||
},
|
||||
"inserted": {
|
||||
"version": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
"updated": {},
|
||||
},
|
||||
"id": "id6",
|
||||
},
|
||||
@@ -6394,15 +6385,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id9": true,
|
||||
},
|
||||
"selectedLinearElementId": "id9",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id9",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id6": true,
|
||||
},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6470,13 +6462,19 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id12": true,
|
||||
},
|
||||
"selectedLinearElementId": "id12",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id12",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"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": {
|
||||
"id15": true,
|
||||
},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id12": true,
|
||||
},
|
||||
"selectedLinearElementId": "id12",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id12",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6677,12 +6676,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElementId": "id15",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id15",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6700,15 +6700,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id22": true,
|
||||
},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id15": true,
|
||||
},
|
||||
"selectedLinearElementId": "id15",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id15",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6833,12 +6834,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElementId": "id22",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id22",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6873,12 +6875,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElementId": "id22",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id22",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -8719,13 +8722,14 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -8946,13 +8950,14 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -9365,13 +9370,14 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -9770,13 +9776,14 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -10885,32 +10892,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] undo s
|
||||
},
|
||||
},
|
||||
},
|
||||
"updated": {
|
||||
"id0": {
|
||||
"deleted": {
|
||||
"version": 6,
|
||||
},
|
||||
"inserted": {
|
||||
"version": 4,
|
||||
},
|
||||
},
|
||||
"id3": {
|
||||
"deleted": {
|
||||
"version": 6,
|
||||
},
|
||||
"inserted": {
|
||||
"version": 4,
|
||||
},
|
||||
},
|
||||
"id6": {
|
||||
"deleted": {
|
||||
"version": 6,
|
||||
},
|
||||
"inserted": {
|
||||
"version": 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
"updated": {},
|
||||
},
|
||||
"id": "id21",
|
||||
},
|
||||
@@ -14494,12 +14476,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElementIsEditing": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElementId": "id6",
|
||||
"selectedLinearElementIsEditing": false,
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id6",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -60,7 +60,11 @@ describe("restoreElements", () => {
|
||||
const rectElement = API.createElement({ type: "rectangle" });
|
||||
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", () => {
|
||||
@@ -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", () => {
|
||||
const textElement: any = API.createElement({
|
||||
type: "text",
|
||||
@@ -97,10 +118,9 @@ describe("restoreElements", () => {
|
||||
textElement.font = "10 unknown";
|
||||
|
||||
expect(textElement.isDeleted).toBe(false);
|
||||
const restoredText = restore.restoreElements(
|
||||
[textElement],
|
||||
null,
|
||||
)[0] as ExcalidrawTextElement;
|
||||
const restoredText = restore.restoreElements([textElement], null, {
|
||||
deleteInvisibleElements: true,
|
||||
})[0] as ExcalidrawTextElement;
|
||||
expect(restoredText.isDeleted).toBe(true);
|
||||
expect(restoredText).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
@@ -177,13 +197,16 @@ describe("restoreElements", () => {
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||
const restoredElements = restore.restoreElements([arrowElement], null, {
|
||||
deleteInvisibleElements: true,
|
||||
});
|
||||
|
||||
const restoredArrow = restoredElements[0] as
|
||||
| ExcalidrawArrowElement
|
||||
| undefined;
|
||||
|
||||
expect(restoredArrow).toBeUndefined();
|
||||
expect(restoredArrow).not.toBeUndefined();
|
||||
expect(restoredArrow?.isDeleted).toBe(true);
|
||||
});
|
||||
|
||||
it("should keep 'imperceptibly' small freedraw/line elements", () => {
|
||||
@@ -848,6 +871,7 @@ describe("repairing bindings", () => {
|
||||
let restoredElements = restore.restoreElements(
|
||||
[container, invisibleBoundElement, boundElement],
|
||||
null,
|
||||
{ deleteInvisibleElements: true },
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
@@ -855,6 +879,11 @@ describe("repairing bindings", () => {
|
||||
id: container.id,
|
||||
boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: invisibleBoundElement.id,
|
||||
containerId: container.id,
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
@@ -864,7 +893,7 @@ describe("repairing bindings", () => {
|
||||
restoredElements = restore.restoreElements(
|
||||
[container, invisibleBoundElement, boundElement],
|
||||
null,
|
||||
{ repairBindings: true },
|
||||
{ repairBindings: true, deleteInvisibleElements: true },
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
@@ -872,6 +901,11 @@ describe("repairing bindings", () => {
|
||||
id: container.id,
|
||||
boundElements: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: invisibleBoundElement.id,
|
||||
containerId: container.id,
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
|
||||
@@ -315,7 +315,12 @@ describe("Test dragCreate", () => {
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "arrow",
|
||||
isDeleted: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("line", async () => {
|
||||
@@ -344,7 +349,12 @@ describe("Test dragCreate", () => {
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "line",
|
||||
isDeleted: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3043,15 +3043,14 @@ describe("history", () => {
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(3);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(h.state.selectedLinearElement).not.toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
expect(API.getRedoStack().length).toBe(4);
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(4);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -4056,7 +4055,7 @@ describe("history", () => {
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
boundElements: [{ id: remoteText.id, type: "text" }],
|
||||
isDeleted: false, // isDeleted got remotely updated to false
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: text.id,
|
||||
@@ -4066,7 +4065,7 @@ describe("history", () => {
|
||||
expect.objectContaining({
|
||||
id: remoteText.id,
|
||||
// unbound
|
||||
containerId: container.id,
|
||||
containerId: null,
|
||||
isDeleted: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -248,8 +248,10 @@ export type ObservedElementsAppState = {
|
||||
editingGroupId: AppState["editingGroupId"];
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
selectedGroupIds: AppState["selectedGroupIds"];
|
||||
selectedLinearElementId: LinearElementEditor["elementId"] | null;
|
||||
selectedLinearElementIsEditing: boolean | null;
|
||||
selectedLinearElement: {
|
||||
elementId: LinearElementEditor["elementId"];
|
||||
isEditing: boolean;
|
||||
} | null;
|
||||
croppingElementId: AppState["croppingElementId"];
|
||||
lockedMultiSelections: AppState["lockedMultiSelections"];
|
||||
activeLockedId: AppState["activeLockedId"];
|
||||
@@ -729,6 +731,8 @@ export type AppClassProperties = {
|
||||
|
||||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
||||
updateEditorAtom: App["updateEditorAtom"];
|
||||
|
||||
defaultSelectionTool: "selection" | "lasso";
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
@@ -778,6 +782,10 @@ export type PointerDownState = Readonly<{
|
||||
// by default same as PointerDownState.origin. On alt-duplication, reset
|
||||
// to current pointer position at time of duplication.
|
||||
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
|
||||
eventListeners: {
|
||||
@@ -799,6 +807,7 @@ export type UnsubscribeCallback = () => void;
|
||||
|
||||
export interface ExcalidrawImperativeAPI {
|
||||
updateScene: InstanceType<typeof App>["updateScene"];
|
||||
applyDeltas: InstanceType<typeof App>["applyDeltas"];
|
||||
mutateElement: InstanceType<typeof App>["mutateElement"];
|
||||
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
|
||||
resetScene: InstanceType<typeof App>["resetScene"];
|
||||
|
||||
@@ -704,7 +704,7 @@ describe("textWysiwyg", () => {
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements.length).toBe(3);
|
||||
|
||||
text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
@@ -1198,7 +1198,11 @@ describe("textWysiwyg", () => {
|
||||
updateTextEditor(editor, " ");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
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 () => {
|
||||
|
||||
@@ -49,6 +49,7 @@ export const exportToCanvas = ({
|
||||
{ elements, appState },
|
||||
null,
|
||||
null,
|
||||
{ deleteInvisibleElements: true },
|
||||
);
|
||||
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
||||
return _exportToCanvas(
|
||||
@@ -179,6 +180,7 @@ export const exportToSvg = async ({
|
||||
{ elements, appState },
|
||||
null,
|
||||
null,
|
||||
{ deleteInvisibleElements: true },
|
||||
);
|
||||
|
||||
const exportAppState = {
|
||||
|
||||
@@ -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
@@ -144,7 +144,7 @@ const askToCommit = (tag, nextVersion) => {
|
||||
});
|
||||
|
||||
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) => {
|
||||
rl.close();
|
||||
|
||||
@@ -189,7 +189,7 @@ const askToPublish = (tag, version) => {
|
||||
});
|
||||
|
||||
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) => {
|
||||
rl.close();
|
||||
|
||||
|
||||
@@ -2162,6 +2162,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
|
||||
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":
|
||||
version "1.0.0-next.28"
|
||||
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.28.tgz#d45e01c4a56f143ee69c54dd6b12eade9e270a73"
|
||||
@@ -2945,6 +2952,13 @@
|
||||
dependencies:
|
||||
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":
|
||||
version "22.13.8"
|
||||
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"
|
||||
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:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||
@@ -7838,6 +7857,20 @@ pkg-dir@4.2.0:
|
||||
dependencies:
|
||||
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:
|
||||
version "1.0.0"
|
||||
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"
|
||||
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:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2"
|
||||
|
||||
Reference in New Issue
Block a user