Compare commits

..

8 Commits

Author SHA1 Message Date
Mark Tolmacs
2ed78d4895
fix: Improve rendering stability 2025-09-05 12:58:22 +02:00
Mark Tolmacs
5ba2798306
Iterating on arrows 2025-08-26 14:55:40 +02:00
Mark Tolmacs
b8aae34e32
Fixes and handling 2025-08-25 14:46:07 +02:00
Mark Tolmacs
6317ac16ee
Working screenshot diff 2025-08-25 11:20:38 +02:00
Mark Tolmacs
9ad75b8375
Direct function generation 2025-08-25 10:42:47 +02:00
Mark Tolmacs
4c9ad1a22f
Implementation of one test 2025-08-20 17:53:20 +02:00
Mark Tolmacs
023895b49b
Can generate and play back 2025-08-20 15:17:49 +02:00
Mark Tolmacs
eb37be953a
Install playwright 2025-08-20 15:17:35 +02:00
74 changed files with 2139 additions and 3705 deletions

27
.github/workflows/playwright.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm install -g yarn && yarn
- name: Install Playwright Browsers
run: yarn playwright install --with-deps
- name: Run Playwright tests
run: yarn playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

6
.gitignore vendored
View File

@ -27,3 +27,9 @@ dev-dist
html html
meta*.json meta*.json
.claude .claude
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@ -20,6 +20,7 @@ import {
APP_NAME, APP_NAME,
EVENT, EVENT,
THEME, THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT, VERSION_TIMEOUT,
debounce, debounce,
getVersion, getVersion,
@ -73,6 +74,7 @@ import type {
import type { ResolutionType } from "@excalidraw/common/utility-types"; import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils"; import type { ResolvablePromise } from "@excalidraw/common/utils";
import "./record";
import CustomStats from "./CustomStats"; import CustomStats from "./CustomStats";
import { import {
Provider, Provider,
@ -498,6 +500,11 @@ const ExcalidrawWrapper = () => {
} }
}; };
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => { const syncData = debounce(() => {
if (isTestEnv()) { if (isTestEnv()) {
return; return;
@ -588,6 +595,7 @@ const ExcalidrawWrapper = () => {
visibilityChange, visibilityChange,
false, false,
); );
clearTimeout(titleTimeout);
}; };
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]); }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);

View File

@ -8,8 +8,7 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
// should be aligned with MAX_ALLOWED_FILE_BYTES export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631) // 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000; export const FILE_CACHE_MAX_AGE_SEC = 31536000;

View File

@ -2,7 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Excalidraw Whiteboard</title> <title>
Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw
</title>
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"

View File

@ -46,6 +46,7 @@
"build:version": "node ../scripts/build-version.js", "build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"start": "yarn && vite", "start": "yarn && vite",
"start:test": "yarn && vite --mode test",
"start:production": "yarn build && yarn serve", "start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o", "serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000" "build:preview": "yarn build && vite preview --port 5000"

270
excalidraw-app/record.ts Normal file
View File

@ -0,0 +1,270 @@
import { isDevEnv } from "@excalidraw/common";
declare global {
interface Window {
record: typeof Record;
}
}
export class Record {
private static recording: boolean = false;
private static events: string = "";
private static timestamp: number = 0;
public static get isRecording() {
return Record.recording;
}
private static header() {
Record.events += " await page.addInitScript(() => {\n";
Record.events += " Math.random = () => 0.42;\n\n";
// Capture LocalStorage, which is essential to re-establish state
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key != null) {
const value = JSON.stringify(localStorage.getItem(key));
Record.events += ` localStorage.getItem("${key}");\n`;
Record.events += ` localStorage.setItem("${key}", ${value});\n`;
}
}
Record.events += " });\n";
Record.events += ` await page.setViewportSize({ width: ${window.innerWidth}, height: ${window.innerHeight} });\n`;
Record.events += ` await page.goto("http://localhost:3000");\n`;
Record.events += ` await page.waitForLoadState("load");\n`;
}
public static restart() {
if (!Record.recording) {
Record.start();
return;
}
Record.events += `});\n\n`;
Record.events += `test("${
Date.now() + Math.floor(Math.random() * Date.now()).toString(36)
}", async ({ page }) => {\n`;
Record.header();
}
public static start() {
Record.recording = true;
// Record header
this.header();
// Set up the events
Record.timestamp = performance.now();
window.addEventListener("mousemove", this.onMouseMove);
window.addEventListener("mousedown", this.onMouseDown);
window.addEventListener("mouseup", this.onMouseUp);
window.addEventListener("keydown", this.onKeyDown);
window.addEventListener("keyup", this.onKeyUp);
}
public static stop() {
window.removeEventListener("mousemove", this.onMouseMove);
window.removeEventListener("mousedown", this.onMouseDown);
window.removeEventListener("mouseup", this.onMouseUp);
window.removeEventListener("keydown", this.onKeyDown);
window.removeEventListener("keyup", this.onKeyUp);
Record.recording = false;
}
/// Displays a window as an absolutely positioned DIV with the generated
/// events within <pre> tags as formatted JSON, so it can be copied easily.
public static showGeneratedEvents() {
if (Record.recording) {
Record.stop();
}
const div = document.createElement("div");
div.style.position = "absolute";
div.style.top = "10px";
div.style.right = "10px";
div.style.left = "10px";
div.style.height = "60vh";
div.style.backgroundColor = "gray";
div.style.padding = "10px";
div.style.zIndex = "10000";
const pre = document.createElement("pre");
let textContent = `import { expect, test } from "@playwright/test";\n\n`;
textContent += `test("${
Date.now() + Math.floor(Math.random() * Date.now()).toString(36)
}", async ({ page }) => {\n`;
textContent += Record.events;
textContent += `});\n`;
pre.textContent = textContent;
//pre.textContent = Record.events;
pre.style.marginTop = "18px";
pre.style.maxHeight = "60vh";
pre.style.overflow = "auto";
div.appendChild(pre);
const copyBtn = document.createElement("button");
copyBtn.textContent = "Copy";
copyBtn.title = "Copy generated events to clipboard";
copyBtn.setAttribute("aria-label", "Copy generated events to clipboard");
copyBtn.style.position = "absolute";
copyBtn.style.top = "4px";
copyBtn.style.left = "4px";
copyBtn.style.border = "none";
copyBtn.style.background = "transparent";
copyBtn.style.fontSize = "12px";
copyBtn.style.lineHeight = "1";
copyBtn.style.cursor = "pointer";
copyBtn.style.padding = "4px 8px";
copyBtn.addEventListener("click", async () => {
const text = pre.textContent ?? "";
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
const orig = copyBtn.textContent;
copyBtn.textContent = "Copied";
setTimeout(() => (copyBtn.textContent = orig), 1000);
} catch {}
});
div.appendChild(copyBtn);
const closeBtn = document.createElement("button");
closeBtn.textContent = "×";
closeBtn.title = "Close";
closeBtn.style.position = "absolute";
closeBtn.style.top = "4px";
closeBtn.style.right = "4px";
closeBtn.style.border = "none";
closeBtn.style.background = "transparent";
closeBtn.style.fontSize = "18px";
closeBtn.style.lineHeight = "1";
closeBtn.style.cursor = "pointer";
closeBtn.addEventListener("click", () => {
// remove the dialog from DOM
if (div.parentNode) {
div.parentNode.removeChild(div);
}
});
div.appendChild(closeBtn);
document.body.appendChild(div);
}
private static onMouseMove(event: MouseEvent) {
if (
event.clientX < 0 ||
event.clientX > window.innerWidth ||
event.clientY < 0 ||
event.clientY > window.innerHeight
) {
return;
}
const now = event.timeStamp || performance.now();
const delay = now - Record.timestamp;
Record.timestamp = now;
if (delay > 0) {
Record.events += ` await page.waitForTimeout(${delay});\n`;
}
Record.events += ` await page.mouse.move(${event.clientX}, ${event.clientY});\n`;
}
private static onMouseDown(event: MouseEvent) {
const now = event.timeStamp || performance.now();
const delay = now - Record.timestamp;
Record.timestamp = now;
if (delay > 0) {
Record.events += ` await page.waitForTimeout(${delay});\n`;
}
const button =
event.button === 0 ? "left" : event.button === 1 ? "middle" : "right";
Record.events += ` await page.mouse.down({ button: "${button}" });\n`;
}
private static onMouseUp(event: MouseEvent) {
const now = event.timeStamp || performance.now();
const delay = now - Record.timestamp;
Record.timestamp = now;
if (delay > 0) {
Record.events += ` await page.waitForTimeout(${delay});\n`;
}
const button =
event.button === 0 ? "left" : event.button === 1 ? "middle" : "right";
Record.events += ` await page.mouse.up({ button: "${button}" });\n`;
Record.events += " await expect(page).toHaveScreenshot({\n";
Record.events += " maxDiffPixels: 100,\n";
Record.events += " maxDiffPixelRatio: 0.01,\n";
Record.events += " });\n";
}
private static onKeyDown(event: KeyboardEvent) {
// Only record if the recording key is not pressed
if (event.key !== "F2") {
const now = event.timeStamp || performance.now();
const delay = now - Record.timestamp;
Record.timestamp = now;
if (delay > 0) {
Record.events += ` await page.waitForTimeout(${delay});\n`;
}
Record.events += ` await page.keyboard.down("${event.key}");\n`;
}
}
private static onKeyUp(event: KeyboardEvent) {
// Only record if the recording key is not pressed
if (event.key !== "F2") {
const now = event.timeStamp || performance.now();
const delay = now - Record.timestamp;
Record.timestamp = now;
if (delay > 0) {
Record.events += ` await page.waitForTimeout(${delay});\n`;
}
Record.events += ` await page.keyboard.up("${event.key}");\n`;
Record.events += " await expect(page).toHaveScreenshot({\n";
Record.events += " maxDiffPixels: 100,\n";
Record.events += " maxDiffPixelRatio: 0.01,\n";
Record.events += " });\n";
}
}
}
if (isDevEnv()) {
window.record = Record;
window.addEventListener("keyup", (event) => {
if (event.key === "F2") {
if (Record.isRecording) {
if (event.ctrlKey) {
console.info("Stopping Playwright recording");
Record.stop();
} else {
Record.restart();
}
} else {
console.info("Starting Playwright recording");
Record.start();
}
} else if (event.key === "Enter" && event.ctrlKey) {
Record.showGeneratedEvents();
}
});
}

View File

@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
}, },
"isTouchScreen": false, "isTouchScreen": false,
"viewport": { "viewport": {
"isLandscape": true, "isLandscape": false,
"isMobile": true, "isMobile": true,
}, },
} }

View File

@ -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

View File

@ -11,9 +11,11 @@
"@babel/preset-env": "7.26.9", "@babel/preset-env": "7.26.9",
"@excalidraw/eslint-config": "1.0.3", "@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
"@playwright/test": "1.55.0",
"@types/chai": "4.3.0", "@types/chai": "4.3.0",
"@types/jest": "27.4.0", "@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7", "@types/lodash.throttle": "4.1.7",
"@types/node": "24.3.0",
"@types/react": "19.0.10", "@types/react": "19.0.10",
"@types/react-dom": "19.0.4", "@types/react-dom": "19.0.4",
"@types/socket.io-client": "3.0.0", "@types/socket.io-client": "3.0.0",
@ -62,6 +64,7 @@
"build:preview": "yarn --cwd ./excalidraw-app build:preview", "build:preview": "yarn --cwd ./excalidraw-app build:preview",
"start": "yarn --cwd ./excalidraw-app start", "start": "yarn --cwd ./excalidraw-app start",
"start:production": "yarn --cwd ./excalidraw-app start:production", "start:production": "yarn --cwd ./excalidraw-app start:production",
"start:test": "yarn --cwd ./excalidraw-app start:test",
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start", "start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest", "test:app": "vitest",

View File

@ -28,9 +28,11 @@ export const isBrave = () =>
export const isMobile = export const isMobile =
isIOS || isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test( /android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent, navigator.userAgent.toLowerCase(),
) || ) ||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform); /android|ios|ipod|blackberry|windows phone/i.test(
navigator.platform.toLowerCase(),
);
export const supportsResizeObserver = export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window; typeof window !== "undefined" && "ResizeObserver" in window;
@ -129,7 +131,6 @@ export const CLASSES = {
ZOOM_ACTIONS: "zoom-actions", ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
}; };
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@ -260,17 +261,13 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif", jfif: "image/jfif",
} as const; } as const;
export const STRING_MIME_TYPES = { export const MIME_TYPES = {
text: "text/plain", text: "text/plain",
html: "text/html", html: "text/html",
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json", excalidrawlib: "application/vnd.excalidrawlib+json",
} as const;
export const MIME_TYPES = {
...STRING_MIME_TYPES,
// image-encoded excalidraw data // image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml", "excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png", "excalidraw.png": "image/png",
@ -347,17 +344,10 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
// breakpoints // breakpoints
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// md screen
// mobile: up to 699px export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_MOBILE = 599; export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
// desktop/laptop
export const MQ_MIN_WIDTH_DESKTOP = 1440;
// sidebar // sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229; export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -534,5 +524,3 @@ export enum UserIdleState {
* the start and end points) * the start and end points)
*/ */
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;

View File

@ -21,8 +21,6 @@ import {
FONT_FAMILY, FONT_FAMILY,
getFontFamilyFallbacks, getFontFamilyFallbacks,
isDarwin, isDarwin,
isAndroid,
isIOS,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
@ -1280,59 +1278,3 @@ export const reduceToCommonValue = <T, R = T>(
return commonValue; return commonValue;
}; };
export const isMobileOrTablet = (): boolean => {
const ua = navigator.userAgent || "";
const platform = navigator.platform || "";
const uaData = (navigator as any).userAgentData as
| { mobile?: boolean; platform?: string }
| undefined;
// --- 1) chromium: prefer ua client hints -------------------------------
if (uaData) {
const plat = (uaData.platform || "").toLowerCase();
const isDesktopOS =
plat === "windows" ||
plat === "macos" ||
plat === "linux" ||
plat === "chrome os";
if (uaData.mobile === true) {
return true;
}
if (uaData.mobile === false && plat === "android") {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
if (isDesktopOS) {
return false;
}
}
// --- 2) ios (includes ipad) --------------------------------------------
if (isIOS) {
return true;
}
// --- 3) android legacy ua fallback -------------------------------------
if (isAndroid) {
const isAndroidPhone = /Mobile/i.test(ua);
const isAndroidTablet = !isAndroidPhone;
if (isAndroidPhone || isAndroidTablet) {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
}
// --- 4) last resort desktop exclusion ----------------------------------
const looksDesktopPlatform =
/Win|Linux|CrOS|Mac/.test(platform) ||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
if (looksDesktopPlatform) {
return false;
}
return false;
};

View File

@ -1126,9 +1126,7 @@ export interface BoundingBox {
} }
export const getCommonBoundingBox = ( export const getCommonBoundingBox = (
elements: elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
| readonly ExcalidrawElement[]
| readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => { ): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { return {

View File

@ -1111,16 +1111,16 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
inserted, inserted,
}: Delta<ElementPartial>) => }: Delta<ElementPartial>) =>
!!( !!(
deleted.version &&
inserted.version &&
// versions are required integers // versions are required integers
(
Number.isInteger(deleted.version) && Number.isInteger(deleted.version) &&
Number.isInteger(inserted.version) && Number.isInteger(inserted.version) &&
// versions should be positive, zero included // versions should be positive, zero included
deleted.version! >= 0 && deleted.version >= 0 &&
inserted.version! >= 0 && inserted.version >= 0 &&
// versions should never be the same // versions should never be the same
deleted.version !== inserted.version deleted.version !== inserted.version
)
); );
private static satisfiesUniqueInvariants = ( private static satisfiesUniqueInvariants = (
@ -1191,10 +1191,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
// ignore updates which would "delete" already deleted element
if (!prevElement.isDeleted) { if (!prevElement.isDeleted) {
removed[prevElement.id] = delta; removed[prevElement.id] = delta;
} else {
updated[prevElement.id] = delta;
} }
} }
} }
@ -1222,8 +1221,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
// ignore updates which would "delete" already deleted element // ignore updates which would "delete" already deleted element
if (!nextElement.isDeleted) { if (!nextElement.isDeleted) {
added[nextElement.id] = delta; added[nextElement.id] = delta;
} else {
updated[nextElement.id] = delta;
} }
continue; continue;
@ -1253,9 +1250,17 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
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; updated[nextElement.id] = delta;
} }
} }
}
return ElementsDelta.create(added, removed, updated); return ElementsDelta.create(added, removed, updated);
} }
@ -1367,8 +1372,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
latestDelta = delta; latestDelta = delta;
} }
const strippedDeleted = ElementsDelta.stripVersionProps(
latestDelta.deleted,
);
const strippedInserted = ElementsDelta.stripVersionProps(
latestDelta.inserted,
);
// it might happen that after applying latest changes the delta itself does not contain any changes // it might happen that after applying latest changes the delta itself does not contain any changes
if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) { if (Delta.isInnerDifferent(strippedDeleted, strippedInserted)) {
modifiedDeltas[id] = latestDelta; modifiedDeltas[id] = latestDelta;
} }
} }
@ -2063,4 +2075,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return strippedPartial; return strippedPartial;
} }
private static stripVersionProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { version, versionNonce, ...strippedPartial } = partial;
return strippedPartial;
}
} }

View File

@ -359,12 +359,6 @@ const handleSegmentRelease = (
null, null,
); );
if (!restoredPoints || restoredPoints.length < 2) {
throw new Error(
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
);
}
const nextPoints: GlobalPoint[] = []; const nextPoints: GlobalPoint[] = [];
// First part of the arrow are the old points // First part of the arrow are the old points
@ -712,7 +706,7 @@ const handleEndpointDrag = (
endGlobalPoint: GlobalPoint, endGlobalPoint: GlobalPoint,
hoveredStartElement: ExcalidrawBindableElement | null, hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null,
): ElementUpdate<ExcalidrawElbowArrowElement> => { ) => {
let startIsSpecial = arrow.startIsSpecial ?? null; let startIsSpecial = arrow.startIsSpecial ?? null;
let endIsSpecial = arrow.endIsSpecial ?? null; let endIsSpecial = arrow.endIsSpecial ?? null;
const globalUpdatedPoints = updatedPoints.map((p, i) => const globalUpdatedPoints = updatedPoints.map((p, i) =>
@ -747,15 +741,8 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point // Calculate the moving second point connection and add the start point
{ {
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1); const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2); const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
if (!secondPoint || !thirdPoint) {
throw new Error(
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
);
}
const startIsHorizontal = headingIsHorizontal(startHeading); const startIsHorizontal = headingIsHorizontal(startHeading);
const secondIsHorizontal = headingIsHorizontal( const secondIsHorizontal = headingIsHorizontal(
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)), vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
@ -814,19 +801,10 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection // Calculate the moving second to last point connection
{ {
const secondToLastPoint = globalUpdatedPoints.at( const secondToLastPoint =
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2), globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
); const thirdToLastPoint =
const thirdToLastPoint = globalUpdatedPoints.at( globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
);
if (!secondToLastPoint || !thirdToLastPoint) {
throw new Error(
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
);
}
const endIsHorizontal = headingIsHorizontal(endHeading); const endIsHorizontal = headingIsHorizontal(endHeading);
const secondIsHorizontal = headingForPointIsHorizontal( const secondIsHorizontal = headingForPointIsHorizontal(
thirdToLastPoint, thirdToLastPoint,
@ -2093,7 +2071,16 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null, nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): ElementUpdate<ExcalidrawElbowArrowElement> => { ): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
const offsetX = global[0][0]; const offsetX = global[0][0];
const offsetY = global[0][1]; const offsetY = global[0][1];
let points = global.map((p) => let points = global.map((p) =>

View File

@ -97,7 +97,6 @@ export * from "./image";
export * from "./linearElementEditor"; export * from "./linearElementEditor";
export * from "./mutateElement"; export * from "./mutateElement";
export * from "./newElement"; export * from "./newElement";
export * from "./positionElementsOnGrid";
export * from "./renderElement"; export * from "./renderElement";
export * from "./resizeElements"; export * from "./resizeElements";
export * from "./resizeTest"; export * from "./resizeTest";

View File

@ -1,112 +0,0 @@
import { getCommonBounds } from "./bounds";
import { type ElementUpdate, newElementWith } from "./mutateElement";
import type { ExcalidrawElement } from "./types";
// TODO rewrite (mostly vibe-coded)
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
elements: TElement[] | TElement[][],
centerX: number,
centerY: number,
padding = 50,
): TElement[] => {
// Ensure there are elements to position
if (!elements || elements.length === 0) {
return [];
}
const res: TElement[] = [];
// Normalize input to work with atomic units (groups of elements)
// If elements is a flat array, treat each element as its own atomic unit
const atomicUnits: TElement[][] = Array.isArray(elements[0])
? (elements as TElement[][])
: (elements as TElement[]).map((element) => [element]);
// Determine the number of columns for atomic units
// A common approach for a "grid-like" layout without specific column constraints
// is to aim for a roughly square arrangement.
const numUnits = atomicUnits.length;
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
// Group atomic units into rows based on the calculated number of columns
const rows: TElement[][][] = [];
for (let i = 0; i < numUnits; i += numColumns) {
rows.push(atomicUnits.slice(i, i + numColumns));
}
// Calculate properties for each row (total width, max height)
// and the total actual height of all row content.
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
const rowProperties = rows.map((rowUnits) => {
let rowWidth = 0;
let maxUnitHeightInRow = 0;
const unitBounds = rowUnits.map((unit) => {
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
return {
elements: unit,
bounds: [minX, minY, maxX, maxY] as const,
width: maxX - minX,
height: maxY - minY,
};
});
unitBounds.forEach((unitBound, index) => {
rowWidth += unitBound.width;
// Add padding between units in the same row, but not after the last one
if (index < unitBounds.length - 1) {
rowWidth += padding;
}
if (unitBound.height > maxUnitHeightInRow) {
maxUnitHeightInRow = unitBound.height;
}
});
totalGridActualHeight += maxUnitHeightInRow;
return {
unitBounds,
width: rowWidth,
maxHeight: maxUnitHeightInRow,
};
});
// Calculate the total height of the grid including padding between rows
const totalGridHeightWithPadding =
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
// Calculate the starting Y position to center the entire grid vertically around centerY
let currentY = centerY - totalGridHeightWithPadding / 2;
// Position atomic units row by row
rowProperties.forEach((rowProp) => {
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
// Calculate the starting X for the current row to center it horizontally around centerX
let currentX = centerX - rowWidth / 2;
unitBounds.forEach((unitBound) => {
// Calculate the offset needed to position this atomic unit
const [originalMinX, originalMinY] = unitBound.bounds;
const offsetX = currentX - originalMinX;
const offsetY = currentY - originalMinY;
// Apply the offset to all elements in this atomic unit
unitBound.elements.forEach((element) => {
res.push(
newElementWith(element, {
x: element.x + offsetX,
y: element.y + offsetY,
} as ElementUpdate<TElement>),
);
});
// Move X for the next unit in the row
currentX += unitBound.width + padding;
});
// Move Y to the starting position for the next row
// This accounts for the tallest unit in the current row and the inter-row padding
currentY += rowMaxHeight + padding;
});
return res;
};

View File

@ -1,14 +1,7 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand"; import { getStroke } from "perfect-freehand";
import { import { isRightAngleRads } from "@excalidraw/math";
type GlobalPoint,
isRightAngleRads,
lineSegment,
pointFrom,
pointRotateRads,
type Radians,
} from "@excalidraw/math";
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
@ -21,7 +14,6 @@ import {
getFontString, getFontString,
isRTL, isRTL,
getVerticalOffset, getVerticalOffset,
invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@ -40,7 +32,7 @@ import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
} from "@excalidraw/excalidraw/scene/types"; } from "@excalidraw/excalidraw/scene/types";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { getUncroppedImageElement } from "./cropElement"; import { getUncroppedImageElement } from "./cropElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { import {
@ -1047,66 +1039,6 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
} }
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) { export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
}
export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement,
points: [number, number][],
elementsMap: ElementsMap,
) {
const bounds = getElementBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
const center = pointFrom<GlobalPoint>(
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
);
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
return points.slice(2).reduce(
(acc, curr) => {
acc.push(
lineSegment<GlobalPoint>(
acc[acc.length - 1][1],
pointRotateRads(
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
center,
element.angle,
),
),
);
return acc;
},
[
lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(
points[0][0] + element.x,
points[0][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
points[1][0] + element.x,
points[1][1] + element.y,
),
center,
element.angle,
),
),
],
);
}
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot // If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure const inputPoints = element.simulatePressure
? element.points ? element.points
@ -1125,7 +1057,7 @@ export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
}; };
return getStroke(inputPoints as number[][], options) as [number, number][]; return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
} }
function med(A: number[], B: number[]) { function med(A: number[], B: number[]) {

View File

@ -35,7 +35,6 @@ import {
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement"; } from "./textElement";
import { import {
getMinTextElementWidth, getMinTextElementWidth,
@ -226,16 +225,7 @@ const rotateSingleElement = (
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId); scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) { if (textElement && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition( scene.mutateElement(textElement, { angle });
element,
textElement,
scene.getNonDeletedElementsMap(),
);
scene.mutateElement(textElement, {
angle,
x,
y,
});
} }
} }
}; };
@ -426,15 +416,9 @@ const rotateMultipleElements = (
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) { if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition(
element,
boundText,
elementsMap,
);
scene.mutateElement(boundText, { scene.mutateElement(boundText, {
x, x: boundText.x + (rotatedCX - cx),
y, y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians), angle: normalizeRadians((centerAngle + origAngle) as Radians),
}); });
} }

View File

@ -21,6 +21,7 @@ import {
assertNever, assertNever,
COLOR_PALETTE, COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE, LINE_POLYGON_POINT_MERGE_DISTANCE,
isTestEnv,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator"; import { RoughGenerator } from "roughjs/bin/generator";
@ -182,7 +183,7 @@ export const generateRoughOptions = (
continuousPath = false, continuousPath = false,
): Options => { ): Options => {
const options: Options = { const options: Options = {
seed: element.seed, seed: isTestEnv() ? 1 : element.seed,
strokeLineDash: strokeLineDash:
element.strokeStyle === "dashed" element.strokeStyle === "dashed"
? getDashArrayDashed(element.strokeWidth) ? getDashArrayDashed(element.strokeWidth)

View File

@ -10,12 +10,12 @@ import {
invariant, invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types"; import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { import {
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
@ -254,26 +254,6 @@ export const computeBoundTextPosition = (
x = x =
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2); containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
} }
const angle = (container.angle ?? 0) as Radians;
if (angle !== 0) {
const contentCenter = pointFrom(
containerCoords.x + maxContainerWidth / 2,
containerCoords.y + maxContainerHeight / 2,
);
const textCenter = pointFrom(
x + boundTextElement.width / 2,
y + boundTextElement.height / 2,
);
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
return {
x: rx - boundTextElement.width / 2,
y: ry - boundTextElement.height / 2,
};
}
return { x, y }; return { x, y };
}; };

View File

@ -8,7 +8,7 @@ import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
describe("ElementsDelta", () => { describe("ElementsDelta", () => {
describe("elements delta calculation", () => { describe("elements delta calculation", () => {
it("should not throw when element gets removed but was already deleted", () => { it("should not create removed delta when element gets removed but was already deleted", () => {
const element = API.createElement({ const element = API.createElement({
type: "rectangle", type: "rectangle",
x: 100, x: 100,
@ -19,12 +19,12 @@ describe("ElementsDelta", () => {
const prevElements = new Map([[element.id, element]]); const prevElements = new Map([[element.id, element]]);
const nextElements = new Map(); const nextElements = new Map();
expect(() => const delta = ElementsDelta.calculate(prevElements, nextElements);
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow(); expect(delta.isEmpty()).toBeTruthy();
}); });
it("should not throw when adding element as already deleted", () => { it("should not create added delta when adding element as already deleted", () => {
const element = API.createElement({ const element = API.createElement({
type: "rectangle", type: "rectangle",
x: 100, x: 100,
@ -35,12 +35,12 @@ describe("ElementsDelta", () => {
const prevElements = new Map(); const prevElements = new Map();
const nextElements = new Map([[element.id, element]]); const nextElements = new Map([[element.id, element]]);
expect(() => const delta = ElementsDelta.calculate(prevElements, nextElements);
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow(); expect(delta.isEmpty()).toBeTruthy();
}); });
it("should create updated delta even when there is only version and versionNonce change", () => { it("should not create updated delta when there is only version and versionNonce change", () => {
const baseElement = API.createElement({ const baseElement = API.createElement({
type: "rectangle", type: "rectangle",
x: 100, x: 100,
@ -65,24 +65,7 @@ describe("ElementsDelta", () => {
nextElements as SceneElementsMap, nextElements as SceneElementsMap,
); );
expect(delta).toEqual( expect(delta.isEmpty()).toBeTruthy();
ElementsDelta.create(
{},
{},
{
[baseElement.id]: Delta.create(
{
version: baseElement.version,
versionNonce: baseElement.versionNonce,
},
{
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
},
),
},
),
);
}); });
}); });

View File

@ -1,14 +1,13 @@
import { getLineHeight } from "@excalidraw/common"; import { getLineHeight } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common"; import { FONT_FAMILY } from "@excalidraw/common";
import { import {
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getContainerCoords, getContainerCoords,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getBoundTextMaxHeight, getBoundTextMaxHeight,
computeBoundTextPosition,
} from "../src/textElement"; } from "../src/textElement";
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements"; import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
@ -208,172 +207,3 @@ describe("Test getDefaultLineHeight", () => {
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
}); });
}); });
describe("Test computeBoundTextPosition", () => {
const createMockElementsMap = () => new Map();
// Helper function to create rectangle test case with 90-degree rotation
const createRotatedRectangleTestCase = (
textAlign: string,
verticalAlign: string,
) => {
const container = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
angle: (Math.PI / 2) as any, // 90 degrees
});
const boundTextElement = API.createElement({
type: "text",
width: 80,
height: 40,
text: "hello darkness my old friend",
textAlign: textAlign as any,
verticalAlign: verticalAlign as any,
containerId: container.id,
}) as ExcalidrawTextElementWithContainer;
const elementsMap = createMockElementsMap();
return { container, boundTextElement, elementsMap };
};
describe("90-degree rotation with all alignment combinations", () => {
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.MIDDLE,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.BOTTOM,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(185, 1);
});
});
});

View File

@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps, data }) => { PanelComponent: ({ elements, appState, updateData, appProps }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx // FIXME move me to src/components/mainMenu/DefaultItems.tsx
return ( return (
<ColorPicker <ColorPicker
@ -83,7 +83,6 @@ export const actionChangeViewBackgroundColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/> />
); );
}, },

View File

@ -88,10 +88,6 @@ export const actionToggleLinearEditor = register({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement; })[0] as ExcalidrawLinearElement;
if (!selectedElement) {
return null;
}
const label = t( const label = t(
selectedElement.type === "arrow" selectedElement.type === "arrow"
? "labels.lineEditor.editArrow" ? "labels.lineEditor.editArrow"

View File

@ -137,11 +137,6 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import {
withCaretPositionPreservation,
restoreCaretPosition,
} from "../hooks/useTextEditorFocus";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState, Primitive } from "../types"; import type { AppClassProperties, AppState, Primitive } from "../types";
@ -326,11 +321,9 @@ export const actionChangeStrokeColor = register({
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<> <>
{appState.stylesPanelMode === "full" && (
<h3 aria-hidden="true">{t("labels.stroke")}</h3> <h3 aria-hidden="true">{t("labels.stroke")}</h3>
)}
<ColorPicker <ColorPicker
topPicks={DEFAULT_ELEMENT_STROKE_PICKS} topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE} palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
@ -348,7 +341,6 @@ export const actionChangeStrokeColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/> />
</> </>
), ),
@ -406,11 +398,9 @@ export const actionChangeBackgroundColor = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<> <>
{appState.stylesPanelMode === "full" && (
<h3 aria-hidden="true">{t("labels.background")}</h3> <h3 aria-hidden="true">{t("labels.background")}</h3>
)}
<ColorPicker <ColorPicker
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS} topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE} palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
@ -428,7 +418,6 @@ export const actionChangeBackgroundColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/> />
</> </>
), ),
@ -529,11 +518,9 @@ export const actionChangeStrokeWidth = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.strokeWidth")}</legend> <legend>{t("labels.strokeWidth")}</legend>
)}
<div className="buttonList"> <div className="buttonList">
<RadioSelection <RadioSelection
group="stroke-width" group="stroke-width"
@ -588,11 +575,9 @@ export const actionChangeSloppiness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.sloppiness")}</legend> <legend>{t("labels.sloppiness")}</legend>
)}
<div className="buttonList"> <div className="buttonList">
<RadioSelection <RadioSelection
group="sloppiness" group="sloppiness"
@ -643,11 +628,9 @@ export const actionChangeStrokeStyle = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.strokeStyle")}</legend> <legend>{t("labels.strokeStyle")}</legend>
)}
<div className="buttonList"> <div className="buttonList">
<RadioSelection <RadioSelection
group="strokeStyle" group="strokeStyle"
@ -714,7 +697,7 @@ export const actionChangeFontSize = register({
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, () => value, value); return changeFontSize(elements, appState, app, () => value, value);
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.fontSize")}</legend> <legend>{t("labels.fontSize")}</legend>
<div className="buttonList"> <div className="buttonList">
@ -773,14 +756,7 @@ export const actionChangeFontSize = register({
? null ? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE, : appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)} )}
onChange={(value) => { onChange={(value) => updateData(value)}
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@ -1040,7 +1016,7 @@ export const actionChangeFontFamily = register({
return result; return result;
}, },
PanelComponent: ({ elements, appState, app, updateData, data }) => { PanelComponent: ({ elements, appState, app, updateData }) => {
const cachedElementsRef = useRef<ElementsMap>(new Map()); const cachedElementsRef = useRef<ElementsMap>(new Map());
const prevSelectedFontFamilyRef = useRef<number | null>(null); const prevSelectedFontFamilyRef = useRef<number | null>(null);
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
@ -1118,28 +1094,20 @@ export const actionChangeFontFamily = register({
return ( return (
<fieldset> <fieldset>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>
)}
<FontPicker <FontPicker
isOpened={appState.openPopup === "fontFamily"} isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily} hoveredFontFamily={appState.currentHoveredFontFamily}
compactMode={appState.stylesPanelMode === "compact"}
onSelect={(fontFamily) => { onSelect={(fontFamily) => {
withCaretPositionPreservation(
() => {
setBatchedData({ setBatchedData({
openPopup: null, openPopup: null,
currentHoveredFontFamily: null, currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily, currentItemFontFamily: fontFamily,
}); });
// defensive clear so immediate close won't abuse the cached elements // defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear(); cachedElementsRef.current.clear();
},
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
);
}} }}
onHover={(fontFamily) => { onHover={(fontFamily) => {
setBatchedData({ setBatchedData({
@ -1196,28 +1164,25 @@ export const actionChangeFontFamily = register({
} }
setBatchedData({ setBatchedData({
...batchedData,
openPopup: "fontFamily", openPopup: "fontFamily",
}); });
} else { } else {
const fontFamilyData = { // close, use the cache and clear it afterwards
const data = {
openPopup: null,
currentHoveredFontFamily: null, currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current), cachedElements: new Map(cachedElementsRef.current),
resetAll: true, resetAll: true,
} as ChangeFontFamilyData; } as ChangeFontFamilyData;
setBatchedData({ if (isUnmounted.current) {
...fontFamilyData, // in case the component was unmounted by the parent, trigger the update directly
}); updateData({ ...batchedData, ...data });
cachedElementsRef.current.clear(); } else {
setBatchedData(data);
// Refocus text editor when font picker closes if we were editing text
if (
appState.stylesPanelMode === "compact" &&
appState.editingTextElement
) {
restoreCaretPosition(null); // Just refocus without saved position
} }
cachedElementsRef.current.clear();
} }
}} }}
/> />
@ -1260,9 +1225,8 @@ export const actionChangeTextAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.textAlign")}</legend> <legend>{t("labels.textAlign")}</legend>
@ -1311,14 +1275,7 @@ export const actionChangeTextAlign = register({
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign, hasSelection ? null : appState.currentItemTextAlign,
)} )}
onChange={(value) => { onChange={(value) => updateData(value)}
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@ -1360,7 +1317,7 @@ export const actionChangeVerticalAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
return ( return (
<fieldset> <fieldset>
<div className="buttonList"> <div className="buttonList">
@ -1410,14 +1367,7 @@ export const actionChangeVerticalAlign = register({
) !== null, ) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)} )}
onChange={(value) => { onChange={(value) => updateData(value)}
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@ -1666,25 +1616,6 @@ export const actionChangeArrowhead = register({
}, },
}); });
export const actionChangeArrowProperties = register({
name: "changeArrowProperties",
label: "Change arrow properties",
trackEvent: false,
perform: (elements, appState, value, app) => {
// This action doesn't perform any changes directly
// It's just a container for the arrow type and arrowhead actions
return false;
},
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
return (
<div className="selected-shape-actions">
{renderAction("changeArrowType")}
{renderAction("changeArrowhead")}
</div>
);
},
});
export const actionChangeArrowType = register({ export const actionChangeArrowType = register({
name: "changeArrowType", name: "changeArrowType",
label: "Change arrow types", label: "Change arrow types",

View File

@ -18,7 +18,6 @@ export {
actionChangeFontFamily, actionChangeFontFamily,
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign, actionChangeVerticalAlign,
actionChangeArrowProperties,
} from "./actionProperties"; } from "./actionProperties";
export { export {

View File

@ -69,7 +69,6 @@ export type ActionName =
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType" | "changeArrowType"
| "changeArrowProperties"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
| "toggleCanvasMenu" | "toggleCanvasMenu"

View File

@ -123,7 +123,6 @@ export const getDefaultAppState = (): Omit<
searchMatches: null, searchMatches: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
stylesPanelMode: "full",
}; };
}; };
@ -248,7 +247,6 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true }, lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false }, activeLockedId: { browser: false, export: false, server: false },
stylesPanelMode: { browser: true, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <

View File

@ -1,7 +1,6 @@
import { import {
createPasteEvent, createPasteEvent,
parseClipboard, parseClipboard,
parseDataTransferEvent,
serializeAsClipboardJSON, serializeAsClipboardJSON,
} from "./clipboard"; } from "./clipboard";
import { API } from "./tests/helpers/api"; import { API } from "./tests/helpers/api";
@ -14,9 +13,7 @@ describe("parseClipboard()", () => {
text = "123"; text = "123";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }), createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@ -24,9 +21,7 @@ describe("parseClipboard()", () => {
text = "[123]"; text = "[123]";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }), createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@ -34,9 +29,7 @@ describe("parseClipboard()", () => {
text = JSON.stringify({ val: 42 }); text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }), createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
}); });
@ -46,13 +39,11 @@ describe("parseClipboard()", () => {
const json = serializeAsClipboardJSON({ elements: [rect], files: null }); const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/plain": json, "text/plain": json,
}, },
}), }),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
}); });
@ -65,25 +56,21 @@ describe("parseClipboard()", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": json, "text/html": json,
}, },
}), }),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `<div> ${json}</div>`, "text/html": `<div> ${json}</div>`,
}, },
}), }),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -93,13 +80,11 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `<img src="https://example.com/image.png" />`, "text/html": `<img src="https://example.com/image.png" />`,
}, },
}), }),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@ -109,13 +94,11 @@ describe("parseClipboard()", () => {
]); ]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`, "text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
}, },
}), }),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@ -131,13 +114,11 @@ describe("parseClipboard()", () => {
it("should parse text content alongside <image> `src` urls out of text/html", async () => { it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`, "text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
}, },
}), }),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@ -160,7 +141,6 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/plain": `a b "text/plain": `a b
@ -169,7 +149,6 @@ describe("parseClipboard()", () => {
7 10`, 7 10`,
}, },
}), }),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@ -178,7 +157,6 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `a b "text/html": `a b
@ -187,7 +165,6 @@ describe("parseClipboard()", () => {
7 10`, 7 10`,
}, },
}), }),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@ -196,7 +173,6 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `<html> "text/html": `<html>
@ -210,7 +186,6 @@ describe("parseClipboard()", () => {
7 10`, 7 10`,
}, },
}), }),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",

View File

@ -5,7 +5,6 @@ import {
arrayToMap, arrayToMap,
isMemberOf, isMemberOf,
isPromiseLike, isPromiseLike,
EVENT,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element"; import { mutateElement } from "@excalidraw/element";
@ -17,26 +16,15 @@ import {
import { getContainingFrame } from "@excalidraw/element"; import { getContainingFrame } from "@excalidraw/element";
import type { ValueOf } from "@excalidraw/common/utility-types";
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { ExcalidrawError } from "./errors"; import { ExcalidrawError } from "./errors";
import { import { createFile, isSupportedImageFileType } from "./data/blob";
createFile,
getFileHandle,
isSupportedImageFileType,
normalizeFile,
} from "./data/blob";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import type { FileSystemHandle } from "./data/filesystem";
import type { Spreadsheet } from "./charts"; import type { Spreadsheet } from "./charts";
import type { BinaryFiles } from "./types"; import type { BinaryFiles } from "./types";
@ -104,7 +92,7 @@ export const createPasteEvent = ({
console.warn("createPasteEvent: no types or files provided"); console.warn("createPasteEvent: no types or files provided");
} }
const event = new ClipboardEvent(EVENT.PASTE, { const event = new ClipboardEvent("paste", {
clipboardData: new DataTransfer(), clipboardData: new DataTransfer(),
}); });
@ -113,11 +101,10 @@ export const createPasteEvent = ({
if (typeof value !== "string") { if (typeof value !== "string") {
files = files || []; files = files || [];
files.push(value); files.push(value);
event.clipboardData?.items.add(value);
continue; continue;
} }
try { try {
event.clipboardData?.items.add(value, type); event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) { if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`); throw new Error(`Failed to set "${type}" as clipboardData item`);
} }
@ -242,10 +229,14 @@ function parseHTMLTree(el: ChildNode) {
return result; return result;
} }
const maybeParseHTMLDataItem = ( const maybeParseHTMLPaste = (
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>, event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => { ): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = dataItem.value; const html = event.clipboardData?.getData(MIME_TYPES.html);
if (!html) {
return null;
}
try { try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html); const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
@ -341,21 +332,18 @@ export const readSystemClipboard = async () => {
* Parses "paste" ClipboardEvent. * Parses "paste" ClipboardEvent.
*/ */
const parseClipboardEventTextData = async ( const parseClipboardEventTextData = async (
dataList: ParsedDataTranferList, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ParsedClipboardEventTextData> => { ): Promise<ParsedClipboardEventTextData> => {
try { try {
const htmlItem = dataList.findByType(MIME_TYPES.html); const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
const mixedContent =
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
if (mixedContent) { if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) { if (mixedContent.value.every((item) => item.type === "text")) {
return { return {
type: "text", type: "text",
value: value:
dataList.getData(MIME_TYPES.text) ?? event.clipboardData?.getData(MIME_TYPES.text) ||
mixedContent.value mixedContent.value
.map((item) => item.value) .map((item) => item.value)
.join("\n") .join("\n")
@ -366,155 +354,23 @@ const parseClipboardEventTextData = async (
return mixedContent; return mixedContent;
} }
return { const text = event.clipboardData?.getData(MIME_TYPES.text);
type: "text",
value: (dataList.getData(MIME_TYPES.text) || "").trim(), return { type: "text", value: (text || "").trim() };
};
} catch { } catch {
return { type: "text", value: "" }; return { type: "text", value: "" };
} }
}; };
type AllowedParsedDataTransferItem =
| {
type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
type ParsedDataTransferItem =
| {
type: string;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: string; kind: "string"; value: string };
type ParsedDataTransferItemType<
T extends AllowedParsedDataTransferItem["type"],
> = AllowedParsedDataTransferItem & { type: T };
export type ParsedDataTransferFile = Extract<
AllowedParsedDataTransferItem,
{ kind: "file" }
>;
type ParsedDataTranferList = ParsedDataTransferItem[] & {
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
findByType: typeof findDataTransferItemType;
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
getData: typeof getDataTransferItemData;
getFiles: typeof getDataTransferFiles;
};
const findDataTransferItemType = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
return (
this.find(
(item): item is ParsedDataTransferItemType<T> => item.type === type,
) || null
);
};
const getDataTransferItemData = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(
this: ParsedDataTranferList,
type: T,
):
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
| null {
const item = this.find(
(
item,
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
item.type === type,
);
return item?.value ?? null;
};
const getDataTransferFiles = function (
this: ParsedDataTranferList,
): ParsedDataTransferFile[] {
return this.filter(
(item): item is ParsedDataTransferFile => item.kind === "file",
);
};
export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
const dragEvent = event;
items = dragEvent.dataTransfer?.items;
}
const dataItems = (
await Promise.all(
Array.from(items || []).map(
async (item): Promise<ParsedDataTransferItem | null> => {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
const fileHandle = await getFileHandle(item);
return {
type: file.type,
kind: "file",
file: await normalizeFile(file),
fileHandle,
};
}
} else if (item.kind === "string") {
const { type } = item;
let value: string;
if ("clipboardData" in event && event.clipboardData) {
value = event.clipboardData?.getData(type);
} else {
value = await new Promise<string>((resolve) => {
item.getAsString((str) => resolve(str));
});
}
return { type, kind: "string", value };
}
return null;
},
),
)
).filter((data): data is ParsedDataTransferItem => data != null);
return Object.assign(dataItems, {
findByType: findDataTransferItemType,
getData: getDataTransferItemData,
getFiles: getDataTransferFiles,
});
};
/** /**
* Attempts to parse clipboard event. * Attempts to parse clipboard event.
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
dataList: ParsedDataTranferList, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEventTextData( const parsedEventData = await parseClipboardEventTextData(
dataList, event,
isPlainPaste, isPlainPaste,
); );
@ -663,14 +519,3 @@ const copyTextViaExecCommand = (text: string | null) => {
return success; return success;
}; };
export const isClipboardEvent = (
event: React.SyntheticEvent | Event,
): event is ClipboardEvent => {
/** not using instanceof ClipboardEvent due to tests (jsdom) */
return (
event.type === EVENT.PASTE ||
event.type === EVENT.COPY ||
event.type === EVENT.CUT
);
};

View File

@ -91,120 +91,3 @@
} }
} }
} }
.compact-shape-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: calc(100vh - 200px);
overflow-y: auto;
padding: 0.5rem;
.compact-action-item {
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-height: 2.5rem;
--default-button-size: 2rem;
.compact-action-button {
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--border-radius-lg);
background: transparent;
color: var(--color-on-surface);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
svg {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
}
&:hover {
background: var(--button-hover-bg, var(--island-bg-color));
border-color: var(
--button-hover-border,
var(--button-border, var(--default-border-color))
);
}
&:active {
background: var(--button-active-bg, var(--island-bg-color));
border-color: var(--button-active-border, var(--color-primary-darkest));
}
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
}
}
.compact-shape-actions-island {
width: fit-content;
overflow-x: hidden;
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
.shape-actions-theme-scope {
--button-border: transparent;
--button-bg: var(--color-surface-mid);
}
:root.theme--dark .shape-actions-theme-scope {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}

View File

@ -1,6 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useState } from "react"; import { useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { import {
CLASSES, CLASSES,
@ -20,7 +19,6 @@ import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
isArrowElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element"; import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
@ -48,20 +46,15 @@ import {
hasStrokeWidth, hasStrokeWidth,
} from "../scene"; } from "../scene";
import { getFormValue } from "../actions/actionProperties";
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
import { getToolbarTools } from "./shapes"; import { getToolbarTools } from "./shapes";
import "./Actions.scss"; import "./Actions.scss";
import { useDevice, useExcalidrawContainer } from "./App"; import { useDevice } from "./App";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import DropdownMenu from "./dropdownMenu/DropdownMenu"; import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { PropertiesPopover } from "./PropertiesPopover";
import { import {
EmbedIcon, EmbedIcon,
extraToolsIcon, extraToolsIcon,
@ -70,29 +63,11 @@ import {
laserPointerToolIcon, laserPointerToolIcon,
MagicIcon, MagicIcon,
LassoIcon, LassoIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
TextSizeIcon,
adjustmentsIcon,
DotsHorizontalIcon,
} from "./icons"; } from "./icons";
import type { import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
AppClassProperties,
AppProps,
UIAppState,
Zoom,
AppState,
} from "../types";
import type { ActionManager } from "../actions/manager"; import type { ActionManager } from "../actions/manager";
// Common CSS class combinations
const PROPERTIES_CLASSES = clsx([
CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
"properties-content",
]);
export const canChangeStrokeColor = ( export const canChangeStrokeColor = (
appState: UIAppState, appState: UIAppState,
targetElements: ExcalidrawElement[], targetElements: ExcalidrawElement[],
@ -305,437 +280,6 @@ export const SelectedShapeActions = ({
); );
}; };
export const CompactShapeActions = ({
appState,
elementsMap,
renderAction,
app,
setAppState,
}: {
appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const targetElements = getTargetElements(elementsMap, appState);
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
const { container } = useExcalidrawContainer();
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const showFillIcons =
(hasBackground(appState.activeTool.type) &&
!isTransparent(appState.currentItemBackgroundColor)) ||
targetElements.some(
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
);
const showLinkIcon = targetElements.length === 1;
const showLineEditorAction =
!appState.selectedLinearElement?.isEditing &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
const showAlignActions = alignActionsPredicate(appState, app);
let isSingleElementBoundContainer = false;
if (
targetElements.length === 2 &&
(hasBoundTextElement(targetElements[0]) ||
hasBoundTextElement(targetElements[1]))
) {
isSingleElementBoundContainer = true;
}
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
return (
<div className="compact-shape-actions">
{/* Stroke Color */}
{canChangeStrokeColor(appState, targetElements) && (
<div className={clsx("compact-action-item")}>
{renderAction("changeStrokeColor")}
</div>
)}
{/* Background Color */}
{canChangeBackgroundColor(appState, targetElements) && (
<div className="compact-action-item">
{renderAction("changeBackgroundColor")}
</div>
)}
{/* Combined Properties (Fill, Stroke, Opacity) */}
{(showFillIcons ||
hasStrokeWidth(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeWidth(element.type)) ||
hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeStyle(element.type)) ||
canChangeRoundness(appState.activeTool.type) ||
targetElements.some((element) => canChangeRoundness(element.type))) && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactStrokeStyles"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactStrokeStyles" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.stroke")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactStrokeStyles"
? null
: "compactStrokeStyles",
});
}}
>
{adjustmentsIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactStrokeStyles" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{ maxWidth: "13rem" }}
onClose={() => {}}
>
<div className="selected-shape-actions">
{showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) ||
targetElements.some((element) =>
hasStrokeWidth(element.type),
)) &&
renderAction("changeStrokeWidth")}
{(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) =>
hasStrokeStyle(element.type),
)) && (
<>
{renderAction("changeStrokeStyle")}
{renderAction("changeSloppiness")}
</>
)}
{(canChangeRoundness(appState.activeTool.type) ||
targetElements.some((element) =>
canChangeRoundness(element.type),
)) &&
renderAction("changeRoundness")}
{renderAction("changeOpacity")}
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
{/* Combined Arrow Properties */}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactArrowProperties"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactArrowProperties" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.arrowtypes")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactArrowProperties"
? null
: "compactArrowProperties",
});
}}
>
{(() => {
// Show an icon based on the current arrow type
const arrowType = getFormValue(
targetElements,
app,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? "elbow"
: element.roundness
? "round"
: "sharp";
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
);
if (arrowType === "elbow") {
return elbowArrowIcon;
}
if (arrowType === "round") {
return roundArrowIcon;
}
return sharpArrowIcon;
})()}
</button>
</Popover.Trigger>
{appState.openPopup === "compactArrowProperties" && (
<PropertiesPopover
container={container}
className="properties-content"
style={{ maxWidth: "13rem" }}
onClose={() => {}}
>
{renderAction("changeArrowProperties")}
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
{/* Linear Editor */}
{showLineEditorAction && (
<div className="compact-action-item">
{renderAction("toggleLinearEditor")}
</div>
)}
{/* Text Properties */}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
<div className="compact-action-item">
{renderAction("changeFontFamily")}
</div>
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactTextProperties"}
onOpenChange={(open) => {
if (open) {
if (appState.editingTextElement) {
saveCaretPosition();
}
setAppState({ openPopup: "compactTextProperties" });
} else {
setAppState({ openPopup: null });
if (appState.editingTextElement) {
restoreCaretPosition();
}
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.textAlign")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (appState.openPopup === "compactTextProperties") {
setAppState({ openPopup: null });
} else {
if (appState.editingTextElement) {
saveCaretPosition();
}
setAppState({ openPopup: "compactTextProperties" });
}
}}
>
{TextSizeIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactTextProperties" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onClose={() => {
// Refocus text editor when popover closes with caret restoration
if (appState.editingTextElement) {
restoreCaretPosition();
}
}}
>
<div className="selected-shape-actions">
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) &&
renderAction("changeFontSize")}
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")}
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
renderAction("changeVerticalAlign")}
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
</>
)}
{/* Dedicated Copy Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("duplicateSelection")}
</div>
)}
{/* Dedicated Delete Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("deleteSelectedElements")}
</div>
)}
{/* Combined Other Actions */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactOtherProperties"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactOtherProperties" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.actions")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactOtherProperties"
? null
: "compactOtherProperties",
});
}}
>
{DotsHorizontalIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactOtherProperties" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{
maxWidth: "12rem",
// center the popover content
justifyContent: "center",
alignItems: "center",
}}
onClose={() => {}}
>
<div className="selected-shape-actions">
<fieldset>
<legend>{t("labels.layers")}</legend>
<div className="buttonList">
{renderAction("sendToBack")}
{renderAction("sendBackward")}
{renderAction("bringForward")}
{renderAction("bringToFront")}
</div>
</fieldset>
{showAlignActions && !isSingleElementBoundContainer && (
<fieldset>
<legend>{t("labels.align")}</legend>
<div className="buttonList">
{isRTL ? (
<>
{renderAction("alignRight")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignLeft")}
</>
) : (
<>
{renderAction("alignLeft")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignRight")}
</>
)}
{targetElements.length > 2 &&
renderAction("distributeHorizontally")}
{/* breaks the row ˇˇ */}
<div style={{ flexBasis: "100%", height: 0 }} />
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: ".5rem",
marginTop: "-0.5rem",
}}
>
{renderAction("alignTop")}
{renderAction("alignVerticallyCentered")}
{renderAction("alignBottom")}
{targetElements.length > 2 &&
renderAction("distributeVertically")}
</div>
</div>
</fieldset>
)}
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
</div>
</fieldset>
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
</div>
);
};
export const ShapesSwitcher = ({ export const ShapesSwitcher = ({
activeTool, activeTool,
appState, appState,

View File

@ -41,6 +41,9 @@ import {
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
MAX_ALLOWED_FILE_BYTES, MAX_ALLOWED_FILE_BYTES,
MIME_TYPES, MIME_TYPES,
MQ_MAX_HEIGHT_LANDSCAPE,
MQ_MAX_WIDTH_LANDSCAPE,
MQ_MAX_WIDTH_PORTRAIT,
MQ_RIGHT_SIDEBAR_MIN_WIDTH, MQ_RIGHT_SIDEBAR_MIN_WIDTH,
POINTER_BUTTON, POINTER_BUTTON,
ROUNDNESS, ROUNDNESS,
@ -97,12 +100,8 @@ import {
randomInteger, randomInteger,
CLASSES, CLASSES,
Emitter, Emitter,
isMobile,
MINIMUM_ARROW_SIZE, MINIMUM_ARROW_SIZE,
DOUBLE_TAP_POSITION_THRESHOLD,
isMobileOrTablet,
MQ_MAX_MOBILE,
MQ_MIN_TABLET,
MQ_MAX_TABLET,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -237,7 +236,6 @@ import {
isSimpleArrow, isSimpleArrow,
StoreDelta, StoreDelta,
type ApplyToOptions, type ApplyToOptions,
positionElementsOnGrid,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
@ -324,13 +322,7 @@ import {
isEraserActive, isEraserActive,
isHandToolActive, isHandToolActive,
} from "../appState"; } from "../appState";
import { import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
copyTextToSystemClipboard,
parseClipboard,
parseDataTransferEvent,
type ParsedDataTransferFile,
} from "../clipboard";
import { exportCanvas, loadFromBlob } from "../data"; import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore"; import { restore, restoreElements } from "../data/restore";
@ -352,6 +344,7 @@ import {
generateIdFromFile, generateIdFromFile,
getDataURL, getDataURL,
getDataURL_sync, getDataURL_sync,
getFileFromEvent,
ImageURLToFile, ImageURLToFile,
isImageFileHandle, isImageFileHandle,
isSupportedImageFile, isSupportedImageFile,
@ -438,7 +431,7 @@ import type {
ScrollBars, ScrollBars,
} from "../scene/types"; } from "../scene/types";
import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu"; import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem"; import type { FileSystemHandle } from "../data/filesystem";
@ -538,7 +531,6 @@ export const useExcalidrawActionManager = () =>
let didTapTwice: boolean = false; let didTapTwice: boolean = false;
let tappedTwiceTimer = 0; let tappedTwiceTimer = 0;
let firstTapPosition: { x: number; y: number } | null = null;
let isHoldingSpace: boolean = false; let isHoldingSpace: boolean = false;
let isPanning: boolean = false; let isPanning: boolean = false;
let isDraggingScrollBar: boolean = false; let isDraggingScrollBar: boolean = false;
@ -667,7 +659,7 @@ class App extends React.Component<AppProps, AppState> {
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
this.defaultSelectionTool = isMobileOrTablet() this.defaultSelectionTool = this.isMobileOrTablet()
? ("lasso" as const) ? ("lasso" as const)
: ("selection" as const); : ("selection" as const);
const { const {
@ -2420,16 +2412,21 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private isMobileBreakpoint = (width: number, height: number) => { private isMobileOrTablet = (): boolean => {
const minSide = Math.min(width, height); const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
return minSide <= MQ_MAX_MOBILE; const hasCoarsePointer =
"matchMedia" in window &&
window?.matchMedia("(pointer: coarse)")?.matches;
const isTouchMobile = hasTouch && hasCoarsePointer;
return isMobile || isTouchMobile;
}; };
private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => { private isMobileBreakpoint = (width: number, height: number) => {
const minSide = Math.min(editorWidth, editorHeight); return (
const maxSide = Math.max(editorWidth, editorHeight); width < MQ_MAX_WIDTH_PORTRAIT ||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET; );
}; };
private refreshViewportBreakpoints = () => { private refreshViewportBreakpoints = () => {
@ -2438,14 +2435,14 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const { width: editorWidth, height: editorHeight } = const { clientWidth: viewportWidth, clientHeight: viewportHeight } =
container.getBoundingClientRect(); document.body;
const prevViewportState = this.device.viewport; const prevViewportState = this.device.viewport;
const nextViewportState = updateObject(prevViewportState, { const nextViewportState = updateObject(prevViewportState, {
isLandscape: editorWidth > editorHeight, isLandscape: viewportWidth > viewportHeight,
isMobile: this.isMobileBreakpoint(editorWidth, editorHeight), isMobile: this.isMobileBreakpoint(viewportWidth, viewportHeight),
}); });
if (prevViewportState !== nextViewportState) { if (prevViewportState !== nextViewportState) {
@ -2476,17 +2473,6 @@ class App extends React.Component<AppProps, AppState> {
canFitSidebar: editorWidth > sidebarBreakpoint, canFitSidebar: editorWidth > sidebarBreakpoint,
}); });
// also check if we need to update the app state
this.setState({
stylesPanelMode:
// NOTE: we could also remove the isMobileOrTablet check here and
// always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP)
// but not too narrow (> MQ_MAX_WIDTH_MOBILE)
this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet()
? "compact"
: "full",
});
if (prevEditorState !== nextEditorState) { if (prevEditorState !== nextEditorState) {
this.device = { ...this.device, editor: nextEditorState }; this.device = { ...this.device, editor: nextEditorState };
return true; return true;
@ -3003,7 +2989,6 @@ class App extends React.Component<AppProps, AppState> {
private static resetTapTwice() { private static resetTapTwice() {
didTapTwice = false; didTapTwice = false;
firstTapPosition = null;
} }
private onTouchStart = (event: TouchEvent) => { private onTouchStart = (event: TouchEvent) => {
@ -3014,13 +2999,6 @@ class App extends React.Component<AppProps, AppState> {
if (!didTapTwice) { if (!didTapTwice) {
didTapTwice = true; didTapTwice = true;
if (event.touches.length === 1) {
firstTapPosition = {
x: event.touches[0].clientX,
y: event.touches[0].clientY,
};
}
clearTimeout(tappedTwiceTimer); clearTimeout(tappedTwiceTimer);
tappedTwiceTimer = window.setTimeout( tappedTwiceTimer = window.setTimeout(
App.resetTapTwice, App.resetTapTwice,
@ -3028,29 +3006,15 @@ class App extends React.Component<AppProps, AppState> {
); );
return; return;
} }
// insert text only if we tapped twice with a single finger
// insert text only if we tapped twice with a single finger at approximately the same position
// event.touches.length === 1 will also prevent inserting text when user's zooming // event.touches.length === 1 will also prevent inserting text when user's zooming
if (didTapTwice && event.touches.length === 1 && firstTapPosition) { if (didTapTwice && event.touches.length === 1) {
const touch = event.touches[0]; const touch = event.touches[0];
const distance = pointDistance(
pointFrom(touch.clientX, touch.clientY),
pointFrom(firstTapPosition.x, firstTapPosition.y),
);
// only create text if the second tap is within the threshold of the first tap
// this prevents accidental text creation during dragging/selection
if (distance <= DOUBLE_TAP_POSITION_THRESHOLD) {
// end lasso trail and deselect elements just in case
this.lassoTrail.endPath();
this.deselectElements();
// @ts-ignore // @ts-ignore
this.handleCanvasDoubleClick({ this.handleCanvasDoubleClick({
clientX: touch.clientX, clientX: touch.clientX,
clientY: touch.clientY, clientY: touch.clientY,
}); });
}
didTapTwice = false; didTapTwice = false;
clearTimeout(tappedTwiceTimer); clearTimeout(tappedTwiceTimer);
} }
@ -3078,12 +3042,31 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
// TODO: Cover with tests // TODO: this is so spaghetti, we should refactor it and cover it with tests
private async insertClipboardContent( public pasteFromClipboard = withBatchedUpdates(
data: ClipboardData, async (event: ClipboardEvent) => {
dataTransferFiles: ParsedDataTransferFile[], const isPlainPaste = !!IS_PLAIN_PASTE;
isPlainPaste: boolean,
// #686
const target = document.activeElement;
const isExcalidrawActive =
this.excalidrawContainerRef.current?.contains(target);
if (event && !isExcalidrawActive) {
return;
}
const elementUnderCursor = document.elementFromPoint(
this.lastViewportPosition.x,
this.lastViewportPosition.y,
);
if (
event &&
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
isWritableElement(target))
) { ) {
return;
}
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ {
clientX: this.lastViewportPosition.x, clientX: this.lastViewportPosition.x,
@ -3092,56 +3075,60 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
); );
// ------------------- Error ------------------- // must be called in the same frame (thus before any awaits) as the paste
if (data.errorMessage) { // event else some browsers (FF...) will clear the clipboardData
this.setState({ errorMessage: data.errorMessage }); // (something something security)
return; let file = event?.clipboardData?.files[0];
} const data = await parseClipboard(event, isPlainPaste);
if (!file && !isPlainPaste) {
// ------------------- Mixed content with no files ------------------- if (data.mixedContent) {
if (dataTransferFiles.length === 0 && !isPlainPaste && data.mixedContent) { return this.addElementsFromMixedContentPaste(data.mixedContent, {
await this.addElementsFromMixedContentPaste(data.mixedContent, {
isPlainPaste, isPlainPaste,
sceneX, sceneX,
sceneY, sceneY,
}); });
} else if (data.text) {
const string = data.text.trim();
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
// ignore SVG validation/normalization which will be done during image
// initialization
file = SVGStringToFile(string);
}
}
}
// prefer spreadsheet data over image file (MS Office/Libre Office)
if (isSupportedImageFile(file) && !data.spreadsheet) {
if (!this.isToolSupported("image")) {
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
return; return;
} }
// ------------------- Spreadsheet ------------------- this.createImageElement({ sceneX, sceneY, imageFile: file });
if (data.spreadsheet && !isPlainPaste) {
return;
}
if (this.props.onPaste) {
try {
if ((await this.props.onPaste(data, event)) === false) {
return;
}
} catch (error: any) {
console.error(error);
}
}
if (data.errorMessage) {
this.setState({ errorMessage: data.errorMessage });
} else if (data.spreadsheet && !isPlainPaste) {
this.setState({ this.setState({
pasteDialog: { pasteDialog: {
data: data.spreadsheet, data: data.spreadsheet,
shown: true, shown: true,
}, },
}); });
return; } else if (data.elements) {
}
// ------------------- Images or SVG code -------------------
const imageFiles = dataTransferFiles.map((data) => data.file);
if (imageFiles.length === 0 && data.text && !isPlainPaste) {
const trimmedText = data.text.trim();
if (trimmedText.startsWith("<svg") && trimmedText.endsWith("</svg>")) {
// ignore SVG validation/normalization which will be done during image
// initialization
imageFiles.push(SVGStringToFile(trimmedText));
}
}
if (imageFiles.length > 0) {
if (this.isToolSupported("image")) {
await this.insertImages(imageFiles, sceneX, sceneY);
} else {
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
}
return;
}
// ------------------- Elements -------------------
if (data.elements) {
const elements = ( const elements = (
data.programmaticAPI data.programmaticAPI
? convertToExcalidrawElements( ? convertToExcalidrawElements(
@ -3149,24 +3136,17 @@ class App extends React.Component<AppProps, AppState> {
) )
: data.elements : data.elements
) as readonly ExcalidrawElement[]; ) as readonly ExcalidrawElement[];
// TODO: remove formatting from elements if isPlainPaste // TODO remove formatting from elements if isPlainPaste
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
files: data.files || null, files: data.files || null,
position: isMobileOrTablet() ? "center" : "cursor", position: this.isMobileOrTablet() ? "center" : "cursor",
retainSeed: isPlainPaste, retainSeed: isPlainPaste,
}); });
return; } else if (data.text) {
} if (data.text && isMaybeMermaidDefinition(data.text)) {
// ------------------- Only textual stuff remaining -------------------
if (!data.text) {
return;
}
// ------------------- Successful Mermaid -------------------
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw"); const api = await import("@excalidraw/mermaid-to-excalidraw");
try { try {
const { elements: skeletonElements, files } = const { elements: skeletonElements, files } =
await api.parseMermaidToExcalidraw(data.text); await api.parseMermaidToExcalidraw(data.text);
@ -3178,7 +3158,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
files, files,
position: isMobileOrTablet() ? "center" : "cursor", position: this.isMobileOrTablet() ? "center" : "cursor",
}); });
return; return;
@ -3189,23 +3169,26 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
// ------------------- Pure embeddable URLs -------------------
const nonEmptyLines = normalizeEOL(data.text) const nonEmptyLines = normalizeEOL(data.text)
.split(/\n+/) .split(/\n+/)
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
const embbeddableUrls = nonEmptyLines const embbeddableUrls = nonEmptyLines
.map((str) => maybeParseEmbedSrc(str)) .map((str) => maybeParseEmbedSrc(str))
.filter( .filter((string) => {
(string) => return (
embeddableURLValidator(string, this.props.validateEmbeddable) && embeddableURLValidator(string, this.props.validateEmbeddable) &&
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) || (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
getEmbedLink(string)?.type === "video"), getEmbedLink(string)?.type === "video")
); );
});
if ( if (
!isPlainPaste && !IS_PLAIN_PASTE &&
embbeddableUrls.length > 0 && embbeddableUrls.length > 0 &&
// if there were non-embeddable text (lines) mixed in with embeddable
// urls, ignore and paste as text
embbeddableUrls.length === nonEmptyLines.length embbeddableUrls.length === nonEmptyLines.length
) { ) {
const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = []; const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
@ -3233,56 +3216,8 @@ class App extends React.Component<AppProps, AppState> {
} }
return; return;
} }
// ------------------- Text -------------------
this.addTextFromPaste(data.text, isPlainPaste); this.addTextFromPaste(data.text, isPlainPaste);
} }
public pasteFromClipboard = withBatchedUpdates(
async (event: ClipboardEvent) => {
const isPlainPaste = !!IS_PLAIN_PASTE;
// #686
const target = document.activeElement;
const isExcalidrawActive =
this.excalidrawContainerRef.current?.contains(target);
if (event && !isExcalidrawActive) {
return;
}
const elementUnderCursor = document.elementFromPoint(
this.lastViewportPosition.x,
this.lastViewportPosition.y,
);
if (
event &&
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
isWritableElement(target))
) {
return;
}
// must be called in the same frame (thus before any awaits) as the paste
// event else some browsers (FF...) will clear the clipboardData
// (something something security)
const dataTransferList = await parseDataTransferEvent(event);
const filesList = dataTransferList.getFiles();
const data = await parseClipboard(dataTransferList, isPlainPaste);
if (this.props.onPaste) {
try {
if ((await this.props.onPaste(data, event)) === false) {
return;
}
} catch (error: any) {
console.error(error);
}
}
await this.insertClipboardContent(data, filesList, isPlainPaste);
this.setActiveTool({ type: this.defaultSelectionTool }, true); this.setActiveTool({ type: this.defaultSelectionTool }, true);
event?.preventDefault(); event?.preventDefault();
}, },
@ -3472,11 +3407,45 @@ class App extends React.Component<AppProps, AppState> {
} }
}), }),
); );
let y = sceneY;
let firstImageYOffsetDone = false;
const nextSelectedIds: Record<ExcalidrawElement["id"], true> = {};
for (const response of responses) {
if (response.file) {
const initializedImageElement = await this.createImageElement({
sceneX,
sceneY: y,
imageFile: response.file,
});
if (initializedImageElement) {
// vertically center first image in the batch
if (!firstImageYOffsetDone) {
firstImageYOffsetDone = true;
y -= initializedImageElement.height / 2;
}
// hack to reset the `y` coord because we vertically center during
// insertImageElement
this.scene.mutateElement(
initializedImageElement,
{ y },
{ informMutation: false, isDragging: false },
);
y = initializedImageElement.y + initializedImageElement.height + 25;
nextSelectedIds[initializedImageElement.id] = true;
}
}
}
this.setState({
selectedElementIds: makeNextSelectedElementIds(
nextSelectedIds,
this.state,
),
});
const imageFiles = responses
.filter((response): response is { file: File } => !!response.file)
.map((response) => response.file);
await this.insertImages(imageFiles, sceneX, sceneY);
const error = responses.find((response) => !!response.errorMessage); const error = responses.find((response) => !!response.errorMessage);
if (error && error.errorMessage) { if (error && error.errorMessage) {
this.setState({ errorMessage: error.errorMessage }); this.setState({ errorMessage: error.errorMessage });
@ -4813,7 +4782,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ suggestedBindings: [] }); this.setState({ suggestedBindings: [] });
} }
if (nextActiveTool.type === "image") { if (nextActiveTool.type === "image") {
this.onImageToolbarButtonClick(); this.onImageAction();
} }
this.setState((prevState) => { this.setState((prevState) => {
@ -6674,6 +6643,8 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit.element && pointerDownState.hit.element &&
this.isASelectedElement(pointerDownState.hit.element); this.isASelectedElement(pointerDownState.hit.element);
const isMobileOrTablet = this.isMobileOrTablet();
if ( if (
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType && !pointerDownState.resize.handleType &&
@ -6687,12 +6658,12 @@ class App extends React.Component<AppProps, AppState> {
// block dragging after lasso selection on PCs until the next pointer down // block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately) // (on mobile or tablet, we want to allow user to drag immediately)
pointerDownState.drag.blockDragging = !isMobileOrTablet(); pointerDownState.drag.blockDragging = !isMobileOrTablet;
} }
// only for mobile or tablet, if we hit an element, select it immediately like normal selection // only for mobile or tablet, if we hit an element, select it immediately like normal selection
if ( if (
isMobileOrTablet() && isMobileOrTablet &&
pointerDownState.hit.element && pointerDownState.hit.element &&
!hitSelectedElement !hitSelectedElement
) { ) {
@ -7248,16 +7219,6 @@ class App extends React.Component<AppProps, AppState> {
selectedElements.length === 1 && selectedElements.length === 1 &&
!this.state.selectedLinearElement?.isEditing && !this.state.selectedLinearElement?.isEditing &&
!isElbowArrow(selectedElements[0]) && !isElbowArrow(selectedElements[0]) &&
!(
isLineElement(selectedElements[0]) &&
LinearElementEditor.getPointIndexUnderCursor(
selectedElements[0],
elementsMap,
this.state.zoom,
pointerDownState.origin.x,
pointerDownState.origin.y,
) !== -1
) &&
!( !(
this.state.selectedLinearElement && this.state.selectedLinearElement &&
this.state.selectedLinearElement.hoverPointIndex !== -1 this.state.selectedLinearElement.hoverPointIndex !== -1
@ -7857,14 +7818,16 @@ class App extends React.Component<AppProps, AppState> {
return element; return element;
}; };
private newImagePlaceholder = ({ private createImageElement = async ({
sceneX, sceneX,
sceneY, sceneY,
addToFrameUnderCursor = true, addToFrameUnderCursor = true,
imageFile,
}: { }: {
sceneX: number; sceneX: number;
sceneY: number; sceneY: number;
addToFrameUnderCursor?: boolean; addToFrameUnderCursor?: boolean;
imageFile: File;
}) => { }) => {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
sceneX, sceneX,
@ -7883,7 +7846,7 @@ class App extends React.Component<AppProps, AppState> {
const placeholderSize = 100 / this.state.zoom.value; const placeholderSize = 100 / this.state.zoom.value;
return newImageElement({ const placeholderImageElement = newImageElement({
type: "image", type: "image",
strokeColor: this.state.currentItemStrokeColor, strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor, backgroundColor: this.state.currentItemBackgroundColor,
@ -7900,6 +7863,13 @@ class App extends React.Component<AppProps, AppState> {
width: placeholderSize, width: placeholderSize,
height: placeholderSize, height: placeholderSize,
}); });
const initializedImageElement = await this.insertImageElement(
placeholderImageElement,
imageFile,
);
return initializedImageElement;
}; };
private handleLinearElementOnPointerDown = ( private handleLinearElementOnPointerDown = (
@ -8503,7 +8473,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
this.state.activeTool.type === "lasso" && this.state.activeTool.type === "lasso" &&
this.lassoTrail.hasCurrentTrail && this.lassoTrail.hasCurrentTrail &&
!(isMobileOrTablet() && pointerDownState.hit.element) && !(this.isMobileOrTablet() && pointerDownState.hit.element) &&
!this.state.activeTool.fromSelection !this.state.activeTool.fromSelection
) { ) {
return; return;
@ -10221,7 +10191,64 @@ class App extends React.Component<AppProps, AppState> {
); );
}; };
private onImageToolbarButtonClick = async () => { /**
* inserts image into elements array and rerenders
*/
private insertImageElement = async (
placeholderImageElement: ExcalidrawImageElement,
imageFile: File,
) => {
// we should be handling all cases upstream, but in case we forget to handle
// a future case, let's throw here
if (!this.isToolSupported("image")) {
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
return;
}
this.scene.insertElement(placeholderImageElement);
try {
const initializedImageElement = await this.initializeImage(
placeholderImageElement,
imageFile,
);
const nextElements = this.scene
.getElementsIncludingDeleted()
.map((element) => {
if (element.id === initializedImageElement.id) {
return initializedImageElement;
}
return element;
});
this.updateScene({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
elements: nextElements,
appState: {
selectedElementIds: makeNextSelectedElementIds(
{ [initializedImageElement.id]: true },
this.state,
),
},
});
return initializedImageElement;
} catch (error: any) {
this.store.scheduleAction(CaptureUpdateAction.NEVER);
this.scene.mutateElement(placeholderImageElement, {
isDeleted: true,
});
this.actionManager.executeAction(actionFinalize);
this.setState({
errorMessage: error.message || t("errors.imageInsertError"),
});
return null;
}
};
private onImageAction = async () => {
try { try {
const clientX = this.state.width / 2 + this.state.offsetLeft; const clientX = this.state.width / 2 + this.state.offsetLeft;
const clientY = this.state.height / 2 + this.state.offsetTop; const clientY = this.state.height / 2 + this.state.offsetTop;
@ -10231,15 +10258,24 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
); );
const imageFiles = await fileOpen({ const imageFile = await fileOpen({
description: "Image", description: "Image",
extensions: Object.keys( extensions: Object.keys(
IMAGE_MIME_TYPES, IMAGE_MIME_TYPES,
) as (keyof typeof IMAGE_MIME_TYPES)[], ) as (keyof typeof IMAGE_MIME_TYPES)[],
multiple: true,
}); });
this.insertImages(imageFiles, x, y); await this.createImageElement({
sceneX: x,
sceneY: y,
addToFrameUnderCursor: false,
imageFile,
});
// avoid being batched (just in case)
this.setState({}, () => {
this.actionManager.executeAction(actionFinalize);
});
} catch (error: any) { } catch (error: any) {
if (error.name !== "AbortError") { if (error.name !== "AbortError") {
console.error(error); console.error(error);
@ -10436,82 +10472,23 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private insertImages = async (
imageFiles: File[],
sceneX: number,
sceneY: number,
) => {
const gridPadding = 50 / this.state.zoom.value;
// Create, position, and insert placeholders
const placeholders = positionElementsOnGrid(
imageFiles.map(() => this.newImagePlaceholder({ sceneX, sceneY })),
sceneX,
sceneY,
gridPadding,
);
placeholders.forEach((el) => this.scene.insertElement(el));
// Create, position, insert and select initialized (replacing placeholders)
const initialized = await Promise.all(
placeholders.map(async (placeholder, i) => {
try {
return await this.initializeImage(placeholder, imageFiles[i]);
} catch (error: any) {
this.setState({
errorMessage: error.message || t("errors.imageInsertError"),
});
return newElementWith(placeholder, { isDeleted: true });
}
}),
);
const initializedMap = arrayToMap(initialized);
const positioned = positionElementsOnGrid(
initialized.filter((el) => !el.isDeleted),
sceneX,
sceneY,
gridPadding,
);
const positionedMap = arrayToMap(positioned);
const nextElements = this.scene
.getElementsIncludingDeleted()
.map((el) => positionedMap.get(el.id) ?? initializedMap.get(el.id) ?? el);
this.updateScene({
appState: {
selectedElementIds: makeNextSelectedElementIds(
Object.fromEntries(positioned.map((el) => [el.id, true])),
this.state,
),
},
elements: nextElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
this.setState({}, () => {
// actionFinalize after all state values have been updated
this.actionManager.executeAction(actionFinalize);
});
};
private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => { private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => {
// must be retrieved first, in the same frame
const { file, fileHandle } = await getFileFromEvent(event);
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event, event,
this.state, this.state,
); );
const dataTransferList = await parseDataTransferEvent(event);
// must be retrieved first, in the same frame try {
const fileItems = dataTransferList.getFiles(); // if image tool not supported, don't show an error here and let it fall
// through so we still support importing scene data from images. If no
// scene data encoded, we'll show an error then
if (isSupportedImageFile(file) && this.isToolSupported("image")) {
// first attempt to decode scene from the image if it's embedded
// ---------------------------------------------------------------------
if (fileItems.length === 1) { if (file?.type === MIME_TYPES.png || file?.type === MIME_TYPES.svg) {
const { file, fileHandle } = fileItems[0];
if (
file &&
(file.type === MIME_TYPES.png || file.type === MIME_TYPES.svg)
) {
try { try {
const scene = await loadFromBlob( const scene = await loadFromBlob(
file, file,
@ -10530,23 +10507,28 @@ class App extends React.Component<AppProps, AppState> {
}); });
return; return;
} catch (error: any) { } catch (error: any) {
// Don't throw for image scene daa
if (error.name !== "EncodingError") { if (error.name !== "EncodingError") {
throw new Error(t("alerts.couldNotLoadInvalidFile")); throw new Error(t("alerts.couldNotLoadInvalidFile"));
} }
// if EncodingError, fall through to insert as regular image
}
} }
} }
const imageFiles = fileItems // if no scene is embedded or we fail for whatever reason, fall back
.map((data) => data.file) // to importing as regular image
.filter((file) => isSupportedImageFile(file)); // ---------------------------------------------------------------------
this.createImageElement({ sceneX, sceneY, imageFile: file });
if (imageFiles.length > 0 && this.isToolSupported("image")) { return;
return this.insertImages(imageFiles, sceneX, sceneY); }
} catch (error: any) {
return this.setState({
isLoading: false,
errorMessage: error.message,
});
} }
const libraryJSON = dataTransferList.getData(MIME_TYPES.excalidrawlib); const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
if (libraryJSON && typeof libraryJSON === "string") { if (libraryJSON && typeof libraryJSON === "string") {
try { try {
const libraryItems = parseLibraryJSON(libraryJSON); const libraryItems = parseLibraryJSON(libraryJSON);
@ -10561,18 +10543,13 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (fileItems.length > 0) {
const { file, fileHandle } = fileItems[0];
if (file) { if (file) {
// Attempt to parse an excalidraw/excalidrawlib file // Attempt to parse an excalidraw/excalidrawlib file
await this.loadFileToCanvas(file, fileHandle); await this.loadFileToCanvas(file, fileHandle);
} }
}
const textItem = dataTransferList.findByType(MIME_TYPES.text); if (event.dataTransfer?.types?.includes("text/plain")) {
const text = event.dataTransfer?.getData("text");
if (textItem) {
const text = textItem.value;
if ( if (
text && text &&
embeddableURLValidator(text, this.props.validateEmbeddable) && embeddableURLValidator(text, this.props.validateEmbeddable) &&

View File

@ -22,12 +22,6 @@
@include isMobile { @include isMobile {
max-width: 11rem; max-width: 11rem;
} }
&.color-picker-container--no-top-picks {
display: flex;
justify-content: center;
grid-template-columns: unset;
}
} }
.color-picker__top-picks { .color-picker__top-picks {
@ -86,16 +80,6 @@
} }
} }
.color-picker__button-background {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
}
}
&.active { &.active {
.color-picker__button-outline { .color-picker__button-outline {
position: absolute; position: absolute;

View File

@ -1,6 +1,6 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx"; import clsx from "clsx";
import { useRef, useEffect } from "react"; import { useRef } from "react";
import { import {
COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_OUTLINE_CONTRAST_THRESHOLD,
@ -18,12 +18,7 @@ import { useExcalidrawContainer } from "../App";
import { ButtonSeparator } from "../ButtonSeparator"; import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
import { backgroundIcon, slashIcon, strokeIcon } from "../icons"; import { slashIcon } from "../icons";
import {
saveCaretPosition,
restoreCaretPosition,
temporarilyDisableTextEditorBlur,
} from "../../hooks/useTextEditorFocus";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
@ -72,7 +67,6 @@ interface ColorPickerProps {
palette?: ColorPaletteCustom | null; palette?: ColorPaletteCustom | null;
topPicks?: ColorTuple; topPicks?: ColorTuple;
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
compactMode?: boolean;
} }
const ColorPickerPopupContent = ({ const ColorPickerPopupContent = ({
@ -83,8 +77,6 @@ const ColorPickerPopupContent = ({
elements, elements,
palette = COLOR_PALETTE, palette = COLOR_PALETTE,
updateData, updateData,
getOpenPopup,
appState,
}: Pick< }: Pick<
ColorPickerProps, ColorPickerProps,
| "type" | "type"
@ -94,10 +86,7 @@ const ColorPickerPopupContent = ({
| "elements" | "elements"
| "palette" | "palette"
| "updateData" | "updateData"
| "appState" >) => {
> & {
getOpenPopup: () => AppState["openPopup"];
}) => {
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
@ -128,8 +117,6 @@ const ColorPickerPopupContent = ({
<PropertiesPopover <PropertiesPopover
container={container} container={container}
style={{ maxWidth: "13rem" }} style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onFocusOutside={(event) => { onFocusOutside={(event) => {
// refocus due to eye dropper // refocus due to eye dropper
focusPickerContent(); focusPickerContent();
@ -144,23 +131,8 @@ const ColorPickerPopupContent = ({
} }
}} }}
onClose={() => { onClose={() => {
// only clear if we're still the active popup (avoid racing with switch)
if (getOpenPopup() === type) {
updateData({ openPopup: null }); updateData({ openPopup: null });
}
setActiveColorPickerSection(null); setActiveColorPickerSection(null);
// Refocus text editor when popover closes if we were editing text
if (appState.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}} }}
> >
{palette ? ( {palette ? (
@ -169,17 +141,7 @@ const ColorPickerPopupContent = ({
palette={palette} palette={palette}
color={color} color={color}
onChange={(changedColor) => { onChange={(changedColor) => {
// Save caret position before color change if editing text
const savedSelection = appState.editingTextElement
? saveCaretPosition()
: null;
onChange(changedColor); onChange(changedColor);
// Restore caret position after color change if editing text
if (appState.editingTextElement && savedSelection) {
restoreCaretPosition(savedSelection);
}
}} }}
onEyeDropperToggle={(force) => { onEyeDropperToggle={(force) => {
setEyeDropperState((state) => { setEyeDropperState((state) => {
@ -206,7 +168,6 @@ const ColorPickerPopupContent = ({
if (eyeDropperState) { if (eyeDropperState) {
setEyeDropperState(null); setEyeDropperState(null);
} else { } else {
// close explicitly on Escape
updateData({ openPopup: null }); updateData({ openPopup: null });
} }
}} }}
@ -227,32 +188,11 @@ const ColorPickerTrigger = ({
label, label,
color, color,
type, type,
compactMode = false,
mode = "background",
onToggle,
editingTextElement,
}: { }: {
color: string | null; color: string | null;
label: string; label: string;
type: ColorPickerType; type: ColorPickerType;
compactMode?: boolean;
mode?: "background" | "stroke";
onToggle: () => void;
editingTextElement?: boolean;
}) => { }) => {
const handleClick = (e: React.MouseEvent) => {
// use pointerdown so we run before outside-close logic
e.preventDefault();
e.stopPropagation();
// If editing text, temporarily disable the wysiwyg blur event
if (editingTextElement) {
temporarilyDisableTextEditorBlur();
}
onToggle();
};
return ( return (
<Popover.Trigger <Popover.Trigger
type="button" type="button"
@ -268,37 +208,8 @@ const ColorPickerTrigger = ({
? t("labels.showStroke") ? t("labels.showStroke")
: t("labels.showBackground") : t("labels.showBackground")
} }
data-openpopup={type}
onClick={handleClick}
> >
<div className="color-picker__button-outline">{!color && slashIcon}</div> <div className="color-picker__button-outline">{!color && slashIcon}</div>
{compactMode && color && (
<div className="color-picker__button-background">
{mode === "background" ? (
<span
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{backgroundIcon}
</span>
) : (
<span
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{strokeIcon}
</span>
)}
</div>
)}
</Popover.Trigger> </Popover.Trigger>
); );
}; };
@ -313,59 +224,25 @@ export const ColorPicker = ({
topPicks, topPicks,
updateData, updateData,
appState, appState,
compactMode = false,
}: ColorPickerProps) => { }: ColorPickerProps) => {
const openRef = useRef(appState.openPopup);
useEffect(() => {
openRef.current = appState.openPopup;
}, [appState.openPopup]);
return ( return (
<div> <div>
<div <div role="dialog" aria-modal="true" className="color-picker-container">
role="dialog"
aria-modal="true"
className={clsx("color-picker-container", {
"color-picker-container--no-top-picks": compactMode,
})}
>
{!compactMode && (
<TopPicks <TopPicks
activeColor={color} activeColor={color}
onChange={onChange} onChange={onChange}
type={type} type={type}
topPicks={topPicks} topPicks={topPicks}
/> />
)} <ButtonSeparator />
{!compactMode && <ButtonSeparator />}
<Popover.Root <Popover.Root
open={appState.openPopup === type} open={appState.openPopup === type}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) { updateData({ openPopup: open ? type : null });
updateData({ openPopup: type });
}
}} }}
> >
{/* serves as an active color indicator as well */} {/* serves as an active color indicator as well */}
<ColorPickerTrigger <ColorPickerTrigger color={color} label={label} type={type} />
color={color}
label={label}
type={type}
compactMode={compactMode}
mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement}
onToggle={() => {
// atomic switch: if another popup is open, close it first, then open this one next tick
if (appState.openPopup === type) {
// toggle off on same trigger
updateData({ openPopup: null });
} else if (appState.openPopup) {
updateData({ openPopup: type });
} else {
// open this one
updateData({ openPopup: type });
}
}}
/>
{/* popup content */} {/* popup content */}
{appState.openPopup === type && ( {appState.openPopup === type && (
<ColorPickerPopupContent <ColorPickerPopupContent
@ -376,8 +253,6 @@ export const ColorPicker = ({
elements={elements} elements={elements}
palette={palette} palette={palette}
updateData={updateData} updateData={updateData}
getOpenPopup={() => openRef.current}
appState={appState}
/> />
)} )}
</Popover.Root> </Popover.Root>

View File

@ -11,10 +11,5 @@
2rem + 4 * var(--default-button-size) 2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons ); // 4 gaps + 4 buttons
} }
&--compact {
display: block;
grid-template-columns: none;
}
} }
} }

View File

@ -1,5 +1,4 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { FONT_FAMILY } from "@excalidraw/common"; import { FONT_FAMILY } from "@excalidraw/common";
@ -59,7 +58,6 @@ interface FontPickerProps {
onHover: (fontFamily: FontFamilyValues) => void; onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void; onLeave: () => void;
onPopupChange: (open: boolean) => void; onPopupChange: (open: boolean) => void;
compactMode?: boolean;
} }
export const FontPicker = React.memo( export const FontPicker = React.memo(
@ -71,7 +69,6 @@ export const FontPicker = React.memo(
onHover, onHover,
onLeave, onLeave,
onPopupChange, onPopupChange,
compactMode = false,
}: FontPickerProps) => { }: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []); const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback( const onSelectCallback = useCallback(
@ -84,14 +81,7 @@ export const FontPicker = React.memo(
); );
return ( return (
<div <div role="dialog" aria-modal="true" className="FontPicker__container">
role="dialog"
aria-modal="true"
className={clsx("FontPicker__container", {
"FontPicker__container--compact": compactMode,
})}
>
{!compactMode && (
<div className="buttonList"> <div className="buttonList">
<RadioSelection<FontFamilyValues | false> <RadioSelection<FontFamilyValues | false>
type="button" type="button"
@ -100,13 +90,9 @@ export const FontPicker = React.memo(
onClick={onSelectCallback} onClick={onSelectCallback}
/> />
</div> </div>
)} <ButtonSeparator />
{!compactMode && <ButtonSeparator />}
<Popover.Root open={isOpened} onOpenChange={onPopupChange}> <Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger <FontPickerTrigger selectedFontFamily={selectedFontFamily} />
selectedFontFamily={selectedFontFamily}
isOpened={isOpened}
/>
{isOpened && ( {isOpened && (
<FontPickerList <FontPickerList
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}

View File

@ -90,8 +90,7 @@ export const FontPickerList = React.memo(
onClose, onClose,
}: FontPickerListProps) => { }: FontPickerListProps) => {
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const app = useApp(); const { fonts } = useApp();
const { fonts } = app;
const { showDeprecatedFonts } = useAppProps(); const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@ -188,42 +187,6 @@ export const FontPickerList = React.memo(
onLeave, onLeave,
]); ]);
// Create a wrapped onSelect function that preserves caret position
const wrappedOnSelect = useCallback(
(fontFamily: FontFamilyValues) => {
// Save caret position before font selection if editing text
let savedSelection: { start: number; end: number } | null = null;
if (app.state.editingTextElement) {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
savedSelection = {
start: textEditor.selectionStart,
end: textEditor.selectionEnd,
};
}
}
onSelect(fontFamily);
// Restore caret position after font selection if editing text
if (app.state.editingTextElement && savedSelection) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor && savedSelection) {
textEditor.focus();
textEditor.selectionStart = savedSelection.start;
textEditor.selectionEnd = savedSelection.end;
}
}, 0);
}
},
[onSelect, app.state.editingTextElement],
);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>( const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => { (event) => {
const handled = fontPickerKeyHandler({ const handled = fontPickerKeyHandler({
@ -231,7 +194,7 @@ export const FontPickerList = React.memo(
inputRef, inputRef,
hoveredFont, hoveredFont,
filteredFonts, filteredFonts,
onSelect: wrappedOnSelect, onSelect,
onHover, onHover,
onClose, onClose,
}); });
@ -241,7 +204,7 @@ export const FontPickerList = React.memo(
event.stopPropagation(); event.stopPropagation();
} }
}, },
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose], [hoveredFont, filteredFonts, onSelect, onHover, onClose],
); );
useEffect(() => { useEffect(() => {
@ -277,7 +240,7 @@ export const FontPickerList = React.memo(
// allow to tab between search and selected font // allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1} tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => { onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value)); onSelect(Number(e.currentTarget.value));
}} }}
onMouseMove={() => { onMouseMove={() => {
if (hoveredFont?.value !== font.value) { if (hoveredFont?.value !== font.value) {
@ -319,24 +282,9 @@ export const FontPickerList = React.memo(
className="properties-content" className="properties-content"
container={container} container={container}
style={{ width: "15rem" }} style={{ width: "15rem" }}
onClose={() => { onClose={onClose}
onClose();
// Refocus text editor when font picker closes if we were editing text
if (app.state.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
onPointerLeave={onLeave} onPointerLeave={onLeave}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement}
> >
<QuickSearch <QuickSearch
ref={inputRef} ref={inputRef}

View File

@ -1,4 +1,5 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { useMemo } from "react";
import type { FontFamilyValues } from "@excalidraw/element/types"; import type { FontFamilyValues } from "@excalidraw/element/types";
@ -6,38 +7,33 @@ import { t } from "../../i18n";
import { ButtonIcon } from "../ButtonIcon"; import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons"; import { TextIcon } from "../icons";
import { useExcalidrawSetAppState } from "../App"; import { isDefaultFont } from "./FontPicker";
interface FontPickerTriggerProps { interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null; selectedFontFamily: FontFamilyValues | null;
isOpened?: boolean;
} }
export const FontPickerTrigger = ({ export const FontPickerTrigger = ({
selectedFontFamily, selectedFontFamily,
isOpened = false,
}: FontPickerTriggerProps) => { }: FontPickerTriggerProps) => {
const setAppState = useExcalidrawSetAppState(); const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily],
);
return ( return (
<Popover.Trigger asChild> <Popover.Trigger asChild>
<div data-openpopup="fontFamily" className="properties-trigger"> {/* Empty div as trigger so it's stretched 100% due to different button sizes */}
<div>
<ButtonIcon <ButtonIcon
standalone standalone
icon={TextIcon} icon={TextIcon}
title={t("labels.showFonts")} title={t("labels.showFonts")}
className="properties-trigger" className="properties-trigger"
testId={"font-family-show-fonts"} testId={"font-family-show-fonts"}
active={isOpened} active={isTriggerActive}
onClick={() => { // no-op
setAppState((appState) => ({ onClick={() => {}}
openPopup:
appState.openPopup === "fontFamily" ? null : appState.openPopup,
}));
}}
style={{
border: "none",
}}
/> />
</div> </div>
</Popover.Trigger> </Popover.Trigger>

View File

@ -24,10 +24,6 @@
gap: 0.75rem; gap: 0.75rem;
pointer-events: none !important; pointer-events: none !important;
&--compact {
gap: 0.5rem;
}
& > * { & > * {
pointer-events: var(--ui-pointerEvents); pointer-events: var(--ui-pointerEvents);
} }

View File

@ -4,7 +4,6 @@ import React from "react";
import { import {
CLASSES, CLASSES,
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
MQ_MIN_WIDTH_DESKTOP,
TOOL_TYPE, TOOL_TYPE,
arrayToMap, arrayToMap,
capitalizeString, capitalizeString,
@ -29,11 +28,7 @@ import { useAtom, useAtomValue } from "../editor-jotai";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
SelectedShapeActions,
ShapesSwitcher,
CompactShapeActions,
} from "./Actions";
import { LoadingMessage } from "./LoadingMessage"; import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { MobileMenu } from "./MobileMenu"; import { MobileMenu } from "./MobileMenu";
@ -162,25 +157,6 @@ const LayerUI = ({
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
const spacing =
appState.stylesPanelMode === "compact"
? {
menuTopGap: 4,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 0.5,
islandPadding: 1,
collabMarginLeft: 8,
}
: {
menuTopGap: 6,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 1,
islandPadding: 1,
collabMarginLeft: 8,
};
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider; const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
@ -233,35 +209,13 @@ const LayerUI = ({
</div> </div>
); );
const renderSelectedShapeActions = () => { const renderSelectedShapeActions = () => (
const isCompactMode = appState.stylesPanelMode === "compact";
return (
<Section <Section
heading="selectedShapeActions" heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", { className={clsx("selected-shape-actions zen-mode-transition", {
"transition-left": appState.zenModeEnabled, "transition-left": appState.zenModeEnabled,
})} })}
> >
{isCompactMode ? (
<Island
className={clsx("compact-shape-actions-island")}
padding={0}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
>
<CompactShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
setAppState={setAppState}
/>
</Island>
) : (
<Island <Island
className={CLASSES.SHAPE_ACTIONS_MENU} className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2} padding={2}
@ -278,10 +232,8 @@ const LayerUI = ({
app={app} app={app}
/> />
</Island> </Island>
)}
</Section> </Section>
); );
};
const renderFixedSideContainer = () => { const renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions = showSelectedShapeActions( const shouldRenderSelectedShapeActions = showSelectedShapeActions(
@ -298,19 +250,9 @@ const LayerUI = ({
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
<Stack.Col <Stack.Col gap={6} className={clsx("App-menu_top__left")}>
gap={spacing.menuTopGap}
className={clsx("App-menu_top__left")}
>
{renderCanvasActions()} {renderCanvasActions()}
<div
className={clsx("selected-shape-actions-container", {
"selected-shape-actions-container--compact":
appState.stylesPanelMode === "compact",
})}
>
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</div>
</Stack.Col> </Stack.Col>
{!appState.viewModeEnabled && {!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && ( appState.openDialog?.name !== "elementLinkSelector" && (
@ -320,19 +262,17 @@ const LayerUI = ({
{renderWelcomeScreen && ( {renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out /> <tunnels.WelcomeScreenToolbarHintTunnel.Out />
)} )}
<Stack.Col gap={spacing.toolbarColGap} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row
gap={spacing.toolbarRowGap} gap={1}
className={clsx("App-toolbar-container", { className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
})} })}
> >
<Island <Island
padding={spacing.islandPadding} padding={1}
className={clsx("App-toolbar", { className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
"App-toolbar--compact":
appState.stylesPanelMode === "compact",
})} })}
> >
<HintViewer <HintViewer
@ -342,7 +282,7 @@ const LayerUI = ({
app={app} app={app}
/> />
{heading} {heading}
<Stack.Row gap={spacing.toolbarInnerRowGap}> <Stack.Row gap={1}>
<PenModeButton <PenModeButton
zenModeEnabled={appState.zenModeEnabled} zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode} checked={appState.penMode}
@ -376,7 +316,7 @@ const LayerUI = ({
{isCollaborating && ( {isCollaborating && (
<Island <Island
style={{ style={{
marginLeft: spacing.collabMarginLeft, marginLeft: 8,
alignSelf: "center", alignSelf: "center",
height: "fit-content", height: "fit-content",
}} }}
@ -404,8 +344,6 @@ const LayerUI = ({
"layer-ui__wrapper__top-right zen-mode-transition", "layer-ui__wrapper__top-right zen-mode-transition",
{ {
"transition-right": appState.zenModeEnabled, "transition-right": appState.zenModeEnabled,
"layer-ui__wrapper__top-right--compact":
appState.stylesPanelMode === "compact",
}, },
)} )}
> >
@ -480,9 +418,7 @@ const LayerUI = ({
}} }}
tab={DEFAULT_SIDEBAR.defaultTab} tab={DEFAULT_SIDEBAR.defaultTab}
> >
{appState.stylesPanelMode === "full" && {t("toolBar.library")}
appState.width >= MQ_MIN_WIDTH_DESKTOP &&
t("toolBar.library")}
</DefaultSidebar.Trigger> </DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog /> <DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />} {appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}

View File

@ -17,7 +17,6 @@ interface PropertiesPopoverProps {
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>; onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"]; onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"]; onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
preventAutoFocusOnTouch?: boolean;
} }
export const PropertiesPopover = React.forwardRef< export const PropertiesPopover = React.forwardRef<
@ -35,7 +34,6 @@ export const PropertiesPopover = React.forwardRef<
onFocusOutside, onFocusOutside,
onPointerLeave, onPointerLeave,
onPointerDownOutside, onPointerDownOutside,
preventAutoFocusOnTouch = false,
}, },
ref, ref,
) => { ) => {
@ -66,12 +64,6 @@ export const PropertiesPopover = React.forwardRef<
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onFocusOutside={onFocusOutside} onFocusOutside={onFocusOutside}
onPointerDownOutside={onPointerDownOutside} onPointerDownOutside={onPointerDownOutside}
onOpenAutoFocus={(e) => {
// prevent auto-focus on touch devices to avoid keyboard popup
if (preventAutoFocusOnTouch && device.isTouchScreen) {
e.preventDefault();
}
}}
onCloseAutoFocus={(e) => { onCloseAutoFocus={(e) => {
e.stopPropagation(); e.stopPropagation();
// prevents focusing the trigger // prevents focusing the trigger

View File

@ -10,16 +10,6 @@
} }
} }
&--compact {
.ToolIcon__keybinding {
display: none;
}
.App-toolbar__divider {
margin: 0;
}
}
&__divider { &__divider {
width: 1px; width: 1px;
height: 1.5rem; height: 1.5rem;

View File

@ -118,17 +118,6 @@ export const DotsIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
// tabler-icons: dots-horizontal (horizontal equivalent of dots-vertical)
export const DotsHorizontalIcon = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</g>,
tablerIconProps,
);
// tabler-icons: pinned // tabler-icons: pinned
export const PinIcon = createIcon( export const PinIcon = createIcon(
<svg strokeWidth="1.5"> <svg strokeWidth="1.5">
@ -407,19 +396,6 @@ export const TextIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const TextSizeIcon = createIcon(
<g stroke="currentColor" strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 7v-2h13v2" />
<path d="M10 5v14" />
<path d="M12 19h-4" />
<path d="M15 13v-1h6v1" />
<path d="M18 12v7" />
<path d="M17 19h2" />
</g>,
tablerIconProps,
);
// modified tabler-icons: photo // modified tabler-icons: photo
export const ImageIcon = createIcon( export const ImageIcon = createIcon(
<g strokeWidth="1.25"> <g strokeWidth="1.25">
@ -2293,48 +2269,3 @@ export const elementLinkIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
export const resizeIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 11v8a1 1 0 0 0 1 1h8m-9 -14v-1a1 1 0 0 1 1 -1h1m5 0h2m5 0h1a1 1 0 0 1 1 1v1m0 5v2m0 5v1a1 1 0 0 1 -1 1h-1" />
<path d="M4 12h7a1 1 0 0 1 1 1v7" />
</g>,
tablerIconProps,
);
export const adjustmentsIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 6l8 0" />
<path d="M16 6l4 0" />
<path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 12l2 0" />
<path d="M10 12l10 0" />
<path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 18l11 0" />
<path d="M19 18l1 0" />
</g>,
tablerIconProps,
);
export const backgroundIcon = createIcon(
<g strokeWidth={1}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 10l4 -4" />
<path d="M6 14l8 -8" />
<path d="M6 18l12 -12" />
<path d="M10 18l8 -8" />
<path d="M14 18l4 -4" />
</g>,
tablerIconProps,
);
export const strokeIcon = createIcon(
<g strokeWidth={1}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="6" y="6" width="12" height="12" fill="none" />
</g>,
tablerIconProps,
);

View File

@ -1,7 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { isMobileOrTablet, MQ_MIN_WIDTH_DESKTOP } from "@excalidraw/common";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { Button } from "../Button"; import { Button } from "../Button";
import { share } from "../icons"; import { share } from "../icons";
@ -19,8 +17,7 @@ const LiveCollaborationTrigger = ({
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => { } & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const appState = useUIAppState(); const appState = useUIAppState();
const showIconOnly = const showIconOnly = appState.width < 830;
isMobileOrTablet() || appState.width < MQ_MIN_WIDTH_DESKTOP;
return ( return (
<Button <Button

View File

@ -317,7 +317,7 @@ body.excalidraw-cursor-resize * {
.App-menu_top { .App-menu_top {
grid-template-columns: 1fr 2fr 1fr; grid-template-columns: 1fr 2fr 1fr;
grid-gap: 1rem; grid-gap: 2rem;
align-items: flex-start; align-items: flex-start;
cursor: default; cursor: default;
pointer-events: none !important; pointer-events: none !important;
@ -336,14 +336,6 @@ body.excalidraw-cursor-resize * {
justify-self: flex-start; justify-self: flex-start;
} }
.selected-shape-actions-container {
width: fit-content;
&--compact {
min-width: 48px;
}
}
.App-menu_top > *:last-child { .App-menu_top > *:last-child {
justify-self: flex-end; justify-self: flex-end;
} }

View File

@ -96,8 +96,6 @@ export const getMimeType = (blob: Blob | string): string => {
return MIME_TYPES.jpg; return MIME_TYPES.jpg;
} else if (/\.svg$/.test(name)) { } else if (/\.svg$/.test(name)) {
return MIME_TYPES.svg; return MIME_TYPES.svg;
} else if (/\.excalidrawlib$/.test(name)) {
return MIME_TYPES.excalidrawlib;
} }
return ""; return "";
}; };
@ -391,18 +389,23 @@ export const ImageURLToFile = async (
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
}; };
export const getFileFromEvent = async (
event: React.DragEvent<HTMLDivElement>,
) => {
const file = event.dataTransfer.files.item(0);
const fileHandle = await getFileHandle(event);
return { file: file ? await normalizeFile(file) : null, fileHandle };
};
export const getFileHandle = async ( export const getFileHandle = async (
event: DragEvent | React.DragEvent | DataTransferItem, event: React.DragEvent<HTMLDivElement>,
): Promise<FileSystemHandle | null> => { ): Promise<FileSystemHandle | null> => {
if (nativeFileSystemSupported) { if (nativeFileSystemSupported) {
try { try {
const dataTransferItem = const item = event.dataTransfer.items[0];
event instanceof DataTransferItem
? event
: (event as DragEvent).dataTransfer?.items?.[0];
const handle: FileSystemHandle | null = const handle: FileSystemHandle | null =
(await (dataTransferItem as any).getAsFileSystemHandle()) || null; (await (item as any).getAsFileSystemHandle()) || null;
return handle; return handle;
} catch (error: any) { } catch (error: any) {

View File

@ -387,10 +387,7 @@ export const restoreElement = (
elbowed: true, elbowed: true,
startBinding: repairBinding(element, element.startBinding), startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding), endBinding: repairBinding(element, element.endBinding),
fixedSegments: fixedSegments: element.fixedSegments,
element.fixedSegments?.length && base.points.length >= 4
? element.fixedSegments
: null,
startIsSpecial: element.startIsSpecial, startIsSpecial: element.startIsSpecial,
endIsSpecial: element.endIsSpecial, endIsSpecial: element.endIsSpecial,
}) })

View File

@ -1,26 +1,11 @@
import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
distanceToElement,
doBoundsIntersect,
getBoundTextElement, getBoundTextElement,
getElementBounds,
getFreedrawOutlineAsSegments,
getFreedrawOutlinePoints,
intersectElementWithLineSegment, intersectElementWithLineSegment,
isArrowElement,
isFreeDrawElement,
isLineElement,
isPointInElement, isPointInElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { import { lineSegment, pointFrom } from "@excalidraw/math";
lineSegment,
lineSegmentsDistance,
pointFrom,
polygon,
polygonIncludesPointNonZero,
} from "@excalidraw/math";
import { getElementsInGroup } from "@excalidraw/element"; import { getElementsInGroup } from "@excalidraw/element";
@ -28,8 +13,6 @@ import { shouldTestInside } from "@excalidraw/element";
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
import { getBoundTextElementId } from "@excalidraw/element"; import { getBoundTextElementId } from "@excalidraw/element";
import type { Bounds } from "@excalidraw/element";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
@ -113,7 +96,6 @@ export class EraserTrail extends AnimatedTrail {
pathSegment, pathSegment,
element, element,
candidateElementsMap, candidateElementsMap,
this.app.state.zoom.value,
); );
if (intersects) { if (intersects) {
@ -149,7 +131,6 @@ export class EraserTrail extends AnimatedTrail {
pathSegment, pathSegment,
element, element,
candidateElementsMap, candidateElementsMap,
this.app.state.zoom.value,
); );
if (intersects) { if (intersects) {
@ -199,33 +180,8 @@ const eraserTest = (
pathSegment: LineSegment<GlobalPoint>, pathSegment: LineSegment<GlobalPoint>,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: number,
): boolean => { ): boolean => {
const lastPoint = pathSegment[1]; const lastPoint = pathSegment[1];
// PERF: Do a quick bounds intersection test first because it's cheap
const threshold = isFreeDrawElement(element) ? 15 : element.strokeWidth / 2;
const segmentBounds = [
Math.min(pathSegment[0][0], pathSegment[1][0]) - threshold,
Math.min(pathSegment[0][1], pathSegment[1][1]) - threshold,
Math.max(pathSegment[0][0], pathSegment[1][0]) + threshold,
Math.max(pathSegment[0][1], pathSegment[1][1]) + threshold,
] as Bounds;
const origElementBounds = getElementBounds(element, elementsMap);
const elementBounds: Bounds = [
origElementBounds[0] - threshold,
origElementBounds[1] - threshold,
origElementBounds[2] + threshold,
origElementBounds[3] + threshold,
];
if (!doBoundsIntersect(segmentBounds, elementBounds)) {
return false;
}
// There are shapes where the inner area should trigger erasing
// even though the eraser path segment doesn't intersect with or
// get close to the shape's stroke
if ( if (
shouldTestInside(element) && shouldTestInside(element) &&
isPointInElement(lastPoint, element, elementsMap) isPointInElement(lastPoint, element, elementsMap)
@ -233,50 +189,6 @@ const eraserTest = (
return true; return true;
} }
// Freedraw elements are tested for erasure by measuring the distance
// of the eraser path and the freedraw shape outline lines to a tolerance
// which offers a good visual precision at various zoom levels
if (isFreeDrawElement(element)) {
const outlinePoints = getFreedrawOutlinePoints(element);
const strokeSegments = getFreedrawOutlineAsSegments(
element,
outlinePoints,
elementsMap,
);
const tolerance = Math.max(2.25, 5 / zoom); // NOTE: Visually fine-tuned approximation
for (const seg of strokeSegments) {
if (lineSegmentsDistance(seg, pathSegment) <= tolerance) {
return true;
}
}
const poly = polygon(
...(outlinePoints.map(([x, y]) =>
pointFrom<GlobalPoint>(element.x + x, element.y + y),
) as GlobalPoint[]),
);
// PERF: Check only one point of the eraser segment. If the eraser segment
// start is inside the closed freedraw shape, the other point is either also
// inside or the eraser segment will intersect the shape outline anyway
if (polygonIncludesPointNonZero(pathSegment[0], poly)) {
return true;
}
return false;
} else if (
isArrowElement(element) ||
(isLineElement(element) && !element.polygon)
) {
const tolerance = Math.max(
element.strokeWidth,
(element.strokeWidth * 2) / zoom,
);
return distanceToElement(element, elementsMap, lastPoint) <= tolerance;
}
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
return ( return (

View File

@ -1,112 +0,0 @@
import { useState, useCallback } from "react";
// Utility type for caret position
export type CaretPosition = {
start: number;
end: number;
};
// Utility function to get text editor element
const getTextEditor = (): HTMLTextAreaElement | null => {
return document.querySelector(".excalidraw-wysiwyg") as HTMLTextAreaElement;
};
// Utility functions for caret position management
export const saveCaretPosition = (): CaretPosition | null => {
const textEditor = getTextEditor();
if (textEditor) {
return {
start: textEditor.selectionStart,
end: textEditor.selectionEnd,
};
}
return null;
};
export const restoreCaretPosition = (position: CaretPosition | null): void => {
setTimeout(() => {
const textEditor = getTextEditor();
if (textEditor) {
textEditor.focus();
if (position) {
textEditor.selectionStart = position.start;
textEditor.selectionEnd = position.end;
}
}
}, 0);
};
export const withCaretPositionPreservation = (
callback: () => void,
isCompactMode: boolean,
isEditingText: boolean,
onPreventClose?: () => void,
): void => {
// Prevent popover from closing in compact mode
if (isCompactMode && onPreventClose) {
onPreventClose();
}
// Save caret position if editing text
const savedPosition =
isCompactMode && isEditingText ? saveCaretPosition() : null;
// Execute the callback
callback();
// Restore caret position if needed
if (isCompactMode && isEditingText) {
restoreCaretPosition(savedPosition);
}
};
// Hook for managing text editor caret position with state
export const useTextEditorFocus = () => {
const [savedCaretPosition, setSavedCaretPosition] =
useState<CaretPosition | null>(null);
const saveCaretPositionToState = useCallback(() => {
const position = saveCaretPosition();
setSavedCaretPosition(position);
}, []);
const restoreCaretPositionFromState = useCallback(() => {
setTimeout(() => {
const textEditor = getTextEditor();
if (textEditor) {
textEditor.focus();
if (savedCaretPosition) {
textEditor.selectionStart = savedCaretPosition.start;
textEditor.selectionEnd = savedCaretPosition.end;
setSavedCaretPosition(null);
}
}
}, 0);
}, [savedCaretPosition]);
const clearSavedPosition = useCallback(() => {
setSavedCaretPosition(null);
}, []);
return {
saveCaretPosition: saveCaretPositionToState,
restoreCaretPosition: restoreCaretPositionFromState,
clearSavedPosition,
hasSavedPosition: !!savedCaretPosition,
};
};
// Utility function to temporarily disable text editor blur
export const temporarilyDisableTextEditorBlur = (
duration: number = 100,
): void => {
const textEditor = getTextEditor();
if (textEditor) {
const originalOnBlur = textEditor.onblur;
textEditor.onblur = null;
setTimeout(() => {
textEditor.onblur = originalOnBlur;
}, duration);
}
};

View File

@ -83,7 +83,7 @@
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/mermaid-to-excalidraw": "1.1.3", "@excalidraw/mermaid-to-excalidraw": "1.1.2",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.1.6", "@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-tabs": "1.1.3", "@radix-ui/react-tabs": "1.1.3",

View File

@ -981,7 +981,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -1173,7 +1172,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": { "toast": {
@ -1386,7 +1384,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -1716,7 +1713,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2046,7 +2042,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": { "toast": {
@ -2257,7 +2252,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2499,7 +2493,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2801,7 +2794,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3167,7 +3159,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": { "toast": {
@ -3659,7 +3650,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3981,7 +3971,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4306,7 +4295,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5590,7 +5578,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -6808,7 +6795,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7738,7 +7724,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8736,7 +8721,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9729,7 +9713,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,

View File

@ -688,7 +688,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-haspopup="dialog" aria-haspopup="dialog"
aria-label="Canvas background" aria-label="Canvas background"
class="color-picker__button active-color properties-trigger has-outline" class="color-picker__button active-color properties-trigger has-outline"
data-openpopup="canvasBackground"
data-state="closed" data-state="closed"
style="--swatch-color: #ffffff;" style="--swatch-color: #ffffff;"
title="Show background color picker" title="Show background color picker"

File diff suppressed because it is too large Load Diff

View File

@ -108,7 +108,6 @@ exports[`given element A and group of elements B and given both are selected whe
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -535,7 +534,6 @@ exports[`given element A and group of elements B and given both are selected whe
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -941,7 +939,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -1506,7 +1503,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -1717,7 +1713,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2097,7 +2092,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2222,16 +2216,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] undo
}, },
}, },
}, },
"updated": { "updated": {},
"id0": {
"deleted": {
"version": 5,
},
"inserted": {
"version": 3,
},
},
},
}, },
"id": "id6", "id": "id6",
}, },
@ -2339,7 +2324,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2520,7 +2504,6 @@ exports[`regression tests > can drag element that covers another element, while
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2842,7 +2825,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3098,7 +3080,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3338,7 +3319,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3573,7 +3553,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3831,7 +3810,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4143,7 +4121,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4605,7 +4582,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4859,7 +4835,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5161,7 +5136,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5340,7 +5314,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5539,7 +5512,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5935,7 +5907,6 @@ exports[`regression tests > drags selected elements from point inside common bou
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -6225,7 +6196,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7085,7 +7055,6 @@ exports[`regression tests > given a group of selected elements with an element t
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7418,7 +7387,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7695,7 +7663,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7929,7 +7896,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8166,7 +8132,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8345,7 +8310,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8524,7 +8488,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8730,7 +8693,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8959,7 +8921,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9157,7 +9118,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9381,7 +9341,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9583,7 +9542,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9789,7 +9747,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9989,7 +9946,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -10166,7 +10122,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -10363,7 +10318,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -10550,7 +10504,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -10939,32 +10892,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] undo s
}, },
}, },
}, },
"updated": { "updated": {},
"id0": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id3": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id6": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
},
}, },
"id": "id21", "id": "id21",
}, },
@ -11074,7 +11002,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11349,7 +11276,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11473,7 +11399,6 @@ exports[`regression tests > shift click on selected element should deselect it o
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11676,7 +11601,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11996,7 +11920,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -12428,7 +12351,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -13058,7 +12980,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -13184,7 +13105,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -13843,7 +13763,6 @@ exports[`regression tests > switches from group of selected elements to another
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14180,7 +14099,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14411,7 +14329,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14535,7 +14452,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14924,7 +14840,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -15049,7 +14964,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,

View File

@ -35,10 +35,8 @@ describe("appState", () => {
expect(h.state.viewBackgroundColor).toBe("#F00"); expect(h.state.viewBackgroundColor).toBe("#F00");
}); });
await API.drop([ await API.drop(
{ new Blob(
kind: "file",
file: new Blob(
[ [
JSON.stringify({ JSON.stringify({
type: EXPORT_DATA_TYPES.excalidraw, type: EXPORT_DATA_TYPES.excalidraw,
@ -50,8 +48,7 @@ describe("appState", () => {
], ],
{ type: MIME_TYPES.json }, { type: MIME_TYPES.json },
), ),
}, );
]);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);

View File

@ -57,7 +57,7 @@ describe("export", () => {
blob: pngBlob, blob: pngBlob,
metadata: serializeAsJSON(testElements, h.state, {}, "local"), metadata: serializeAsJSON(testElements, h.state, {}, "local"),
}); });
await API.drop([{ kind: "file", file: pngBlobEmbedded }]); await API.drop(pngBlobEmbedded);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
@ -94,12 +94,7 @@ describe("export", () => {
}); });
it("import embedded png (legacy v1)", async () => { it("import embedded png (legacy v1)", async () => {
await API.drop([ await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
{
kind: "file",
file: await API.loadFile("./fixtures/test_embedded_v1.png"),
},
]);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "test" }), expect.objectContaining({ type: "text", text: "test" }),
@ -108,12 +103,7 @@ describe("export", () => {
}); });
it("import embedded png (v2)", async () => { it("import embedded png (v2)", async () => {
await API.drop([ await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.png"));
{
kind: "file",
file: await API.loadFile("./fixtures/smiley_embedded_v2.png"),
},
]);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "😀" }), expect.objectContaining({ type: "text", text: "😀" }),
@ -122,12 +112,7 @@ describe("export", () => {
}); });
it("import embedded svg (legacy v1)", async () => { it("import embedded svg (legacy v1)", async () => {
await API.drop([ await API.drop(await API.loadFile("./fixtures/test_embedded_v1.svg"));
{
kind: "file",
file: await API.loadFile("./fixtures/test_embedded_v1.svg"),
},
]);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "test" }), expect.objectContaining({ type: "text", text: "test" }),
@ -136,12 +121,7 @@ describe("export", () => {
}); });
it("import embedded svg (v2)", async () => { it("import embedded svg (v2)", async () => {
await API.drop([ await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.svg"));
{
kind: "file",
file: await API.loadFile("./fixtures/smiley_embedded_v2.svg"),
},
]);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "😀" }), expect.objectContaining({ type: "text", text: "😀" }),

View File

@ -1,9 +0,0 @@
export const DEER_IMAGE_DIMENSIONS = {
width: 318,
height: 335,
};
export const SMILEY_IMAGE_DIMENSIONS = {
width: 56,
height: 77,
};

View File

@ -25,7 +25,6 @@ import { Excalidraw } from "../index";
// Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason) // Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason)
import * as blobModule from "../data/blob"; import * as blobModule from "../data/blob";
import { SMILEY_IMAGE_DIMENSIONS } from "./fixtures/constants";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { UI, Pointer, Keyboard } from "./helpers/ui"; import { UI, Pointer, Keyboard } from "./helpers/ui";
import { import {
@ -745,6 +744,11 @@ describe("freedraw", () => {
//image //image
//TODO: currently there is no test for pixel colors at flipped positions. //TODO: currently there is no test for pixel colors at flipped positions.
describe("image", () => { describe("image", () => {
const smileyImageDimensions = {
width: 56,
height: 77,
};
beforeEach(() => { beforeEach(() => {
// it's necessary to specify the height in order to calculate natural dimensions of the image // it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000; h.state.height = 1000;
@ -752,8 +756,8 @@ describe("image", () => {
beforeAll(() => { beforeAll(() => {
mockHTMLImageElement( mockHTMLImageElement(
SMILEY_IMAGE_DIMENSIONS.width, smileyImageDimensions.width,
SMILEY_IMAGE_DIMENSIONS.height, smileyImageDimensions.height,
); );
}); });

View File

@ -478,43 +478,33 @@ export class API {
}); });
}; };
static drop = async (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => { static drop = async (blob: Blob) => {
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas); const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
const text = await new Promise<string>((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.readAsText(blob);
} catch (error: any) {
reject(error);
}
});
const dataTransferFileItems = items.filter(i => i.kind === "file") as {kind: "file", file: File | Blob, type: string }[]; const files = [blob] as File[] & { item: (index: number) => File };
const files = dataTransferFileItems.map(item => item.file) as File[] & { item: (index: number) => File };
// https://developer.mozilla.org/en-US/docs/Web/API/FileList/item
files.item = (index: number) => files[index]; files.item = (index: number) => files[index];
Object.defineProperty(fileDropEvent, "dataTransfer", { Object.defineProperty(fileDropEvent, "dataTransfer", {
value: { value: {
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/files
files, files,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items
items: items.map((item, idx) => {
if (item.kind === "string") {
return {
kind: "string",
type: item.type,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsString
getAsString: (cb: (text: string) => any) => cb(item.value),
};
}
return {
kind: "file",
type: item.type || item.file.type,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsFile
getAsFile: () => item.file,
};
}),
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/getData
getData: (type: string) => { getData: (type: string) => {
return items.find((item) => item.type === "string" && item.type === type) || ""; if (type === blob.type || type === "text") {
return text;
}
return "";
}, },
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types types: [blob.type],
types: Array.from(new Set(items.map((item) => item.kind === "file" ? "Files" : item.type))),
}, },
}); });
Object.defineProperty(fileDropEvent, "clientX", { Object.defineProperty(fileDropEvent, "clientX", {

View File

@ -1,6 +0,0 @@
export const INITIALIZED_IMAGE_PROPS = {
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
};

View File

@ -58,35 +58,3 @@ export const mockHTMLImageElement = (
}, },
); );
}; };
// Mocks for multiple HTMLImageElements (dimensions are assigned in the order of image initialization)
export const mockMultipleHTMLImageElements = (
sizes: (readonly [number, number])[],
) => {
const _sizes = [...sizes];
vi.stubGlobal(
"Image",
class extends Image {
constructor() {
super();
const size = _sizes.shift();
if (!size) {
throw new Error("Insufficient sizes");
}
Object.defineProperty(this, "naturalWidth", {
value: size[0],
});
Object.defineProperty(this, "naturalHeight", {
value: size[1],
});
queueMicrotask(() => {
this.onload?.({} as Event);
});
}
},
);
};

View File

@ -47,43 +47,42 @@ class DataTransferItem {
} }
} }
class DataTransferItemList extends Array<DataTransferItem> { class DataTransferList {
items: DataTransferItem[] = [];
add(data: string | File, type: string = ""): void { add(data: string | File, type: string = ""): void {
if (typeof data === "string") { if (typeof data === "string") {
this.push(new DataTransferItem("string", type, data)); this.items.push(new DataTransferItem("string", type, data));
} else if (data instanceof File) { } else if (data instanceof File) {
this.push(new DataTransferItem("file", type, data)); this.items.push(new DataTransferItem("file", type, data));
} }
} }
clear(): void { clear(): void {
this.clear(); this.items = [];
} }
} }
class DataTransfer { class DataTransfer {
public items: DataTransferItemList = new DataTransferItemList(); public items: DataTransferList = new DataTransferList();
private _types: Record<string, string> = {};
get files() { get files() {
return this.items return this.items.items
.filter((item) => item.kind === "file") .filter((item) => item.kind === "file")
.map((item) => item.getAsFile()!); .map((item) => item.getAsFile()!);
} }
add(data: string | File, type: string = ""): void { add(data: string | File, type: string = ""): void {
if (typeof data === "string") {
this.items.add(data, type); this.items.add(data, type);
} else {
this.items.add(data);
}
} }
setData(type: string, value: string) { setData(type: string, value: string) {
this.items.add(value, type); this._types[type] = value;
} }
getData(type: string) { getData(type: string) {
return this.items.find((item) => item.type === type)?.data || ""; return this._types[type] || "";
} }
} }

View File

@ -20,7 +20,6 @@ import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX, DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
reseed, reseed,
randomId,
} from "@excalidraw/common"; } from "@excalidraw/common";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
@ -59,13 +58,9 @@ import { createPasteEvent } from "../clipboard";
import * as blobModule from "../data/blob"; import * as blobModule from "../data/blob";
import {
DEER_IMAGE_DIMENSIONS,
SMILEY_IMAGE_DIMENSIONS,
} from "./fixtures/constants";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants"; import { mockHTMLImageElement } from "./helpers/mocks";
import { import {
GlobalTestState, GlobalTestState,
act, act,
@ -76,7 +71,6 @@ import {
checkpointHistory, checkpointHistory,
unmountComponent, unmountComponent,
} from "./test-utils"; } from "./test-utils";
import { setupImageTest as _setupImageTest } from "./image.test";
import type { AppState } from "../types"; import type { AppState } from "../types";
@ -129,9 +123,7 @@ describe("history", () => {
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile"); const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile"); const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
generateIdSpy.mockImplementation(() => generateIdSpy.mockImplementation(() => Promise.resolve("fileId" as FileId));
Promise.resolve(randomId() as FileId),
);
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file)); resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
Object.assign(document, { Object.assign(document, {
@ -568,10 +560,8 @@ describe("history", () => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]), expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
); );
await API.drop([ await API.drop(
{ new Blob(
kind: "file",
file: new Blob(
[ [
JSON.stringify({ JSON.stringify({
type: EXPORT_DATA_TYPES.excalidraw, type: EXPORT_DATA_TYPES.excalidraw,
@ -584,8 +574,7 @@ describe("history", () => {
], ],
{ type: MIME_TYPES.json }, { type: MIME_TYPES.json },
), ),
}, );
]);
await waitFor(() => expect(API.getUndoStack().length).toBe(1)); await waitFor(() => expect(API.getUndoStack().length).toBe(1));
expect(h.state.viewBackgroundColor).toBe("#000"); expect(h.state.viewBackgroundColor).toBe("#000");
@ -623,141 +612,201 @@ describe("history", () => {
]); ]);
}); });
it("should create new history entry on image drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const deerImageDimensions = {
width: 318,
height: 335,
};
mockHTMLImageElement(
deerImageDimensions.width,
deerImageDimensions.height,
);
await API.drop(await API.loadFile("./fixtures/deer.png"));
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...deerImageDimensions,
}),
]);
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
).toEqual(
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...deerImageDimensions,
}),
);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true,
...deerImageDimensions,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false,
...deerImageDimensions,
}),
]);
});
it("should create new history entry on embeddable link drag&drop", async () => { it("should create new history entry on embeddable link drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg"; const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
await API.drop([
{
kind: "string",
value: link,
type: MIME_TYPES.text,
},
]);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
});
const setupImageTest = () =>
_setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]);
const assertImageTest = async () => {
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
// need to check that delta actually contains initialized image elements (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed).map(
(val) => val.deleted,
),
).toEqual([
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
...DEER_IMAGE_DIMENSIONS,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
...SMILEY_IMAGE_DIMENSIONS,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: true,
...DEER_IMAGE_DIMENSIONS,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: true,
...SMILEY_IMAGE_DIMENSIONS,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: false,
...DEER_IMAGE_DIMENSIONS,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: false,
...SMILEY_IMAGE_DIMENSIONS,
}),
]);
};
it("should create new history entry on image drag&drop", async () => {
await setupImageTest();
await API.drop( await API.drop(
( new Blob([link], {
await Promise.all([ type: MIME_TYPES.text,
API.loadFile("./fixtures/deer.png"), }),
API.loadFile("./fixtures/smiley.png"),
])
).map((file) => ({
kind: "file",
file,
})),
); );
await assertImageTest(); await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
}); });
it("should create new history entry on image paste", async () => { it("should create new history entry on image paste", async () => {
await setupImageTest(); await render(
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const smileyImageDimensions = {
width: 56,
height: 77,
};
mockHTMLImageElement(
smileyImageDimensions.width,
smileyImageDimensions.height,
);
document.dispatchEvent( document.dispatchEvent(
createPasteEvent({ createPasteEvent({
files: await Promise.all([ files: [await API.loadFile("./fixtures/smiley_embedded_v2.png")],
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]),
}), }),
); );
await assertImageTest(); await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...smileyImageDimensions,
}),
]);
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
).toEqual(
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...smileyImageDimensions,
}),
);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true,
...smileyImageDimensions,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false,
...smileyImageDimensions,
}),
]);
}); });
it("should create new history entry on embeddable link paste", async () => { it("should create new history entry on embeddable link paste", async () => {
@ -4006,7 +4055,7 @@ describe("history", () => {
expect.objectContaining({ expect.objectContaining({
id: container.id, id: container.id,
boundElements: [{ id: remoteText.id, type: "text" }], boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false, isDeleted: true,
}), }),
expect.objectContaining({ expect.objectContaining({
id: text.id, id: text.id,
@ -4015,7 +4064,8 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: remoteText.id, id: remoteText.id,
containerId: container.id, // unbound
containerId: null,
isDeleted: false, isDeleted: false,
}), }),
]); ]);
@ -4305,8 +4355,8 @@ describe("history", () => {
expect.objectContaining({ expect.objectContaining({
...textProps, ...textProps,
// text element got redrawn! // text element got redrawn!
x: 241.295259647664, x: 205,
y: 247.59240920619527, y: 205,
angle: 90, angle: 90,
id: text.id, id: text.id,
containerId: container.id, containerId: container.id,
@ -4349,8 +4399,8 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
...textProps, ...textProps,
x: 241.295259647664, x: 205,
y: 247.59240920619527, y: 205,
angle: 90, angle: 90,
id: text.id, id: text.id,
containerId: container.id, containerId: container.id,

View File

@ -1,115 +0,0 @@
import { randomId, reseed } from "@excalidraw/common";
import type { FileId } from "@excalidraw/element/types";
import * as blobModule from "../data/blob";
import * as filesystemModule from "../data/filesystem";
import { Excalidraw } from "../index";
import { createPasteEvent } from "../clipboard";
import { API } from "./helpers/api";
import { mockMultipleHTMLImageElements } from "./helpers/mocks";
import { UI } from "./helpers/ui";
import { GlobalTestState, render, waitFor } from "./test-utils";
import {
DEER_IMAGE_DIMENSIONS,
SMILEY_IMAGE_DIMENSIONS,
} from "./fixtures/constants";
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
const { h } = window;
export const setupImageTest = async (
sizes: { width: number; height: number }[],
) => {
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
h.state.height = 1000;
mockMultipleHTMLImageElements(sizes.map((size) => [size.width, size.height]));
};
describe("image insertion", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
reseed(7);
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
generateIdSpy.mockImplementation(() =>
Promise.resolve(randomId() as FileId),
);
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas,
});
});
const setup = () =>
setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]);
const assert = async () => {
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
...DEER_IMAGE_DIMENSIONS,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
...SMILEY_IMAGE_DIMENSIONS,
}),
]);
});
// Not placed on top of each other
const dimensionsSet = new Set(h.elements.map((el) => `${el.x}-${el.y}`));
expect(dimensionsSet.size).toEqual(h.elements.length);
};
it("should eventually initialize all dropped images", async () => {
await setup();
const files = await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]);
await API.drop(files.map((file) => ({ kind: "file", file })));
await assert();
});
it("should eventually initialize all pasted images", async () => {
await setup();
document.dispatchEvent(
createPasteEvent({
files: await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]),
}),
);
await assert();
});
it("should eventually initialize all images added through image tool", async () => {
await setup();
const fileOpenSpy = vi.spyOn(filesystemModule, "fileOpen");
fileOpenSpy.mockImplementation(
async () =>
await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]),
);
UI.clickTool("image");
await assert();
});
});

View File

@ -56,13 +56,9 @@ describe("library", () => {
it("import library via drag&drop", async () => { it("import library via drag&drop", async () => {
expect(await h.app.library.getLatestLibrary()).toEqual([]); expect(await h.app.library.getLatestLibrary()).toEqual([]);
await API.drop([ await API.drop(
{ await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
kind: "file", );
type: MIME_TYPES.excalidrawlib,
file: await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
},
]);
await waitFor(async () => { await waitFor(async () => {
expect(await h.app.library.getLatestLibrary()).toEqual([ expect(await h.app.library.getLatestLibrary()).toEqual([
{ {
@ -79,13 +75,11 @@ describe("library", () => {
it("drop library item onto canvas", async () => { it("drop library item onto canvas", async () => {
expect(h.elements).toEqual([]); expect(h.elements).toEqual([]);
const libraryItems = parseLibraryJSON(await libraryJSONPromise); const libraryItems = parseLibraryJSON(await libraryJSONPromise);
await API.drop([ await API.drop(
{ new Blob([serializeLibraryAsJSON(libraryItems)], {
kind: "string",
value: serializeLibraryAsJSON(libraryItems),
type: MIME_TYPES.excalidrawlib, type: MIME_TYPES.excalidrawlib,
}, }),
]); );
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]); expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
}); });
@ -117,10 +111,10 @@ describe("library", () => {
}, },
}); });
await API.drop([ await API.drop(
{ new Blob(
kind: "string", [
value: serializeLibraryAsJSON([ serializeLibraryAsJSON([
{ {
id: "item1", id: "item1",
status: "published", status: "published",
@ -128,9 +122,12 @@ describe("library", () => {
created: 1, created: 1,
}, },
]), ]),
],
{
type: MIME_TYPES.excalidrawlib, type: MIME_TYPES.excalidrawlib,
}, },
]); ),
);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual( expect(h.elements).toEqual(
@ -173,13 +170,11 @@ describe("library", () => {
created: 1, created: 1,
}; };
await API.drop([ await API.drop(
{ new Blob([serializeLibraryAsJSON([item1, item1])], {
kind: "string",
value: serializeLibraryAsJSON([item1, item1]),
type: MIME_TYPES.excalidrawlib, type: MIME_TYPES.excalidrawlib,
}, }),
]); );
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
@ -198,13 +193,11 @@ describe("library", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
expect(h.elements).toEqual([]); expect(h.elements).toEqual([]);
const libraryItems = parseLibraryJSON(await libraryJSONPromise); const libraryItems = parseLibraryJSON(await libraryJSONPromise);
await API.drop([ await API.drop(
{ new Blob([serializeLibraryAsJSON(libraryItems)], {
kind: "string",
value: serializeLibraryAsJSON(libraryItems),
type: MIME_TYPES.excalidrawlib, type: MIME_TYPES.excalidrawlib,
}, }),
]); );
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]); expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
}); });

View File

@ -352,10 +352,6 @@ export interface AppState {
| "elementBackground" | "elementBackground"
| "elementStroke" | "elementStroke"
| "fontFamily" | "fontFamily"
| "compactTextProperties"
| "compactStrokeStyles"
| "compactOtherProperties"
| "compactArrowProperties"
| null; | null;
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null; openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
openDialog: openDialog:
@ -446,9 +442,6 @@ export interface AppState {
// as elements are unlocked, we remove the groupId from the elements // as elements are unlocked, we remove the groupId from the elements
// and also remove groupId from this map // and also remove groupId from this map
lockedMultiSelections: { [groupId: string]: true }; lockedMultiSelections: { [groupId: string]: true };
/** properties sidebar mode - determines whether to show compact or complete sidebar */
stylesPanelMode: "compact" | "full";
} }
export type SearchMatch = { export type SearchMatch = {

View File

@ -7,7 +7,6 @@ import {
getFontString, getFontString,
getFontFamilyString, getFontFamilyString,
isTestEnv, isTestEnv,
MIME_TYPES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -46,7 +45,7 @@ import type {
import { actionSaveToActiveFile } from "../actions"; import { actionSaveToActiveFile } from "../actions";
import { parseDataTransferEvent } from "../clipboard"; import { parseClipboard } from "../clipboard";
import { import {
actionDecreaseFontSize, actionDecreaseFontSize,
actionIncreaseFontSize, actionIncreaseFontSize,
@ -216,12 +215,11 @@ export const textWysiwyg = ({
); );
app.scene.mutateElement(container, { height: targetContainerHeight }); app.scene.mutateElement(container, { height: targetContainerHeight });
} else { } else {
const { x, y } = computeBoundTextPosition( const { y } = computeBoundTextPosition(
container, container,
updatedTextElement as ExcalidrawTextElementWithContainer, updatedTextElement as ExcalidrawTextElementWithContainer,
elementsMap, elementsMap,
); );
coordX = x;
coordY = y; coordY = y;
} }
} }
@ -333,14 +331,12 @@ export const textWysiwyg = ({
if (onChange) { if (onChange) {
editable.onpaste = async (event) => { editable.onpaste = async (event) => {
const textItem = (await parseDataTransferEvent(event)).findByType( const clipboardData = await parseClipboard(event, true);
MIME_TYPES.text, if (!clipboardData.text) {
);
if (!textItem) {
return; return;
} }
const text = normalizeText(textItem.value); const data = normalizeText(clipboardData.text);
if (!text) { if (!data) {
return; return;
} }
const container = getContainerElement( const container = getContainerElement(
@ -358,7 +354,7 @@ export const textWysiwyg = ({
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
); );
const wrappedText = wrapText( const wrappedText = wrapText(
`${editable.value}${text}`, `${editable.value}${data}`,
font, font,
getBoundTextMaxWidth(container, boundTextElement), getBoundTextMaxWidth(container, boundTextElement),
); );
@ -542,7 +538,6 @@ export const textWysiwyg = ({
if (isDestroyed) { if (isDestroyed) {
return; return;
} }
isDestroyed = true; isDestroyed = true;
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg // cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
@ -626,24 +621,14 @@ export const textWysiwyg = ({
const isPropertiesTrigger = const isPropertiesTrigger =
target instanceof HTMLElement && target instanceof HTMLElement &&
target.classList.contains("properties-trigger"); target.classList.contains("properties-trigger");
const isPropertiesContent =
(target instanceof HTMLElement || target instanceof SVGElement) &&
!!(target as Element).closest(".properties-content");
const inShapeActionsMenu =
(target instanceof HTMLElement || target instanceof SVGElement) &&
(!!(target as Element).closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) ||
!!(target as Element).closest(".compact-shape-actions-island"));
setTimeout(() => { setTimeout(() => {
// If we interacted within shape actions menu or its popovers/triggers,
// keep submit disabled and don't steal focus back to textarea.
if (inShapeActionsMenu || isPropertiesTrigger || isPropertiesContent) {
return;
}
// Otherwise, re-enable submit on blur and refocus the editor.
editable.onblur = handleSubmit; editable.onblur = handleSubmit;
// case: clicking on the same property → no change → no update → no focus
if (!isPropertiesTrigger) {
editable.focus(); editable.focus();
}
}); });
}; };
@ -666,7 +651,6 @@ export const textWysiwyg = ({
event.preventDefault(); event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event); app.handleCanvasPanUsingWheelOrSpaceDrag(event);
} }
temporarilyDisableSubmit(); temporarilyDisableSubmit();
return; return;
} }
@ -674,20 +658,15 @@ export const textWysiwyg = ({
const isPropertiesTrigger = const isPropertiesTrigger =
target instanceof HTMLElement && target instanceof HTMLElement &&
target.classList.contains("properties-trigger"); target.classList.contains("properties-trigger");
const isPropertiesContent =
(target instanceof HTMLElement || target instanceof SVGElement) &&
!!(target as Element).closest(".properties-content");
if ( if (
((event.target instanceof HTMLElement || ((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) && event.target instanceof SVGElement) &&
(event.target.closest( event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`, `.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) || ) &&
event.target.closest(".compact-shape-actions-island")) &&
!isWritableElement(event.target)) || !isWritableElement(event.target)) ||
isPropertiesTrigger || isPropertiesTrigger
isPropertiesContent
) { ) {
temporarilyDisableSubmit(); temporarilyDisableSubmit();
} else if ( } else if (

View File

@ -177,19 +177,3 @@ export function lineSegmentIntersectionPoints<
return candidate; return candidate;
} }
export function lineSegmentsDistance<Point extends GlobalPoint | LocalPoint>(
s1: LineSegment<Point>,
s2: LineSegment<Point>,
): number {
if (lineSegmentIntersectionPoints(s1, s2)) {
return 0;
}
return Math.min(
distanceToLineSegment(s1[0], s2),
distanceToLineSegment(s1[1], s2),
distanceToLineSegment(s2[0], s1),
distanceToLineSegment(s2[1], s1),
);
}

View File

@ -100,7 +100,6 @@ exports[`exportToSvg > with default arguments 1`] = `
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,

89
playwright.config.ts Normal file
View File

@ -0,0 +1,89 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./excalidraw-app/tests/regression",
snapshotPathTemplate:
"{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 1,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
headless: true,
},
timeout: 1200000,
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
launchOptions: {
args: ["--disable-font-subpixel-positioning", "--disable-gpu"],
},
},
},
// {
// name: "firefox",
// use: { ...devices["Desktop Firefox"] },
// },
// {
// name: "webkit",
// use: { ...devices["Desktop Safari"] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: "yarn start --no-open",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});

View File

@ -1452,15 +1452,14 @@
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@1.1.3": "@excalidraw/mermaid-to-excalidraw@1.1.2":
version "1.1.3" version "1.1.2"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.3.tgz#3204642c99f3d49c2ad41108217a5d493ef7fd09" resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.2.tgz#74d9507971976a7d3d960a1b2e8fb49a9f1f0d22"
integrity sha512-/50GUWlGotc+FCMX7nM1P1kWm9vNd3fuq38v7upBp9IHqlw6Zmfyj79eG/0vz1heifuYrSW9yzzv0q9jVALzxg== integrity sha512-hAFv/TTIsOdoy0dL5v+oBd297SQ+Z88gZ5u99fCIFuEMHfQuPgLhU/ztKhFSTs7fISwVo6fizny/5oQRR3d4tQ==
dependencies: dependencies:
"@excalidraw/markdown-to-text" "0.1.2" "@excalidraw/markdown-to-text" "0.1.2"
mermaid "10.9.4" mermaid "10.9.3"
nanoid "4.0.2" nanoid "4.0.2"
react-split "^2.0.14"
"@excalidraw/prettier-config@1.0.2": "@excalidraw/prettier-config@1.0.2":
version "1.0.2" version "1.0.2"
@ -2163,6 +2162,13 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@playwright/test@1.55.0":
version "1.55.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.55.0.tgz#080fa6d9ee6d749ff523b1c18259572d0268b963"
integrity sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==
dependencies:
playwright "1.55.0"
"@polka/url@^1.0.0-next.24": "@polka/url@^1.0.0-next.24":
version "1.0.0-next.28" version "1.0.0-next.28"
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.28.tgz#d45e01c4a56f143ee69c54dd6b12eade9e270a73" resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.28.tgz#d45e01c4a56f143ee69c54dd6b12eade9e270a73"
@ -2946,6 +2952,13 @@
dependencies: dependencies:
undici-types "~6.20.0" undici-types "~6.20.0"
"@types/node@24.3.0":
version "24.3.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.3.0.tgz#89b09f45cb9a8ee69466f18ee5864e4c3eb84dec"
integrity sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==
dependencies:
undici-types "~7.10.0"
"@types/node@>=12.12.47", "@types/node@>=13.7.0": "@types/node@>=12.12.47", "@types/node@>=13.7.0":
version "22.13.8" version "22.13.8"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.8.tgz#57e2450295b33a6518d6fd4f65f47236d3e41d8d" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.8.tgz#57e2450295b33a6518d6fd4f65f47236d3e41d8d"
@ -5827,6 +5840,11 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
fsevents@~2.3.2, fsevents@~2.3.3: fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3" version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
@ -7058,10 +7076,10 @@ merge2@^1.3.0, merge2@^1.4.1:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
mermaid@10.9.4: mermaid@10.9.3:
version "10.9.4" version "10.9.3"
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.4.tgz#985fd4b6d73ae795b87f0b32f620a56d3d6bf1f8" resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.3.tgz#90bc6f15c33dbe5d9507fed31592cc0d88fee9f7"
integrity sha512-VIG2B0R9ydvkS+wShA8sXqkzfpYglM2Qwj7VyUeqzNVqSGPoP/tcaUr3ub4ESykv8eqQJn3p99bHNvYdg3gCHQ== integrity sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==
dependencies: dependencies:
"@braintree/sanitize-url" "^6.0.1" "@braintree/sanitize-url" "^6.0.1"
"@types/d3-scale" "^4.0.3" "@types/d3-scale" "^4.0.3"
@ -7839,6 +7857,20 @@ pkg-dir@4.2.0:
dependencies: dependencies:
find-up "^4.0.0" find-up "^4.0.0"
playwright-core@1.55.0:
version "1.55.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.55.0.tgz#ec8a9f8ef118afb3e86e0f46f1393e3bea32adf4"
integrity sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==
playwright@1.55.0:
version "1.55.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.55.0.tgz#7aca7ac3ffd9e083a8ad8b2514d6f9ba401cc78b"
integrity sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==
dependencies:
playwright-core "1.55.0"
optionalDependencies:
fsevents "2.3.2"
png-chunk-text@1.0.0: png-chunk-text@1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/png-chunk-text/-/png-chunk-text-1.0.0.tgz#1c6006d8e34ba471d38e1c9c54b3f53e1085e18f" resolved "https://registry.yarnpkg.com/png-chunk-text/-/png-chunk-text-1.0.0.tgz#1c6006d8e34ba471d38e1c9c54b3f53e1085e18f"
@ -7964,7 +7996,7 @@ progress@2.0.3, progress@^2.0.0:
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
prop-types@^15.5.7, prop-types@^15.8.1: prop-types@^15.8.1:
version "15.8.1" version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@ -8109,14 +8141,6 @@ react-remove-scroll@^2.6.3:
use-callback-ref "^1.3.3" use-callback-ref "^1.3.3"
use-sidecar "^1.1.3" use-sidecar "^1.1.3"
react-split@^2.0.14:
version "2.0.14"
resolved "https://registry.yarnpkg.com/react-split/-/react-split-2.0.14.tgz#ef198259bf43264d605f792fb3384f15f5b34432"
integrity sha512-bKWydgMgaKTg/2JGQnaJPg51T6dmumTWZppFgEbbY0Fbme0F5TuatAScCLaqommbGQQf/ZT1zaejuPDriscISA==
dependencies:
prop-types "^15.5.7"
split.js "^1.6.0"
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
version "2.2.3" version "2.2.3"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
@ -8756,11 +8780,6 @@ sourcemap-codec@^1.4.8:
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
split.js@^1.6.0:
version "1.6.5"
resolved "https://registry.yarnpkg.com/split.js/-/split.js-1.6.5.tgz#f7f61da1044c9984cb42947df4de4fadb5a3f300"
integrity sha512-mPTnGCiS/RiuTNsVhCm9De9cCAUsrNFFviRbADdKiiV+Kk8HKp/0fWu7Kr8pi3/yBmsqLFHuXGT9UUZ+CNLwFw==
sprintf-js@~1.0.2: sprintf-js@~1.0.2:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
@ -9342,6 +9361,11 @@ undici-types@~6.20.0:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433"
integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==
undici-types@~7.10.0:
version "7.10.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.10.0.tgz#4ac2e058ce56b462b056e629cc6a02393d3ff350"
integrity sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==
unicode-canonical-property-names-ecmascript@^2.0.0: unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2"