From e8b4620a96b722646e6db9e98efd0ca2a0184767 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:14:44 +0100 Subject: [PATCH 01/33] feat(editor): put caret at pointer coords when clicking on selected text element (#10970) --- packages/element/src/textElement.ts | 3 +- packages/element/src/textWrapping.ts | 244 +++++++++++++++--- packages/element/tests/textWrapping.test.ts | 71 ++++- packages/excalidraw/components/App.tsx | 152 +++++++++-- .../components/Stats/stats.test.tsx | 6 +- .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 61 +++++ packages/excalidraw/wysiwyg/textWysiwyg.tsx | 205 ++++++++++++++- 7 files changed, 679 insertions(+), 63 deletions(-) diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index 523a8b8804..b891fdf2e9 100644 --- a/packages/element/src/textElement.ts +++ b/packages/element/src/textElement.ts @@ -441,7 +441,8 @@ const VALID_CONTAINER_TYPES = new Set([ export const isValidTextContainer = (element: { type: ExcalidrawElementType; -}) => VALID_CONTAINER_TYPES.has(element.type); +}): element is ExcalidrawTextContainer => + VALID_CONTAINER_TYPES.has(element.type); export const computeContainerDimensionForBoundText = ( dimension: number, diff --git a/packages/element/src/textWrapping.ts b/packages/element/src/textWrapping.ts index 5ec9bb42a9..b580f52ed1 100644 --- a/packages/element/src/textWrapping.ts +++ b/packages/element/src/textWrapping.ts @@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements"; import type { FontString } from "./types"; +/** + * This module approximates browser-like soft wrapping for Excalidraw text. + * + * The flow is: + * 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex. + * 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and + * records where each visual line came from in the source text. + * 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token + * that is wider than the available width. + * 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing + * whitespace so the rendered text stays consistent with what users see on canvas. + * + * Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers + * that need metadata such as mapping visual lines back to `originalText` + * for caret placement or future editor features. + */ let cachedCjkRegex: RegExp | undefined; let cachedLineBreakRegex: RegExp | undefined; let cachedEmojiRegex: RegExp | undefined; @@ -358,6 +374,10 @@ const Break = { /** * Breaks the line into the tokens based on the found line break opporutnities. + * + * Note: tokenization normalizes to NFC first so decomposed graphemes are treated as + * their composed variants for wrapping. Any code that needs exact source offsets should + * keep in mind that this assumes the input text is already NFC-normalized. */ export const parseTokens = (line: string) => { const breakLineRegex = getLineBreakRegex(); @@ -370,56 +390,120 @@ export const parseTokens = (line: string) => { /** * Wraps the original text into the lines based on the given width. + * + * This is a convenience adapter over `getWrappedTextLines()` for call sites + * that only need the rendered wrapped string and not the source offsets. */ export const wrapText = ( text: string, font: FontString, maxWidth: number, ): string => { + return getWrappedTextLines(text, font, maxWidth) + .map((line) => line.text) + .join("\n"); +}; + +/** + * A single rendered visual line produced from the original text. + * + * `start` and `end` are end-exclusive code-unit offsets into the original text, and do + * not include synthetic soft line breaks inserted by this module. If trailing whitespace + * was trimmed away at a wrap boundary, `end` points to the last rendered character. + */ +export type WrappedTextLine = { + text: string; + start: number; + end: number; +}; + +/** + * Splits only on existing hard line breaks and preserves original offsets. + */ +const getHardLineBreaks = (text: string): WrappedTextLine[] => { + let offset = 0; + + return text.split("\n").map((line) => { + const start = offset; + const end = start + line.length; + + offset = end + 1; + + return { + text: line, + start, + end, + }; + }); +}; + +/** + * Returns the rendered visual lines together with their source offsets. + * + * This is the source-of-truth wrapping pipeline for callers that need more than the + * final wrapped string, for example caret placement or future editor/rich-text mapping. + */ +export const getWrappedTextLines = ( + text: string, + font: FontString, + maxWidth: number, +): WrappedTextLine[] => { // if maxWidth is not finite or NaN which can happen in case of bugs in // computation, we need to make sure we don't continue as we'll end up // in an infinite loop if (!Number.isFinite(maxWidth) || maxWidth < 0) { - return text; + return getHardLineBreaks(text); } - const lines: Array = []; - const originalLines = text.split("\n"); + const lines: WrappedTextLine[] = []; + let offset = 0; - for (const originalLine of originalLines) { - const currentLineWidth = getLineWidth(originalLine, font); + for (const originalLine of text.split("\n")) { + const originalLineWidth = getLineWidth(originalLine, font); - if (currentLineWidth <= maxWidth) { - lines.push(originalLine); - continue; + if (originalLineWidth <= maxWidth) { + lines.push({ + text: originalLine, + start: offset, + end: offset + originalLine.length, + }); + } else { + lines.push(...wrapLine(originalLine, font, maxWidth, offset)); } - const wrappedLine = wrapLine(originalLine, font, maxWidth); - lines.push(...wrappedLine); + offset += originalLine.length + 1; } - return lines.join("\n"); + return lines; }; /** - * Wraps the original line into the lines based on the given width. + * Wraps a single hard line into one or more visual lines. + * + * The line-local offsets are tracked in original-text code units so + * we can map the visual line back to the source. */ const wrapLine = ( line: string, font: FontString, maxWidth: number, -): string[] => { - const lines: Array = []; + lineStart: number, +): WrappedTextLine[] => { + const lines: WrappedTextLine[] = []; const tokens = parseTokens(line); - const tokenIterator = tokens[Symbol.iterator](); let currentLine = ""; + let currentLineStart = lineStart; + let currentLineEnd = lineStart; let currentLineWidth = 0; + // Tracks the next token's code-unit position in the original source string. + let tokenOffset = lineStart; + let tokenIndex = 0; - let iterator = tokenIterator.next(); - - while (!iterator.done) { - const token = iterator.value; + while (tokenIndex < tokens.length) { + const token = tokens[tokenIndex]; + const tokenStart = tokenOffset; + const tokenEnd = tokenStart + token.length; const testLine = currentLine + token; // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here @@ -429,37 +513,59 @@ const wrapLine = ( // build up the current line, skipping length check for possibly trailing whitespaces if (/\s/.test(token) || testLineWidth <= maxWidth) { + if (!currentLine) { + currentLineStart = tokenStart; + } currentLine = testLine; + currentLineEnd = tokenEnd; currentLineWidth = testLineWidth; - iterator = tokenIterator.next(); + tokenOffset = tokenEnd; + tokenIndex++; continue; } // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped if (!currentLine) { - const wrappedWord = wrapWord(token, font, maxWidth); - const trailingLine = wrappedWord[wrappedWord.length - 1] ?? ""; + const wrappedWord = wrapWord(token, font, maxWidth, tokenStart); + const trailingLine = wrappedWord[wrappedWord.length - 1] ?? { + text: "", + start: tokenStart, + end: tokenStart, + }; const precedingLines = wrappedWord.slice(0, -1); lines.push(...precedingLines); // trailing line of the wrapped word might still be joined with next token/s - currentLine = trailingLine; - currentLineWidth = getLineWidth(trailingLine, font); - iterator = tokenIterator.next(); + currentLine = trailingLine.text; + currentLineStart = trailingLine.start; + currentLineEnd = trailingLine.end; + currentLineWidth = getLineWidth(trailingLine.text, font); + tokenOffset = tokenEnd; + tokenIndex++; } else { // push & reset, but don't iterate on the next token, as we didn't use it yet! - lines.push(currentLine.trimEnd()); + lines.push( + trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd), + ); // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above currentLine = ""; + currentLineStart = tokenStart; + currentLineEnd = tokenStart; currentLineWidth = 0; } } // iterator done, push the trailing line if exists if (currentLine) { - const trailingLine = trimLine(currentLine, font, maxWidth); + const trailingLine = trimLine( + currentLine, + currentLineStart, + currentLineEnd, + font, + maxWidth, + ); lines.push(trailingLine); } @@ -467,59 +573,100 @@ const wrapLine = ( }; /** - * Wraps the word into the lines based on the given width. + * Wraps a single word that could not be placed on an empty line as-is. */ const wrapWord = ( word: string, font: FontString, maxWidth: number, -): Array => { + wordStart: number, +): WrappedTextLine[] => { // multi-codepoint emojis are already broken apart and shouldn't be broken further if (getEmojiRegex().test(word)) { - return [word]; + return [ + { + text: word, + start: wordStart, + end: wordStart + word.length, + }, + ]; } satisfiesWordInvariant(word); - const lines: Array = []; + const lines: WrappedTextLine[] = []; const chars = Array.from(word); let currentLine = ""; + let currentLineStart = wordStart; + let currentLineEnd = wordStart; let currentLineWidth = 0; + let offset = wordStart; for (const char of chars) { + const charStart = offset; + const charEnd = charStart + char.length; const _charWidth = charWidth.calculate(char, font); const testLineWidth = currentLineWidth + _charWidth; if (testLineWidth <= maxWidth) { + if (!currentLine) { + currentLineStart = charStart; + } currentLine = currentLine + char; + currentLineEnd = charEnd; currentLineWidth = testLineWidth; + offset = charEnd; continue; } if (currentLine) { - lines.push(currentLine); + lines.push({ + text: currentLine, + start: currentLineStart, + end: currentLineEnd, + }); } currentLine = char; + currentLineStart = charStart; + currentLineEnd = charEnd; currentLineWidth = _charWidth; + offset = charEnd; } if (currentLine) { - lines.push(currentLine); + lines.push({ + text: currentLine, + start: currentLineStart, + end: currentLineEnd, + }); } return lines; }; /** - * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`. + * Trims trailing whitespace that is exceeding the `maxWidth`. + * + * Used for the trailing visual line of a hard line, where some trailing + * whitespace may still be visible if it fits into the available width. */ -const trimLine = (line: string, font: FontString, maxWidth: number) => { +const trimLine = ( + line: string, + start: number, + end: number, + font: FontString, + maxWidth: number, +): WrappedTextLine => { const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; if (!shouldTrimWhitespaces) { - return line; + return { + text: line, + start, + end, + }; } // defensively default to `trimeEnd` in case the regex does not match @@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => { trimmedLineWidth = testLineWidth; } - return trimmedLine; + return { + text: trimmedLine, + start, + end: end - (line.length - trimmedLine.length), + }; +}; + +/** + * Used for internal soft-wrap boundaries, where trailing whitespace should not + * survive into the rendered line even though it still exists in the original + * text. + */ +const trimLineEndAtSoftBreak = ( + line: string, + start: number, + end: number, +): WrappedTextLine => { + const trimmedLine = line.trimEnd(); + + return { + text: trimmedLine, + start, + end: end - (line.length - trimmedLine.length), + }; }; /** diff --git a/packages/element/tests/textWrapping.test.ts b/packages/element/tests/textWrapping.test.ts index 87c96a4c91..2149dd5622 100644 --- a/packages/element/tests/textWrapping.test.ts +++ b/packages/element/tests/textWrapping.test.ts @@ -1,4 +1,8 @@ -import { wrapText, parseTokens } from "../src/textWrapping"; +import { + getWrappedTextLines, + parseTokens, + wrapText, +} from "../src/textWrapping"; import type { FontString } from "../src/types"; @@ -102,6 +106,71 @@ describe("Test wrapText", () => { expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); }); + it("should retain original text offsets for wrapped lines", () => { + expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([ + { + text: "Hello", + start: 0, + end: 5, + }, + { + text: "World!", + start: 6, + end: 12, + }, + ]); + }); + + it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => { + expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([ + { + text: " Hello", + start: 0, + end: 7, + }, + { + text: "World", + start: 9, + end: 14, + }, + ]); + }); + + it("should retain offsets when wrapping a single long token", () => { + expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([ + { + text: "Excal", + start: 0, + end: 5, + }, + { + text: "idraw", + start: 5, + end: 10, + }, + ]); + }); + + it("should preserve empty hard lines in metadata", () => { + expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([ + { + text: "A", + start: 0, + end: 1, + }, + { + text: "", + start: 2, + end: 2, + }, + { + text: "B", + start: 3, + end: 4, + }, + ]); + }); + describe("When text is CJK", () => { it("should break each CJK character when width is very small", () => { // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index ab2f4c708c..905c17048f 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -684,6 +684,11 @@ class App extends React.Component { lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = null; + // TODO this is a hack and we should ideally unify touch and pointer events + // and implement our own double click handling end-to-end (currently we're + // using a mix of native browser for click events and manual for touch - + // and browser doubleClick sucks to begin with) + lastPointerUpIsDoubleClick: boolean = false; lastPointerMoveEvent: PointerEvent | null = null; /** current frame pointer cords */ lastPointerMoveCoords: { x: number; y: number } | null = null; @@ -1438,6 +1443,21 @@ class App extends React.Component { return true; } + private isDoubleClick = ( + lastPointerEvent: + | PointerEvent + | React.PointerEvent + | undefined + | null, + currentPointerEvent: PointerEvent | React.PointerEvent, + ) => { + return ( + lastPointerEvent != null && + currentPointerEvent.timeStamp - lastPointerEvent.timeStamp <= + TAP_TWICE_TIMEOUT + ); + }; + private isIframeLikeElementCenter( el: ExcalidrawIframeLikeElement | null, event: React.PointerEvent | PointerEvent, @@ -5617,8 +5637,14 @@ class App extends React.Component { element: ExcalidrawTextElement, { isExistingElement = false, + initialCaretSceneCoords = null, }: { isExistingElement?: boolean; + /** + * supply null if no caret positioning is desired, and instead + * text should be auto-selected + */ + initialCaretSceneCoords?: { x: number; y: number } | null; }, ) { const elementsMap = this.scene.getElementsMapIncludingDeleted(); @@ -5721,6 +5747,7 @@ class App extends React.Component { element, excalidrawContainer: this.excalidrawContainerRef.current, app: this, + initialCaretSceneCoords, // when text is selected, it's hard (at least on iOS) to re-position the // caret (i.e. deselect). There's not much use for always selecting // the text on edit anyway (and users can select-all from contextmenu @@ -5744,6 +5771,68 @@ class App extends React.Component { }); } + private getSelectedTextElement( + container?: ExcalidrawTextContainer | null, + ): NonDeleted | null { + const selectedElements = this.scene.getSelectedElements(this.state); + + if (selectedElements.length !== 1) { + return null; + } + + const selectedElement = selectedElements[0]!; + + if (isTextElement(selectedElement)) { + return selectedElement; + } + + if (!container) { + return null; + } + + return getBoundTextElement( + selectedElement, + this.scene.getNonDeletedElementsMap(), + ); + } + + private getSelectedTextEditingContainerAtPosition( + hitElement: NonDeletedExcalidrawElement | null, + sceneCoords: { x: number; y: number }, + ): ExcalidrawTextContainer | null | undefined { + const selectedElements = this.scene.getSelectedElements(this.state); + + if ( + selectedElements.length !== 1 || + !hitElement || + hitElement.id !== selectedElements[0]!.id + ) { + return null; + } + + const selectedElement = selectedElements[0]!; + + if (isTextElement(selectedElement)) { + return null; + } + + if (!isValidTextContainer(selectedElement)) { + return undefined; + } + + const textElement = this.getSelectedTextElement(selectedElement); + const hitTextElement = this.getTextElementAtPosition( + sceneCoords.x, + sceneCoords.y, + ); + + if (!textElement || hitTextElement?.id !== textElement.id) { + return undefined; + } + + return selectedElement; + } + private getTextElementAtPosition( x: number, y: number, @@ -5969,6 +6058,7 @@ class App extends React.Component { insertAtParentCenter = true, container, autoEdit = true, + initialCaretSceneCoords, }: { /** X position to insert text at */ sceneX: number; @@ -5978,6 +6068,7 @@ class App extends React.Component { insertAtParentCenter?: boolean; container?: ExcalidrawTextContainer | null; autoEdit?: boolean; + initialCaretSceneCoords?: { x: number; y: number }; }) => { let shouldBindToContainer = false; @@ -5998,24 +6089,9 @@ class App extends React.Component { shouldBindToContainer = true; } } - let existingTextElement: NonDeleted | null = null; - - const selectedElements = this.scene.getSelectedElements(this.state); - - if (selectedElements.length === 1) { - if (isTextElement(selectedElements[0])) { - existingTextElement = selectedElements[0]; - } else if (container) { - existingTextElement = getBoundTextElement( - selectedElements[0], - this.scene.getNonDeletedElementsMap(), - ); - } else { - existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); - } - } else { - existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); - } + const existingTextElement = + this.getSelectedTextElement(container) || + this.getTextElementAtPosition(sceneX, sceneY); const fontFamily = existingTextElement?.fontFamily || this.state.currentItemFontFamily; @@ -6116,6 +6192,9 @@ class App extends React.Component { if (autoEdit || existingTextElement || container) { this.handleTextWysiwyg(element, { isExistingElement: !!existingTextElement, + initialCaretSceneCoords: existingTextElement + ? initialCaretSceneCoords + : null, }); } else { this.setState({ @@ -6144,6 +6223,9 @@ class App extends React.Component { private handleCanvasDoubleClick = ( event: React.MouseEvent, ) => { + if (this.state.editingTextElement) { + return; + } // case: double-clicking with arrow/line tool selected would both create // text and enter multiElement mode if (this.state.multiElement) { @@ -7680,6 +7762,10 @@ class App extends React.Component { } this.removePointer(event); + this.lastPointerUpIsDoubleClick = this.isDoubleClick( + this.lastPointerUpEvent, + event, + ); this.lastPointerUpEvent = event; if (!event.ctrlKey) { @@ -8516,6 +8602,7 @@ class App extends React.Component { insertAtParentCenter: !event.altKey, container, autoEdit: false, + initialCaretSceneCoords: { x: sceneX, y: sceneY }, }); resetCursor(this.interactiveCanvas); @@ -11001,6 +11088,35 @@ class App extends React.Component { return; } + const selectedTextEditingContainer = + this.getSelectedTextEditingContainerAtPosition(hitElement, sceneCoords); + + if ( + activeTool.type === this.state.preferredSelectionTool.type && + !this.state.editingTextElement && + !pointerDownState.drag.hasOccurred && + !pointerDownState.hit.wasAddedToSelection && + !childEvent.shiftKey && + !childEvent[KEYS.CTRL_OR_CMD] && + !childEvent.altKey && + childEvent.pointerType !== "touch" && + hitElement && + ((isTextElement(hitElement) && + this.state.selectedElementIds[hitElement.id] && + this.scene.getSelectedElements(this.state).length === 1) || + selectedTextEditingContainer) + ) { + this.startTextEditing({ + sceneX: sceneCoords.x, + sceneY: sceneCoords.y, + container: selectedTextEditingContainer, + initialCaretSceneCoords: this.lastPointerUpIsDoubleClick + ? undefined + : sceneCoords, + }); + return; + } + if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) { this.setState((prevState) => ({ selectedElementIds: makeNextSelectedElementIds( diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index a43a5fb600..283bdb40d3 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -361,12 +361,10 @@ describe("stats for a non-generic element", () => { mouse.clickAt(20, 30); const editor = await getTextEditor(); updateTextEditor(editor, "Hello!"); - act(() => { - editor.blur(); - }); + Keyboard.exitTextEditor(editor); const text = h.elements[0] as ExcalidrawTextElement; - mouse.clickOn(text); + API.setSelectedElements([text]); elementStats = stats?.querySelector("#elementStats"); diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx index 7d3da4d049..352b1a1529 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx @@ -232,6 +232,67 @@ describe("textWysiwyg", () => { expect(h.elements.length).toBe(1); }); + it("should edit selected bound text on single click", async () => { + const container = API.createElement({ + type: "rectangle", + width: 160, + height: 70, + boundElements: [], + }); + const text = API.createElement({ + type: "text", + text: "Hello World!", + x: container.x + 20, + y: container.y + 20, + width: 120, + height: 25, + containerId: container.id, + }); + + API.setElements([container, text]); + API.updateElement(container, { + boundElements: [{ type: "text", id: text.id }], + }); + API.setSelectedElements([container]); + UI.clickTool("selection"); + + mouse.clickAt(text.x + 26, text.y + 10); + + const editor = await getTextEditor(); + + expect(editor).not.toBe(null); + }); + + it("should not edit selected bound text container when only the container was single-clicked", async () => { + const container = API.createElement({ + type: "rectangle", + width: 160, + height: 70, + boundElements: [], + }); + const text = API.createElement({ + type: "text", + text: "Hello World!", + x: container.x + 20, + y: container.y + 20, + width: 120, + height: 25, + containerId: container.id, + }); + + API.setElements([container, text]); + API.updateElement(container, { + boundElements: [{ type: "text", id: text.id }], + }); + API.setSelectedElements([container]); + UI.clickTool("selection"); + + mouse.clickAt(container.x + 5, container.y + 10); + + expect(h.state.editingTextElement).toBe(null); + expect(await getTextEditor({ waitForEditor: false })).toBe(null); + }); + // FIXME too flaky. No one knows why. it.skip("should bump the version of a labeled arrow when the label is updated", async () => { const arrow = UI.createElement("arrow", { diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index 27086e07b9..262558727c 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -10,7 +10,9 @@ import { isTestEnv, MIME_TYPES, applyDarkModeFilter, + isRTL, } from "@excalidraw/common"; +import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math"; import { getTextFromElements, @@ -33,8 +35,11 @@ import { getBoundTextElement, } from "@excalidraw/element"; import { getTextWidth } from "@excalidraw/element"; +import { getLineHeightInPx } from "@excalidraw/element"; +import { getLineWidth } from "@excalidraw/element"; import { normalizeText } from "@excalidraw/element"; import { wrapText } from "@excalidraw/element"; +import { getWrappedTextLines } from "@excalidraw/element"; import { isArrowElement, isBoundToContainer, @@ -91,6 +96,103 @@ const getTransform = ( return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; }; +const getLineDirection = (text: string, offset: number) => { + const hardLineStart = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1; + const hardLineEnd = text.indexOf("\n", offset); + const hardLineText = text.slice( + hardLineStart, + hardLineEnd === -1 ? text.length : hardLineEnd, + ); + + return isRTL(hardLineText) ? "rtl" : "ltr"; +}; + +const getCaretBoundaryOffsets = (text: string) => { + const offsets = [0]; + let offset = 0; + + for (const char of Array.from(text)) { + offset += char.length; + offsets.push(offset); + } + + return offsets; +}; + +const getLineCaretOffsetFromNativeLayout = ({ + text, + font, + lineHeightPx, + direction, + targetX, +}: { + text: string; + font: ReturnType; + lineHeightPx: number; + direction: "ltr" | "rtl"; + targetX: number; +}) => { + if (!text || !document.body || typeof document.createRange !== "function") { + return null; + } + + const offsets = getCaretBoundaryOffsets(text); + const mirror = document.createElement("div"); + const textNode = document.createTextNode(text); + const range = document.createRange(); + const positions: number[] = []; + + mirror.dir = direction; + Object.assign(mirror.style, { + position: "fixed", + top: "0", + left: "0", + margin: 0, + padding: 0, + border: 0, + opacity: "0", + pointerEvents: "none", + whiteSpace: "pre", + font, + lineHeight: `${lineHeightPx}px`, + }); + mirror.append(textNode); + document.body.append(mirror); + + try { + for (const offset of offsets) { + range.setStart(textNode, offset); + range.setEnd(textNode, offset); + const caretRect = range.getBoundingClientRect(); + + if (!Number.isFinite(caretRect.left)) { + return null; + } + + positions.push(caretRect.left); + } + } catch { + return null; + } finally { + mirror.remove(); + } + + const leftEdge = Math.min(...positions); + let closestOffset = offsets[0]; + let closestDistance = Infinity; + + for (let index = 0; index < offsets.length; index++) { + const distance = Math.abs(positions[index] - leftEdge - targetX); + + if (distance < closestDistance) { + closestDistance = distance; + closestOffset = offsets[index]; + } + } + + return closestOffset; +}; + type SubmitHandler = () => void; export const textWysiwyg = ({ @@ -103,6 +205,7 @@ export const textWysiwyg = ({ excalidrawContainer, app, autoSelect = true, + initialCaretSceneCoords = null, }: { id: ExcalidrawElement["id"]; /** @@ -119,7 +222,19 @@ export const textWysiwyg = ({ excalidrawContainer: HTMLDivElement | null; app: App; autoSelect?: boolean; + initialCaretSceneCoords?: { x: number; y: number } | null; }): SubmitHandler => { + let currentTextLayout: { + angle: Radians; + font: ReturnType; + height: number; + lineHeightPx: number; + textAlign: ExcalidrawTextElement["textAlign"]; + width: number; + x: number; + y: number; + } | null = null; + const textPropertiesUpdated = ( updatedTextElement: ExcalidrawTextElement, editable: HTMLTextAreaElement, @@ -254,6 +369,7 @@ export const textWysiwyg = ({ height *= 1.05; const font = getFontString(updatedTextElement); + const angle = getTextElementAngle(updatedTextElement, container); // Make sure text editor height doesn't go beyond viewport const editorMaxHeight = @@ -269,7 +385,7 @@ export const textWysiwyg = ({ transform: getTransform( width, height, - getTextElementAngle(updatedTextElement, container), + angle, appState, maxWidth, editorMaxHeight, @@ -283,6 +399,19 @@ export const textWysiwyg = ({ opacity: updatedTextElement.opacity / 100, maxHeight: `${editorMaxHeight}px`, }); + currentTextLayout = { + angle: angle as Radians, + font, + height: updatedTextElement.height, + lineHeightPx: getLineHeightInPx( + updatedTextElement.fontSize, + updatedTextElement.lineHeight, + ), + textAlign, + width: updatedTextElement.width, + x: coordX, + y: coordY, + }; editable.scrollTop = 0; // For some reason updating font attribute doesn't set font family // hence updating font family explicitly for test environment @@ -333,6 +462,71 @@ export const textWysiwyg = ({ editable.value = element.originalText; updateWysiwygStyle(); + const getCaretIndexFromInitialSceneCoords = () => { + if (!initialCaretSceneCoords || !currentTextLayout) { + return null; + } + + const layout = currentTextLayout; + const center = pointFrom( + layout.x + layout.width / 2, + layout.y + layout.height / 2, + ); + const [unrotatedX, unrotatedY] = pointRotateRads( + pointFrom(initialCaretSceneCoords.x, initialCaretSceneCoords.y), + center, + -layout.angle as Radians, + ); + const localX = unrotatedX - layout.x; + const localY = unrotatedY - layout.y; + const lines = getWrappedTextLines( + editable.value, + layout.font, + whiteSpace === "pre-wrap" ? layout.width : Infinity, + ); + const lineIndex = Math.max( + 0, + Math.min(lines.length - 1, Math.floor(localY / layout.lineHeightPx)), + ); + const line = lines[lineIndex]; + const direction = getLineDirection(editable.value, line.start); + const lineWidth = getLineWidth(line.text, layout.font); + const lineStartX = + layout.textAlign === "center" + ? (layout.width - lineWidth) / 2 + : layout.textAlign === "right" + ? layout.width - lineWidth + : 0; + const relativeX = localX - lineStartX; + + if (!line.text) { + return line.start; + } + + const lineCaretOffset = getLineCaretOffsetFromNativeLayout({ + text: line.text, + font: layout.font, + lineHeightPx: layout.lineHeightPx, + direction, + targetX: relativeX, + }); + + return line.start + (lineCaretOffset || 0); + }; + + let pendingInitialSelection = (() => { + const caretIndex = getCaretIndexFromInitialSceneCoords(); + + if (caretIndex === null) { + return null; + } + + return { + start: caretIndex, + end: caretIndex, + }; + })(); + if (onChange) { editable.onpaste = async (event) => { // we need to synchronously get the MIME types so we can preventDefault() @@ -696,6 +890,13 @@ export const textWysiwyg = ({ // Otherwise, re-enable submit on blur and refocus the editor. editable.onblur = handleSubmit; editable.focus(); + if (pendingInitialSelection) { + editable.setSelectionRange( + pendingInitialSelection.start, + pendingInitialSelection.end, + ); + pendingInitialSelection = null; + } }); }; @@ -786,7 +987,7 @@ export const textWysiwyg = ({ let isDestroyed = false; - if (autoSelect) { + if (autoSelect && !pendingInitialSelection) { // select on init (focusing is done separately inside the bindBlurEvent() // because we need it to happen *after* the blur event from `pointerdown`) editable.select(); From 81ab857a6fc66254239947524faccf45d5925f69 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:00:58 +0100 Subject: [PATCH 02/33] feat(editor): various text related improvements (#10979) --- packages/element/src/selection.ts | 18 ++ .../excalidraw/actions/actionProperties.tsx | 31 ++- .../actions/actionTextAutoResize.ts | 17 +- packages/excalidraw/components/App.tsx | 183 +++++++++++++++++- packages/excalidraw/components/Range.tsx | 70 +++---- .../components/canvases/InteractiveCanvas.tsx | 2 + .../excalidraw/renderer/interactiveScene.ts | 71 ++++++- .../tests/__snapshots__/history.test.tsx.snap | 68 +++---- packages/excalidraw/tests/helpers/ui.ts | 14 +- packages/excalidraw/textAutoResizeHandle.ts | 88 +++++++++ packages/excalidraw/types.ts | 8 +- .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 43 +++- packages/math/src/point.ts | 9 +- 13 files changed, 518 insertions(+), 104 deletions(-) create mode 100644 packages/excalidraw/textAutoResizeHandle.ts diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts index bb94166dbd..ea7fdb1b77 100644 --- a/packages/element/src/selection.ts +++ b/packages/element/src/selection.ts @@ -11,6 +11,7 @@ import { isBoundToContainer, isFrameLikeElement, isLinearElement, + isTextElement, } from "./typeChecks"; import { elementOverlapsWithFrame, @@ -25,6 +26,7 @@ import type { ElementsMap, ElementsMapOrArray, ExcalidrawElement, + NonDeleted, NonDeletedExcalidrawElement, } from "./types"; @@ -288,3 +290,19 @@ export const getSelectionStateForElements = ( ), }; }; + +/** + * Returns editing or single-selected text element, if any. + */ +export const getActiveTextElement = ( + selectedElements: readonly NonDeleted[], + appState: Pick, +) => { + const activeTextElement = + appState.editingTextElement || + (selectedElements.length === 1 && + isTextElement(selectedElements[0]) && + selectedElements[0]); + + return activeTextElement || null; +}; diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index bca2237940..a046db6ea8 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -191,7 +191,7 @@ export const getFormValue = function ( elements: readonly ExcalidrawElement[], app: AppClassProperties, getAttribute: (element: ExcalidrawElement) => T, - isRelevantElement: true | ((element: ExcalidrawElement) => boolean), + elementPredicate: true | ((element: ExcalidrawElement) => boolean), defaultValue: T | ((isSomeElementSelected: boolean) => T), ): T { const editingTextElement = app.state.editingTextElement; @@ -209,9 +209,9 @@ export const getFormValue = function ( if (hasSelection) { const selectedElements = app.scene.getSelectedElements(app.state); const targetElements = - isRelevantElement === true + elementPredicate === true ? selectedElements - : selectedElements.filter((el) => isRelevantElement(el)); + : selectedElements.filter((el) => elementPredicate(el)); ret = reduceToCommonValue(targetElements, getAttribute) ?? @@ -730,9 +730,28 @@ export const actionChangeOpacity = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ app, updateData }) => ( - - ), + PanelComponent: ({ elements, appState, app, updateData }) => { + const opacity = getFormValue( + elements, + app, + (element) => element.opacity, + true, + (hasSelection) => (hasSelection ? null : appState.currentItemOpacity), + ); + + return ( + + ); + }, }); export const actionChangeFontSize = register( diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts index d4338e7797..dcc0f16955 100644 --- a/packages/excalidraw/actions/actionTextAutoResize.ts +++ b/packages/excalidraw/actions/actionTextAutoResize.ts @@ -1,24 +1,24 @@ import { getFontString } from "@excalidraw/common"; -import { newElementWith } from "@excalidraw/element"; +import { isExcalidrawElement, newElementWith } from "@excalidraw/element"; import { measureText } from "@excalidraw/element"; import { isTextElement } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; +import type { ExcalidrawElement } from "@excalidraw/element/types"; + import { getSelectedElements } from "../scene"; import { register } from "./register"; -import type { AppClassProperties } from "../types"; - export const actionTextAutoResize = register({ name: "autoResize", label: "labels.autoResize", icon: null, trackEvent: { category: "element" }, - predicate: (elements, appState, _: unknown, app: AppClassProperties) => { + predicate: (elements, appState, _: unknown) => { const selectedElements = getSelectedElements(elements, appState); return ( selectedElements.length === 1 && @@ -26,13 +26,18 @@ export const actionTextAutoResize = register({ !selectedElements[0].autoResize ); }, - perform: (elements, appState, _, app) => { + perform: (elements, appState, targetElement) => { const selectedElements = getSelectedElements(elements, appState); + const targetTextElement = + isExcalidrawElement(targetElement) && isTextElement(targetElement) + ? targetElement + : (selectedElements[0] as ExcalidrawElement | undefined); + return { appState, elements: elements.map((element) => { - if (element.id === selectedElements[0].id && isTextElement(element)) { + if (element.id === targetTextElement?.id && isTextElement(element)) { const metrics = measureText( element.originalText, getFontString(element), diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 905c17048f..480dae2f7b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -257,6 +257,7 @@ import { handleFocusPointPointerUp, maybeHandleArrowPointlikeDrag, getUncroppedWidthAndHeight, + getActiveTextElement, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -416,6 +417,7 @@ import { import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; +import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle"; import { textWysiwyg } from "../wysiwyg/textWysiwyg"; import { isOverScrollBars } from "../scene/scrollbars"; @@ -692,6 +694,7 @@ class App extends React.Component { lastPointerMoveEvent: PointerEvent | null = null; /** current frame pointer cords */ lastPointerMoveCoords: { x: number; y: number } | null = null; + private lastCompletedCanvasClicks: { x: number; y: number }[] = []; /** previous frame pointer coords */ previousPointerMoveCoords: { x: number; y: number } | null = null; lastViewportPosition = { x: 0, y: 0 }; @@ -1253,6 +1256,26 @@ class App extends React.Component { ) as NullableGridSize; }; + private getTextCreationGridPoint = (x: number, y: number) => { + const effectiveGridSize = this.getEffectiveGridSize(); + + if (effectiveGridSize === null) { + return null; + } + + const getTextCreationGridCoordinate = (coordinate: number) => { + const topLeftGridPoint = + Math.floor(coordinate / effectiveGridSize) * effectiveGridSize; + + return topLeftGridPoint; + }; + + return { + x: getTextCreationGridCoordinate(x), + y: getTextCreationGridCoordinate(y), + }; + }; + private getHTMLIFrameElement( element: ExcalidrawIframeLikeElement, ): HTMLIFrameElement | undefined { @@ -2341,6 +2364,7 @@ class App extends React.Component { } handleCanvasRef={this.handleInteractiveCanvasRef} onContextMenu={this.handleCanvasContextMenu} + onClick={this.handleCanvasClick} onPointerMove={this.handleCanvasPointerMove} onPointerUp={this.handleCanvasPointerUp} onPointerCancel={this.removePointer} @@ -3594,10 +3618,14 @@ class App extends React.Component { this.lassoTrail.endPath(); this.deselectElements(); - // @ts-ignore this.handleCanvasDoubleClick({ clientX: touch.clientX, clientY: touch.clientY, + type: "touch", + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, }); } didTapTwice = false; @@ -5846,6 +5874,58 @@ class App extends React.Component { return null; } + private isHittingTextAutoResizeHandle = ( + selectedElements: NonDeleted[], + point: Readonly<{ x: number; y: number }>, + ): boolean => { + const activeTextElement = getActiveTextElement( + selectedElements, + this.state, + ); + + if ( + activeTextElement && + !activeTextElement.isDeleted && + !activeTextElement.autoResize && + isPointHittingTextAutoResizeHandle( + point, + activeTextElement, + this.state.zoom.value, + this.editorInterface.formFactor, + ) + ) { + return true; + } + + return false; + }; + + private handleTextAutoResizeHandlePointerDown = ( + selectedElements: NonDeleted[], + point: Readonly<{ x: number; y: number }>, + ) => { + const activeTextElement = getActiveTextElement( + selectedElements, + this.state, + ); + if ( + !activeTextElement || + !this.isHittingTextAutoResizeHandle(selectedElements, point) + ) { + return false; + } + + this.actionManager.executeAction( + actionTextAutoResize, + "ui", + // we need to pass down the element since it may already be deselected + // due to the pointerdown + activeTextElement, + ); + this.resetCursor(); + return true; + }; + // NOTE: Hot path for hit testing, so avoid unnecessary computations private getElementAtPosition( x: number, @@ -6138,11 +6218,32 @@ class App extends React.Component { y: sceneY, }); + const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY); + + const newTextElementPosition = parentCenterPosition + ? { + x: parentCenterPosition.elementCenterX, + y: parentCenterPosition.elementCenterY, + } + : !existingTextElement + ? { + x: textCreationGridPoint?.x ?? sceneX, + y: + textCreationGridPoint === null + ? // Free text starts from a point cursor, so center the first line box on it. + sceneY - getLineHeightInPx(fontSize, lineHeight) / 2 + : textCreationGridPoint.y, + } + : { + x: sceneX, + y: sceneY, + }; + const element = existingTextElement || newTextElement({ - x: parentCenterPosition ? parentCenterPosition.elementCenterX : sceneX, - y: parentCenterPosition ? parentCenterPosition.elementCenterY : sceneY, + x: newTextElementPosition.x, + y: newTextElementPosition.y, strokeColor: this.state.currentItemStrokeColor, backgroundColor: this.state.currentItemBackgroundColor, fillStyle: this.state.currentItemFillStyle, @@ -6220,10 +6321,46 @@ class App extends React.Component { } }; + private shouldHandleBrowserCanvasDoubleClick = (type: string) => { + // TODO remove this once we consolidate double-click logic and handle + // ourselves for all event types together + if (type === "touch") { + return true; + } + if (this.lastCompletedCanvasClicks.length === 0) { + return true; + } + + if (this.lastCompletedCanvasClicks.length < 2) { + return false; + } + + const [firstClick, secondClick] = this.lastCompletedCanvasClicks; + + return ( + pointDistance( + pointFrom(firstClick.x, firstClick.y), + pointFrom(secondClick.x, secondClick.y), + ) <= DOUBLE_TAP_POSITION_THRESHOLD + ); + }; + private handleCanvasDoubleClick = ( - event: React.MouseEvent, + event: Pick< + React.MouseEvent, + | "type" + | "clientX" + | "clientY" + | "altKey" + | "ctrlKey" + | "metaKey" + | "shiftKey" + >, ) => { - if (this.state.editingTextElement) { + if ( + this.state.editingTextElement || + !this.shouldHandleBrowserCanvasDoubleClick(event.type) + ) { return; } // case: double-clicking with arrow/line tool selected would both create @@ -6413,6 +6550,21 @@ class App extends React.Component { } }; + private handleCanvasClick = (event: React.MouseEvent) => { + if (event.button !== POINTER_BUTTON.MAIN) { + this.lastCompletedCanvasClicks = []; + return; + } + + this.lastCompletedCanvasClicks = [ + ...this.lastCompletedCanvasClicks.slice(-1), + { + x: event.clientX, + y: event.clientY, + }, + ]; + }; + private getElementLinkAtPosition = ( scenePointer: Readonly<{ x: number; y: number }>, hitElementMightBeLocked: NonDeletedExcalidrawElement | null, @@ -6949,6 +7101,12 @@ class App extends React.Component { const elements = this.scene.getNonDeletedElements(); const selectedElements = this.scene.getSelectedElements(this.state); + + if (this.isHittingTextAutoResizeHandle(selectedElements, scenePointer)) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + return; + } + if ( selectedElements.length === 1 && !isOverScrollBar && @@ -7093,7 +7251,9 @@ class App extends React.Component { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } else if ( // if using cmd/ctrl, we're not dragging - !event[KEYS.CTRL_OR_CMD] + !event[KEYS.CTRL_OR_CMD] && + // editing text -> don't show move cursor when hovering over its bbox + hitElement?.id !== this.state.editingTextElement?.id ) { if ( (hitElement || @@ -7314,6 +7474,8 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + const selectedElements = this.scene.getSelectedElements(this.state); + // If Ctrl is not held, ensure isBindingEnabled reflects the user preference. if (!event.ctrlKey) { const preferenceEnabled = this.state.bindingPreference === "enabled"; @@ -7537,6 +7699,15 @@ class App extends React.Component { selectedElementsAreBeingDragged: false, }); + if ( + this.handleTextAutoResizeHandlePointerDown( + selectedElements, + pointerDownState.origin, + ) + ) { + return; + } + if (this.handleDraggingScrollBar(event, pointerDownState)) { return; } diff --git a/packages/excalidraw/components/Range.tsx b/packages/excalidraw/components/Range.tsx index 501ad7d188..7a4bf397e7 100644 --- a/packages/excalidraw/components/Range.tsx +++ b/packages/excalidraw/components/Range.tsx @@ -1,74 +1,78 @@ import React, { useEffect } from "react"; -import { t } from "../i18n"; - import "./Range.scss"; -import type { AppClassProperties } from "../types"; - export type RangeProps = { - updateData: (value: number) => void; - app: AppClassProperties; + label: React.ReactNode; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + minLabel?: React.ReactNode; + hasCommonValue?: boolean; testId?: string; }; -export const Range = ({ updateData, app, testId }: RangeProps) => { +export const Range = ({ + label, + value, + onChange, + min = 0, + max = 100, + step = 10, + minLabel = min, + hasCommonValue = true, + testId, +}: RangeProps) => { const rangeRef = React.useRef(null); const valueRef = React.useRef(null); - const selectedElements = app.scene.getSelectedElements(app.state); - let hasCommonOpacity = true; - const firstElement = selectedElements.at(0); - const leastCommonOpacity = selectedElements.reduce((acc, element) => { - if (acc != null && acc !== element.opacity) { - hasCommonOpacity = false; - } - if (acc == null || acc > element.opacity) { - return element.opacity; - } - return acc; - }, firstElement?.opacity ?? null); - - const value = leastCommonOpacity ?? app.state.currentItemOpacity; useEffect(() => { if (rangeRef.current && valueRef.current) { const rangeElement = rangeRef.current; const valueElement = valueRef.current; const inputWidth = rangeElement.offsetWidth; - const thumbWidth = 15; // 15 is the width of the thumb + const thumbWidth = + parseFloat( + getComputedStyle(rangeElement).getPropertyValue( + "--slider-thumb-size", + ), + ) || 16; + const progress = ((value - min) / (max - min || 1)) * 100; const position = - (value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; + (progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; valueElement.style.left = `${position}px`; - rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`; + rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${progress}%, var(--button-bg) ${progress}%, var(--button-bg) 100%)`; } - }, [value]); + }, [max, min, value]); return ( ); diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index a30088f30a..2aabddfbab 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -54,6 +54,7 @@ type InteractiveCanvasProps = { DOMAttributes["onContextMenu"], undefined >; + onClick: Exclude["onClick"], undefined>; onPointerMove: Exclude< DOMAttributes["onPointerMove"], undefined @@ -213,6 +214,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { height={props.appState.height * props.scale} ref={props.handleCanvasRef} onContextMenu={props.onContextMenu} + onClick={props.onClick} onPointerMove={props.onPointerMove} onPointerUp={props.onPointerUp} onPointerCancel={props.onPointerCancel} diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 56c308713e..7d31bf9a32 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -41,6 +41,7 @@ import { maxBindingDistance_simple, isTextElement, LinearElementEditor, + getActiveTextElement, } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element"; @@ -58,6 +59,8 @@ import { isFocusPointVisible, } from "@excalidraw/element"; +import type { EditorInterface } from "@excalidraw/common"; + import type { TransformHandles, TransformHandleType, @@ -86,6 +89,10 @@ import { } from "../scene/scrollbars"; import { getClientColor, renderRemoteCursors } from "../clients"; +import { + getTextAutoResizeHandle, + getTextBoxPadding, +} from "../textAutoResizeHandle"; import { bootstrapCanvas, @@ -1489,21 +1496,58 @@ const renderTextBox = ( selectionColor: InteractiveCanvasRenderConfig["selectionColor"], ) => { context.save(); - const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; + const padding = getTextBoxPadding(appState.zoom.value); const width = text.width + padding * 2; const height = text.height + padding * 2; - const cx = text.x + width / 2; - const cy = text.y + height / 2; - const shiftX = -(width / 2 + padding); - const shiftY = -(height / 2 + padding); + const cx = text.x + text.width / 2; + const cy = text.y + text.height / 2; + const shiftX = -(text.width / 2 + padding); + const shiftY = -(text.height / 2 + padding); context.translate(cx + appState.scrollX, cy + appState.scrollY); context.rotate(text.angle); context.lineWidth = 1 / appState.zoom.value; context.strokeStyle = selectionColor; + context.globalAlpha = 0.5; + context.setLineDash([6 / appState.zoom.value, 4 / appState.zoom.value]); context.strokeRect(shiftX, shiftY, width, height); context.restore(); }; +const renderResetAutoResizeHandle = ( + text: NonDeleted, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + selectionColor: InteractiveCanvasRenderConfig["selectionColor"], + formFactor: EditorInterface["formFactor"], +) => { + const autoResizeHandle = getTextAutoResizeHandle( + text, + appState.zoom.value, + formFactor, + ); + + if (!autoResizeHandle) { + return; + } + + context.save(); + context.globalAlpha = 0.5; + context.lineWidth = 1.5 / appState.zoom.value; + context.lineCap = "round"; + context.strokeStyle = selectionColor; + context.beginPath(); + context.moveTo( + autoResizeHandle.start[0] + appState.scrollX, + autoResizeHandle.start[1] + appState.scrollY, + ); + context.lineTo( + autoResizeHandle.end[0] + appState.scrollX, + autoResizeHandle.end[1] + appState.scrollY, + ); + context.stroke(); + context.restore(); +}; + const _renderInteractiveScene = ({ app, canvas, @@ -1584,10 +1628,19 @@ const _renderInteractiveScene = ({ } } - if ( - appState.editingTextElement && - isTextElement(appState.editingTextElement) - ) { + const activeTextElement = getActiveTextElement(selectedElements, appState); + + if (activeTextElement && !activeTextElement.autoResize) { + renderResetAutoResizeHandle( + activeTextElement, + context, + appState, + renderConfig.selectionColor, + editorInterface.formFactor, + ); + } + + if (appState.editingTextElement) { const textElement = allElementsMap.get(appState.editingTextElement.id) as | ExcalidrawTextElement | undefined; diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index c81459c1c4..050a20878b 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -224,7 +224,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 29, + "version": 28, "width": "94.00000", "x": 0, "y": 0, @@ -350,7 +350,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 28, + "version": 27, "width": "88.00000", }, "inserted": { @@ -381,7 +381,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 25, + "version": 24, "width": "88.00000", }, }, @@ -437,7 +437,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], ], "startBinding": null, - "version": 29, + "version": 28, "width": "94.00000", "x": 0, "y": 0, @@ -462,7 +462,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 28, + "version": 27, "width": "88.00000", "x": 6, "y": "7.20923", @@ -1360,9 +1360,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 8, + "version": 7, "width": 88, - "x": 6, + "x": "6.00000", "y": "2.00947", } `; @@ -1537,12 +1537,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 8, + "version": 7, }, "inserted": { "endBinding": null, "startBinding": null, - "version": 7, + "version": 6, }, }, }, @@ -1722,7 +1722,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "updated": 1, "version": 8, "width": 88, - "x": 6, + "x": "6.00000", "y": "38.80379", } `; @@ -1867,7 +1867,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "version": 8, "width": 88, - "x": 6, + "x": "6.00000", "y": "38.80379", }, "inserted": { @@ -2416,7 +2416,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 11, "width": 488, "x": 6, "y": "-5.39000", @@ -2581,7 +2581,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 12, + "version": 11, "width": 488, "x": 6, "y": "-5.39000", @@ -16254,7 +16254,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", @@ -16307,7 +16307,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 10, + "version": 11, }, "inserted": { "endBinding": { @@ -16327,7 +16327,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 8, + "version": 9, }, }, }, @@ -16669,14 +16669,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 7, + "version": 8, "width": "88.00000", "x": 6, "y": "0.00880", }, "inserted": { "isDeleted": true, - "version": 6, + "version": 7, }, }, }, @@ -17002,7 +17002,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", @@ -17307,14 +17307,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 8, + "version": 9, }, }, }, @@ -17648,7 +17648,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", @@ -17953,14 +17953,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 8, + "version": 9, }, }, }, @@ -18292,7 +18292,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", @@ -18361,7 +18361,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 10, + "version": 11, }, "inserted": { "endBinding": { @@ -18373,7 +18373,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "mode": "orbit", }, "startBinding": null, - "version": 8, + "version": 9, }, }, "id2": { @@ -18683,14 +18683,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 7, + "version": 8, "width": "88.00000", "x": 6, "y": "0.00880", }, "inserted": { "isDeleted": true, - "version": 6, + "version": 7, }, }, }, @@ -19044,7 +19044,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, + "version": 12, "width": "88.00000", "x": 6, "y": "0.01000", @@ -19124,12 +19124,12 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 11, + "version": 12, }, "inserted": { "endBinding": null, "startBinding": null, - "version": 9, + "version": 10, }, }, }, @@ -19431,14 +19431,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 7, + "version": 8, "width": "88.00000", "x": 6, "y": "0.00880", }, "inserted": { "isDeleted": true, - "version": 6, + "version": 7, }, }, }, diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 80a7e4c561..079af75969 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -4,6 +4,7 @@ import { elementCenterPoint, getCommonBounds, getElementPointsCoords, + getLineHeightInPx, } from "@excalidraw/element"; import { cropElement } from "@excalidraw/element"; import { @@ -20,7 +21,7 @@ import { isTextElement, isFrameLikeElement, } from "@excalidraw/element"; -import { KEYS, arrayToMap } from "@excalidraw/common"; +import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -516,8 +517,17 @@ export class UI { UI.clickTool(type); if (type === "text") { + const clickY = h.state.gridModeEnabled + ? y + : y + + getLineHeightInPx( + h.state.currentItemFontSize, + getLineHeight(h.state.currentItemFontFamily), + ) / + 2; + mouse.reset(); - mouse.click(x, y); + mouse.click(x, clickY); } else if ((type === "line" || type === "arrow") && points.length > 2) { points.forEach((point) => { mouse.reset(); diff --git a/packages/excalidraw/textAutoResizeHandle.ts b/packages/excalidraw/textAutoResizeHandle.ts new file mode 100644 index 0000000000..abf1e76ddc --- /dev/null +++ b/packages/excalidraw/textAutoResizeHandle.ts @@ -0,0 +1,88 @@ +import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "@excalidraw/common"; +import { + pointFrom, + pointRotateRads, + type GlobalPoint, + type Radians, +} from "@excalidraw/math"; + +import type { EditorInterface } from "@excalidraw/common"; + +import type { ExcalidrawTextElement } from "@excalidraw/element/types"; + +const TEXT_AUTO_RESIZE_HANDLE_GAP = 12; +const TEXT_AUTO_RESIZE_HANDLE_LENGTH = 16; +const TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH = 10; +const TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT = + TEXT_AUTO_RESIZE_HANDLE_LENGTH + 2; +const MAX_HANDLE_HEIGHT_RATIO = 0.8; + +export const getTextBoxPadding = (zoomValue: number) => + (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / zoomValue; + +export const getTextAutoResizeHandle = ( + textElement: ExcalidrawTextElement, + zoomValue: number, + formFactor: EditorInterface["formFactor"], +) => { + if ( + formFactor !== "desktop" || + TEXT_AUTO_RESIZE_HANDLE_LENGTH > + textElement.height * zoomValue * MAX_HANDLE_HEIGHT_RATIO + ) { + return null; + } + + const padding = getTextBoxPadding(zoomValue); + const gap = TEXT_AUTO_RESIZE_HANDLE_GAP / zoomValue; + const length = TEXT_AUTO_RESIZE_HANDLE_LENGTH / zoomValue; + const center = pointFrom( + textElement.x + textElement.width / 2, + textElement.y + textElement.height / 2, + ); + const handleCenter = pointRotateRads( + pointFrom(center[0] + textElement.width / 2 + padding + gap, center[1]), + center, + textElement.angle, + ); + + return { + center: handleCenter, + start: pointRotateRads( + pointFrom(handleCenter[0], handleCenter[1] - length / 2), + handleCenter, + textElement.angle, + ) as GlobalPoint, + end: pointRotateRads( + pointFrom(handleCenter[0], handleCenter[1] + length / 2), + handleCenter, + textElement.angle, + ) as GlobalPoint, + hitboxWidth: TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH / zoomValue, + hitboxHeight: TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT / zoomValue, + }; +}; + +export const isPointHittingTextAutoResizeHandle = ( + point: Readonly<{ x: number; y: number }>, + textElement: ExcalidrawTextElement, + zoomValue: number, + formFactor: EditorInterface["formFactor"], +) => { + const handle = getTextAutoResizeHandle(textElement, zoomValue, formFactor); + + if (!handle) { + return false; + } + + const unrotatedPoint = pointRotateRads( + pointFrom(point.x, point.y), + handle.center, + -textElement.angle as Radians, + ); + + return ( + Math.abs(unrotatedPoint[0] - handle.center[0]) <= handle.hitboxWidth / 2 && + Math.abs(unrotatedPoint[1] - handle.center[1]) <= handle.hitboxHeight / 2 + ); +}; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 63397ea488..cae4e7b93f 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -32,6 +32,7 @@ import type { OrderedExcalidrawElement, ExcalidrawNonSelectionElement, BindMode, + ExcalidrawTextElement, } from "@excalidraw/element/types"; import type { @@ -327,7 +328,7 @@ export interface AppState { /** * set when a new text is created or when an existing text is being edited */ - editingTextElement: NonDeletedExcalidrawElement | null; + editingTextElement: ExcalidrawTextElement | null; activeTool: { /** * indicates a previous tool we should revert back to if we deselect the @@ -876,9 +877,8 @@ export type PointerDownState = Readonly<{ // by default same as PointerDownState.origin. On alt-duplication, reset // to current pointer position at time of duplication. origin: { x: number; y: number }; - // Whether to block drag after lasso selection - // this is meant to be used to block dragging after lasso selection on PCs - // until the next pointer down + // explicit flag for specific scenarios such as: + // - after lasso selection until the next pointer down blockDragging: boolean; }; // We need to have these in the state so that we can unsubscribe them diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx index 352b1a1529..2a0e5b73c7 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx @@ -1,7 +1,10 @@ import { queryByText } from "@testing-library/react"; import { pointFrom } from "@excalidraw/math"; -import { getOriginalContainerHeightFromCache } from "@excalidraw/element"; +import { + getLineHeightInPx, + getOriginalContainerHeightFromCache, +} from "@excalidraw/element"; import { CODES, @@ -210,6 +213,42 @@ describe("textWysiwyg", () => { expect(h.elements.length).toBe(1); }); + it("should vertically center newly created text on the cursor when clicked with text tool", async () => { + API.setAppState({ + currentItemFontFamily: FONT_FAMILY.Cascadia, + currentItemFontSize: 40, + }); + UI.clickTool("text"); + + mouse.clickAt(120, 80); + + const editor = await getTextEditor(); + const text = h.elements[0] as ExcalidrawTextElement; + const lineHeightPx = getLineHeightInPx(text.fontSize, text.lineHeight); + + expect(editor).not.toBe(null); + expect(text.y + lineHeightPx / 2).toBe(80); + }); + + it("should snap newly created text top-left to the current grid cell when clicked with text tool in grid mode", async () => { + API.setAppState({ + currentItemFontFamily: FONT_FAMILY.Cascadia, + currentItemFontSize: 40, + gridModeEnabled: true, + gridSize: 24, + }); + UI.clickTool("text"); + + mouse.clickAt(113, 86); + + const editor = await getTextEditor(); + const text = h.elements[0] as ExcalidrawTextElement; + + expect(editor).not.toBe(null); + expect(text.x).toBe(96); + expect(text.y).toBe(72); + }); + it("should edit text under cursor when double-clicked with selection tool", async () => { const text = API.createElement({ type: "text", @@ -1572,7 +1611,7 @@ describe("textWysiwyg", () => { version: 2, width: 610, x: 15, - y: 25, + y: 12.5, }), ); expect(h.elements[2] as ExcalidrawTextElement).toEqual( diff --git a/packages/math/src/point.ts b/packages/math/src/point.ts index ed25a713d8..13121d681b 100644 --- a/packages/math/src/point.ts +++ b/packages/math/src/point.ts @@ -123,10 +123,15 @@ export function pointsEqual( * @returns The rotated point */ export function pointRotateRads( - [x, y]: Point, - [cx, cy]: Point, + point: Point, + center: Point, angle: Radians, ): Point { + if (!angle) { + return point; + } + const [x, y] = point; + const [cx, cy] = center; return pointFrom( (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx, (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy, From 987173b52f8a889984c3ff98450f24b9e65c70fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Sat, 21 Mar 2026 19:26:47 +0100 Subject: [PATCH 03/33] fix: Arrow point index Out-of-Bounds (#10922) * fix: Make OOB not fatal Signed-off-by: Mark Tolmacs * fix: More conservative temp arrow state update Signed-off-by: Mark Tolmacs * chore: Capture condition variables in binding restoration failure Signed-off-by: Mark Tolmacs --------- Signed-off-by: Mark Tolmacs --- packages/element/src/linearElementEditor.ts | 26 +++++++++++++-------- packages/excalidraw/components/App.tsx | 26 +++++++++------------ packages/excalidraw/data/restore.ts | 4 +++- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index e57211abbc..2ef5d53776 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -476,16 +476,22 @@ export class LinearElementEditor { }); } - invariant( - lastClickedPoint > -1 && - selectedPointsIndices.includes(lastClickedPoint) && - element.points[lastClickedPoint], - `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify( - selectedPointsIndices, - )}) points(0..${ - element.points.length - 1 - }) lastClickedPoint(${lastClickedPoint})`, - ); + if ( + lastClickedPoint < 0 || + !selectedPointsIndices.includes(lastClickedPoint) || + !element.points[lastClickedPoint] + ) { + console.error( + `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify( + selectedPointsIndices, + )}) points(0..${ + element.points.length - 1 + }) lastClickedPoint(${lastClickedPoint})`, + ); + + // Fall back to the actual last point as a last resort. + lastClickedPoint = element.points.length - 1; + } // point that's being dragged (out of all selected points) const draggingPoint = element.points[lastClickedPoint]; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 480dae2f7b..4a79e93baf 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -6995,27 +6995,23 @@ class App extends React.Component { }, { informMutation: false, isDragging: false }, ); + const newLastIdx = multiElement.points.length - 1; this.setState({ selectedLinearElement: { ...selectedLinearElement, - selectedPointsIndices: - selectedLinearElement.selectedPointsIndices?.includes( - multiElement.points.length, - ) - ? [ - ...selectedLinearElement.selectedPointsIndices.filter( - (idx) => - idx !== multiElement.points.length && - idx !== multiElement.points.length - 1, + selectedPointsIndices: selectedLinearElement.selectedPointsIndices + ? [ + ...new Set( + selectedLinearElement.selectedPointsIndices.map((idx) => + Math.min(idx, newLastIdx), ), - multiElement.points.length - 1, - ] - : selectedLinearElement.selectedPointsIndices, - lastCommittedPoint: - multiElement.points[multiElement.points.length - 1], + ), + ] + : selectedLinearElement.selectedPointsIndices, + lastCommittedPoint: multiElement.points[newLastIdx], initialState: { ...selectedLinearElement.initialState, - lastClickedPoint: multiElement.points.length - 1, + lastClickedPoint: newLastIdx, }, }, }); diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 4550338edf..1ed068dc1c 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -251,7 +251,9 @@ const repairBinding = ( }; } - console.error(`could not repair binding for element`); + console.error( + `Could not repair binding for element "${boundElement?.id}" out of (${elementsMap?.size}) elements`, + ); } catch (error) { console.error("Error repairing binding:", error); } From a9ca16eb42c2fd7f06065be7b5238b40e08bf8d3 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:44:27 +0100 Subject: [PATCH 04/33] chore(packages/excalidraw): export Fonts helper class (#11008) --- packages/excalidraw/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 7ced19a337..c935f31468 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -382,6 +382,8 @@ export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToC export { getDataURL } from "./data/blob"; export { isElementLink } from "@excalidraw/element"; +export { Fonts } from "./fonts/Fonts"; + export { setCustomTextMetricsProvider } from "@excalidraw/element"; export { CommandPalette } from "./components/CommandPalette/CommandPalette"; From 75789f620d9f61d392027a4d4b317adcedf72056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Mon, 23 Mar 2026 15:54:44 +0100 Subject: [PATCH 05/33] fix: Other endpoint is not immediately updated on midpoint snap (#10933) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/element/src/linearElementEditor.ts | 4 +- .../tests/__snapshots__/history.test.tsx.snap | 72 ++++++++++--------- yarn.lock | 8 +-- 3 files changed, 43 insertions(+), 41 deletions(-) diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 2ef5d53776..9390d56e58 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -2406,7 +2406,7 @@ const pointDraggingUpdates = ( ? nextArrow.points[0] : endBindable ? updateBoundPoint( - element, + nextArrow, "endBinding", nextArrow.endBinding, endBindable, @@ -2437,7 +2437,7 @@ const pointDraggingUpdates = ( ? endLocalPoint : startBindable ? updateBoundPoint( - element, + nextArrow, "startBinding", nextArrow.startBinding, startBindable, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 050a20878b..e3218fd575 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -224,7 +224,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 28, + "version": 22, "width": "94.00000", "x": 0, "y": 0, @@ -350,8 +350,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 27, + "version": 21, "width": "88.00000", + "y": "7.20923", }, "inserted": { "endBinding": { @@ -381,8 +382,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 24, + "version": 20, "width": "88.00000", + "y": "0.01000", }, }, }, @@ -437,7 +439,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], ], "startBinding": null, - "version": 28, + "version": 22, "width": "94.00000", "x": 0, "y": 0, @@ -462,7 +464,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 27, + "version": 21, "width": "88.00000", "x": 6, "y": "7.20923", @@ -16254,7 +16256,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, + "version": 10, "width": "88.00000", "x": 6, "y": "0.01000", @@ -16307,7 +16309,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 11, + "version": 10, }, "inserted": { "endBinding": { @@ -16327,7 +16329,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 9, + "version": 8, }, }, }, @@ -16636,7 +16638,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00120", + "height": 0, "index": "a3", "isDeleted": false, "link": null, @@ -16649,7 +16651,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00120", + 0, ], ], "roughness": 1, @@ -16669,14 +16671,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 8, + "version": 7, "width": "88.00000", "x": 6, - "y": "0.00880", + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 6, }, }, }, @@ -17002,7 +17004,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, + "version": 10, "width": "88.00000", "x": 6, "y": "0.01000", @@ -17307,14 +17309,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 11, + "version": 10, "width": "88.00000", "x": 6, "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 9, + "version": 8, }, }, }, @@ -17648,7 +17650,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, + "version": 10, "width": "88.00000", "x": 6, "y": "0.01000", @@ -17953,14 +17955,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 11, + "version": 10, "width": "88.00000", "x": 6, "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 9, + "version": 8, }, }, }, @@ -18292,7 +18294,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, + "version": 10, "width": "88.00000", "x": 6, "y": "0.01000", @@ -18361,7 +18363,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 11, + "version": 10, }, "inserted": { "endBinding": { @@ -18373,7 +18375,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "mode": "orbit", }, "startBinding": null, - "version": 9, + "version": 8, }, }, "id2": { @@ -18650,7 +18652,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00120", + "height": 0, "index": "a3", "isDeleted": false, "link": null, @@ -18663,7 +18665,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00120", + 0, ], ], "roughness": 1, @@ -18683,14 +18685,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 8, + "version": 7, "width": "88.00000", "x": 6, - "y": "0.00880", + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 6, }, }, }, @@ -19044,7 +19046,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", @@ -19124,12 +19126,12 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 12, + "version": 11, }, "inserted": { "endBinding": null, "startBinding": null, - "version": 10, + "version": 9, }, }, }, @@ -19398,7 +19400,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00120", + "height": 0, "index": "a3", "isDeleted": false, "link": null, @@ -19411,7 +19413,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00120", + 0, ], ], "roughness": 1, @@ -19431,14 +19433,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 8, + "version": 7, "width": "88.00000", "x": 6, - "y": "0.00880", + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 6, }, }, }, diff --git a/yarn.lock b/yarn.lock index 2bc70c4afd..3b2d6cddc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1531,10 +1531,10 @@ resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.0.tgz#a5b9cf87c3185558cda7f9687d87b9937f452358" - integrity sha512-RMd+c2b7WzzUjhERMpKwp8PhF2/XlHDjr/zK+Gxfp8K9sVlafPYJ5OEa/GkN6edi2rBUXRfW+41WdO6L56b6Kw== +"@excalidraw/mermaid-to-excalidraw@2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.1.tgz#659c934a607dd2cf57f2a69282588ee2b0722959" + integrity sha512-jU+frqcxazsY+t5yOBf2mgrQy+WUrbrzA36if3SQB/Vwaf2qOJjnWxucNafgZZk/3+9xGmRotUeOviSOJG+wYA== dependencies: "@excalidraw/markdown-to-text" "0.1.2" "@mermaid-js/parser" "^0.6.3" From d6f0f34fe91a7fab25106f2b31b074c132815d36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Mon, 23 Mar 2026 15:54:59 +0100 Subject: [PATCH 06/33] fix: Rotated rounded arrow center point (#10962) --- packages/element/src/collision.ts | 13 +++++++++-- packages/element/src/distance.ts | 8 +++++-- packages/element/src/linearElementEditor.ts | 15 ++++++++++-- packages/element/src/shape.ts | 23 ++++++------------- packages/element/src/textElement.ts | 1 + packages/element/src/utils.ts | 6 ++--- .../excalidraw/renderer/interactiveScene.ts | 1 + 7 files changed, 41 insertions(+), 26 deletions(-) diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index 46d2dbd46a..b17d563dbe 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -465,7 +465,12 @@ export const intersectElementWithLineSegment = ( case "line": case "freedraw": case "arrow": - return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst); + return intersectLinearOrFreeDrawWithLineSegment( + element, + line, + elementsMap, + onlyFirst, + ); } }; @@ -532,11 +537,15 @@ const lineIntersections = ( const intersectLinearOrFreeDrawWithLineSegment = ( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, segment: LineSegment, + elementsMap: ElementsMap, onlyFirst = false, ): GlobalPoint[] => { // NOTE: This is the only one which return the decomposed elements // rotated! This is due to taking advantage of roughjs definitions. - const [lines, curves] = deconstructLinearOrFreeDrawElement(element); + const [lines, curves] = deconstructLinearOrFreeDrawElement( + element, + elementsMap, + ); const intersections: GlobalPoint[] = []; for (const l of lines) { diff --git a/packages/element/src/distance.ts b/packages/element/src/distance.ts index 4766ac9eef..c94652a1aa 100644 --- a/packages/element/src/distance.ts +++ b/packages/element/src/distance.ts @@ -48,7 +48,7 @@ export const distanceToElement = ( case "line": case "arrow": case "freedraw": - return distanceToLinearOrFreeDraElement(element, p); + return distanceToLinearOrFreeDraElement(element, elementsMap, p); } }; @@ -133,9 +133,13 @@ const distanceToEllipseElement = ( const distanceToLinearOrFreeDraElement = ( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, + elementsMap: ElementsMap, p: GlobalPoint, ) => { - const [lines, curves] = deconstructLinearOrFreeDrawElement(element); + const [lines, curves] = deconstructLinearOrFreeDrawElement( + element, + elementsMap, + ); return Math.min( ...lines.map((s) => distanceToLineSegment(p, s)), ...curves.map((a) => curvePointDistance(a, p)), diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 9390d56e58..8af8ecd858 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -800,6 +800,7 @@ export class LinearElementEditor { element.points[index + 1], index, appState.zoom, + elementsMap, ) ) { midpoints.push(null); @@ -809,6 +810,7 @@ export class LinearElementEditor { const segmentMidPoint = LinearElementEditor.getSegmentMidPoint( element, index + 1, + elementsMap, ); midpoints.push(segmentMidPoint); index++; @@ -896,6 +898,7 @@ export class LinearElementEditor { endPoint: P, index: number, zoom: Zoom, + elementsMap: ElementsMap, ) { if (isElbowArrow(element)) { if (index >= 0 && index < element.points.length) { @@ -910,7 +913,10 @@ export class LinearElementEditor { let distance = pointDistance(startPoint, endPoint); if (element.points.length > 2 && element.roundness) { - const [lines, curves] = deconstructLinearOrFreeDrawElement(element); + const [lines, curves] = deconstructLinearOrFreeDrawElement( + element, + elementsMap, + ); invariant( lines.length === 0 && curves.length > 0, @@ -930,6 +936,7 @@ export class LinearElementEditor { static getSegmentMidPoint( element: NonDeleted, index: number, + elementsMap: ElementsMap, ): GlobalPoint { if (isElbowArrow(element)) { invariant( @@ -942,7 +949,10 @@ export class LinearElementEditor { return pointFrom(element.x + p[0], element.y + p[1]); } - const [lines, curves] = deconstructLinearOrFreeDrawElement(element); + const [lines, curves] = deconstructLinearOrFreeDrawElement( + element, + elementsMap, + ); invariant( (lines.length === 0 && curves.length > 0) || @@ -1857,6 +1867,7 @@ export class LinearElementEditor { const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( element, index + 1, + elementsMap, ); x = midSegmentMidpoint[0] - boundTextElement.width / 2; diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index 9bef1329af..158ace7519 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -57,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading"; import { canChangeRoundness } from "./comparisons"; import { + elementCenterPoint, getArrowheadPoints, - getCenterForBounds, getDiamondPoints, getElementAbsoluteCoords, } from "./bounds"; @@ -583,7 +583,11 @@ const getArrowheadShapes = ( export const generateLinearCollisionShape = ( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, -) => { + elementsMap: ElementsMap, +): { + op: string; + data: number[]; +}[] => { const generator = new RoughGenerator(); const options: Options = { seed: element.seed, @@ -592,20 +596,7 @@ export const generateLinearCollisionShape = ( roughness: 0, preserveVertices: true, }; - const center = getCenterForBounds( - // Need a non-rotated center point - element.points.reduce( - (acc, point) => { - return [ - Math.min(element.x + point[0], acc[0]), - Math.min(element.y + point[1], acc[1]), - Math.max(element.x + point[0], acc[2]), - Math.max(element.y + point[1], acc[3]), - ]; - }, - [Infinity, Infinity, -Infinity, -Infinity], - ), - ); + const center = elementCenterPoint(element, elementsMap); switch (element.type) { case "line": diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index b891fdf2e9..9ff53d035c 100644 --- a/packages/element/src/textElement.ts +++ b/packages/element/src/textElement.ts @@ -347,6 +347,7 @@ export const getContainerCenter = ( midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( container, index + 1, + elementsMap, ); } return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index 96e09bcbf1..819cad562f 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -124,6 +124,7 @@ const setElementShapesCacheEntry = ( */ export function deconstructLinearOrFreeDrawElement( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, + elementsMap: ElementsMap, ): [LineSegment[], Curve[]] { const cachedShape = getElementShapesCacheEntry(element, 0); @@ -131,10 +132,7 @@ export function deconstructLinearOrFreeDrawElement( return cachedShape; } - const ops = generateLinearCollisionShape(element) as { - op: string; - data: number[]; - }[]; + const ops = generateLinearCollisionShape(element, elementsMap); const lines = []; const curves = []; diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 7d31bf9a32..30cadb0ef7 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -1156,6 +1156,7 @@ const renderLinearPointHandles = ( points[idx], idx, appState.zoom, + elementsMap, ) ) { renderSingleLinearPoint( From 1c292e4936795b8e2ac0b46bbccedf612e060a2b Mon Sep 17 00:00:00 2001 From: Kundan Date: Tue, 24 Mar 2026 23:31:08 +0530 Subject: [PATCH 07/33] fix(math): correctly validate second point in isLineSegment (#11007) Co-authored-by: Mark Tolmacs --- packages/math/src/segment.ts | 2 +- packages/math/tests/segment.test.ts | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/math/src/segment.ts b/packages/math/src/segment.ts index ae282bfee5..7523ef3f2f 100644 --- a/packages/math/src/segment.ts +++ b/packages/math/src/segment.ts @@ -40,7 +40,7 @@ export const isLineSegment = ( Array.isArray(segment) && segment.length === 2 && isPoint(segment[0]) && - isPoint(segment[0]); + isPoint(segment[1]); /** * Return the coordinates resulting from rotating the given line about an origin by an angle in radians diff --git a/packages/math/tests/segment.test.ts b/packages/math/tests/segment.test.ts index 6b29bba591..a7091521df 100644 --- a/packages/math/tests/segment.test.ts +++ b/packages/math/tests/segment.test.ts @@ -1,5 +1,9 @@ import { pointFrom } from "../src/point"; -import { lineSegment, lineSegmentIntersectionPoints } from "../src/segment"; +import { + lineSegment, + lineSegmentIntersectionPoints, + isLineSegment, +} from "../src/segment"; describe("line-segment intersections", () => { it("should correctly detect intersection", () => { @@ -19,3 +23,23 @@ describe("line-segment intersections", () => { ).toEqual(null); }); }); +describe("isLineSegment validation", () => { + it("should return true for a valid segment", () => { + expect( + isLineSegment([ + [0, 0], + [1, 1], + ]), + ).toBe(true); + }); + + it("should return false if second element is not a point", () => { + const invalidSegment = [[0, 0], "not-a-point"] as any; + + expect(isLineSegment(invalidSegment)).toBe(false); + }); + + it("should return false for wrong length", () => { + expect(isLineSegment([[0, 0]])).toBe(false); + }); +}); From c1082923eedf62e2df7821fe8c772d839c46438d Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:20:28 +0100 Subject: [PATCH 08/33] feat(editor): support mermaid staate diagrams (#11031) --- packages/excalidraw/locales/en.json | 2 +- packages/excalidraw/package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index b0a8761de4..42e87bdf82 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -661,7 +661,7 @@ "placeholder": { "title": "Let's design your diagram", "description": "Describe the diagram you want to create, and we'll generate it for you.", - "hint": "At the moment we know Flowchart, Sequence, Class, and Entity Relationship diagrams." + "hint": "At the moment we know Flowchart, Sequence, Class, State, and Entity Relationship diagrams." }, "preview": "Preview", "insert": "Insert", diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 3d8fe1daeb..1d01b82bd8 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -88,7 +88,7 @@ "@excalidraw/element": "0.18.0", "@excalidraw/laser-pointer": "1.3.1", "@excalidraw/math": "0.18.0", - "@excalidraw/mermaid-to-excalidraw": "2.1.1", + "@excalidraw/mermaid-to-excalidraw": "2.2.2", "@excalidraw/random-username": "1.1.0", "browser-fs-access": "0.38.0", "canvas-roundrect-polyfill": "0.0.1", diff --git a/yarn.lock b/yarn.lock index 3b2d6cddc9..dd87b52426 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1531,10 +1531,10 @@ resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@2.1.1": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.1.tgz#659c934a607dd2cf57f2a69282588ee2b0722959" - integrity sha512-jU+frqcxazsY+t5yOBf2mgrQy+WUrbrzA36if3SQB/Vwaf2qOJjnWxucNafgZZk/3+9xGmRotUeOviSOJG+wYA== +"@excalidraw/mermaid-to-excalidraw@2.2.2": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz#ee6b597a0d95b9a76f7ae41ce0e3733a9b96e4a0" + integrity sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg== dependencies: "@excalidraw/markdown-to-text" "0.1.2" "@mermaid-js/parser" "^0.6.3" From c09e170bdd50f9e08a82deba2c037043b8fce9e0 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:14:24 +0100 Subject: [PATCH 09/33] feat(editor): deselect on esc (#11035) Co-authored-by: Jawahar Co-authored-by: Andrew Aquino --- packages/excalidraw/actions/actionDeselect.ts | 145 ++++++ .../excalidraw/actions/actionFinalize.tsx | 4 +- packages/excalidraw/actions/index.ts | 1 + packages/excalidraw/actions/types.ts | 1 + packages/excalidraw/components/App.tsx | 12 +- .../tests/__snapshots__/history.test.tsx.snap | 483 ++++++++++++++++++ packages/excalidraw/tests/history.test.tsx | 76 +++ packages/excalidraw/tests/selection.test.tsx | 65 ++- .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 83 +++ 9 files changed, 856 insertions(+), 14 deletions(-) create mode 100644 packages/excalidraw/actions/actionDeselect.ts diff --git a/packages/excalidraw/actions/actionDeselect.ts b/packages/excalidraw/actions/actionDeselect.ts new file mode 100644 index 0000000000..2b48eb825e --- /dev/null +++ b/packages/excalidraw/actions/actionDeselect.ts @@ -0,0 +1,145 @@ +import { + getElementsInGroup, + isSomeElementSelected, + makeNextSelectedElementIds, + selectGroupsForSelectedElements, +} from "@excalidraw/element"; +import { CaptureUpdateAction } from "@excalidraw/element"; +import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common"; + +import type { GroupId } from "@excalidraw/element/types"; + +import { register } from "./register"; + +import type { AppClassProperties, AppState } from "../types"; + +const getNextActiveTool = ( + appState: Readonly, + app: AppClassProperties, +) => { + if (appState.activeTool.type === "eraser") { + return updateActiveTool(appState, { + ...(appState.activeTool.lastActiveTool || { + type: app.state.preferredSelectionTool.type, + }), + lastActiveToolBeforeEraser: null, + }); + } + + return updateActiveTool(appState, { + type: app.state.preferredSelectionTool.type, + }); +}; + +const getParentEditingGroupId = ( + appState: Readonly, + app: AppClassProperties, + selectedElementIds: AppState["selectedElementIds"], +): GroupId | null => { + if (!appState.editingGroupId) { + return null; + } + + const nonDeletedElements = app.scene.getNonDeletedElements(); + const selectedElements = app.scene.getSelectedElements({ + selectedElementIds, + elements: nonDeletedElements, + }); + const candidateElements = selectedElements.length + ? selectedElements + : getElementsInGroup(nonDeletedElements, appState.editingGroupId); + + for (const element of candidateElements) { + const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId); + if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) { + return element.groupIds[editingGroupIndex + 1] as GroupId; + } + } + + return null; +}; + +export const actionDeselect = register({ + name: "deselect", + label: "", + trackEvent: false, + perform: (_elements, appState, _, app) => { + const activeTool = getNextActiveTool(appState, app); + + if (appState.editingGroupId) { + const nonDeletedElements = app.scene.getNonDeletedElements(); + const selectedElementIds = + Object.keys(appState.selectedElementIds).length > 0 + ? appState.selectedElementIds + : getElementsInGroup( + nonDeletedElements, + appState.editingGroupId, + ).reduce((acc, element) => { + acc[element.id] = true; + return acc; + }, {} as Record); + + return { + appState: { + ...appState, + ...selectGroupsForSelectedElements( + { + editingGroupId: getParentEditingGroupId( + appState, + app, + selectedElementIds, + ), + selectedElementIds, + }, + nonDeletedElements, + appState, + app, + ), + activeEmbeddable: null, + activeTool, + selectedLinearElement: null, + selectionElement: null, + showHyperlinkPopup: false, + suggestedBinding: null, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + } + + return { + appState: { + ...appState, + activeEmbeddable: null, + activeTool, + editingGroupId: null, + selectedElementIds: makeNextSelectedElementIds({}, appState), + selectedGroupIds: {}, + selectedLinearElement: null, + selectionElement: null, + showHyperlinkPopup: false, + suggestedBinding: null, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event, appState, _, app) => { + if (event.key !== KEYS.ESCAPE) { + return false; + } + + if (isWritableElement(event.target)) { + return false; + } + + return ( + !appState.newElement && + appState.multiElement === null && + !appState.selectedLinearElement?.isEditing && + (appState.activeEmbeddable !== null || + appState.activeTool.type !== app.state.preferredSelectionTool.type || + !!appState.editingGroupId || + !!appState.selectedLinearElement || + isSomeElementSelected(app.scene.getNonDeletedElements(), appState)) + ); + }, +}); diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 9e529621a7..37332c4124 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -348,9 +348,7 @@ export const actionFinalize = register({ }; }, keyTest: (event, appState) => - (event.key === KEYS.ESCAPE && - (appState.selectedLinearElement?.isEditing || - (!appState.newElement && appState.multiElement === null))) || + (event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) || ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && appState.multiElement !== null), PanelComponent: ({ appState, updateData, data }) => ( diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index cc9ca1789c..d630c6ecaa 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -34,6 +34,7 @@ export { export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable"; export { actionFinalize } from "./actionFinalize"; +export { actionDeselect } from "./actionDeselect"; export { actionChangeProjectName, diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index ae80e4107c..02c67d34f3 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -114,6 +114,7 @@ export type ActionName = | "distributeVertically" | "flipHorizontal" | "flipVertical" + | "deselect" | "viewMode" | "exportWithDarkMode" | "toggleTheme" diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4a79e93baf..5561594cb0 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5725,13 +5725,13 @@ class App extends React.Component { const isDeleted = !nextOriginalText.trim(); updateElement(nextOriginalText, isDeleted); - // select the created text element only if submitting via keyboard - // (when submitting via click it should act as signal to deselect) - if (!isDeleted && viaKeyboard) { - const elementIdToSelect = element.containerId - ? element.containerId - : element.id; + // keyboard-submit keeps focus on the edited object. For bound text, keep + // the container selected even if the text becomes empty and is deleted. + const elementIdToSelect = viaKeyboard + ? element.containerId || (!isDeleted ? element.id : null) + : null; + if (elementIdToSelect) { // needed to ensure state is updated before "finalize" action // that's invoked on keyboard-submit as well // TODO either move this into finalize as well, or handle all state diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index e3218fd575..3a400a0128 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -11222,6 +11222,489 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end of test] undo stack 1`] = `[]`; +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] appState 1`] = ` +{ + "activeEmbeddable": null, + "activeLockedId": null, + "activeTool": { + "customType": null, + "fromSelection": false, + "lastActiveTool": null, + "locked": false, + "type": "selection", + }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "collaborators": Map {}, + "contextMenu": null, + "croppingElementId": null, + "currentHoveredFontFamily": null, + "currentItemArrowType": "round", + "currentItemBackgroundColor": "transparent", + "currentItemEndArrowhead": "arrow", + "currentItemFillStyle": "solid", + "currentItemFontFamily": 5, + "currentItemFontSize": 20, + "currentItemOpacity": 100, + "currentItemRoughness": 1, + "currentItemRoundness": "sharp", + "currentItemStartArrowhead": null, + "currentItemStrokeColor": "#1e1e1e", + "currentItemStrokeStyle": "solid", + "currentItemStrokeWidth": 2, + "currentItemTextAlign": "left", + "cursorButton": "up", + "defaultSidebarDockedPreference": false, + "editingFrame": null, + "editingGroupId": null, + "editingTextElement": null, + "elementsToHighlight": null, + "errorMessage": null, + "exportBackground": true, + "exportEmbedScene": false, + "exportScale": 1, + "exportWithDarkMode": false, + "fileHandle": null, + "followedBy": Set {}, + "frameRendering": { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, + "frameToHighlight": null, + "gridModeEnabled": false, + "gridSize": 20, + "gridStep": 5, + "height": 0, + "hoveredElementIds": {}, + "isBindingEnabled": true, + "isCropping": false, + "isLoading": false, + "isMidpointSnappingEnabled": true, + "isResizing": false, + "isRotating": false, + "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, + "multiElement": null, + "newElement": null, + "objectsSnapModeEnabled": false, + "offsetLeft": 0, + "offsetTop": 0, + "openDialog": null, + "openMenu": null, + "openPopup": null, + "openSidebar": null, + "originSnapOffset": null, + "penDetected": false, + "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, + "previousSelectedElementIds": {}, + "resizingElement": null, + "scrollX": 0, + "scrollY": 0, + "searchMatches": null, + "selectedElementIds": {}, + "selectedElementsAreBeingDragged": false, + "selectedGroupIds": {}, + "selectionElement": null, + "shouldCacheIgnoreZoom": false, + "showHyperlinkPopup": false, + "showWelcomeScreen": true, + "snapLines": [], + "startBoundElement": null, + "stats": { + "open": false, + "panels": 3, + }, + "suggestedBinding": null, + "theme": "light", + "toast": null, + "userToFollow": null, + "viewBackgroundColor": "#ffffff", + "viewModeEnabled": false, + "width": 0, + "zenModeEnabled": false, + "zoom": { + "value": 1, + }, +} +`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] element 0 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "inner", + "outer", + ], + "height": 100, + "id": "id0", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 2, + "width": 100, + "x": 0, + "y": 0, +} +`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] element 1 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "outer", + ], + "height": 100, + "id": "id1", + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 2, + "width": 100, + "x": 100, + "y": 100, +} +`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] element 2 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "inner", + "outer", + ], + "height": 100, + "id": "id2", + "index": "a2", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 2, + "width": 100, + "x": 200, + "y": 200, +} +`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] number of elements 1`] = `3`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] number of renders 1`] = `16`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] redo stack 1`] = `[]`; + +exports[`history > multiplayer undo/redo > should support undo and redo when escape unwinds nested group editing > [end of test] undo stack 1`] = ` +[ + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedElementIds": { + "id0": true, + "id1": true, + "id2": true, + }, + "selectedGroupIds": { + "outer": true, + }, + }, + "inserted": { + "selectedElementIds": {}, + "selectedGroupIds": {}, + }, + }, + }, + "elements": { + "added": {}, + "removed": { + "id0": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "inner", + "outer", + ], + "height": 100, + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 2, + "width": 100, + "x": 0, + "y": 0, + }, + "inserted": { + "isDeleted": true, + "version": 1, + }, + }, + "id1": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "outer", + ], + "height": 100, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 2, + "width": 100, + "x": 100, + "y": 100, + }, + "inserted": { + "isDeleted": true, + "version": 1, + }, + }, + "id2": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "inner", + "outer", + ], + "height": 100, + "index": "a2", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 2, + "width": 100, + "x": 200, + "y": 200, + }, + "inserted": { + "isDeleted": true, + "version": 1, + }, + }, + }, + "updated": {}, + }, + "id": "id5", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "editingGroupId": "outer", + "selectedElementIds": {}, + "selectedGroupIds": { + "inner": true, + }, + }, + "inserted": { + "editingGroupId": null, + "selectedElementIds": { + "id1": true, + }, + "selectedGroupIds": { + "outer": true, + }, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id7", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "editingGroupId": "inner", + "selectedElementIds": {}, + "selectedGroupIds": {}, + }, + "inserted": { + "editingGroupId": "outer", + "selectedElementIds": { + "id2": true, + }, + "selectedGroupIds": { + "inner": true, + }, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id9", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "editingGroupId": "outer", + "selectedElementIds": { + "id2": true, + }, + "selectedGroupIds": { + "inner": true, + }, + }, + "inserted": { + "editingGroupId": "inner", + "selectedElementIds": {}, + "selectedGroupIds": {}, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id19", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "editingGroupId": null, + "selectedElementIds": { + "id1": true, + }, + "selectedGroupIds": { + "outer": true, + }, + }, + "inserted": { + "editingGroupId": "outer", + "selectedElementIds": {}, + "selectedGroupIds": { + "inner": true, + }, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id20", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedElementIds": {}, + "selectedGroupIds": {}, + }, + "inserted": { + "selectedElementIds": { + "id0": true, + "id1": true, + "id2": true, + }, + "selectedGroupIds": { + "outer": true, + }, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id21", + }, +] +`; + exports[`history > multiplayer undo/redo > should update history entries after remote changes on the same properties > [end of test] appState 1`] = ` { "activeEmbeddable": null, diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 96e948958e..f6706a0f99 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -2971,6 +2971,82 @@ describe("history", () => { expect(h.state.editingGroupId).toBeNull(); }); + // TODO mark with "noncritical" tag once we migrate to vitest 4 + it.skip("should support undo and redo when escape unwinds nested group editing", async () => { + const rectA = API.createElement({ + type: "rectangle", + groupIds: ["inner", "outer"], + x: 0, + }); + const rectB = API.createElement({ + type: "rectangle", + groupIds: ["outer"], + x: 100, + }); + const rectC = API.createElement({ + type: "rectangle", + groupIds: ["inner", "outer"], + x: 200, + }); + + API.setElements([rectA, rectB, rectC]); + mouse.select(rectA); + mouse.doubleClickOn(rectA); + mouse.doubleClickOn(rectA); + + assertSelectedElements([rectA]); + expect(h.state.editingGroupId).toBe("inner"); + expect(API.getUndoStack().length).toBe(3); + expect(API.getRedoStack().length).toBe(0); + + Keyboard.keyPress(KEYS.ESCAPE); + assertSelectedElements([rectA, rectC]); + expect(h.state.editingGroupId).toBe("outer"); + expect(API.getUndoStack().length).toBe(4); + expect(API.getRedoStack().length).toBe(0); + + Keyboard.keyPress(KEYS.ESCAPE); + assertSelectedElements([rectA, rectB, rectC]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({ outer: true }); + expect(API.getUndoStack().length).toBe(5); + expect(API.getRedoStack().length).toBe(0); + + Keyboard.keyPress(KEYS.ESCAPE); + expect(API.getSelectedElements()).toEqual([]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({}); + expect(API.getUndoStack().length).toBe(6); + expect(API.getRedoStack().length).toBe(0); + + Keyboard.undo(); + assertSelectedElements([rectA, rectB, rectC]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({ outer: true }); + + Keyboard.undo(); + assertSelectedElements([rectA, rectC]); + expect(h.state.editingGroupId).toBe("outer"); + + Keyboard.undo(); + assertSelectedElements([rectA]); + expect(h.state.editingGroupId).toBe("inner"); + + Keyboard.redo(); + assertSelectedElements([rectA, rectC]); + expect(h.state.editingGroupId).toBe("outer"); + + Keyboard.redo(); + assertSelectedElements([rectA, rectB, rectC]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({ outer: true }); + + Keyboard.redo(); + expect(API.getSelectedElements()).toEqual([]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({}); + }); + it("should iterate through the history when selected or editing linear element was remotely deleted", async () => { // create three point arrow UI.clickTool("arrow"); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 93135fb2e1..64204f1a00 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -326,7 +326,7 @@ describe("select single element on the scene", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(8); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -359,7 +359,7 @@ describe("select single element on the scene", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(8); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -392,7 +392,7 @@ describe("select single element on the scene", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(8); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -438,7 +438,7 @@ describe("select single element on the scene", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(10); - expect(renderStaticScene).toHaveBeenCalledTimes(8); + expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -483,7 +483,7 @@ describe("select single element on the scene", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(10); - expect(renderStaticScene).toHaveBeenCalledTimes(8); + expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -558,3 +558,58 @@ describe("selectedElementIds stability", () => { expect(h.state.selectedElementIds).toBe(selectedElementIds_2); }); }); + +describe("deselecting", () => { + beforeEach(async () => { + await render(); + }); + + it("esc unwinds nested group editing before deselecting", () => { + const rectA = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + groupIds: ["inner", "outer"], + }); + const rectB = API.createElement({ + type: "rectangle", + x: 100, + y: 0, + groupIds: ["outer"], + }); + const rectC = API.createElement({ + type: "rectangle", + x: 200, + y: 0, + groupIds: ["inner", "outer"], + }); + + API.setElements([rectA, rectB, rectC]); + + mouse.select(rectA); + assertSelectedElements(rectA, rectB, rectC); + expect(h.state.editingGroupId).toBeNull(); + + mouse.doubleClickOn(rectA); + assertSelectedElements(rectA, rectC); + expect(h.state.editingGroupId).toBe("outer"); + + mouse.doubleClickOn(rectA); + assertSelectedElements(rectA); + expect(h.state.editingGroupId).toBe("inner"); + + Keyboard.keyPress(KEYS.ESCAPE); + assertSelectedElements(rectA, rectC); + expect(h.state.editingGroupId).toBe("outer"); + + Keyboard.keyPress(KEYS.ESCAPE); + assertSelectedElements(rectA, rectB, rectC); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({ outer: true }); + + Keyboard.keyPress(KEYS.ESCAPE); + expect(API.getSelectedElements()).toEqual([]); + expect(h.state.editingGroupId).toBeNull(); + expect(h.state.selectedGroupIds).toEqual({}); + }); +}); diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx index 2a0e5b73c7..a092576de0 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx @@ -45,6 +45,28 @@ unmountComponent(); const tab = " "; const mouse = new Pointer("mouse"); +const exitTextEditorAndAssertSelection = async ({ + editor, + selectedIds, + nextText, +}: { + editor: HTMLTextAreaElement; + selectedIds: string[]; + nextText?: string; +}) => { + if (nextText !== undefined) { + updateTextEditor(editor, nextText); + } + + Keyboard.exitTextEditor(editor); + + expect(await getTextEditor({ waitForEditor: false })).toBe(null); + expect(window.h.state.editingTextElement).toBeNull(); + expect(API.getSelectedElements().map((element) => element.id)).toEqual( + selectedIds, + ); +}; + describe("textWysiwyg", () => { describe("start text editing", () => { const { h } = window; @@ -271,6 +293,33 @@ describe("textWysiwyg", () => { expect(h.elements.length).toBe(1); }); + it("should reselect text after exiting wysiwyg with escape", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + API.setSelectedElements([text]); + UI.clickTool("selection"); + + Keyboard.keyPress(KEYS.ENTER); + + const editor = await getTextEditor(); + + expect(editor).not.toBe(null); + expect(h.state.editingTextElement?.id).toBe(text.id); + + await exitTextEditorAndAssertSelection({ + editor, + selectedIds: [text.id], + }); + }); + it("should edit selected bound text on single click", async () => { const container = API.createElement({ type: "rectangle", @@ -1305,6 +1354,40 @@ describe("textWysiwyg", () => { ); }); + it.each([ + { + label: "container", + createElements: () => API.createTextContainer(), + }, + { + label: "arrow", + createElements: () => API.createLabeledArrow(), + }, + ])( + "should reselect $label after deleting bound text with escape", + async ({ createElements }) => { + const [selectedElement, text] = createElements(); + API.setElements([selectedElement, text]); + API.setSelectedElements([selectedElement]); + + Keyboard.keyPress(KEYS.ENTER); + const editor = await getTextEditor(); + + await exitTextEditorAndAssertSelection({ + editor, + nextText: "", + selectedIds: [selectedElement.id], + }); + + expect(selectedElement.boundElements).toStrictEqual([]); + expect(h.elements[1]).toEqual( + expect.objectContaining({ + isDeleted: true, + }), + ); + }, + ); + it("should restore original container height and clear cache once text is unbind", async () => { const container = API.createElement({ type: "rectangle", From 4a5c9e990ca2dca3106b7bbe674b21cf58acec7d Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:56:48 +0100 Subject: [PATCH 10/33] fix(editor): ensure font picker font names are not quoted (#11036) --- .../components/FontPicker/FontPickerList.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/FontPicker/FontPickerList.tsx b/packages/excalidraw/components/FontPicker/FontPickerList.tsx index e5e7b85aea..5d107cbeb1 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerList.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerList.tsx @@ -46,6 +46,7 @@ import { import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import type { JSX } from "react"; +import type { ExcalidrawFontFace } from "../../fonts/ExcalidrawFontFace"; export interface FontDescriptor { value: number; @@ -86,6 +87,15 @@ const getFontFamilyIcon = (fontFamily: FontFamilyValues): JSX.Element => { } }; +const getFontFamilyLabel = ( + fontFamily: FontFamilyValues, + fontFaces: ExcalidrawFontFace[], +) => + // prefer our config as the browser resolved names may be wrapped in quotes and such + Object.entries(FONT_FAMILY).find(([, id]) => id === fontFamily)?.[0] ?? + fontFaces[0]?.fontFace?.family ?? + "Unknown"; + export const FontPickerList = React.memo( ({ selectedFontFamily, @@ -114,7 +124,7 @@ export const FontPickerList = React.memo( const fontDescriptor = { value: familyId, icon: getFontFamilyIcon(familyId), - text: fontFaces[0]?.fontFace?.family ?? "Unknown", + text: getFontFamilyLabel(familyId, fontFaces), }; if (metadata.deprecated) { From 4be4cc0ed03e8bd3af23bb07b7f4a04debdb7bdf Mon Sep 17 00:00:00 2001 From: dagecko Date: Mon, 30 Mar 2026 10:49:27 -0400 Subject: [PATCH 11/33] fix: pin 9 actions to commit SHA (#11075) --- .github/workflows/cancel.yml | 2 +- .github/workflows/locales-coverage.yml | 2 +- .github/workflows/publish-docker.yml | 8 ++++---- .github/workflows/semantic-pr-title.yml | 2 +- .github/workflows/size-limit.yml | 2 +- .github/workflows/test-coverage-pr.yml | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cancel.yml b/.github/workflows/cancel.yml index e1ef216651..a572df9bed 100644 --- a/.github/workflows/cancel.yml +++ b/.github/workflows/cancel.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 3 steps: - - uses: styfle/cancel-workflow-action@0.6.0 + - uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0 with: workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604 access_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/locales-coverage.yml b/.github/workflows/locales-coverage.yml index d69f1b1449..67a942438b 100644 --- a/.github/workflows/locales-coverage.yml +++ b/.github/workflows/locales-coverage.yml @@ -40,7 +40,7 @@ jobs: echo ::set-output name=body::$body - name: Update description with coverage - uses: kt3k/update-pr-description@v1.0.1 + uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1 with: pr_body: ${{ steps.getCommentBody.outputs.body }} pr_title: "chore: Update translations from Crowdin" diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 68eee27755..d0aedcb26b 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -13,16 +13,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . push: true diff --git a/.github/workflows/semantic-pr-title.yml b/.github/workflows/semantic-pr-title.yml index 34a6413fe2..8bfc1327b4 100644 --- a/.github/workflows/semantic-pr-title.yml +++ b/.github/workflows/semantic-pr-title.yml @@ -11,6 +11,6 @@ jobs: semantic: runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index ded07f91fb..2a24507630 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -20,7 +20,7 @@ jobs: working-directory: packages/excalidraw env: CI: true - - uses: andresz1/size-limit-action@v1 + - uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} build_script: build:esm diff --git a/.github/workflows/test-coverage-pr.yml b/.github/workflows/test-coverage-pr.yml index 227991a7bc..0cb6327564 100644 --- a/.github/workflows/test-coverage-pr.yml +++ b/.github/workflows/test-coverage-pr.yml @@ -21,6 +21,6 @@ jobs: run: yarn test:coverage - name: "Report Coverage" if: always() # Also generate the report if tests are failing - uses: davelosert/vitest-coverage-report-action@v2 + uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} From d9e8a33aa4b48d920ecb9d3fde15cf0e8b72fd5c Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:41:11 +0200 Subject: [PATCH 12/33] feat(editor): implement overlap box selection (#11053) Co-authored-by: Mark Tolmacs --- packages/element/src/bounds.ts | 28 +- packages/element/src/collision.ts | 43 +- packages/element/src/frame.ts | 20 +- packages/element/src/selection.ts | 300 +++++++++-- packages/element/tests/collision.test.tsx | 4 +- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/App.tsx | 48 +- .../excalidraw/components/RadioGroup.scss | 5 +- .../components/Stats/stats.test.tsx | 2 +- .../components/dropdownMenu/DropdownMenu.scss | 2 +- .../DropdownMenuItemContentRadio.tsx | 8 +- .../components/main-menu/DefaultItems.tsx | 43 +- packages/excalidraw/data/index.ts | 8 +- packages/excalidraw/data/restore.ts | 6 + packages/excalidraw/locales/en.json | 3 + packages/excalidraw/scene/export.ts | 6 +- .../__snapshots__/contextmenu.test.tsx.snap | 29 +- .../tests/__snapshots__/history.test.tsx.snap | 63 +++ .../regressionTests.test.tsx.snap | 52 ++ packages/excalidraw/tests/history.test.tsx | 2 +- .../excalidraw/tests/regressionTests.test.tsx | 6 +- packages/excalidraw/tests/selection.test.tsx | 509 +++++++++++++++++- packages/excalidraw/types.ts | 4 + packages/math/src/curve.ts | 44 +- .../tests/__snapshots__/export.test.ts.snap | 1 + scripts/woff2/woff2-vite-plugins.js | 7 + 26 files changed, 1141 insertions(+), 104 deletions(-) diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index a73a7d28bb..a072b81a90 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -680,8 +680,9 @@ export const getMinMaxXYFromCurvePathOps = ( return [minX, minY, maxX, maxY]; }; -export const getBoundsFromPoints = ( - points: ExcalidrawFreeDrawElement["points"], +export const getBoundsFromPoints =

( + points: readonly P[], + padding: number = 0, ): Bounds => { let minX = Infinity; let minY = Infinity; @@ -695,7 +696,7 @@ export const getBoundsFromPoints = ( maxY = Math.max(maxY, y); } - return [minX, minY, maxX, maxY]; + return [minX - padding, minY - padding, maxX + padding, maxY + padding]; }; const getFreeDrawElementAbsoluteCoords = ( @@ -1261,6 +1262,17 @@ export const pointInsideBounds =

( ): boolean => p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; +// TODO make pointInsideBounds inclusive and remove this function once we +// test nothing is breaking +export const pointInsideBoundsInclusive =

( + p: P, + bounds: Bounds, +): boolean => + p[0] >= bounds[0] && + p[0] <= bounds[2] && + p[1] >= bounds[1] && + p[1] <= bounds[3]; + export const doBoundsIntersect = ( bounds1: Bounds | null, bounds2: Bounds | null, @@ -1275,13 +1287,21 @@ export const doBoundsIntersect = ( return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2; }; +export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) => + [ + pointFrom(innerBounds[0], innerBounds[1]), + pointFrom(innerBounds[0], innerBounds[3]), + pointFrom(innerBounds[2], innerBounds[1]), + pointFrom(innerBounds[2], innerBounds[3]), + ].every((point) => pointInsideBoundsInclusive(point, outerBounds)); + export const elementCenterPoint = ( element: ExcalidrawElement, elementsMap: ElementsMap, xOffset: number = 0, yOffset: number = 0, ) => { - if (isLinearElement(element)) { + if (isLinearElement(element) || isFreeDrawElement(element)) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x, y] = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index b17d563dbe..c260ae5267 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -154,14 +154,11 @@ export const hitElementItself = ({ // Hit test against the extended, rotated bounding box of the element first const bounds = getElementBounds(element, elementsMap, true); - const hitBounds = isPointWithinBounds( - pointFrom(bounds[0] - threshold, bounds[1] - threshold), - pointRotateRads( - point, - getCenterForBounds(bounds), - -element.angle as Radians, - ), - pointFrom(bounds[2] + threshold, bounds[3] + threshold), + const hitBounds = isPointInRotatedBounds( + point, + bounds, + element.angle, + threshold, ); // PERF: Bail out early if the point is not even in the @@ -192,18 +189,32 @@ export const hitElementItself = ({ return result; }; +const isPointInRotatedBounds = ( + point: GlobalPoint, + bounds: Bounds, + angle: Radians, + tolerance = 0, +) => { + const adjustedPoint = + angle === 0 + ? point + : pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians); + + return isPointWithinBounds( + pointFrom(bounds[0] - tolerance, bounds[1] - tolerance), + adjustedPoint, + pointFrom(bounds[2] + tolerance, bounds[3] + tolerance), + ); +}; + export const hitElementBoundingBox = ( point: GlobalPoint, element: ExcalidrawElement, elementsMap: ElementsMap, tolerance = 0, ) => { - let [x1, y1, x2, y2] = getElementBounds(element, elementsMap); - x1 -= tolerance; - y1 -= tolerance; - x2 += tolerance; - y2 += tolerance; - return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2)); + const bounds = getElementBounds(element, elementsMap, true); + return isPointInRotatedBounds(point, bounds, element.angle, tolerance); }; export const hitElementBoundingBoxOnly = ( @@ -573,7 +584,9 @@ const intersectLinearOrFreeDrawWithLineSegment = ( continue; } - const hits = curveIntersectLineSegment(c, segment); + const hits = curveIntersectLineSegment(c, segment, { + iterLimit: 10, + }); if (hits.length > 0) { intersections.push(...hits); diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 3c82099546..787c2692f9 100644 --- a/packages/element/src/frame.ts +++ b/packages/element/src/frame.ts @@ -1,7 +1,6 @@ import { arrayToMap } from "@excalidraw/common"; import { isPointWithinBounds, pointFrom } from "@excalidraw/math"; import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox"; -import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds"; import type { AppClassProperties, @@ -18,6 +17,8 @@ import { getElementLineSegments, getCommonBounds, getElementAbsoluteCoords, + doBoundsIntersect, + getElementBounds, } from "./bounds"; import { mutateElement } from "./mutateElement"; import { getBoundTextElement, getContainerElement } from "./textElement"; @@ -920,16 +921,17 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => { export const getElementsOverlappingFrame = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { - return ( - elementsOverlappingBBox({ - elements, - bounds: frame, - type: "overlap", - }) - // removes elements who are overlapping, but are in a different frame, + return elements.filter( + (el) => + // exclude elements which are overlapping, but are in a different frame, // and thus invisible in target frame - .filter((el) => !el.frameId || el.frameId === frame.id) + (!el.frameId || el.frameId === frame.id) && + doBoundsIntersect( + getElementBounds(el, elementsMap), + getElementBounds(frame, elementsMap), + ), ); }; diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts index ea7fdb1b77..8e9c4e8086 100644 --- a/packages/element/src/selection.ts +++ b/packages/element/src/selection.ts @@ -1,15 +1,32 @@ -import { arrayToMap, isShallowEqual } from "@excalidraw/common"; +import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common"; +import { + lineSegment, + pointFrom, + pointRotateRads, + type GlobalPoint, +} from "@excalidraw/math"; import type { AppState, + BoxSelectionMode, InteractiveCanvasAppState, } from "@excalidraw/excalidraw/types"; -import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; +import { + boundsContainBounds, + doBoundsIntersect, + elementCenterPoint, + getElementAbsoluteCoords, + getElementBounds, + pointInsideBounds, +} from "./bounds"; +import { intersectElementWithLineSegment } from "./collision"; import { isElementInViewport } from "./sizeHelpers"; import { + isArrowElement, isBoundToContainer, isFrameLikeElement, + isFreeDrawElement, isLinearElement, isTextElement, } from "./typeChecks"; @@ -17,19 +34,38 @@ import { elementOverlapsWithFrame, getContainingFrame, getFrameChildren, + isElementIntersectingFrame, } from "./frame"; import { LinearElementEditor } from "./linearElementEditor"; import { selectGroupsForSelectedElements } from "./groups"; +import { getBoundTextElement } from "./textElement"; import type { ElementsMap, ElementsMapOrArray, ExcalidrawElement, + ExcalidrawFrameLikeElement, NonDeleted, NonDeletedExcalidrawElement, } from "./types"; +const shouldIgnoreElementFromSelection = ( + element: NonDeletedExcalidrawElement, +) => element.locked || isBoundToContainer(element); + +const excludeElementsFromFrames = ( + selectedElements: readonly T[], + framesInSelection: Set, +) => { + return selectedElements.filter((element) => { + if (element.frameId && framesInSelection.has(element.frameId)) { + return false; + } + return true; + }); +}; + /** * Frames and their containing elements are not to be selected at the same time. * Given an array of selected elements, if there are frames and their containing elements @@ -49,55 +85,243 @@ export const excludeElementsInFramesFromSelection = < } }); - return selectedElements.filter((element) => { - if (element.frameId && framesInSelection.has(element.frameId)) { - return false; - } - return true; - }); + return excludeElementsFromFrames(selectedElements, framesInSelection); }; export const getElementsWithinSelection = ( elements: readonly NonDeletedExcalidrawElement[], selection: NonDeletedExcalidrawElement, elementsMap: ElementsMap, + // TODO remove (this flag is effectively unused AFAIK) excludeElementsInFrames: boolean = true, -) => { - const [selectionX1, selectionY1, selectionX2, selectionY2] = + boxSelectionMode: BoxSelectionMode = "contain", +): NonDeletedExcalidrawElement[] => { + const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] = getElementAbsoluteCoords(selection, elementsMap); + const selectionX1 = Math.min(selectionStartX, selectionEndX); + const selectionY1 = Math.min(selectionStartY, selectionEndY); + const selectionX2 = Math.max(selectionStartX, selectionEndX); + const selectionY2 = Math.max(selectionStartY, selectionEndY); + const selectionBounds = [ + selectionX1, + selectionY1, + selectionX2, + selectionY2, + ] as Bounds; + const selectionEdges = [ + lineSegment( + pointFrom(selectionX1, selectionY1), + pointFrom(selectionX2, selectionY1), + ), + lineSegment( + pointFrom(selectionX2, selectionY1), + pointFrom(selectionX2, selectionY2), + ), + lineSegment( + pointFrom(selectionX2, selectionY2), + pointFrom(selectionX1, selectionY2), + ), + lineSegment( + pointFrom(selectionX1, selectionY2), + pointFrom(selectionX1, selectionY1), + ), + ]; - let elementsInSelection = elements.filter((element) => { - let [elementX1, elementY1, elementX2, elementY2] = getElementBounds( - element, - elementsMap, - ); + const framesInSelection = excludeElementsInFrames + ? new Set() + : null; + let elementsInSelection: NonDeletedExcalidrawElement[] = []; - const containingFrame = getContainingFrame(element, elementsMap); - if (containingFrame) { - const [fx1, fy1, fx2, fy2] = getElementBounds( - containingFrame, - elementsMap, - ); - - elementX1 = Math.max(fx1, elementX1); - elementY1 = Math.max(fy1, elementY1); - elementX2 = Math.min(fx2, elementX2); - elementY2 = Math.min(fy2, elementY2); + for (const element of elements) { + if (shouldIgnoreElementFromSelection(element)) { + continue; } - return ( - element.locked === false && - element.type !== "selection" && - !isBoundToContainer(element) && - selectionX1 <= elementX1 && - selectionY1 <= elementY1 && - selectionX2 >= elementX2 && - selectionY2 >= elementY2 - ); - }); + const strokeWidth = element.strokeWidth; + let labelAABB: Bounds | null = null; + let elementAABB = getElementBounds(element, elementsMap); - elementsInSelection = excludeElementsInFrames - ? excludeElementsInFramesFromSelection(elementsInSelection) + elementAABB = [ + elementAABB[0] - strokeWidth / 2, + elementAABB[1] - strokeWidth / 2, + elementAABB[2] + strokeWidth / 2, + elementAABB[3] + strokeWidth / 2, + ] as Bounds; + + // Whether the element bounds should include the bound text element bounds + const boundTextElement = + isArrowElement(element) && getBoundTextElement(element, elementsMap); + if (boundTextElement) { + const { x, y } = LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + elementsMap, + ); + labelAABB = [ + x, + y, + x + boundTextElement.width, + y + boundTextElement.height, + ] as Bounds; + } + + // Clip element bounds by its containing frame (if any), since only the + // visible (frame-clipped) portion of the element is relevant for selection. + const associatedFrame = getContainingFrame(element, elementsMap); + if ( + associatedFrame && + isElementIntersectingFrame(element, associatedFrame, elementsMap) + ) { + const frameAABB = getElementBounds(associatedFrame, elementsMap); + elementAABB = [ + Math.max(elementAABB[0], frameAABB[0]), + Math.max(elementAABB[1], frameAABB[1]), + Math.min(elementAABB[2], frameAABB[2]), + Math.min(elementAABB[3], frameAABB[3]), + ] as Bounds; + + labelAABB = labelAABB + ? ([ + Math.max(labelAABB[0], frameAABB[0]), + Math.max(labelAABB[1], frameAABB[1]), + Math.min(labelAABB[2], frameAABB[2]), + Math.min(labelAABB[3], frameAABB[3]), + ] as Bounds) + : null; + } + + const commonAABB = labelAABB + ? ([ + Math.min(labelAABB[0], elementAABB[0]), + Math.min(labelAABB[1], elementAABB[1]), + Math.max(labelAABB[2], elementAABB[2]), + Math.max(labelAABB[3], elementAABB[3]), + ] as Bounds) + : elementAABB; + + // ============== Evaluation ============== + + // 1. If the selection box WRAPs the element's AABB, then add it to the + // selection and move on, regardless of the selection mode. + // + // PERF: This trick only works with axis-aligned box selection and the + // current convex element shapes! + if (boundsContainBounds(selectionBounds, commonAABB)) { + if (framesInSelection && isFrameLikeElement(element)) { + framesInSelection.add(element.id); + } else { + elementsInSelection.push(element); + continue; + } + } + + // 2. Handle the case where the label is overlapped by the selection box + if ( + boxSelectionMode === "overlap" && + labelAABB && + doBoundsIntersect(selectionBounds, labelAABB) + ) { + elementsInSelection.push(element); + continue; + } + + // 3. Handle the case where the selection is not wrapping the element, but + // it does intersect the element's outline (non-AABB). + if ( + boxSelectionMode === "overlap" && + doBoundsIntersect(selectionBounds, elementAABB) + ) { + let hasIntersection = false; + + // Preliminary check potential intersection imprecision + if (isLinearElement(element) || isFreeDrawElement(element)) { + const center = elementCenterPoint(element, elementsMap); + hasIntersection = element.points.some((point) => { + const rotatedPoint = pointRotateRads( + pointFrom(element.x + point[0], element.y + point[1]), + center, + element.angle, + ); + + return pointInsideBounds(rotatedPoint, selectionBounds); + }); + } else { + const nonRotatedElementBounds = getElementBounds( + element, + elementsMap, + true, + ); + const center = elementCenterPoint(element, elementsMap); + hasIntersection = [ + pointRotateRads( + pointFrom( + (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, + nonRotatedElementBounds[1], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + nonRotatedElementBounds[2], + (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2, + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, + nonRotatedElementBounds[3], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + nonRotatedElementBounds[0], + (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2, + ), + center, + element.angle, + ), + ].some((point) => { + return pointInsideBounds( + pointRotateRads(point, center, element.angle), + selectionBounds, + ); + }); + } + + if (!hasIntersection) { + hasIntersection = selectionEdges.some( + (selectionEdge) => + intersectElementWithLineSegment( + element, + elementsMap, + selectionEdge, + strokeWidth / 2, + true, // Stop at first hit for better performance + ).length > 0, + ); + } + + if (hasIntersection) { + if (framesInSelection && isFrameLikeElement(element)) { + framesInSelection.add(element.id); + } + + elementsInSelection.push(element); + continue; + } + } + + // 4. We don't need to handle when the selection is inside the element + // as it is separately handled in App. + } + + elementsInSelection = framesInSelection + ? excludeElementsFromFrames(elementsInSelection, framesInSelection) : elementsInSelection; elementsInSelection = elementsInSelection.filter((element) => { diff --git a/packages/element/tests/collision.test.tsx b/packages/element/tests/collision.test.tsx index 4061a16cb6..a44f1f7bb0 100644 --- a/packages/element/tests/collision.test.tsx +++ b/packages/element/tests/collision.test.tsx @@ -1,4 +1,4 @@ -import { arrayToMap } from "@excalidraw/common"; +import { arrayToMap, reseed } from "@excalidraw/common"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { Excalidraw } from "@excalidraw/excalidraw"; import { API } from "@excalidraw/excalidraw/tests/helpers/api"; @@ -12,6 +12,7 @@ import { hitElementItself } from "../src/collision"; describe("check rotated elements can be hit:", () => { beforeEach(async () => { localStorage.clear(); + reseed(7); await render(); }); @@ -56,6 +57,7 @@ describe("hitElementItself cache", () => { }); localStorage.clear(); + reseed(7); await render(); }); diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index e51865b2ea..93fe770286 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -128,6 +128,7 @@ export const getDefaultAppState = (): Omit< lockedMultiSelections: {}, activeLockedId: null, bindMode: "orbit", + boxSelectionMode: "contain", }; }; @@ -193,6 +194,7 @@ const APP_STATE_STORAGE_CONF = (< gridModeEnabled: { browser: true, export: true, server: true }, height: { browser: false, export: false, server: false }, isBindingEnabled: { browser: true, export: false, server: false }, + boxSelectionMode: { browser: true, export: false, server: false }, bindingPreference: { browser: true, export: false, server: false }, isMidpointSnappingEnabled: { browser: true, export: false, server: false }, defaultSidebarDockedPreference: { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5561594cb0..4db823c2d4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -27,6 +27,7 @@ import { KEYS, APP_NAME, CURSOR_TYPE, + DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, DEFAULT_VERTICAL_ALIGN, DRAGGING_THRESHOLD, @@ -2524,6 +2525,7 @@ class App extends React.Component { const magicFrameChildren = getElementsOverlappingFrame( this.scene.getNonDeletedElements(), magicFrame, + this.scene.getNonDeletedElementsMap(), ).filter((el) => !isMagicFrameElement(el)); if (!magicFrameChildren.length) { @@ -7239,6 +7241,14 @@ class App extends React.Component { this.interactiveCanvas, isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR, ); + } else if ( + !event[KEYS.CTRL_OR_CMD] && + this.isHittingCommonBoundingBoxOfSelectedElements( + scenePointer, + selectedElements, + ) + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } else if (this.state.viewModeEnabled) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); } else if (this.state.openDialog?.name === "elementLinkSelector") { @@ -7730,17 +7740,24 @@ class App extends React.Component { const hitSelectedElement = pointerDownState.hit.element && this.isASelectedElement(pointerDownState.hit.element); + const shouldForceLassoReselect = + event.altKey && + event[KEYS.CTRL_OR_CMD] && + !pointerDownState.resize.handleType; + const shouldStartLassoSelection = + shouldForceLassoReselect || + (!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && + !pointerDownState.resize.handleType && + !hitSelectedElement); - if ( - !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && - !pointerDownState.resize.handleType && - !hitSelectedElement - ) { - this.lassoTrail.startPath( - pointerDownState.origin.x, - pointerDownState.origin.y, - event.shiftKey, - ); + if (shouldStartLassoSelection) { + if (!this.lassoTrail.hasCurrentTrail) { + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + event.shiftKey, + ); + } // block dragging after lasso selection on PCs until the next pointer down // (on mobile or tablet, we want to allow user to drag immediately) @@ -8729,12 +8746,14 @@ class App extends React.Component { DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value, 1, ); + const boundsPadding = + (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / this.state.zoom.value; const [x1, y1, x2, y2] = getCommonBounds(selectedElements); return ( - point.x > x1 - threshold && - point.x < x2 + threshold && - point.y > y1 - threshold && - point.y < y2 + threshold + point.x > x1 - boundsPadding - threshold && + point.x < x2 + boundsPadding + threshold && + point.y > y1 - boundsPadding - threshold && + point.y < y2 + boundsPadding + threshold ); } @@ -10267,6 +10286,7 @@ class App extends React.Component { this.state.selectionElement, this.scene.getNonDeletedElementsMap(), false, + this.state.boxSelectionMode, ) : []; diff --git a/packages/excalidraw/components/RadioGroup.scss b/packages/excalidraw/components/RadioGroup.scss index 28ddc8889b..d550d95b3b 100644 --- a/packages/excalidraw/components/RadioGroup.scss +++ b/packages/excalidraw/components/RadioGroup.scss @@ -26,13 +26,16 @@ background: var(--RadioGroup-background); border: 1px solid var(--RadioGroup-border); + gap: 2px; + &__choice { position: relative; display: flex; align-items: center; justify-content: center; - width: 32px; + min-width: 20px; height: 24px; + padding: 0 0.375rem; color: var(--RadioGroup-choice-color-off); background: var(--RadioGroup-choice-background-off); diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 283bdb40d3..24d739afa9 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -750,7 +750,7 @@ describe("frame resizing behavior", () => { x: 0, y: 0, width: 100, - height: 100, + height: 103, }); // Create a rectangle outside the frame diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index e207203d60..3080314250 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -2,7 +2,7 @@ .excalidraw { .dropdown-menu { - max-width: 16rem; + max-width: 20rem; z-index: 1; &--placement-top { diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx index 4f2986c30e..63646745fb 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx @@ -1,4 +1,5 @@ import { useEditorInterface } from "../App"; +import { Ellipsify } from "../Ellipsify"; import { RadioGroup } from "../RadioGroup"; type Props = { @@ -12,6 +13,7 @@ type Props = { onChange: (value: T) => void; children: React.ReactNode; name: string; + icon?: React.ReactNode; }; const DropdownMenuItemContentRadio = ({ @@ -21,13 +23,17 @@ const DropdownMenuItemContentRadio = ({ choices, children, name, + icon, }: Props) => { const editorInterface = useEditorInterface(); return ( <>

- + {icon &&
{icon}
} + { ); }; +const PreferencesBoxSelectionModeItem = () => { + const { t } = useI18n(); + const appState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + + return ( + + name="boxSelectionMode" + icon={emptyIcon} + value={appState.boxSelectionMode} + onChange={(value) => { + setAppState({ + boxSelectionMode: value, + }); + }} + choices={[ + { + value: "contain", + label: t("labels.boxSelectionContain"), + ariaLabel: t("labels.boxSelectionContain"), + }, + { + value: "overlap", + label: t("labels.boxSelectionOverlap"), + ariaLabel: t("labels.boxSelectionOverlap"), + }, + ]} + > + {t("labels.boxSelectionMode")} + + ); +}; + const PreferencesToggleSnapModeItem = () => { const { t } = useI18n(); const actionManager = useExcalidrawActionManager(); @@ -568,6 +607,7 @@ export const Preferences = ({ {children || ( <> + @@ -585,6 +625,7 @@ export const Preferences = ({ }; Preferences.ToggleToolLock = PreferencesToggleToolLockItem; +Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem; Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem; Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index 1083a47897..d31ab931fd 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -6,6 +6,7 @@ import { MIME_TYPES, cloneJSON, SVG_DOCUMENT_PREAMBLE, + arrayToMap, } from "@excalidraw/common"; import { getNonDeletedElements } from "@excalidraw/element"; @@ -49,6 +50,7 @@ export const prepareElementsForExport = ( exportSelectionOnly: boolean, ) => { elements = getNonDeletedElements(elements); + const elementsMap = arrayToMap(elements); const isExportingSelection = exportSelectionOnly && @@ -71,7 +73,11 @@ export const prepareElementsForExport = ( isFrameLikeElement(exportedElements[0]) ) { exportingFrame = exportedElements[0]; - exportedElements = getElementsOverlappingFrame(elements, exportingFrame); + exportedElements = getElementsOverlappingFrame( + elements, + exportingFrame, + elementsMap, + ); } else if (exportedElements.length > 1) { exportedElements = getSelectedElements( elements, diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 1ed068dc1c..7a8b9e586c 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -936,6 +936,12 @@ export const restoreAppState = ( : defaultValue; } + const boxSelectionMode = + appState.boxSelectionMode ?? localAppState?.boxSelectionMode; + if (boxSelectionMode !== undefined) { + nextAppState.boxSelectionMode = boxSelectionMode; + } + return { ...nextAppState, cursorButton: localAppState?.cursorButton || "up", diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 42e87bdf82..05d3bf702e 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -185,6 +185,9 @@ "shapeSwitch": "Switch shape", "preferences": "Preferences", "preferences_toolLock": "Tool lock", + "boxSelectionMode": "Select on", + "boxSelectionContain": "Wrap", + "boxSelectionOverlap": "Overlap", "arrowBinding": "Arrow binding", "midpointSnapping": "Snap to midpoints" }, diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 7cf111f6ce..8d8c405e82 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -157,7 +157,11 @@ const prepareElementsForRender = ({ let nextElements: readonly ExcalidrawElement[]; if (exportingFrame) { - nextElements = getElementsOverlappingFrame(elements, exportingFrame); + nextElements = getElementsOverlappingFrame( + elements, + exportingFrame, + arrayToMap(elements), + ); } else if (frameRendering.enabled && frameRendering.name) { nextElements = addFrameLabelsAsTextElements(elements, { exportWithDarkMode, diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 15458fa366..cb548f5b62 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -13,6 +13,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -1086,6 +1087,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1300,6 +1302,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1631,6 +1634,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1962,6 +1966,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2176,6 +2181,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2417,6 +2423,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2715,6 +2722,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3087,6 +3095,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3580,6 +3589,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3903,6 +3913,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4226,6 +4237,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4358,7 +4370,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "type": "rectangle", "updated": 1, "version": 5, - "versionNonce": 760410951, + "versionNonce": 1006504105, "width": 20, "x": -10, "y": 0, @@ -4383,14 +4395,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "opacity": 100, "roughness": 1, "roundness": null, - "seed": 238820263, + "seed": 400692809, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 5, - "versionNonce": 1006504105, + "versionNonce": 289600103, "width": 20, "x": 20, "y": 30, @@ -4637,6 +4649,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -5854,6 +5867,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -6864,7 +6878,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 747212839, + "versionNonce": 1723083209, "width": 10, "x": -10, "y": 0, @@ -6891,14 +6905,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "opacity": 100, "roughness": 1, "roundness": null, - "seed": 238820263, + "seed": 400692809, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 1723083209, + "versionNonce": 760410951, "width": 10, "x": 12, "y": 0, @@ -7122,6 +7136,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -7811,6 +7826,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -8802,6 +8818,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 3a400a0128..2923e9b321 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -13,6 +13,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -646,6 +647,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1207,6 +1209,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1567,6 +1570,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1929,6 +1933,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2192,6 +2197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2645,6 +2651,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2948,6 +2955,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3267,6 +3275,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3561,6 +3570,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3847,6 +3857,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4082,6 +4093,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4339,6 +4351,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4610,6 +4623,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4839,6 +4853,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5068,6 +5083,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5315,6 +5331,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5571,6 +5588,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5829,6 +5847,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6158,6 +6177,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6585,6 +6605,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6959,6 +6980,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7271,6 +7293,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7563,6 +7586,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7793,6 +7817,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8145,6 +8170,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8497,6 +8523,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8903,6 +8930,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9182,6 +9210,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9446,6 +9475,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9711,6 +9741,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9943,6 +9974,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10240,6 +10272,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10557,6 +10590,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10793,6 +10827,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11718,6 +11753,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11978,6 +12014,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12213,6 +12250,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12450,6 +12488,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12841,6 +12880,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13051,6 +13091,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13258,6 +13299,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13559,6 +13601,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13857,6 +13900,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14102,6 +14146,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14339,6 +14384,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14576,6 +14622,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14823,6 +14870,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15154,6 +15202,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15324,6 +15373,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15608,6 +15658,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15871,6 +15922,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16024,6 +16076,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16306,6 +16359,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16468,6 +16522,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17216,6 +17271,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17862,6 +17918,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -18508,6 +18565,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -19257,6 +19315,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20025,6 +20084,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20505,6 +20565,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -21016,6 +21077,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -21475,6 +21537,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index e97991d96f..e502925b25 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -13,6 +13,7 @@ exports[`given element A and group of elements B and given both are selected whe }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -439,6 +440,7 @@ exports[`given element A and group of elements B and given both are selected whe }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -855,6 +857,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1421,6 +1424,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1628,6 +1632,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2012,6 +2017,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2257,6 +2263,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2437,6 +2444,7 @@ exports[`regression tests > can drag element that covers another element, while }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2762,6 +2770,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3017,6 +3026,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3258,6 +3268,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3494,6 +3505,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3752,6 +3764,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4066,6 +4079,7 @@ exports[`regression tests > deleting last but one element in editing group shoul }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4502,6 +4516,7 @@ exports[`regression tests > deselects group of selected elements on pointer down }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4785,6 +4800,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5061,6 +5077,7 @@ exports[`regression tests > deselects selected element on pointer down when poin }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5269,6 +5286,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5469,6 +5487,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5862,6 +5881,7 @@ exports[`regression tests > drags selected elements from point inside common bou }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6159,6 +6179,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6947,6 +6968,7 @@ exports[`regression tests > given a group of selected elements with an element t }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7281,6 +7303,7 @@ exports[`regression tests > given a selected element A and a not selected elemen }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7560,6 +7583,7 @@ exports[`regression tests > given selected element A with lower z-index than uns }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7795,6 +7819,7 @@ exports[`regression tests > given selected element A with lower z-index than uns }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8035,6 +8060,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8215,6 +8241,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8395,6 +8422,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8575,6 +8603,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8807,6 +8836,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9037,6 +9067,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9229,6 +9260,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9461,6 +9493,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9641,6 +9674,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9871,6 +9905,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10051,6 +10086,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10243,6 +10279,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10423,6 +10460,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10954,6 +10992,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11234,6 +11273,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11357,6 +11397,7 @@ exports[`regression tests > shift click on selected element should deselect it o }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11557,6 +11598,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11876,6 +11918,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12305,6 +12348,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12945,6 +12989,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13071,6 +13116,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13702,6 +13748,7 @@ exports[`regression tests > switches from group of selected elements to another }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14041,6 +14088,7 @@ exports[`regression tests > switches selected element on pointer down > [end of }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14305,6 +14353,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14428,6 +14477,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14792,6 +14842,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14915,6 +14966,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index f6706a0f99..78b431b773 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -314,7 +314,7 @@ describe("history", () => { expect.objectContaining({ id: rect2.id, isDeleted: true }), ]); - mouse.downAt(0, 0); + mouse.downAt(-10, -10); mouse.moveTo(25, 25); mouse.moveTo(50, 50); mouse.upAt(50, 50); diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx index d12a845d63..986c828f26 100644 --- a/packages/excalidraw/tests/regressionTests.test.tsx +++ b/packages/excalidraw/tests/regressionTests.test.tsx @@ -468,7 +468,7 @@ describe("regression tests", () => { mouse.reset(); mouse.down(); mouse.move(-1000, -1000); - mouse.restorePosition(...end); + mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.up(); expect(h.elements.length).toBe(3); @@ -519,7 +519,7 @@ describe("regression tests", () => { mouse.reset(); mouse.down(); mouse.move(-1000, -1000); - mouse.restorePosition(...end); + mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.up(); for (const element of h.elements) { @@ -537,7 +537,7 @@ describe("regression tests", () => { mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further mouse.down(); mouse.move(-1000, -1000); - mouse.restorePosition(...end); + mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.up(); Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 64204f1a00..ef0bd46b2c 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -1,7 +1,9 @@ import React from "react"; import { vi } from "vitest"; -import { KEYS, reseed } from "@excalidraw/common"; +import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common"; +import { getElementBounds, getElementLineSegments } from "@excalidraw/element"; +import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math"; import { SHAPES } from "../components/shapes"; @@ -12,6 +14,7 @@ import * as StaticScene from "../renderer/staticScene"; import { API } from "./helpers/api"; import { Keyboard, Pointer, UI } from "./helpers/ui"; import { + act, render, fireEvent, mockBoundingClientRect, @@ -39,6 +42,19 @@ const { h } = window; const mouse = new Pointer("mouse"); +const getOutlineBounds = (element: ReturnType) => { + const sceneElement = API.getElement(element); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const points = getElementLineSegments(sceneElement, elementsMap).flat(); + + return [ + Math.min(...points.map((point) => point[0])), + Math.min(...points.map((point) => point[1])), + Math.max(...points.map((point) => point[0])), + Math.max(...points.map((point) => point[1])), + ] as const; +}; + describe("box-selection", () => { beforeEach(async () => { await render(); @@ -108,6 +124,497 @@ describe("box-selection", () => { assertSelectedElements([]); }); + + it("should not select an element when the selection box only partially overlaps it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 50, + height: 50, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + mouse.downAt(25, -20); + mouse.move(-1000, -1000); + mouse.moveTo(75, 70); + mouse.up(); + + assertSelectedElements([]); + }); +}); + +describe("lasso reselection", () => { + beforeEach(async () => { + await render(); + }); + + it("should allow ctrl+alt lasso reselection when starting inside the active common bounds", () => { + const rectA = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "red", + fillStyle: "solid", + }); + const rectB = API.createElement({ + type: "rectangle", + x: 220, + y: 0, + width: 100, + height: 100, + backgroundColor: "blue", + fillStyle: "solid", + }); + + API.setElements([rectA, rectB]); + mouse.select([rectA, rectB]); + act(() => { + h.app.setActiveTool({ type: "lasso" }); + }); + + Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => { + mouse.downAt(110, 50); + mouse.moveTo(50, -20); + + expect(h.app.lassoTrail.hasCurrentTrail).toBe(true); + + mouse.moveTo(-20, 50); + mouse.moveTo(50, 120); + mouse.moveTo(110, 50); + mouse.up(); + }); + + assertSelectedElements([rectA.id]); + }); +}); + +describe("box-selection overlap mode", () => { + const boxSelect = ( + startX: number, + startY: number, + endX: number, + endY: number, + ) => { + mouse.downAt(startX, startY); + mouse.move(-1000, -1000); + mouse.moveTo(endX, endY); + mouse.up(); + }; + + const boxSelectTopLeftAabbCorner = ( + element: ReturnType, + ) => { + const sceneElement = API.getElement(element); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [x1, y1] = getElementBounds(sceneElement, elementsMap); + + boxSelect(x1 + 2, y1 + 2, x1 + 12, y1 + 12); + }; + + const boxSelectTopRightAabbCorner = ( + element: ReturnType, + ) => { + const sceneElement = API.getElement(element); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [, y1, x2] = getElementBounds(sceneElement, elementsMap); + + boxSelect(x2 - 12, y1 + 2, x2 - 2, y1 + 12); + }; + + const boxSelectTopLeftRotatedLocalBoundsCorner = ( + element: ReturnType, + ) => { + const sceneElement = API.getElement(element); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [x1, y1, x2, y2] = getElementBounds(sceneElement, elementsMap, true); + const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); + const [cornerX, cornerY] = pointRotateRads( + pointFrom(x1, y1), + center, + sceneElement.angle, + ); + + boxSelect(cornerX - 4, cornerY - 4, cornerX + 4, cornerY + 4); + }; + + beforeEach(async () => { + await render( + , + ); + }); + + it("should select an element when the selection box partially overlaps it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 50, + height: 50, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(25, -20, 75, 70); + + assertSelectedElements([rect1.id]); + }); + + it("should not select a transparent rectangle when the selection box stays inside it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(25, 25, 75, 75); + + assertSelectedElements([]); + }); + + it("should select a transparent rectangle when the selection box crosses its outline", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(25, 25, 125, 75); + + assertSelectedElements([rect1.id]); + }); + + it("should not select a rotated transparent rectangle when the selection box stays inside it", () => { + const rect1 = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([rect1]); + + boxSelect(40, 40, 60, 60); + + assertSelectedElements([]); + }); + + it("should select a rotated rounded rectangle when the selection box contains its outline but not its bounds", () => { + const rect = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 180, + angle: Math.PI / 6, + backgroundColor: "transparent", + fillStyle: "solid", + roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS }, + roughness: 0, + }); + + API.setElements([rect]); + + const sceneRect = API.getElement(rect); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [boundsX1, boundsY1, boundsX2, boundsY2] = getElementBounds( + sceneRect, + elementsMap, + ); + const [outlineX1, outlineY1, outlineX2, outlineY2] = getOutlineBounds(rect); + + expect(outlineX1).toBeGreaterThan(boundsX1 - 1); + expect(outlineY1).toBeGreaterThan(boundsY1 - 1); + expect(outlineX2).toBeLessThan(boundsX2 + 1); + expect(outlineY2).toBeLessThan(boundsY2 + 1); + + boxSelect( + outlineX1 - (outlineX1 - boundsX1) / 2, + outlineY1 - (outlineY1 - boundsY1) / 2, + outlineX2 + (boundsX2 - outlineX2) / 2, + outlineY2 + (boundsY2 - outlineY2) / 2, + ); + + assertSelectedElements([rect.id]); + }); + + it("should not select a filled rotated rectangle when the selection box only overlaps its axis-aligned bounds", () => { + const rect = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([rect]); + + boxSelectTopLeftAabbCorner(rect); + + assertSelectedElements([]); + }); + + it("should not select a filled ellipse when the selection box only overlaps its bounds corner", () => { + const ellipse = API.createElement({ + type: "ellipse", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([ellipse]); + + boxSelectTopRightAabbCorner(ellipse); + + assertSelectedElements([]); + }); + + it("should not select a filled diamond when the selection box only overlaps its bounds corner", () => { + const diamond = API.createElement({ + type: "diamond", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([diamond]); + + boxSelectTopRightAabbCorner(diamond); + + assertSelectedElements([]); + }); + + it("should not select a filled rotated ellipse when the selection box only overlaps its axis-aligned bounds", () => { + const ellipse = API.createElement({ + type: "ellipse", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([ellipse]); + + boxSelectTopLeftRotatedLocalBoundsCorner(ellipse); + + assertSelectedElements([]); + }); + + it("should not select a filled rotated diamond when the selection box only overlaps its rotated local bounds", () => { + const diamond = API.createElement({ + type: "diamond", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([diamond]); + + boxSelectTopLeftRotatedLocalBoundsCorner(diamond); + + assertSelectedElements([]); + }); + + it("should not select rotated text when the selection box only overlaps its axis-aligned bounds", () => { + const text = API.createElement({ + type: "text", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + text: "test", + }); + + API.setElements([text]); + + boxSelect(-18, -18, -8, -8); + + assertSelectedElements([]); + }); + + it("should not select rotated image when the selection box only overlaps its axis-aligned bounds", () => { + const image = API.createElement({ + type: "image", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + fileId: "file_A", + status: "saved", + }); + + API.setElements([image]); + + boxSelect(-18, -18, -8, -8); + + assertSelectedElements([]); + }); + + it("should deselect a selected rotated rectangle when clicking in the empty corner of its axis-aligned bounds", () => { + const rect = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([rect]); + + mouse.clickAt(50, 50); + assertSelectedElements([rect.id]); + + const sceneRect = API.getElement(rect); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [x1, y1] = getElementBounds(sceneRect, elementsMap); + + mouse.clickAt(x1 + 2, y1 + 2); + + assertSelectedElements([]); + }); + + it("should not select a line when the selection box only overlaps its bounds", () => { + const line = API.createElement({ + type: "line", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + points: [pointFrom(0, 0), pointFrom(100, 100)], + }); + + API.setElements([line]); + + boxSelect(20, 50, 30, 60); + + assertSelectedElements([]); + }); + + it("should not click-select rotated freedraw in the corner of its axis-aligned bounds", () => { + const freedraw = API.createElement({ + type: "freedraw", + x: 0, + y: 0, + width: 100, + height: 100, + angle: Math.PI / 4, + backgroundColor: "transparent", + points: [ + pointFrom(0, 0), + pointFrom(100, 0), + pointFrom(100, 100), + pointFrom(0, 100), + pointFrom(0, 0), + ], + }); + + API.setElements([freedraw]); + + const sceneFreedraw = API.getElement(freedraw); + const elementsMap = h.scene.getNonDeletedElementsMap(); + const [x1, y1] = getElementBounds(sceneFreedraw, elementsMap); + + mouse.clickAt(x1 + 2, y1 + 2); + + assertSelectedElements([]); + }); + + it("should not select a freedraw when the selection box only overlaps its bounds", () => { + const freedraw = API.createElement({ + type: "freedraw", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + points: [ + pointFrom(0, 0), + pointFrom(50, 50), + pointFrom(100, 100), + ], + }); + + API.setElements([freedraw]); + + boxSelect(20, 50, 30, 60); + + assertSelectedElements([]); + }); + + it("should not select a transparent framed element when the selection box stays inside its clipped bounds", () => { + const frame = API.createElement({ + type: "frame", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + fillStyle: "solid", + }); + const rect1 = API.createElement({ + type: "rectangle", + x: 50, + y: 10, + width: 100, + height: 80, + frameId: frame.id, + backgroundColor: "transparent", + fillStyle: "solid", + }); + + API.setElements([frame, rect1]); + + boxSelect(60, 20, 90, 60); + + assertSelectedElements([]); + }); }); describe("inner box-selection", () => { diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index cae4e7b93f..a41512fe64 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -269,6 +269,8 @@ export type ObservedElementsAppState = { activeLockedId: AppState["activeLockedId"]; }; +export type BoxSelectionMode = "contain" | "overlap"; + export interface AppState { contextMenu: { items: ContextMenuItems; @@ -307,6 +309,8 @@ export interface AppState { * `bindingPreference` and keyboard modifiers (ctrl/alt) */ isBindingEnabled: boolean; + /** user box selection preference; defaults to "contain" when unset */ + boxSelectionMode: BoxSelectionMode; /** user arrow binding preference */ bindingPreference: "enabled" | "disabled"; /** user preference whether arrow snap to midpoints while binding */ diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index 32f537f434..8e53727f84 100644 --- a/packages/math/src/curve.ts +++ b/packages/math/src/curve.ts @@ -137,8 +137,17 @@ const calculate = ( [t0, s0]: [number, number], l: LineSegment, c: Curve, + tolerance: number = 1e-2, + iterLimit: number = 4, ) => { - const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 4); + const solution = solveWithAnalyticalJacobian( + c, + l, + t0, + s0, + tolerance, + iterLimit, + ); if (!solution) { return null; @@ -158,18 +167,43 @@ const calculate = ( */ export function curveIntersectLineSegment< Point extends GlobalPoint | LocalPoint, ->(c: Curve, l: LineSegment): Point[] { - let solution = calculate(initial_guesses[0], l, c); +>( + c: Curve, + l: LineSegment, + opts?: { + tolerance?: number; + iterLimit?: number; + }, +): Point[] { + let solution = calculate( + initial_guesses[0], + l, + c, + opts?.tolerance, + opts?.iterLimit, + ); if (solution) { return [solution]; } - solution = calculate(initial_guesses[1], l, c); + solution = calculate( + initial_guesses[1], + l, + c, + opts?.tolerance, + opts?.iterLimit, + ); if (solution) { return [solution]; } - solution = calculate(initial_guesses[2], l, c); + solution = calculate( + initial_guesses[2], + l, + c, + opts?.tolerance, + opts?.iterLimit, + ); if (solution) { return [solution]; } diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index affae46199..f59d0a9e84 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -13,6 +13,7 @@ exports[`exportToSvg > with default arguments 1`] = ` }, "bindMode": "orbit", "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, diff --git a/scripts/woff2/woff2-vite-plugins.js b/scripts/woff2/woff2-vite-plugins.js index b62a76cffb..3870bbcdfc 100644 --- a/scripts/woff2/woff2-vite-plugins.js +++ b/scripts/woff2/woff2-vite-plugins.js @@ -90,6 +90,13 @@ module.exports.woff2BrowserPlugin = () => { type="font/woff2" crossorigin="anonymous" /> + Date: Thu, 2 Apr 2026 14:07:02 +0530 Subject: [PATCH 13/33] Fix typo in Discord badge URL parameter (#11096) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 74fc2c084f..48b9d1de9f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ PRs welcome! - Chat on Discord + Chat on Discord Ask DeepWiki From 1caec99b290c75cda05385e637138998807a65ae Mon Sep 17 00:00:00 2001 From: Tom Louveau Date: Mon, 13 Apr 2026 10:30:58 +0200 Subject: [PATCH 14/33] docs: change twitter label by X (#11158) Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com> --- dev-docs/docusaurus.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-docs/docusaurus.config.js b/dev-docs/docusaurus.config.js index 4e8d75800a..d2b34843ee 100644 --- a/dev-docs/docusaurus.config.js +++ b/dev-docs/docusaurus.config.js @@ -97,8 +97,8 @@ const config = { href: "https://discord.gg/UexuTaE", }, { - label: "Twitter", - href: "https://twitter.com/excalidraw", + label: "𝕏", + href: "https://x.com/excalidraw", }, { label: "Linkedin", From b1c6bfcf40cba255d208d8d525060684b10cb3cc Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:07:00 +0200 Subject: [PATCH 15/33] chore(docker): bump node (#11208) --- .codesandbox/Dockerfile | 2 +- Dockerfile | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.codesandbox/Dockerfile b/.codesandbox/Dockerfile index fd5b38d1e8..ce8c857650 100644 --- a/.codesandbox/Dockerfile +++ b/.codesandbox/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-bullseye +FROM node:24-bullseye # Vite wants to open the browser using `open`, so we # need to install those utils. diff --git a/Dockerfile b/Dockerfile index c08385d654..e15b425704 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=${BUILDPLATFORM} node:18 AS build +FROM --platform=${BUILDPLATFORM} node:24 AS build WORKDIR /opt/node_app @@ -13,7 +13,7 @@ ARG NODE_ENV=production RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker -FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine +FROM nginx:1.27-alpine COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html From 2e1a529c6781b1534d27ca79198dccd40b97ec7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Sat, 25 Apr 2026 12:03:50 +0200 Subject: [PATCH 16/33] fix(editor): remove extremely large arrows on restore (#11235) * fix: Temp fix for elbow arrow at restore Co-authored-by: Copilot Signed-off-by: Mark Tolmacs * fix: Speculative fixes to avoid Infinity Co-authored-by: Copilot Signed-off-by: Mark Tolmacs * validate/remove arrow size after point normalization & move binding repairs back * validate even simple arrows * remove x/y check * remove duplicate constant --------- Signed-off-by: Mark Tolmacs Co-authored-by: Copilot Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/element/src/binding.ts | 10 +++--- packages/element/src/elbowArrow.ts | 4 +-- packages/excalidraw/data/restore.ts | 47 +++++++++++++++++++++++++---- packages/math/src/constants.ts | 2 -- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 566ef3c4e4..3f80ffbae2 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1943,9 +1943,9 @@ export const calculateFixedPointForElbowArrowBinding = ( return { fixedPoint: normalizeFixedPoint([ (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) / - hoveredElement.width, + Math.max(hoveredElement.width, PRECISION), (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) / - hoveredElement.height, + Math.max(hoveredElement.height, PRECISION), ]), }; }; @@ -1976,9 +1976,11 @@ export const calculateFixedPointForNonElbowArrowBinding = ( // Calculate the ratio relative to the element's bounds const fixedPointX = - (nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width; + (nonRotatedPoint[0] - hoveredElement.x) / + Math.max(hoveredElement.width, PRECISION); const fixedPointY = - (nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height; + (nonRotatedPoint[1] - hoveredElement.y) / + Math.max(hoveredElement.height, PRECISION); return { fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]), diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 9543b4182f..024b846b12 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -2124,8 +2124,8 @@ const normalizeArrowElementUpdate = ( offsetY < -MAX_POS || offsetY > MAX_POS || offsetX + points[points.length - 1][0] < -MAX_POS || - offsetY + points[points.length - 1][0] > MAX_POS || - offsetX + points[points.length - 1][1] < -MAX_POS || + offsetX + points[points.length - 1][0] > MAX_POS || + offsetY + points[points.length - 1][1] < -MAX_POS || offsetY + points[points.length - 1][1] > MAX_POS ) { console.error( diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 7a8b9e586c..7ffe9b712f 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -96,6 +96,8 @@ type RestoredAppState = Omit< "offsetTop" | "offsetLeft" | "width" | "height" >; +const MAX_ARROW_PX = 75_000; + export const AllowedExcalidrawActiveTools: Record< AppState["activeTool"]["type"], boolean @@ -467,8 +469,8 @@ export const restoreElement = ( element.endArrowhead === undefined ? "arrow" : normalizeArrowhead(element.endArrowhead); - const x: number | undefined = element.x; - const y: number | undefined = element.y; + const x = element.x as number | undefined; + const y = element.y as number | undefined; const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one !Array.isArray(element.points) || element.points.length < 2 ? [pointFrom(0, 0), pointFrom(element.width, element.height)] @@ -493,8 +495,8 @@ export const restoreElement = ( startArrowhead, endArrowhead, points, - x, - y, + x: x ?? 0, + y: y ?? 0, elbowed: (element as ExcalidrawArrowElement).elbowed, ...getSizeFromPoints(points), }; @@ -513,12 +515,44 @@ export const restoreElement = ( }) : restoreElementWithProperties(element as ExcalidrawArrowElement, base); - return { + const normalizedRestoredElement = { ...restoredElement, ...LinearElementEditor.getNormalizeElementPointsAndCoords( restoredElement, ), }; + + // Last resort fix for extremely large arrows + if ( + normalizedRestoredElement.width > MAX_ARROW_PX || + normalizedRestoredElement.height > MAX_ARROW_PX + ) { + console.error( + `Removing extremely large arrow ${ + normalizedRestoredElement.id + } (type: ${ + isElbowArrow(normalizedRestoredElement) ? "elbow" : "simple" + }, width: ${normalizedRestoredElement.width}, height: ${ + normalizedRestoredElement.height + }, x: ${normalizedRestoredElement.x}, y: ${ + normalizedRestoredElement.y + })`, + ); + return { + ...normalizedRestoredElement, + x: 0, + y: 0, + width: 100, + height: 100, + points: [ + pointFrom(0, 0), + pointFrom(100, 100), + ], + isDeleted: true, + }; + } + + return normalizedRestoredElement; } // generic elements @@ -666,6 +700,7 @@ export const restoreElements = ( const existingElementsMap = existingElements ? arrayToMap(existingElements) : null; + const restoredElements = syncInvalidIndices( (targetElements || []).reduce((elements, element) => { // filtering out selection, which is legacy, no longer kept in elements, @@ -762,7 +797,7 @@ export const restoreElements = ( } } - // NOTE (mtolmacs): Temporary fix for extremely large arrows + // NOTE (mtolmacs): Temporary fix for invalid/self-bound elbow arrows // Need to iterate again so we have attached text nodes in elementsMap return restoredElements.map((element) => { if ( diff --git a/packages/math/src/constants.ts b/packages/math/src/constants.ts index ce39e42682..6ba5eb8f7f 100644 --- a/packages/math/src/constants.ts +++ b/packages/math/src/constants.ts @@ -1,5 +1,3 @@ -export const PRECISION = 10e-5; - // Legendre-Gauss abscissae (x values) and weights for n=24 // Refeerence: https://pomax.github.io/bezierinfo/legendre-gauss.html export const LegendreGaussN24TValues = [ From 43fa4b56028478f53dbb65045df1f9f7737c4321 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:23:10 +0200 Subject: [PATCH 17/33] fix: frame selection and membership (#11250) fix: element fully overlapping frame --- packages/element/src/frame.ts | 6 ++- packages/element/src/selection.ts | 8 ++-- packages/element/tests/frame.test.tsx | 44 +++++++++++++++++++- packages/excalidraw/tests/selection.test.tsx | 26 ++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 787c2692f9..6138633ff4 100644 --- a/packages/element/src/frame.ts +++ b/packages/element/src/frame.ts @@ -19,6 +19,7 @@ import { getElementAbsoluteCoords, doBoundsIntersect, getElementBounds, + boundsContainBounds, } from "./bounds"; import { mutateElement } from "./mutateElement"; import { getBoundTextElement, getContainerElement } from "./textElement"; @@ -101,8 +102,9 @@ export const isElementContainingFrame = ( frame: ExcalidrawFrameLikeElement, elementsMap: ElementsMap, ) => { - return getElementsWithinSelection([frame], element, elementsMap).some( - (e) => e.id === frame.id, + return boundsContainBounds( + getElementBounds(element, elementsMap), + getElementBounds(frame, elementsMap), ); }; diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts index 8e9c4e8086..88bb1c2c80 100644 --- a/packages/element/src/selection.ts +++ b/packages/element/src/selection.ts @@ -34,7 +34,6 @@ import { elementOverlapsWithFrame, getContainingFrame, getFrameChildren, - isElementIntersectingFrame, } from "./frame"; import { LinearElementEditor } from "./linearElementEditor"; @@ -170,7 +169,7 @@ export const getElementsWithinSelection = ( const associatedFrame = getContainingFrame(element, elementsMap); if ( associatedFrame && - isElementIntersectingFrame(element, associatedFrame, elementsMap) + elementOverlapsWithFrame(element, associatedFrame, elementsMap) ) { const frameAABB = getElementBounds(associatedFrame, elementsMap); elementAABB = [ @@ -209,10 +208,9 @@ export const getElementsWithinSelection = ( if (boundsContainBounds(selectionBounds, commonAABB)) { if (framesInSelection && isFrameLikeElement(element)) { framesInSelection.add(element.id); - } else { - elementsInSelection.push(element); - continue; } + elementsInSelection.push(element); + continue; } // 2. Handle the case where the label is overlapped by the selection box diff --git a/packages/element/tests/frame.test.tsx b/packages/element/tests/frame.test.tsx index 47f2160ac3..e92267130a 100644 --- a/packages/element/tests/frame.test.tsx +++ b/packages/element/tests/frame.test.tsx @@ -2,6 +2,7 @@ import { convertToExcalidrawElements, Excalidraw, } from "@excalidraw/excalidraw"; +import { arrayToMap } from "@excalidraw/common"; import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; @@ -10,7 +11,12 @@ import { render, } from "@excalidraw/excalidraw/tests/test-utils"; -import type { ExcalidrawElement } from "../src/types"; +import { elementOverlapsWithFrame } from "../src/frame"; + +import type { + ExcalidrawElement, + ExcalidrawFrameLikeElement, +} from "../src/types"; const { h } = window; const mouse = new Pointer("mouse"); @@ -125,6 +131,26 @@ describe("adding elements to frames", () => { }); }); + it("should treat an element fully containing a frame as overlapping the frame", () => { + const containingRect = API.createElement({ + type: "rectangle", + x: -50, + y: -50, + width: 250, + height: 250, + }); + + API.setElements([containingRect, frame]); + + expect( + elementOverlapsWithFrame( + containingRect, + frame as ExcalidrawFrameLikeElement, + arrayToMap(h.elements), + ), + ).toBe(true); + }); + const commonTestCases = async ( func: typeof resizeFrameOverElement | typeof dragElementIntoFrame, ) => { @@ -415,6 +441,22 @@ describe("adding elements to frames", () => { describe("dragging elements into the frame", async () => { await commonTestCases(dragElementIntoFrame); + it("should add a dragged element fully containing the frame", () => { + const containingRect = API.createElement({ + type: "rectangle", + x: 220, + y: 20, + width: 300, + height: 300, + }); + + API.setElements([frame, containingRect]); + + dragElementIntoFrame(frame, containingRect); + + expect(API.getElement(containingRect).frameId).toBe(frame.id); + }); + it.skip("should drag element inside, duplicate it and keep it in frame", () => { API.setElements([frame, rect2]); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index ef0bd46b2c..5fd7a303d7 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -615,6 +615,32 @@ describe("box-selection overlap mode", () => { assertSelectedElements([]); }); + + it("should not select a framed element when selection only overlaps its clipped-out outline", () => { + const frame = API.createElement({ + type: "frame", + x: 100, + y: 100, + width: 100, + height: 100, + }); + const rect1 = API.createElement({ + type: "rectangle", + x: 50, + y: 50, + width: 200, + height: 200, + frameId: frame.id, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([frame, rect1]); + + boxSelect(40, 170, 70, 220); + + assertSelectedElements([]); + }); }); describe("inner box-selection", () => { From 278cd357724b17e1119b6c76416520c42958d0e3 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:49:40 +0200 Subject: [PATCH 18/33] feat(editor): scale video embeddables based on zoom (#11251) --- packages/excalidraw/components/App.tsx | 75 +++++++++++++++++--------- packages/excalidraw/css/styles.scss | 8 +++ 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4db823c2d4..299304eb0e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -603,6 +603,8 @@ const YOUTUBE_VIDEO_STATES = new Map< ValueOf >(); +const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4; + let IS_PLAIN_PASTE = false; let IS_PLAIN_PASTE_TIMER = 0; let PLAIN_PASTE_TOAST_SHOWN = false; @@ -1735,6 +1737,18 @@ class App extends React.Component { this.state.activeEmbeddable?.element === el && this.state.activeEmbeddable?.state === "hover"; + // scale video embeds based on zoom (capped) so that smaller embeds + // on canvas when zoomed are still of legible quality + // (note: for some embed types like gdrive, the quality is poor when + // scaling mid playback and works only when you initially start the + // playback at the higher zoom level) + const shouldScaleEmbeddableViewport = src?.type === "video"; + const embeddableViewportScale = clamp( + shouldScaleEmbeddableViewport ? scale : 1, + 0.75, + MAX_EMBEDDABLE_VIEWPORT_SCALE, + ); + return (
{ padding: `${el.strokeWidth}px`, }} > - {(isEmbeddableElement(el) - ? this.props.renderEmbeddable?.(el, this.state) - : null) ?? ( -