From e46f03813214da79c1bdb62244d19cb41cb71090 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Thu, 17 Jul 2025 15:22:32 +0200 Subject: [PATCH 01/60] feat: expose `applyTo` options, don't commit empty text element (#9744) * Expose applyTo options, skip re-draw for empty text * Don't commit empty text elements --- packages/element/src/store.ts | 12 ++++++++++-- packages/excalidraw/components/App.tsx | 19 +++++++++++++++---- .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 4 ++-- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index 0f5933422c..ae0c969e5a 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -27,6 +27,8 @@ import { isImageElement, } from "./index"; +import type { ApplyToOptions } from "./delta"; + import type { ExcalidrawElement, OrderedExcalidrawElement, @@ -570,9 +572,15 @@ export class StoreDelta { delta: StoreDelta, elements: SceneElementsMap, appState: AppState, + options: ApplyToOptions = { + excludedProperties: new Set(), + }, ): [SceneElementsMap, AppState, boolean] { - const [nextElements, elementsContainVisibleChange] = - delta.elements.applyTo(elements); + const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( + elements, + StoreSnapshot.empty().elements, + options, + ); const [nextAppState, appStateContainsVisibleChange] = delta.appState.applyTo(appState, nextElements); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 87d1be2779..1397a8b7c2 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -4925,7 +4925,17 @@ class App extends React.Component { }), onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { const isDeleted = !nextOriginalText.trim(); - updateElement(nextOriginalText, isDeleted); + + if (isDeleted && !isExistingElement) { + // let's just remove the element from the scene, as it's an empty just created text element + this.scene.replaceAllElements( + this.scene + .getElementsIncludingDeleted() + .filter((x) => x.id !== element.id), + ); + } else { + updateElement(nextOriginalText, isDeleted); + } // select the created text element only if submitting via keyboard // (when submitting via click it should act as signal to deselect) if (!isDeleted && viaKeyboard) { @@ -4954,9 +4964,10 @@ class App extends React.Component { element, ]); } - if (!isDeleted || isExistingElement) { - this.store.scheduleCapture(); - } + + // we need to record either way, whether the text element was added or removed + // since we need to sync this delta to other clients, otherwise it would end up with inconsistencies + this.store.scheduleCapture(); flushSync(() => { this.setState({ diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx index c1a2f33094..08301a3042 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx @@ -704,7 +704,7 @@ describe("textWysiwyg", () => { rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ); - expect(h.elements.length).toBe(3); + expect(h.elements.length).toBe(2); text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); @@ -1198,7 +1198,7 @@ describe("textWysiwyg", () => { updateTextEditor(editor, " "); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([]); - expect(h.elements[1].isDeleted).toBe(true); + expect(h.elements[1]).toBeUndefined(); }); it("should restore original container height and clear cache once text is unbind", async () => { From 8492b144b05b56634fa020ec278adc7936fbfc4e Mon Sep 17 00:00:00 2001 From: Christopher Tangonan <161169629+cTangonan123@users.noreply.github.com> Date: Thu, 17 Jul 2025 10:52:16 -0700 Subject: [PATCH 02/60] test: added test file for distribute (#9754) --- packages/element/tests/distribute.test.tsx | 128 +++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 packages/element/tests/distribute.test.tsx diff --git a/packages/element/tests/distribute.test.tsx b/packages/element/tests/distribute.test.tsx new file mode 100644 index 0000000000..b59567e42c --- /dev/null +++ b/packages/element/tests/distribute.test.tsx @@ -0,0 +1,128 @@ +import { + distributeHorizontally, + distributeVertically, +} from "@excalidraw/excalidraw/actions"; +import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n"; +import { Excalidraw } from "@excalidraw/excalidraw"; + +import { API } from "@excalidraw/excalidraw/tests/helpers/api"; +import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui"; +import { + act, + unmountComponent, + render, +} from "@excalidraw/excalidraw/tests/test-utils"; + +const mouse = new Pointer("mouse"); + +// Scenario: three rectangles that will be distributed with gaps +const createAndSelectThreeRectanglesWithGap = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + mouse.reset(); + + UI.clickTool("rectangle"); + mouse.down(10, 10); + mouse.up(100, 100); + mouse.reset(); + + UI.clickTool("rectangle"); + mouse.down(300, 300); + mouse.up(100, 100); + mouse.reset(); + + // Last rectangle is selected by default + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.click(0, 10); + mouse.click(10, 0); + }); +}; + +// Scenario: three rectangles that will be distributed by their centers +const createAndSelectThreeRectanglesWithoutGap = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + mouse.reset(); + + UI.clickTool("rectangle"); + mouse.down(10, 10); + mouse.up(200, 200); + mouse.reset(); + + UI.clickTool("rectangle"); + mouse.down(200, 200); + mouse.up(100, 100); + mouse.reset(); + + // Last rectangle is selected by default + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.click(0, 10); + mouse.click(10, 0); + }); +}; + +describe("distributing", () => { + beforeEach(async () => { + unmountComponent(); + mouse.reset(); + + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it("should distribute selected elements horizontally", async () => { + createAndSelectThreeRectanglesWithGap(); + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(10); + expect(API.getSelectedElements()[2].x).toEqual(300); + + API.executeAction(distributeHorizontally); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(150); + expect(API.getSelectedElements()[2].x).toEqual(300); + }); + + it("should distribute selected elements vertically", async () => { + createAndSelectThreeRectanglesWithGap(); + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(10); + expect(API.getSelectedElements()[2].y).toEqual(300); + + API.executeAction(distributeVertically); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(150); + expect(API.getSelectedElements()[2].y).toEqual(300); + }); + + it("should distribute selected elements horizontally based on their centers", async () => { + createAndSelectThreeRectanglesWithoutGap(); + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(10); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(distributeHorizontally); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(50); + expect(API.getSelectedElements()[2].x).toEqual(200); + }); + + it("should distribute selected elements vertically with based on their centers", async () => { + createAndSelectThreeRectanglesWithoutGap(); + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(10); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(distributeVertically); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(50); + expect(API.getSelectedElements()[2].y).toEqual(200); + }); +}); From e5e07260c60543c2dd468474c3fc204b17cb25a3 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Wed, 23 Jul 2025 18:49:56 +1000 Subject: [PATCH 03/60] fix: improve line creation ux on touch screens (#9740) * fix: awkward point adding and removing on touch device * feat: move finalize to next to last point * feat: on touch screen, click would create a default line/arrow * fix: make default adaptive to zoom * fix: increase padding to avoid cutoffs * refactor: simplify * fix: only use bigger padding when needed * center arrow horizontally on pointer * increase min drag distance before we start 2-point-arrow-drag-creating * do not render 0-width arrow while creating * dead code * fix tests * fix: remove redundant code * do not enter line editor on creation --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/common/src/constants.ts | 1 + packages/element/src/renderElement.ts | 5 ++ .../excalidraw/actions/actionFinalize.tsx | 6 +- packages/excalidraw/components/Actions.tsx | 12 --- packages/excalidraw/components/App.tsx | 70 +++++++++++---- .../excalidraw/components/footer/Footer.tsx | 21 +---- .../renderer/renderNewElementScene.ts | 10 ++- .../regressionTests.test.tsx.snap | 88 +++++++++---------- .../excalidraw/tests/regressionTests.test.tsx | 2 +- 9 files changed, 113 insertions(+), 102 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index c3c348cebc..c797c6e8c2 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -36,6 +36,7 @@ export const APP_NAME = "Excalidraw"; // (happens a lot with fast clicks with the text tool) export const TEXT_AUTOWRAP_THRESHOLD = 36; // px export const DRAGGING_THRESHOLD = 10; // px +export const MINIMUM_ARROW_SIZE = 20; // px export const LINE_CONFIRM_THRESHOLD = 8; // px export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_TRANSLATE_AMOUNT = 1; diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index e870d977fb..008d6afc4a 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => { return element.strokeWidth * 12; case "text": return element.fontSize / 2; + case "arrow": + if (element.endArrowhead || element.endArrowhead) { + return 40; + } + return 20; default: return 20; } diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 7a4511e051..b699e9ea64 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -154,11 +154,7 @@ export const actionFinalize = register({ if (element) { // pen and mouse have hover - if ( - appState.multiElement && - element.type !== "freedraw" && - appState.lastPointerDownWith !== "touch" - ) { + if (appState.multiElement && element.type !== "freedraw") { const { points, lastCommittedPoint } = element; if ( !lastCommittedPoint || diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 919e9c688d..1b42e9f402 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -505,15 +505,3 @@ export const ExitZenModeAction = ({ {t("buttons.exitZenMode")} ); - -export const FinalizeAction = ({ - renderAction, - className, -}: { - renderAction: ActionManager["renderAction"]; - className?: string; -}) => ( -
- {renderAction("finalize", { size: "small" })} -
-); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 1397a8b7c2..dcb9f9838f 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -100,6 +100,7 @@ import { randomInteger, CLASSES, Emitter, + MINIMUM_ARROW_SIZE, } from "@excalidraw/common"; import { @@ -8162,7 +8163,9 @@ class App extends React.Component { pointDistance( pointFrom(pointerCoords.x, pointerCoords.y), pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), - ) < DRAGGING_THRESHOLD + ) * + this.state.zoom.value < + MINIMUM_ARROW_SIZE ) { return; } @@ -9113,25 +9116,54 @@ class App extends React.Component { this.state, ); - if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) { - this.scene.mutateElement( - newElement, - { - points: [ - ...newElement.points, - pointFrom( - pointerCoords.x - newElement.x, - pointerCoords.y - newElement.y, - ), - ], - }, - { informMutation: false, isDragging: false }, - ); + const dragDistance = + pointDistance( + pointFrom(pointerCoords.x, pointerCoords.y), + pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), + ) * this.state.zoom.value; - this.setState({ - multiElement: newElement, - newElement, - }); + if ( + (!pointerDownState.drag.hasOccurred || + dragDistance < MINIMUM_ARROW_SIZE) && + newElement && + !multiElement + ) { + if (this.device.isTouchScreen) { + const FIXED_DELTA_X = Math.min( + (this.state.width * 0.7) / this.state.zoom.value, + 100, + ); + + this.scene.mutateElement( + newElement, + { + x: newElement.x - FIXED_DELTA_X / 2, + points: [ + pointFrom(0, 0), + pointFrom(FIXED_DELTA_X, 0), + ], + }, + { informMutation: false, isDragging: false }, + ); + + this.actionManager.executeAction(actionFinalize); + } else { + const dx = pointerCoords.x - newElement.x; + const dy = pointerCoords.y - newElement.y; + + this.scene.mutateElement( + newElement, + { + points: [...newElement.points, pointFrom(dx, dy)], + }, + { informMutation: false, isDragging: false }, + ); + + this.setState({ + multiElement: newElement, + newElement, + }); + } } else if (pointerDownState.drag.hasOccurred && !multiElement) { if ( isBindingEnabled(this.state) && diff --git a/packages/excalidraw/components/footer/Footer.tsx b/packages/excalidraw/components/footer/Footer.tsx index 427628e7c9..3b213d796a 100644 --- a/packages/excalidraw/components/footer/Footer.tsx +++ b/packages/excalidraw/components/footer/Footer.tsx @@ -2,13 +2,7 @@ import clsx from "clsx"; import { actionShortcuts } from "../../actions"; import { useTunnels } from "../../context/tunnels"; -import { - ExitZenModeAction, - FinalizeAction, - UndoRedoActions, - ZoomActions, -} from "../Actions"; -import { useDevice } from "../App"; +import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions"; import { HelpButton } from "../HelpButton"; import { Section } from "../Section"; import Stack from "../Stack"; @@ -29,10 +23,6 @@ const Footer = ({ }) => { const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels(); - const device = useDevice(); - const showFinalize = - !appState.viewModeEnabled && appState.multiElement && device.isTouchScreen; - return (