From 06c5ea94d331b9c42f922ea7ea6c40f22a9cfa8c Mon Sep 17 00:00:00 2001 From: ericvannunen <106070823+ericvannunen@users.noreply.github.com> Date: Tue, 23 Sep 2025 23:47:03 +0200 Subject: [PATCH 01/27] fix: Race conditions when adding many library items (#10013) * Fix for race condition when adding many library items * Remove unused import * Replace any with LibraryItem type * Fix comments on pr * Fix build errors * Fix hoisted variable * new mime type * duplicate before passing down to be sure * lint * fix tests * Remove unused import --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/common/src/constants.ts | 3 + packages/excalidraw/components/App.tsx | 48 ++++-- .../components/LibraryMenuItems.tsx | 14 +- packages/excalidraw/data/library.ts | 1 + packages/excalidraw/data/types.ts | 5 + packages/excalidraw/tests/library.test.tsx | 151 +++++++++++------- 6 files changed, 147 insertions(+), 75 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index b41fb1a37d..a02ccfbb46 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -266,7 +266,10 @@ export const STRING_MIME_TYPES = { json: "application/json", // excalidraw data excalidraw: "application/vnd.excalidraw+json", + // LEGACY: fully-qualified library JSON data excalidrawlib: "application/vnd.excalidrawlib+json", + // list of excalidraw library item ids + excalidrawlibIds: "application/vnd.excalidrawlib.ids+json", } as const; export const MIME_TYPES = { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0f6fa840b0..2def157b4a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -433,6 +433,8 @@ import { findShapeByKey } from "./shapes"; import UnlockPopup from "./UnlockPopup"; +import type { ExcalidrawLibraryIds } from "../data/types"; + import type { RenderInteractiveSceneCallback, ScrollBars, @@ -10545,16 +10547,44 @@ class App extends React.Component { if (imageFiles.length > 0 && this.isToolSupported("image")) { return this.insertImages(imageFiles, sceneX, sceneY); } - - const libraryJSON = dataTransferList.getData(MIME_TYPES.excalidrawlib); - if (libraryJSON && typeof libraryJSON === "string") { + const excalidrawLibrary_ids = dataTransferList.getData( + MIME_TYPES.excalidrawlibIds, + ); + const excalidrawLibrary_data = dataTransferList.getData( + MIME_TYPES.excalidrawlib, + ); + if (excalidrawLibrary_ids || excalidrawLibrary_data) { try { - const libraryItems = parseLibraryJSON(libraryJSON); - this.addElementsFromPasteOrLibrary({ - elements: distributeLibraryItemsOnSquareGrid(libraryItems), - position: event, - files: null, - }); + let libraryItems: LibraryItems | null = null; + if (excalidrawLibrary_ids) { + const { itemIds } = JSON.parse( + excalidrawLibrary_ids, + ) as ExcalidrawLibraryIds; + const allLibraryItems = await this.library.getLatestLibrary(); + libraryItems = allLibraryItems.filter((item) => + itemIds.includes(item.id), + ); + // legacy library dataTransfer format + } else if (excalidrawLibrary_data) { + libraryItems = parseLibraryJSON(excalidrawLibrary_data); + } + if (libraryItems?.length) { + libraryItems = libraryItems.map((item) => ({ + ...item, + // #6465 + elements: duplicateElements({ + type: "everything", + elements: item.elements, + randomizeSeed: true, + }).duplicatedElements, + })); + + this.addElementsFromPasteOrLibrary({ + elements: distributeLibraryItemsOnSquareGrid(libraryItems), + position: event, + files: null, + }); + } } catch (error: any) { this.setState({ errorMessage: error.message }); } diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index 8e06632aad..eb82dde550 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -10,7 +10,6 @@ import { MIME_TYPES, arrayToMap } from "@excalidraw/common"; import { duplicateElements } from "@excalidraw/element"; -import { serializeLibraryAsJSON } from "../data/json"; import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import { useScrollPosition } from "../hooks/useScrollPosition"; import { t } from "../i18n"; @@ -27,6 +26,8 @@ import Stack from "./Stack"; import "./LibraryMenuItems.scss"; +import type { ExcalidrawLibraryIds } from "../data/types"; + import type { ExcalidrawProps, LibraryItem, @@ -175,12 +176,17 @@ export default function LibraryMenuItems({ const onItemDrag = useCallback( (id: LibraryItem["id"], event: React.DragEvent) => { + // we want to serialize just the ids so the operation is fast and there's + // no race condition if people drop the library items on canvas too fast + const data: ExcalidrawLibraryIds = { + itemIds: selectedItems.includes(id) ? selectedItems : [id], + }; event.dataTransfer.setData( - MIME_TYPES.excalidrawlib, - serializeLibraryAsJSON(getInsertedElements(id)), + MIME_TYPES.excalidrawlibIds, + JSON.stringify(data), ); }, - [getInsertedElements], + [selectedItems], ); const isItemSelected = useCallback( diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 4269edbd7f..429ba1046c 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -192,6 +192,7 @@ const createLibraryUpdate = ( class Library { /** latest libraryItems */ private currLibraryItems: LibraryItems = []; + /** snapshot of library items since last onLibraryChange call */ private prevLibraryItems = cloneLibraryItems(this.currLibraryItems); diff --git a/packages/excalidraw/data/types.ts b/packages/excalidraw/data/types.ts index 6878b81b18..94947c2b98 100644 --- a/packages/excalidraw/data/types.ts +++ b/packages/excalidraw/data/types.ts @@ -6,6 +6,7 @@ import type { cleanAppStateForExport } from "../appState"; import type { AppState, BinaryFiles, + LibraryItem, LibraryItems, LibraryItems_anyVersion, } from "../types"; @@ -59,3 +60,7 @@ export interface ImportedLibraryData extends Partial { /** @deprecated v1 */ library?: LibraryItems; } + +export type ExcalidrawLibraryIds = { + itemIds: LibraryItem["id"][]; +}; diff --git a/packages/excalidraw/tests/library.test.tsx b/packages/excalidraw/tests/library.test.tsx index f1c0f0a457..55c25188de 100644 --- a/packages/excalidraw/tests/library.test.tsx +++ b/packages/excalidraw/tests/library.test.tsx @@ -15,7 +15,7 @@ import { Excalidraw } from "../index"; import { API } from "./helpers/api"; import { UI } from "./helpers/ui"; -import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils"; +import { fireEvent, render, waitFor } from "./test-utils"; import type { LibraryItem, LibraryItems } from "../types"; @@ -46,52 +46,8 @@ vi.mock("../data/filesystem.ts", async (importOriginal) => { }; }); -describe("library", () => { +describe("library items inserting", () => { beforeEach(async () => { - await render(); - await act(() => { - return h.app.library.resetLibrary(); - }); - }); - - it("import library via drag&drop", async () => { - expect(await h.app.library.getLatestLibrary()).toEqual([]); - await API.drop([ - { - kind: "file", - type: MIME_TYPES.excalidrawlib, - file: await API.loadFile("./fixtures/fixture_library.excalidrawlib"), - }, - ]); - await waitFor(async () => { - expect(await h.app.library.getLatestLibrary()).toEqual([ - { - status: "unpublished", - elements: [expect.objectContaining({ id: "A" })], - id: "id0", - created: expect.any(Number), - }, - ]); - }); - }); - - // NOTE: mocked to test logic, not actual drag&drop via UI - it("drop library item onto canvas", async () => { - expect(h.elements).toEqual([]); - const libraryItems = parseLibraryJSON(await libraryJSONPromise); - await API.drop([ - { - kind: "string", - value: serializeLibraryAsJSON(libraryItems), - type: MIME_TYPES.excalidrawlib, - }, - ]); - await waitFor(() => { - expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]); - }); - }); - - it("should regenerate ids but retain bindings on library insert", async () => { const rectangle = API.createElement({ id: "rectangle1", type: "rectangle", @@ -117,45 +73,116 @@ describe("library", () => { }, }); + const libraryItems: LibraryItems = [ + { + id: "libraryItem_id0", + status: "unpublished", + elements: [rectangle, text, arrow], + created: 0, + name: "test", + }, + ]; + + await render(); + }); + + afterEach(async () => { + await act(() => { + return h.app.library.resetLibrary(); + }); + }); + + it("should regenerate ids but retain bindings on library insert", async () => { + const libraryItems = await h.app.library.getLatestLibrary(); + + expect(libraryItems.length).toBe(1); + await API.drop([ { kind: "string", - value: serializeLibraryAsJSON([ - { - id: "item1", - status: "published", - elements: [rectangle, text, arrow], - created: 1, - }, - ]), - type: MIME_TYPES.excalidrawlib, + value: JSON.stringify({ + itemIds: [libraryItems[0].id], + }), + type: MIME_TYPES.excalidrawlibIds, }, ]); await waitFor(() => { + const rectangle = h.elements.find((e) => e.type === "rectangle")!; + const text = h.elements.find((e) => e.type === "text")!; + const arrow = h.elements.find((e) => e.type === "arrow")!; expect(h.elements).toEqual( expect.arrayContaining([ expect.objectContaining({ - [ORIG_ID]: "rectangle1", + type: "rectangle", + id: expect.not.stringMatching("rectangle1"), boundElements: expect.arrayContaining([ - { type: "text", id: getCloneByOrigId("text1").id }, - { type: "arrow", id: getCloneByOrigId("arrow1").id }, + { type: "text", id: text.id }, + { type: "arrow", id: arrow.id }, ]), }), expect.objectContaining({ - [ORIG_ID]: "text1", - containerId: getCloneByOrigId("rectangle1").id, + type: "text", + id: expect.not.stringMatching("text1"), + containerId: rectangle.id, }), expect.objectContaining({ - [ORIG_ID]: "arrow1", + type: "arrow", + id: expect.not.stringMatching("arrow1"), endBinding: expect.objectContaining({ - elementId: getCloneByOrigId("rectangle1").id, + elementId: rectangle.id, }), }), ]), ); }); }); +}); + +describe("library", () => { + beforeEach(async () => { + await render(); + await act(() => { + return h.app.library.resetLibrary(); + }); + }); + + it("import library via drag&drop", async () => { + expect(await h.app.library.getLatestLibrary()).toEqual([]); + await API.drop([ + { + kind: "file", + type: MIME_TYPES.excalidrawlib, + file: await API.loadFile("./fixtures/fixture_library.excalidrawlib"), + }, + ]); + await waitFor(async () => { + expect(await h.app.library.getLatestLibrary()).toEqual([ + { + status: "unpublished", + elements: [expect.objectContaining({ id: "A" })], + id: expect.any(String), + created: expect.any(Number), + }, + ]); + }); + }); + + // NOTE: mocked to test logic, not actual drag&drop via UI + it("drop library item onto canvas", async () => { + expect(h.elements).toEqual([]); + const libraryItems = parseLibraryJSON(await libraryJSONPromise); + await API.drop([ + { + kind: "string", + value: serializeLibraryAsJSON(libraryItems), + type: MIME_TYPES.excalidrawlib, + }, + ]); + await waitFor(() => { + expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]); + }); + }); it("should fix duplicate ids between items on insert", async () => { // note, we're not testing for duplicate group ids and such because From 00ae455873f1d29aea75cf1c1d4dfb61471d074b Mon Sep 17 00:00:00 2001 From: Omar Brikaa Date: Wed, 24 Sep 2025 01:18:41 +0300 Subject: [PATCH 02/27] fix: Remove local elements when there is room data during `startCollaboration` (#9786) * Remove local elements when there is room data * Update excalidraw-app/collab/Collab.tsx --------- Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/collab/Collab.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index bed130e24a..3c7e686d38 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -530,7 +530,10 @@ class Collab extends PureComponent { return null; } - if (!existingRoomLinkData) { + if (existingRoomLinkData) { + // when joining existing room, don't merge it with current scene data + this.excalidrawAPI.resetScene(); + } else { const elements = this.excalidrawAPI.getSceneElements().map((element) => { if (isImageElement(element) && element.status === "saved") { return newElementWith(element, { status: "pending" }); From f738b74791b837a2530c1502eca61d3d51b3b583 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 24 Sep 2025 18:17:39 +0200 Subject: [PATCH 03/27] fix: reintroduce height-based mobile query detection (#10020) --- packages/common/src/constants.ts | 3 +++ packages/excalidraw/components/App.tsx | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index a02ccfbb46..646fb08bff 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -354,6 +354,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = { // mobile: up to 699px export const MQ_MAX_MOBILE = 599; +export const MQ_MAX_WIDTH_LANDSCAPE = 1000; +export const MQ_MAX_HEIGHT_LANDSCAPE = 500; + // tablets export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones) export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 2def157b4a..daeb44cefa 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -103,6 +103,8 @@ import { MQ_MAX_MOBILE, MQ_MIN_TABLET, MQ_MAX_TABLET, + MQ_MAX_HEIGHT_LANDSCAPE, + MQ_MAX_WIDTH_LANDSCAPE, } from "@excalidraw/common"; import { @@ -2423,8 +2425,10 @@ class App extends React.Component { }; private isMobileBreakpoint = (width: number, height: number) => { - const minSide = Math.min(width, height); - return minSide <= MQ_MAX_MOBILE; + return ( + width <= MQ_MAX_MOBILE || + (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE) + ); }; private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => { From 91c7748c3d58a54444193695d3b7d3ced34434ad Mon Sep 17 00:00:00 2001 From: Mossberg Date: Wed, 24 Sep 2025 18:30:50 +0200 Subject: [PATCH 04/27] fix: added normalization to images added with the image tool to prevent MIME-mismatches (#10018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: fixed a bug where a MIME-mismatch in an image would cause an error to update cache * fix: fixed a bug where a MIME-mismatch in an image would cause an error to update cache * normalize inside insertImages() --------- Co-authored-by: Mårten Mossberg Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/components/App.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index daeb44cefa..af888b1921 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -10461,7 +10461,10 @@ class App extends React.Component { const initialized = await Promise.all( placeholders.map(async (placeholder, i) => { try { - return await this.initializeImage(placeholder, imageFiles[i]); + return await this.initializeImage( + placeholder, + await normalizeFile(imageFiles[i]), + ); } catch (error: any) { this.setState({ errorMessage: error.message || t("errors.imageInsertError"), From 06c40006db969b10b77b6e80266311a555b4cdbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Wed, 24 Sep 2025 19:22:32 +0200 Subject: [PATCH 05/27] fix: Elbow arrow routing issue with diamonds and ellipses (#10021) --- packages/element/src/binding.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 9d97801f2e..fa1355309b 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -999,6 +999,29 @@ export const bindPointToSnapToElementOutline = ( intersector, FIXED_BINDING_DISTANCE, ).sort(pointDistanceSq)[0]; + + if (!intersection) { + const anotherPoint = pointFrom( + !isHorizontal ? center[0] : snapPoint[0], + isHorizontal ? center[1] : snapPoint[1], + ); + const anotherIntersector = lineSegment( + anotherPoint, + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)), + Math.max(bindableElement.width, bindableElement.height) * 2, + ), + anotherPoint, + ), + ); + intersection = intersectElementWithLineSegment( + bindableElement, + elementsMap, + anotherIntersector, + FIXED_BINDING_DISTANCE, + ).sort(pointDistanceSq)[0]; + } } else { intersection = intersectElementWithLineSegment( bindableElement, From e32836f799b65f978cbf4877eab7ffc42c6e6494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Wed, 24 Sep 2025 19:33:20 +0200 Subject: [PATCH 06/27] fix: Use analytical Jacobian for curve intersection testing (#10007) --- .../tests/__snapshots__/history.test.tsx.snap | 2 +- packages/math/src/curve.ts | 175 ++++++++++-------- packages/math/tests/curve.test.ts | 6 +- 3 files changed, 101 insertions(+), 82 deletions(-) diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index e799a58b1b..8e0b5dabe0 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -363,7 +363,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.00000", + 98, "-0.00656", ], ], diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index fa11abd460..7be0f72245 100644 --- a/packages/math/src/curve.ts +++ b/packages/math/src/curve.ts @@ -21,20 +21,9 @@ export function curve( return [a, b, c, d] as Curve; } -function gradient( - f: (t: number, s: number) => number, - t0: number, - s0: number, - delta: number = 1e-6, -): number[] { - return [ - (f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta), - (f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta), - ]; -} - -function solve( - f: (t: number, s: number) => [number, number], +function solveWithAnalyticalJacobian( + curve: Curve, + lineSegment: LineSegment, t0: number, s0: number, tolerance: number = 1e-3, @@ -48,33 +37,75 @@ function solve( return null; } - const y0 = f(t0, s0); - const jacobian = [ - gradient((t, s) => f(t, s)[0], t0, s0), - gradient((t, s) => f(t, s)[1], t0, s0), - ]; - const b = [[-y0[0]], [-y0[1]]]; - const det = - jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0]; + // Compute bezier point at parameter t0 + const bt = 1 - t0; + const bt2 = bt * bt; + const bt3 = bt2 * bt; + const t0_2 = t0 * t0; + const t0_3 = t0_2 * t0; - if (det === 0) { + const bezierX = + bt3 * curve[0][0] + + 3 * bt2 * t0 * curve[1][0] + + 3 * bt * t0_2 * curve[2][0] + + t0_3 * curve[3][0]; + const bezierY = + bt3 * curve[0][1] + + 3 * bt2 * t0 * curve[1][1] + + 3 * bt * t0_2 * curve[2][1] + + t0_3 * curve[3][1]; + + // Compute line point at parameter s0 + const lineX = + lineSegment[0][0] + s0 * (lineSegment[1][0] - lineSegment[0][0]); + const lineY = + lineSegment[0][1] + s0 * (lineSegment[1][1] - lineSegment[0][1]); + + // Function values + const fx = bezierX - lineX; + const fy = bezierY - lineY; + + error = Math.abs(fx) + Math.abs(fy); + + if (error < tolerance) { + break; + } + + // Analytical derivatives + const dfx_dt = + -3 * bt2 * curve[0][0] + + 3 * bt2 * curve[1][0] - + 6 * bt * t0 * curve[1][0] - + 3 * t0_2 * curve[2][0] + + 6 * bt * t0 * curve[2][0] + + 3 * t0_2 * curve[3][0]; + + const dfy_dt = + -3 * bt2 * curve[0][1] + + 3 * bt2 * curve[1][1] - + 6 * bt * t0 * curve[1][1] - + 3 * t0_2 * curve[2][1] + + 6 * bt * t0 * curve[2][1] + + 3 * t0_2 * curve[3][1]; + + // Line derivatives + const dfx_ds = -(lineSegment[1][0] - lineSegment[0][0]); + const dfy_ds = -(lineSegment[1][1] - lineSegment[0][1]); + + // Jacobian determinant + const det = dfx_dt * dfy_ds - dfx_ds * dfy_dt; + + if (Math.abs(det) < 1e-12) { return null; } - const iJ = [ - [jacobian[1][1] / det, -jacobian[0][1] / det], - [-jacobian[1][0] / det, jacobian[0][0] / det], - ]; - const h = [ - [iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]], - [iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]], - ]; + // Newton step + const invDet = 1 / det; + const dt = invDet * (dfy_ds * -fx - dfx_ds * -fy); + const ds = invDet * (-dfy_dt * -fx + dfx_dt * -fy); - t0 = t0 + h[0][0]; - s0 = s0 + h[1][0]; - - const [tErr, sErr] = f(t0, s0); - error = Math.max(Math.abs(tErr), Math.abs(sErr)); + t0 += dt; + s0 += ds; iter += 1; } @@ -96,63 +127,49 @@ export const bezierEquation = ( t ** 3 * c[3][1], ); +const initial_guesses: [number, number][] = [ + [0.5, 0], + [0.2, 0], + [0.8, 0], +]; + +const calculate = ( + [t0, s0]: [number, number], + l: LineSegment, + c: Curve, +) => { + const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 3); + + if (!solution) { + return null; + } + + const [t, s] = solution; + + if (t < 0 || t > 1 || s < 0 || s > 1) { + return null; + } + + return bezierEquation(c, t); +}; + /** * Computes the intersection between a cubic spline and a line segment. */ export function curveIntersectLineSegment< Point extends GlobalPoint | LocalPoint, >(c: Curve, l: LineSegment): Point[] { - const line = (s: number) => - pointFrom( - l[0][0] + s * (l[1][0] - l[0][0]), - l[0][1] + s * (l[1][1] - l[0][1]), - ); - - const initial_guesses: [number, number][] = [ - [0.5, 0], - [0.2, 0], - [0.8, 0], - ]; - - const calculate = ([t0, s0]: [number, number]) => { - const solution = solve( - (t: number, s: number) => { - const bezier_point = bezierEquation(c, t); - const line_point = line(s); - - return [ - bezier_point[0] - line_point[0], - bezier_point[1] - line_point[1], - ]; - }, - t0, - s0, - ); - - if (!solution) { - return null; - } - - const [t, s] = solution; - - if (t < 0 || t > 1 || s < 0 || s > 1) { - return null; - } - - return bezierEquation(c, t); - }; - - let solution = calculate(initial_guesses[0]); + let solution = calculate(initial_guesses[0], l, c); if (solution) { return [solution]; } - solution = calculate(initial_guesses[1]); + solution = calculate(initial_guesses[1], l, c); if (solution) { return [solution]; } - solution = calculate(initial_guesses[2]); + solution = calculate(initial_guesses[2], l, c); if (solution) { return [solution]; } diff --git a/packages/math/tests/curve.test.ts b/packages/math/tests/curve.test.ts index 7395620968..0d1f3001de 100644 --- a/packages/math/tests/curve.test.ts +++ b/packages/math/tests/curve.test.ts @@ -46,9 +46,11 @@ describe("Math curve", () => { pointFrom(10, 50), pointFrom(50, 50), ); - const l = lineSegment(pointFrom(0, 112.5), pointFrom(90, 0)); + const l = lineSegment(pointFrom(10, -60), pointFrom(10, 60)); - expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([[50, 50]]); + expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([ + [9.99, 5.05], + ]); }); it("can be detected where the determinant is overly precise", () => { From a89a03c66ccbd3d44078be78a82d7adfb8f05953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Wed, 24 Sep 2025 20:28:41 +0200 Subject: [PATCH 07/27] fix: Arrow eraser precision arrow selection (#10006) --- packages/element/src/bounds.ts | 48 +++++++++++++++++++++-------- packages/excalidraw/eraser/index.ts | 25 ++++++++++----- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index 6b190de1b7..0f3970db80 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -42,6 +42,7 @@ import { isBoundToContainer, isFreeDrawElement, isLinearElement, + isLineElement, isTextElement, } from "./typeChecks"; @@ -321,19 +322,42 @@ export const getElementLineSegments = ( if (shape.type === "polycurve") { const curves = shape.data; - const points = curves - .map((curve) => pointsOnBezierCurves(curve, 10)) - .flat(); - let i = 0; + const pointsOnCurves = curves.map((curve) => + pointsOnBezierCurves(curve, 10), + ); + const segments: LineSegment[] = []; - while (i < points.length - 1) { - segments.push( - lineSegment( - pointFrom(points[i][0], points[i][1]), - pointFrom(points[i + 1][0], points[i + 1][1]), - ), - ); - i++; + + if ( + (isLineElement(element) && !element.polygon) || + isArrowElement(element) + ) { + for (const points of pointsOnCurves) { + let i = 0; + + while (i < points.length - 1) { + segments.push( + lineSegment( + pointFrom(points[i][0], points[i][1]), + pointFrom(points[i + 1][0], points[i + 1][1]), + ), + ); + i++; + } + } + } else { + const points = pointsOnCurves.flat(); + let i = 0; + + while (i < points.length - 1) { + segments.push( + lineSegment( + pointFrom(points[i][0], points[i][1]), + pointFrom(points[i + 1][0], points[i + 1][1]), + ), + ); + i++; + } } return segments; diff --git a/packages/excalidraw/eraser/index.ts b/packages/excalidraw/eraser/index.ts index 8d09b1aafc..d587bb3811 100644 --- a/packages/excalidraw/eraser/index.ts +++ b/packages/excalidraw/eraser/index.ts @@ -2,10 +2,10 @@ import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; import { computeBoundTextPosition, - distanceToElement, doBoundsIntersect, getBoundTextElement, getElementBounds, + getElementLineSegments, getFreedrawOutlineAsSegments, getFreedrawOutlinePoints, intersectElementWithLineSegment, @@ -265,19 +265,28 @@ const eraserTest = ( } return false; - } else if ( - isArrowElement(element) || - (isLineElement(element) && !element.polygon) - ) { + } + + const boundTextElement = getBoundTextElement(element, elementsMap); + + if (isArrowElement(element) || (isLineElement(element) && !element.polygon)) { const tolerance = Math.max( element.strokeWidth, (element.strokeWidth * 2) / zoom, ); - return distanceToElement(element, elementsMap, lastPoint) <= tolerance; - } + // If the eraser movement is so fast that a large distance is covered + // between the last two points, the distanceToElement miss, so we test + // agaist each segment of the linear element + const segments = getElementLineSegments(element, elementsMap); + for (const seg of segments) { + if (lineSegmentsDistance(seg, pathSegment) <= tolerance) { + return true; + } + } - const boundTextElement = getBoundTextElement(element, elementsMap); + return false; + } return ( intersectElementWithLineSegment(element, elementsMap, pathSegment, 0, true) From a8acc8212df612da3e4b5d302d759214d5826552 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 25 Sep 2025 22:26:58 +0200 Subject: [PATCH 08/27] feat: better file normalization (#10024) * feat: better file normalization * fix lint * fix png detection * optimize * fix type --- packages/excalidraw/clipboard.ts | 5 +- packages/excalidraw/data/blob.ts | 82 +++++++++++++------------- packages/excalidraw/data/filesystem.ts | 15 ++++- packages/excalidraw/data/json.ts | 9 +-- 4 files changed, 57 insertions(+), 54 deletions(-) diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 007a02161b..ae532a6c27 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -470,13 +470,14 @@ export const parseDataTransferEvent = async ( Array.from(items || []).map( async (item): Promise => { if (item.kind === "file") { - const file = item.getAsFile(); + let file = item.getAsFile(); if (file) { const fileHandle = await getFileHandle(item); + file = await normalizeFile(file); return { type: file.type, kind: "file", - file: await normalizeFile(file), + file, fileHandle, }; } diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index 2b6829a938..e8a5401a7a 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -25,7 +25,7 @@ import { restore, restoreLibraryItems } from "./restore"; import type { AppState, DataURL, LibraryItem } from "../types"; -import type { FileSystemHandle } from "./filesystem"; +import type { FileSystemHandle } from "browser-fs-access"; import type { ImportedLibraryData } from "./types"; const parseFileContents = async (blob: Blob | File): Promise => { @@ -416,37 +416,42 @@ export const getFileHandle = async ( /** * attempts to detect if a buffer is a valid image by checking its leading bytes */ -const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => { - let mimeType: ValueOf> | null = - null; +const getActualMimeTypeFromImage = async (file: Blob | File) => { + let mimeType: ValueOf< + Pick + > | null = null; - const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `; + const leadingBytes = [ + ...new Uint8Array(await blobToArrayBuffer(file.slice(0, 15))), + ].join(" "); // uint8 leading bytes - const headerBytes = { + const bytes = { // https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header - png: "137 80 78 71 13 10 26 10 ", + png: /^137 80 78 71 13 10 26 10\b/, // https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure // jpg is a bit wonky. Checking the first three bytes should be enough, // but may yield false positives. (https://stackoverflow.com/a/23360709/927631) - jpg: "255 216 255 ", + jpg: /^255 216 255\b/, // https://en.wikipedia.org/wiki/GIF#Example_GIF_file - gif: "71 73 70 56 57 97 ", + gif: /^71 73 70 56 57 97\b/, + // 4 bytes for RIFF + 4 bytes for chunk size + WEBP identifier + webp: /^82 73 70 70 \d+ \d+ \d+ \d+ 87 69 66 80 86 80 56\b/, }; - if (first8Bytes === headerBytes.png) { - mimeType = MIME_TYPES.png; - } else if (first8Bytes.startsWith(headerBytes.jpg)) { - mimeType = MIME_TYPES.jpg; - } else if (first8Bytes.startsWith(headerBytes.gif)) { - mimeType = MIME_TYPES.gif; + for (const type of Object.keys(bytes) as (keyof typeof bytes)[]) { + if (leadingBytes.match(bytes[type])) { + mimeType = MIME_TYPES[type]; + break; + } } - return mimeType; + + return mimeType || file.type || null; }; export const createFile = ( blob: File | Blob | ArrayBuffer, - mimeType: ValueOf, + mimeType: string, name: string | undefined, ) => { return new File([blob], name || "", { @@ -454,40 +459,33 @@ export const createFile = ( }); }; +const normalizedFileSymbol = Symbol("fileNormalized"); + /** attempts to detect correct mimeType if none is set, or if an image * has an incorrect extension. * Note: doesn't handle missing .excalidraw/.excalidrawlib extension */ export const normalizeFile = async (file: File) => { - if (!file.type) { - if (file?.name?.endsWith(".excalidrawlib")) { - file = createFile( - await blobToArrayBuffer(file), - MIME_TYPES.excalidrawlib, - file.name, - ); - } else if (file?.name?.endsWith(".excalidraw")) { - file = createFile( - await blobToArrayBuffer(file), - MIME_TYPES.excalidraw, - file.name, - ); - } else { - const buffer = await blobToArrayBuffer(file); - const mimeType = getActualMimeTypeFromImage(buffer); - if (mimeType) { - file = createFile(buffer, mimeType, file.name); - } - } + // to prevent double normalization (perf optim) + if ((file as any)[normalizedFileSymbol]) { + return file; + } + + if (file?.name?.endsWith(".excalidrawlib")) { + file = createFile(file, MIME_TYPES.excalidrawlib, file.name); + } else if (file?.name?.endsWith(".excalidraw")) { + file = createFile(file, MIME_TYPES.excalidraw, file.name); + } else if (!file.type || file.type?.startsWith("image/")) { // when the file is an image, make sure the extension corresponds to the - // actual mimeType (this is an edge case, but happens sometime) - } else if (isSupportedImageFile(file)) { - const buffer = await blobToArrayBuffer(file); - const mimeType = getActualMimeTypeFromImage(buffer); + // actual mimeType (this is an edge case, but happens - especially + // with AI generated images) + const mimeType = await getActualMimeTypeFromImage(file); if (mimeType && mimeType !== file.type) { - file = createFile(buffer, mimeType, file.name); + file = createFile(file, mimeType, file.name); } } + (file as any)[normalizedFileSymbol] = true; + return file; }; diff --git a/packages/excalidraw/data/filesystem.ts b/packages/excalidraw/data/filesystem.ts index 0f4ae745f9..44474a6f61 100644 --- a/packages/excalidraw/data/filesystem.ts +++ b/packages/excalidraw/data/filesystem.ts @@ -8,13 +8,15 @@ import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common"; import { AbortError } from "../errors"; +import { normalizeFile } from "./blob"; + import type { FileSystemHandle } from "browser-fs-access"; type FILE_EXTENSION = Exclude; const INPUT_CHANGE_INTERVAL_MS = 500; -export const fileOpen = (opts: { +export const fileOpen = async (opts: { extensions?: FILE_EXTENSION[]; description: string; multiple?: M; @@ -35,7 +37,7 @@ export const fileOpen = (opts: { return acc.concat(`.${ext}`); }, [] as string[]); - return _fileOpen({ + const files = await _fileOpen({ description: opts.description, extensions, mimeTypes, @@ -74,7 +76,14 @@ export const fileOpen = (opts: { } }; }, - }) as Promise; + }); + + if (Array.isArray(files)) { + return (await Promise.all( + files.map((file) => normalizeFile(file)), + )) as RetType; + } + return (await normalizeFile(files)) as RetType; }; export const fileSave = ( diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts index 52cbf99581..b8fb0f62cc 100644 --- a/packages/excalidraw/data/json.ts +++ b/packages/excalidraw/data/json.ts @@ -15,7 +15,7 @@ import type { ExcalidrawElement } from "@excalidraw/element/types"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; -import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob"; +import { isImageFileHandle, loadFromBlob } from "./blob"; import { fileOpen, fileSave } from "./filesystem"; import type { AppState, BinaryFiles, LibraryItems } from "../types"; @@ -108,12 +108,7 @@ export const loadFromJSON = async ( // gets resolved. Else, iOS users cannot open `.excalidraw` files. // extensions: ["json", "excalidraw", "png", "svg"], }); - return loadFromBlob( - await normalizeFile(file), - localAppState, - localElements, - file.handle, - ); + return loadFromBlob(file, localAppState, localElements, file.handle); }; export const isValidExcalidrawData = (data?: { From dcdeb2be5781cb8d1b0834c8251add578e0fc04f Mon Sep 17 00:00:00 2001 From: Davide Wietlisbach Date: Fri, 26 Sep 2025 16:30:23 +0200 Subject: [PATCH 09/27] fix: increase rejection delay for opening files with legacy api (#8961) * Increased input change interval to 1000 ms to fix IOS 18 file opening issue * increase more --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/data/filesystem.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/data/filesystem.ts b/packages/excalidraw/data/filesystem.ts index 44474a6f61..4a8d43c35f 100644 --- a/packages/excalidraw/data/filesystem.ts +++ b/packages/excalidraw/data/filesystem.ts @@ -14,7 +14,7 @@ import type { FileSystemHandle } from "browser-fs-access"; type FILE_EXTENSION = Exclude; -const INPUT_CHANGE_INTERVAL_MS = 500; +const INPUT_CHANGE_INTERVAL_MS = 5000; export const fileOpen = async (opts: { extensions?: FILE_EXTENSION[]; From ec070911b8a1e729ebff0e6e90e4c3ac0a6bce1e Mon Sep 17 00:00:00 2001 From: Archie Sengupta <71402528+ArchishmanSengupta@users.noreply.github.com> Date: Sun, 28 Sep 2025 13:16:28 -0700 Subject: [PATCH 10/27] feat: library search (#9903) * feat(utils): add support for search input type in isWritableElement * feat(i18n): add search text * feat(cmdp+lib): add search functionality for command pallete and lib menu items * chore: fix formats, and whitespaces * fix: opt to optimal code changes * chore: fix for linting * focus input on mount * tweak placeholder * design and UX changes * tweak item hover/active/seletected states * unrelated: move publish button above delete/clear to keep it more stable * esc to clear search input / close sidebar * refactor command pallete library stuff * make library commands bigger --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/common/src/constants.ts | 1 + packages/common/src/utils.ts | 8 +- .../components/ColorPicker/ColorPicker.tsx | 5 +- .../CommandPalette/CommandPalette.scss | 27 +- .../CommandPalette/CommandPalette.tsx | 89 +++++- packages/excalidraw/components/InlineIcon.tsx | 13 +- .../excalidraw/components/LibraryMenu.scss | 8 +- .../excalidraw/components/LibraryMenu.tsx | 38 ++- .../components/LibraryMenuHeaderContent.tsx | 16 +- .../components/LibraryMenuItems.scss | 67 +++- .../components/LibraryMenuItems.tsx | 293 +++++++++++------- .../components/LibraryMenuSection.tsx | 2 +- .../excalidraw/components/LibraryUnit.scss | 6 +- .../excalidraw/components/LibraryUnit.tsx | 20 +- .../excalidraw/components/Sidebar/Sidebar.tsx | 14 +- packages/excalidraw/components/TextField.scss | 4 + packages/excalidraw/components/TextField.tsx | 3 + .../excalidraw/hooks/useLibraryItemSvg.ts | 17 + packages/excalidraw/locales/en.json | 9 +- 19 files changed, 458 insertions(+), 182 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 646fb08bff..3ac7a52b93 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -125,6 +125,7 @@ export const ENV = { }; export const CLASSES = { + SIDEBAR: "sidebar", SHAPE_ACTIONS_MENU: "App-menu__left", ZOOM_ACTIONS: "zoom-actions", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 8130482db5..c65efaacf9 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -93,7 +93,8 @@ export const isWritableElement = ( (target instanceof HTMLInputElement && (target.type === "text" || target.type === "number" || - target.type === "password")); + target.type === "password" || + target.type === "search")); export const getFontFamilyString = ({ fontFamily, @@ -121,6 +122,11 @@ export const getFontString = ({ return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString; }; +/** executes callback in the frame that's after the current one */ +export const nextAnimationFrame = async (cb: () => any) => { + requestAnimationFrame(() => requestAnimationFrame(cb)); +}; + export const debounce = ( fn: (...args: T) => void, timeout: number, diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index 51c7bbd2c5..ad0bea3610 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -6,6 +6,7 @@ import { COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_PALETTE, isTransparent, + isWritableElement, } from "@excalidraw/common"; import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common"; @@ -132,7 +133,9 @@ const ColorPickerPopupContent = ({ preventAutoFocusOnTouch={!!appState.editingTextElement} onFocusOutside={(event) => { // refocus due to eye dropper - focusPickerContent(); + if (!isWritableElement(event.target)) { + focusPickerContent(); + } event.preventDefault(); }} onPointerDownOutside={(event) => { diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.scss b/packages/excalidraw/components/CommandPalette/CommandPalette.scss index 90db95db69..0a02c23b04 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.scss +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.scss @@ -100,6 +100,19 @@ $verticalBreakpoint: 861px; border-radius: var(--border-radius-lg); cursor: pointer; + --icon-size: 1rem; + + &.command-item-large { + height: 2.75rem; + --icon-size: 1.5rem; + + .icon { + width: var(--icon-size); + height: var(--icon-size); + margin-right: 0.625rem; + } + } + &:active { background-color: var(--color-surface-low); } @@ -130,9 +143,17 @@ $verticalBreakpoint: 861px; } .icon { - width: 16px; - height: 16px; - margin-right: 6px; + width: var(--icon-size, 1rem); + height: var(--icon-size, 1rem); + margin-right: 0.375rem; + + .library-item-icon { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + } } } } diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 3c6f110d27..03f9c93cb8 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import fuzzy from "fuzzy"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useMemo, useState } from "react"; import { DEFAULT_SIDEBAR, @@ -61,12 +61,21 @@ import { useStable } from "../../hooks/useStable"; import { Ellipsify } from "../Ellipsify"; -import * as defaultItems from "./defaultCommandPaletteItems"; +import { + distributeLibraryItemsOnSquareGrid, + libraryItemsAtom, +} from "../../data/library"; +import { + useLibraryCache, + useLibraryItemSvg, +} from "../../hooks/useLibraryItemSvg"; + +import * as defaultItems from "./defaultCommandPaletteItems"; import "./CommandPalette.scss"; import type { CommandPaletteItem } from "./types"; -import type { AppProps, AppState, UIAppState } from "../../types"; +import type { AppProps, AppState, LibraryItem, UIAppState } from "../../types"; import type { ShortcutName } from "../../actions/shortcuts"; import type { TranslationKeys } from "../../i18n"; import type { Action } from "../../actions/types"; @@ -80,6 +89,7 @@ export const DEFAULT_CATEGORIES = { editor: "Editor", elements: "Elements", links: "Links", + library: "Library", }; const getCategoryOrder = (category: string) => { @@ -207,6 +217,34 @@ function CommandPaletteInner({ appProps, }); + const [libraryItemsData] = useAtom(libraryItemsAtom); + const libraryCommands: CommandPaletteItem[] = useMemo(() => { + return ( + libraryItemsData.libraryItems + ?.filter( + (libraryItem): libraryItem is MarkRequired => + !!libraryItem.name, + ) + .map((libraryItem) => ({ + label: libraryItem.name, + icon: ( + + ), + category: "Library", + order: getCategoryOrder("Library"), + haystack: deburr(libraryItem.name), + perform: () => { + app.onInsertElements( + distributeLibraryItemsOnSquareGrid([libraryItem]), + ); + }, + })) || [] + ); + }, [app, libraryItemsData.libraryItems]); + useEffect(() => { // these props change often and we don't want them to re-run the effect // which would renew `allCommands`, cascading down and resetting state. @@ -588,8 +626,9 @@ function CommandPaletteInner({ setAllCommands(allCommands); setLastUsed( - allCommands.find((command) => command.label === lastUsed?.label) ?? - null, + [...allCommands, ...libraryCommands].find( + (command) => command.label === lastUsed?.label, + ) ?? null, ); } }, [ @@ -600,6 +639,7 @@ function CommandPaletteInner({ lastUsed?.label, setLastUsed, setAppState, + libraryCommands, ]); const [commandSearch, setCommandSearch] = useState(""); @@ -796,9 +836,12 @@ function CommandPaletteInner({ return nextCommandsByCategory; }; - let matchingCommands = allCommands - .filter(isCommandAvailable) - .sort((a, b) => a.order - b.order); + let matchingCommands = + commandSearch?.length > 1 + ? [...allCommands, ...libraryCommands] + : allCommands + .filter(isCommandAvailable) + .sort((a, b) => a.order - b.order); const showLastUsed = !commandSearch && lastUsed && isCommandAvailable(lastUsed); @@ -822,14 +865,20 @@ function CommandPaletteInner({ ); matchingCommands = fuzzy .filter(_query, matchingCommands, { - extract: (command) => command.haystack, + extract: (command) => command.haystack ?? "", }) .sort((a, b) => b.score - a.score) .map((item) => item.original); setCommandsByCategory(getNextCommandsByCategory(matchingCommands)); setCurrentCommand(matchingCommands[0] ?? null); - }, [commandSearch, allCommands, isCommandAvailable, lastUsed]); + }, [ + commandSearch, + allCommands, + isCommandAvailable, + lastUsed, + libraryCommands, + ]); return ( setCurrentCommand(command)} showShortcut={!app.device.viewport.isMobile} appState={uiAppState} + size={category === "Library" ? "large" : "small"} /> ))} @@ -919,6 +969,20 @@ function CommandPaletteInner({ ); } +const LibraryItemIcon = ({ + id, + elements, +}: { + id: LibraryItem["id"] | null; + elements: LibraryItem["elements"] | undefined; +}) => { + const ref = useRef(null); + const { svgCache } = useLibraryCache(); + + useLibraryItemSvg(id, elements, svgCache, ref); + + return
; +}; const CommandItem = ({ command, @@ -928,6 +992,7 @@ const CommandItem = ({ onClick, showShortcut, appState, + size = "small", }: { command: CommandPaletteItem; isSelected: boolean; @@ -936,6 +1001,7 @@ const CommandItem = ({ onClick: (event: React.MouseEvent) => void; showShortcut: boolean; appState: UIAppState; + size?: "small" | "large"; }) => { const noop = () => {}; @@ -944,6 +1010,7 @@ const CommandItem = ({ className={clsx("command-item", { "item-selected": isSelected, "item-disabled": disabled, + "command-item-large": size === "large", })} ref={(ref) => { if (isSelected && !disabled) { @@ -959,6 +1026,8 @@ const CommandItem = ({
{command.icon && ( { +export const InlineIcon = ({ + className, + icon, + size = "1em", +}: { + className?: string; + icon: React.ReactNode; + size?: string; +}) => { return ( { const memoizedLibrary = useMemo(() => app.library, [app.library]); const pendingElements = usePendingElementsMemo(appState, app); + useEffect(() => { + return addEventListener( + document, + EVENT.KEYDOWN, + (event) => { + if (event.key === KEYS.ESCAPE && event.target instanceof HTMLElement) { + const target = event.target; + if (target.closest(`.${CLASSES.SIDEBAR}`)) { + // stop propagation so that we don't prevent it downstream + // (default browser behavior is to clear search input on ESC) + event.stopPropagation(); + if (selectedItems.length > 0) { + setSelectedItems([]); + } else if ( + isWritableElement(target) && + target instanceof HTMLInputElement && + !target.value + ) { + // if search input empty -> close library + // (maybe not a good idea?) + setAppState({ openSidebar: null }); + app.focusContainer(); + } + } + } + }, + { capture: true }, + ); + }, [selectedItems, setAppState, app]); + const onInsertLibraryItems = useCallback( (libraryItems: LibraryItems) => { onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); + app.focusContainer(); }, - [onInsertElements], + [onInsertElements, app], ); const deselectItems = useCallback(() => { diff --git a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx index 5b003effa1..9d7e0d1c84 100644 --- a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx +++ b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx @@ -220,14 +220,6 @@ export const LibraryDropdownMenuButton: React.FC<{ {t("buttons.export")} )} - {!!items.length && ( - setShowRemoveLibAlert(true)} - icon={TrashIcon} - > - {resetLabel} - - )} {itemsSelected && ( )} + {!!items.length && ( + setShowRemoveLibAlert(true)} + icon={TrashIcon} + > + {resetLabel} + + )} ); diff --git a/packages/excalidraw/components/LibraryMenuItems.scss b/packages/excalidraw/components/LibraryMenuItems.scss index 59cd9f1cf9..3e67774348 100644 --- a/packages/excalidraw/components/LibraryMenuItems.scss +++ b/packages/excalidraw/components/LibraryMenuItems.scss @@ -1,24 +1,42 @@ @import "open-color/open-color"; .excalidraw { - --container-padding-y: 1.5rem; + --container-padding-y: 1rem; --container-padding-x: 0.75rem; + .library-menu-items-header { + display: flex; + padding-top: 1rem; + padding-bottom: 0.5rem; + gap: 0.5rem; + } + .library-menu-items__no-items { text-align: center; color: var(--color-gray-70); line-height: 1.5; font-size: 0.875rem; width: 100%; + min-height: 55px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; &__label { color: var(--color-primary); font-weight: 700; font-size: 1.125rem; - margin-bottom: 0.75rem; + margin-bottom: 0.25rem; } } + .library-menu-items__no-items__hint { + color: var(--color-border-outline); + padding: 0.75rem 1rem; + } + &.theme--dark { .library-menu-items__no-items { color: var(--color-gray-40); @@ -34,7 +52,7 @@ overflow-y: auto; flex-direction: column; height: 100%; - justify-content: center; + justify-content: flex-start; margin: 0; position: relative; @@ -51,26 +69,45 @@ } &__items { + // so that spinner is relative-positioned to this container + position: relative; + row-gap: 0.5rem; - padding: var(--container-padding-y) 0; + padding: 1rem 0 var(--container-padding-y) 0; flex: 1; overflow-y: auto; overflow-x: hidden; - margin-bottom: 1rem; } &__header { + display: flex; + align-items: center; + flex: 1 1 auto; + color: var(--color-primary); font-size: 1.125rem; font-weight: 700; margin-bottom: 0.75rem; width: 100%; - padding-right: 4rem; // due to dropdown button box-sizing: border-box; &--excal { margin-top: 2rem; } + + &__hint { + margin-left: auto; + font-size: 10px; + color: var(--color-border-outline); + font-weight: 400; + + kbd { + font-family: monospace; + border: 1px solid var(--color-border-outline); + border-radius: 4px; + padding: 1px 3px; + } + } } &__grid { @@ -79,6 +116,24 @@ grid-gap: 1rem; } + &__search { + flex: 1 1 auto; + margin: 0; + + .ExcTextField__input { + height: var(--lg-button-size); + input { + font-size: 0.875rem; + } + } + + &.hideCancelButton input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + display: none; + } + } + .separator { width: 100%; display: flex; diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index eb82dde550..3a78bbec4e 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -6,10 +6,14 @@ import React, { useState, } from "react"; -import { MIME_TYPES, arrayToMap } from "@excalidraw/common"; +import { MIME_TYPES, arrayToMap, nextAnimationFrame } from "@excalidraw/common"; import { duplicateElements } from "@excalidraw/element"; +import clsx from "clsx"; + +import { deburr } from "../deburr"; + import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import { useScrollPosition } from "../hooks/useScrollPosition"; import { t } from "../i18n"; @@ -26,6 +30,10 @@ import Stack from "./Stack"; import "./LibraryMenuItems.scss"; +import { TextField } from "./TextField"; + +import { useDevice } from "./App"; + import type { ExcalidrawLibraryIds } from "../data/types"; import type { @@ -65,6 +73,7 @@ export default function LibraryMenuItems({ selectedItems: LibraryItem["id"][]; onSelectItems: (id: LibraryItem["id"][]) => void; }) { + const device = useDevice(); const libraryContainerRef = useRef(null); const scrollPosition = useScrollPosition(libraryContainerRef); @@ -76,6 +85,30 @@ export default function LibraryMenuItems({ }, []); // eslint-disable-line react-hooks/exhaustive-deps const { svgCache } = useLibraryCache(); + const [lastSelectedItem, setLastSelectedItem] = useState< + LibraryItem["id"] | null + >(null); + + const [searchInputValue, setSearchInputValue] = useState(""); + + const IS_LIBRARY_EMPTY = !libraryItems.length && !pendingElements.length; + + const IS_SEARCHING = !IS_LIBRARY_EMPTY && !!searchInputValue.trim(); + + const filteredItems = useMemo(() => { + const searchQuery = deburr(searchInputValue.trim().toLowerCase()); + if (!searchQuery) { + return []; + } + + return libraryItems.filter((item) => { + const itemName = item.name || ""; + return ( + itemName.trim() && deburr(itemName.toLowerCase()).includes(searchQuery) + ); + }); + }, [libraryItems, searchInputValue]); + const unpublishedItems = useMemo( () => libraryItems.filter((item) => item.status !== "published"), [libraryItems], @@ -86,23 +119,10 @@ export default function LibraryMenuItems({ [libraryItems], ); - const showBtn = !libraryItems.length && !pendingElements.length; - - const isLibraryEmpty = - !pendingElements.length && - !unpublishedItems.length && - !publishedItems.length; - - const [lastSelectedItem, setLastSelectedItem] = useState< - LibraryItem["id"] | null - >(null); - const onItemSelectToggle = useCallback( (id: LibraryItem["id"], event: React.MouseEvent) => { const shouldSelect = !selectedItems.includes(id); - const orderedItems = [...unpublishedItems, ...publishedItems]; - if (shouldSelect) { if (event.shiftKey && lastSelectedItem) { const rangeStart = orderedItems.findIndex( @@ -128,7 +148,6 @@ export default function LibraryMenuItems({ }, [], ); - onSelectItems(nextSelectedIds); } else { onSelectItems([...selectedItems, id]); @@ -194,7 +213,6 @@ export default function LibraryMenuItems({ if (!id) { return false; } - return selectedItems.includes(id); }, [selectedItems], @@ -214,10 +232,120 @@ export default function LibraryMenuItems({ ); const itemsRenderedPerBatch = - svgCache.size >= libraryItems.length + svgCache.size >= + (filteredItems.length ? filteredItems : libraryItems).length ? CACHED_ITEMS_RENDERED_PER_BATCH : ITEMS_RENDERED_PER_BATCH; + const searchInputRef = useRef(null); + useEffect(() => { + // focus could be stolen by tab trigger button + nextAnimationFrame(() => { + searchInputRef.current?.focus(); + }); + }, []); + + const JSX_whenNotSearching = !IS_SEARCHING && ( + <> + {!IS_LIBRARY_EMPTY && ( +
+ {t("labels.personalLib")} +
+ )} + {!pendingElements.length && !unpublishedItems.length ? ( +
+ {!publishedItems.length && ( +
+ {t("library.noItems")} +
+ )} +
+ {publishedItems.length > 0 + ? t("library.hint_emptyPrivateLibrary") + : t("library.hint_emptyLibrary")} +
+
+ ) : ( + + {pendingElements.length > 0 && ( + + )} + + + )} + + {publishedItems.length > 0 && ( +
+ {t("labels.excalidrawLib")} +
+ )} + {publishedItems.length > 0 && ( + + + + )} + + ); + + const JSX_whenSearching = IS_SEARCHING && ( + <> +
+ {t("library.search.heading")} + {!isLoading && ( +
+ esc to clear +
+ )} +
+ {filteredItems.length > 0 ? ( + + + + ) : ( +
+
+ {t("library.search.noResults")} +
+
+ )} + + ); + return (
- {!isLibraryEmpty && ( +
+ {!IS_LIBRARY_EMPTY && ( + setSearchInputValue(value)} + /> + )} - )} +
0 ? 1 : "0 1 auto", - marginBottom: 0, + margin: IS_LIBRARY_EMPTY ? "auto" : 0, }} ref={libraryContainerRef} > - <> - {!isLibraryEmpty && ( -
- {t("labels.personalLib")} -
- )} - {isLoading && ( -
- -
- )} - {!pendingElements.length && !unpublishedItems.length ? ( -
-
- {t("library.noItems")} -
-
- {publishedItems.length > 0 - ? t("library.hint_emptyPrivateLibrary") - : t("library.hint_emptyLibrary")} -
-
- ) : ( - - {pendingElements.length > 0 && ( - - )} - - - )} - + {isLoading && ( +
+ +
+ )} - <> - {(publishedItems.length > 0 || - pendingElements.length > 0 || - unpublishedItems.length > 0) && ( -
- {t("labels.excalidrawLib")} -
- )} - {publishedItems.length > 0 ? ( - - - - ) : unpublishedItems.length > 0 ? ( -
- {t("library.noItems")} -
- ) : null} - + {JSX_whenNotSearching} + {JSX_whenSearching} - {showBtn && ( + {IS_LIBRARY_EMPTY && ( - - + /> )}
diff --git a/packages/excalidraw/components/LibraryMenuSection.tsx b/packages/excalidraw/components/LibraryMenuSection.tsx index d98b413fbb..9ff84f5724 100644 --- a/packages/excalidraw/components/LibraryMenuSection.tsx +++ b/packages/excalidraw/components/LibraryMenuSection.tsx @@ -10,7 +10,7 @@ import type { SvgCache } from "../hooks/useLibraryItemSvg"; import type { LibraryItem } from "../types"; import type { ReactNode } from "react"; -type LibraryOrPendingItem = ( +type LibraryOrPendingItem = readonly ( | LibraryItem | /* pending library item */ { id: null; diff --git a/packages/excalidraw/components/LibraryUnit.scss b/packages/excalidraw/components/LibraryUnit.scss index 5ebe83f414..a0d2161c21 100644 --- a/packages/excalidraw/components/LibraryUnit.scss +++ b/packages/excalidraw/components/LibraryUnit.scss @@ -18,12 +18,12 @@ } &--hover { - border-color: var(--color-primary); + background-color: var(--color-surface-mid); } + &:active:not(:has(.library-unit__checkbox:hover)), &--selected { - border-color: var(--color-primary); - border-width: 1px; + background-color: var(--color-surface-high); } &--skeleton { diff --git a/packages/excalidraw/components/LibraryUnit.tsx b/packages/excalidraw/components/LibraryUnit.tsx index 9cd891715c..36607910e5 100644 --- a/packages/excalidraw/components/LibraryUnit.tsx +++ b/packages/excalidraw/components/LibraryUnit.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useRef, useState } from "react"; import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; @@ -33,23 +33,7 @@ export const LibraryUnit = memo( svgCache: SvgCache; }) => { const ref = useRef(null); - const svg = useLibraryItemSvg(id, elements, svgCache); - - useEffect(() => { - const node = ref.current; - - if (!node) { - return; - } - - if (svg) { - node.innerHTML = svg.outerHTML; - } - - return () => { - node.innerHTML = ""; - }; - }, [svg]); + const svg = useLibraryItemSvg(id, elements, svgCache, ref); const [isHovered, setIsHovered] = useState(false); const isMobile = useDevice().editor.isMobile; diff --git a/packages/excalidraw/components/Sidebar/Sidebar.tsx b/packages/excalidraw/components/Sidebar/Sidebar.tsx index d08ba5f597..5f0ca487f2 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.tsx +++ b/packages/excalidraw/components/Sidebar/Sidebar.tsx @@ -9,7 +9,13 @@ import React, { useCallback, } from "react"; -import { EVENT, isDevEnv, KEYS, updateObject } from "@excalidraw/common"; +import { + CLASSES, + EVENT, + isDevEnv, + KEYS, + updateObject, +} from "@excalidraw/common"; import { useUIAppState } from "../../context/ui-appState"; import { atom, useSetAtom } from "../../editor-jotai"; @@ -137,7 +143,11 @@ export const SidebarInner = forwardRef( return ( diff --git a/packages/excalidraw/components/TextField.scss b/packages/excalidraw/components/TextField.scss index c46cd2fe8c..fefea7e802 100644 --- a/packages/excalidraw/components/TextField.scss +++ b/packages/excalidraw/components/TextField.scss @@ -12,6 +12,10 @@ --ExcTextField--border-active: var(--color-brand-active); --ExcTextField--placeholder: var(--color-border-outline-variant); + &.theme--dark { + --ExcTextField--border: var(--color-border-outline-variant); + } + .ExcTextField { position: relative; diff --git a/packages/excalidraw/components/TextField.tsx b/packages/excalidraw/components/TextField.tsx index d6bc315b18..4e724aceda 100644 --- a/packages/excalidraw/components/TextField.tsx +++ b/packages/excalidraw/components/TextField.tsx @@ -28,6 +28,7 @@ type TextFieldProps = { className?: string; placeholder?: string; isRedacted?: boolean; + type?: "text" | "search"; } & ({ value: string } | { defaultValue: string }); export const TextField = forwardRef( @@ -43,6 +44,7 @@ export const TextField = forwardRef( isRedacted = false, icon, className, + type, ...rest }, ref, @@ -96,6 +98,7 @@ export const TextField = forwardRef( ref={innerRef} onChange={(event) => onChange?.(event.target.value)} onKeyDown={onKeyDown} + type={type} /> {isRedacted && (
)} diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 9a9a7b9cac..8279cb4344 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -185,7 +185,8 @@ "search": { "inputPlaceholder": "Search library", "heading": "Library matches", - "noResults": "No matching items found..." + "noResults": "No matching items found...", + "clearSearch": "Clear search" } }, "search": { From f1b097ad06d9cacc764405004b4d2786d5d5d38e Mon Sep 17 00:00:00 2001 From: Omar Eltomy <97570527+omareltomy@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:46:42 +0300 Subject: [PATCH 12/27] fix: support bidirectional shift+click selection in library items (#10034) * fix: support bidirectional shift+click selection in library items - Enable bottom-up multi-selection (previously only top-down worked) - Use Math.min/max to handle selection range in both directions - Maintains existing behavior for preserving non-contiguous selections - Fixes issue where shift+clicking items above last selected item failed * improve deselection behavior --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/components/LibraryMenu.tsx | 12 +++++++++++- packages/excalidraw/components/LibraryMenuItems.tsx | 13 ++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/components/LibraryMenu.tsx b/packages/excalidraw/components/LibraryMenu.tsx index 0aa6071aa0..9a4f29f179 100644 --- a/packages/excalidraw/components/LibraryMenu.tsx +++ b/packages/excalidraw/components/LibraryMenu.tsx @@ -281,19 +281,29 @@ export const LibraryMenu = memo(() => { if (target.closest(`.${CLASSES.SIDEBAR}`)) { // stop propagation so that we don't prevent it downstream // (default browser behavior is to clear search input on ESC) - event.stopPropagation(); if (selectedItems.length > 0) { + event.stopPropagation(); setSelectedItems([]); } else if ( isWritableElement(target) && target instanceof HTMLInputElement && !target.value ) { + event.stopPropagation(); // if search input empty -> close library // (maybe not a good idea?) setAppState({ openSidebar: null }); app.focusContainer(); } + } else if (selectedItems.length > 0) { + const { x, y } = app.lastViewportPosition; + const elementUnderCursor = document.elementFromPoint(x, y); + // also deselect elements if sidebar doesn't have focus but the + // cursor is over it + if (elementUnderCursor?.closest(`.${CLASSES.SIDEBAR}`)) { + event.stopPropagation(); + setSelectedItems([]); + } } } }, diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index 2d111b7f7b..c64351b1b3 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -138,10 +138,13 @@ export default function LibraryMenuItems({ } const selectedItemsMap = arrayToMap(selectedItems); + // Support both top-down and bottom-up selection by using min/max + const minRange = Math.min(rangeStart, rangeEnd); + const maxRange = Math.max(rangeStart, rangeEnd); const nextSelectedIds = orderedItems.reduce( (acc: LibraryItem["id"][], item, idx) => { if ( - (idx >= rangeStart && idx <= rangeEnd) || + (idx >= minRange && idx <= maxRange) || selectedItemsMap.has(item.id) ) { acc.push(item.id); @@ -169,6 +172,14 @@ export default function LibraryMenuItems({ ], ); + useEffect(() => { + // if selection is removed (e.g. via esc), reset last selected item + // so that subsequent shift+clicks don't select a large range + if (!selectedItems.length) { + setLastSelectedItem(null); + } + }, [selectedItems]); + const getInsertedElements = useCallback( (id: string) => { let targetElements; From 7c4194485647fcd3a1aaa75612c0193208d65bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Tue, 30 Sep 2025 12:09:20 -0300 Subject: [PATCH 13/27] fix: small improvement on binary heap implementation (#9992) --- packages/common/src/binary-heap.ts | 43 +++++++++++++++++------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/common/src/binary-heap.ts b/packages/common/src/binary-heap.ts index 788a05c223..5abf484998 100644 --- a/packages/common/src/binary-heap.ts +++ b/packages/common/src/binary-heap.ts @@ -5,17 +5,18 @@ export class BinaryHeap { sinkDown(idx: number) { const node = this.content[idx]; + const nodeScore = this.scoreFunction(node); while (idx > 0) { const parentN = ((idx + 1) >> 1) - 1; const parent = this.content[parentN]; - if (this.scoreFunction(node) < this.scoreFunction(parent)) { - this.content[parentN] = node; + if (nodeScore < this.scoreFunction(parent)) { this.content[idx] = parent; idx = parentN; // TODO: Optimize } else { break; } } + this.content[idx] = node; } bubbleUp(idx: number) { @@ -24,35 +25,39 @@ export class BinaryHeap { const score = this.scoreFunction(node); while (true) { - const child2N = (idx + 1) << 1; - const child1N = child2N - 1; - let swap = null; - let child1Score = 0; + const child1N = ((idx + 1) << 1) - 1; + const child2N = child1N + 1; + let smallestIdx = idx; + let smallestScore = score; + // Check left child if (child1N < length) { - const child1 = this.content[child1N]; - child1Score = this.scoreFunction(child1); - if (child1Score < score) { - swap = child1N; + const child1Score = this.scoreFunction(this.content[child1N]); + if (child1Score < smallestScore) { + smallestIdx = child1N; + smallestScore = child1Score; } } + // Check right child if (child2N < length) { - const child2 = this.content[child2N]; - const child2Score = this.scoreFunction(child2); - if (child2Score < (swap === null ? score : child1Score)) { - swap = child2N; + const child2Score = this.scoreFunction(this.content[child2N]); + if (child2Score < smallestScore) { + smallestIdx = child2N; } } - if (swap !== null) { - this.content[idx] = this.content[swap]; - this.content[swap] = node; - idx = swap; // TODO: Optimize - } else { + if (smallestIdx === idx) { break; } + + // Move the smaller child up, continue finding position for node + this.content[idx] = this.content[smallestIdx]; + idx = smallestIdx; } + + // Place node in its final position + this.content[idx] = node; } push(node: T) { From fde796a7a00d43dc2bb434b5ec98fb57afe9e896 Mon Sep 17 00:00:00 2001 From: zsviczian Date: Tue, 30 Sep 2025 20:38:10 +0200 Subject: [PATCH 14/27] feat: Make naming of library items discoverable (#10041) * updated library relevant strings * fix: detect name changes * clarify hashing function --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/element/src/index.ts | 3 +++ .../excalidraw/components/PublishLibrary.tsx | 2 +- packages/excalidraw/data/library.ts | 22 +++++++++++++++---- packages/excalidraw/locales/en.json | 3 ++- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 4fc1ef5579..d677859ad5 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -29,6 +29,9 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => { // string hash function (using djb2). Not cryptographically secure, use only // for versioning and such. +// note: hashes individual code units (not code points), +// but for hashing purposes this is fine as it iterates through every code unit +// (as such, no need to encode to byte string first) export const hashString = (s: string): number => { let hash: number = 5381; for (let i = 0; i < s.length; i++) { diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx index 076b303d70..cdc038dac3 100644 --- a/packages/excalidraw/components/PublishLibrary.tsx +++ b/packages/excalidraw/components/PublishLibrary.tsx @@ -518,7 +518,7 @@ const PublishLibrary = ({
diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 429ba1046c..abe2fec853 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -62,6 +62,7 @@ type LibraryUpdate = { deletedItems: Map; /** newly added items in the library */ addedItems: Map; + updatedItems: Map; }; // an object so that we can later add more properties to it without breaking, @@ -170,6 +171,7 @@ const createLibraryUpdate = ( const update: LibraryUpdate = { deletedItems: new Map(), addedItems: new Map(), + updatedItems: new Map(), }; for (const item of prevLibraryItems) { @@ -181,8 +183,11 @@ const createLibraryUpdate = ( const prevItemsMap = arrayToMap(prevLibraryItems); for (const item of nextLibraryItems) { - if (!prevItemsMap.has(item.id)) { + const prevItem = prevItemsMap.get(item.id); + if (!prevItem) { update.addedItems.set(item.id, item); + } else if (getLibraryItemHash(prevItem) !== getLibraryItemHash(item)) { + update.updatedItems.set(item.id, item); } } @@ -586,12 +591,14 @@ class AdapterTransaction { let lastSavedLibraryItemsHash = 0; let librarySaveCounter = 0; +const getLibraryItemHash = (item: LibraryItem) => { + return `${item.id}:${item.name || ""}:${hashElementsVersion(item.elements)}`; +}; + export const getLibraryItemsHash = (items: LibraryItems) => { return hashString( items - .map((item) => { - return `${item.id}:${hashElementsVersion(item.elements)}`; - }) + .map((item) => getLibraryItemHash(item)) .sort() .join(), ); @@ -641,6 +648,13 @@ const persistLibraryUpdate = async ( } } + // replace existing items with their updated versions + if (update.updatedItems) { + for (const [id, item] of update.updatedItems) { + nextLibraryItemsMap.set(id, item); + } + } + const nextLibraryItems = addedItems.concat( Array.from(nextLibraryItemsMap.values()), ); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 8279cb4344..4bd76fe876 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -230,10 +230,11 @@ "objectsSnapMode": "Snap to objects", "exitZenMode": "Exit zen mode", "cancel": "Cancel", + "saveLibNames": "Save name(s) and exit", "clear": "Clear", "remove": "Remove", "embed": "Toggle embedding", - "publishLibrary": "Publish selected", + "publishLibrary": "Rename or publish", "submit": "Submit", "confirm": "Confirm", "embeddableInteractionButton": "Click to interact" From 835eb8d2fdf21afd93175367838e6b9ecd9e9271 Mon Sep 17 00:00:00 2001 From: Emil <73137047+h0lm1@users.noreply.github.com> Date: Tue, 30 Sep 2025 23:54:43 +0200 Subject: [PATCH 15/27] fix: display error message when local storage quota is exceeded (#9961) * fix: display error message when local storage quota is exceeded * add danger alert instead of toast * tweak text --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/App.tsx | 10 +++++++++- excalidraw-app/data/LocalData.ts | 17 +++++++++++++++++ excalidraw-app/index.scss | 14 +++++++++++--- packages/excalidraw/locales/en.json | 3 ++- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index b972e6e5b0..a5d01769cc 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -119,6 +119,7 @@ import { LibraryIndexedDBAdapter, LibraryLocalStorageMigrationAdapter, LocalData, + localStorageQuotaExceededAtom, } from "./data/LocalData"; import { isBrowserStorageStateNewer } from "./data/tabSync"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; @@ -727,6 +728,8 @@ const ExcalidrawWrapper = () => { const isOffline = useAtomValue(isOfflineAtom); + const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom); + const onCollabDialogOpen = useCallback( () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }), [setShareDialogState], @@ -901,10 +904,15 @@ const ExcalidrawWrapper = () => { {isCollaborating && isOffline && ( -
+
{t("alerts.collabOfflineWarning")}
)} + {localStorageQuotaExceeded && ( +
+ {t("alerts.localStorageQuotaExceeded")} +
+ )} {latestShareableLink && ( { await entries(filesStore).then((entries) => { @@ -69,6 +73,9 @@ const saveDataStateToLocalStorage = ( elements: readonly ExcalidrawElement[], appState: AppState, ) => { + const localStorageQuotaExceeded = appJotaiStore.get( + localStorageQuotaExceededAtom, + ); try { const _appState = clearAppStateForLocalStorage(appState); @@ -88,12 +95,22 @@ const saveDataStateToLocalStorage = ( JSON.stringify(_appState), ); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); + if (localStorageQuotaExceeded) { + appJotaiStore.set(localStorageQuotaExceededAtom, false); + } } catch (error: any) { // Unable to access window.localStorage console.error(error); + if (isQuotaExceededError(error) && !localStorageQuotaExceeded) { + appJotaiStore.set(localStorageQuotaExceededAtom, true); + } } }; +const isQuotaExceededError = (error: any) => { + return error instanceof DOMException && error.name === "QuotaExceededError"; +}; + type SavingLockTypes = "collaboration"; export class LocalData { diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index cfaaf9cea2..9f320775be 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -58,7 +58,7 @@ } } - .collab-offline-warning { + .alert { pointer-events: none; position: absolute; top: 6.5rem; @@ -69,10 +69,18 @@ text-align: center; line-height: 1.5; border-radius: var(--border-radius-md); - background-color: var(--color-warning); - color: var(--color-text-warning); z-index: 6; white-space: pre; + + &--warning { + background-color: var(--color-warning); + color: var(--color-text-warning); + } + + &--danger { + background-color: var(--color-danger-dark); + color: var(--color-danger-text); + } } } diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 4bd76fe876..feebe6da0f 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -260,7 +260,8 @@ "resetLibrary": "This will clear your library. Are you sure?", "removeItemsFromsLibrary": "Delete {{count}} item(s) from library?", "invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.", - "collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!" + "collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!", + "localStorageQuotaExceeded": "Browser storage quota exceeded. Changes will not be saved." }, "errors": { "unsupportedFileType": "Unsupported file type.", From f3c16a600d4aae624776660c2ee4462b3bcd6a66 Mon Sep 17 00:00:00 2001 From: Akibur Rahman <48094649+akib22@users.noreply.github.com> Date: Thu, 2 Oct 2025 20:47:26 +0600 Subject: [PATCH 16/27] fix: text to diagram translation update issue on language update (#10016) --- packages/excalidraw/components/Actions.tsx | 25 ++++++++----------- .../components/TTDDialog/TTDDialogTrigger.tsx | 6 ++--- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index f43a4925de..ae44dafd04 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -12,19 +12,16 @@ import { import { shouldAllowVerticalAlign, suppportsHorizontalAlign, -} from "@excalidraw/element"; - -import { hasBoundTextElement, isElbowArrow, isImageElement, isLinearElement, isTextElement, isArrowElement, + hasStrokeColor, + toolIsArrow, } from "@excalidraw/element"; -import { hasStrokeColor, toolIsArrow } from "@excalidraw/element"; - import type { ExcalidrawElement, ExcalidrawElementType, @@ -902,16 +899,14 @@ export const ShapesSwitcher = ({ {t("toolBar.mermaidToExcalidraw")} {app.props.aiEnabled !== false && app.plugins.diagramToCode && ( - <> - app.onMagicframeToolSelect()} - icon={MagicIcon} - data-testid="toolbar-magicframe" - > - {t("toolBar.magicframe")} - AI - - + app.onMagicframeToolSelect()} + icon={MagicIcon} + data-testid="toolbar-magicframe" + > + {t("toolBar.magicframe")} + AI + )} diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx index 833b659fe2..0d5c62f331 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx @@ -1,12 +1,11 @@ import { trackEvent } from "../../analytics"; import { useTunnels } from "../../context/tunnels"; -import { t } from "../../i18n"; +import { useI18n } from "../../i18n"; import { useExcalidrawSetAppState } from "../App"; import DropdownMenu from "../dropdownMenu/DropdownMenu"; import { brainIcon } from "../icons"; -import type { ReactNode } from "react"; -import type { JSX } from "react"; +import type { JSX, ReactNode } from "react"; export const TTDDialogTrigger = ({ children, @@ -15,6 +14,7 @@ export const TTDDialogTrigger = ({ children?: ReactNode; icon?: JSX.Element; }) => { + const { t } = useI18n(); const { TTDDialogTriggerTunnel } = useTunnels(); const setAppState = useExcalidrawSetAppState(); From 98e0cd9078feab548c0af13f462195c4dbf10135 Mon Sep 17 00:00:00 2001 From: David Espinoza <69441741+despinozap@users.noreply.github.com> Date: Sun, 5 Oct 2025 09:48:54 -0300 Subject: [PATCH 17/27] build: Docker compose version removed (#10074) --- docker-compose.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b82053e57b..5beb3c15b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: excalidraw: build: From 416e8b3e421971bf2162cb78a2dee7bbc6dabb23 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 10 Oct 2025 08:48:31 +1100 Subject: [PATCH 18/27] feat: new mobile layout (#9996) * compact bottom toolbar * put menu trigger to top left * add popup to switch between grouped tool types * add a dedicated mobile toolbar * update position for mobile * fix active tool type * add mobile mode as well * mobile actions * remove refactored popups * excali logo mobile * include mobile * update mobile menu layout * move selection and deletion back to right * do not fill eraser * fix styling * fix active styling * bigger buttons, smaller gaps * fix other tools not opened * fix: Style panel persistence and restore Signed-off-by: Mark Tolmacs * move hidden action btns to extra popover * fix dropdown overlapping with welcome screen * replace custom popup with popover * improve button styles * swapping redo and delete * always show undo & redo and improve styling * change background * toolbar styles * no any * persist perferred selection tool and align tablet as well * add a renderTopLeftUI to props * tweak border and bg * show combined properties only when using suitable tools * fix preferred tool * new stroke icon * hide color picker hot keys * init preferred tool based on device * fix main menu sizing * fix welcome screen offset * put text before image * disable call highlight on buttons * fix renderTopLeftUI --------- Signed-off-by: Mark Tolmacs Co-authored-by: Mark Tolmacs Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/common/src/constants.ts | 5 + packages/element/src/comparisons.ts | 8 +- packages/excalidraw/actions/actionCanvas.tsx | 9 +- .../actions/actionDeleteSelected.tsx | 18 +- .../actions/actionDuplicateSelection.tsx | 11 +- .../excalidraw/actions/actionFinalize.tsx | 4 +- packages/excalidraw/actions/actionHistory.tsx | 22 +- .../excalidraw/actions/actionProperties.tsx | 47 +- packages/excalidraw/appState.ts | 7 +- packages/excalidraw/components/Actions.scss | 70 +- packages/excalidraw/components/Actions.tsx | 1067 +++++++++++------ packages/excalidraw/components/App.tsx | 57 +- .../components/ColorPicker/ColorPicker.scss | 15 + .../components/ColorPicker/ColorPicker.tsx | 44 +- .../components/ColorPicker/Picker.tsx | 22 +- .../ColorPicker/PickerColorList.tsx | 4 +- .../components/ColorPicker/ShadeList.tsx | 12 +- .../excalidraw/components/ExcalidrawLogo.scss | 14 + .../excalidraw/components/ExcalidrawLogo.tsx | 2 +- .../components/FontPicker/FontPicker.tsx | 1 + .../components/FontPicker/FontPickerList.tsx | 12 +- .../FontPicker/FontPickerTrigger.tsx | 13 + packages/excalidraw/components/HandButton.tsx | 2 +- packages/excalidraw/components/IconPicker.tsx | 10 +- packages/excalidraw/components/LayerUI.tsx | 8 +- packages/excalidraw/components/MobileMenu.tsx | 209 ++-- .../excalidraw/components/MobileToolBar.scss | 78 ++ .../excalidraw/components/MobileToolBar.tsx | 471 ++++++++ .../excalidraw/components/ToolPopover.scss | 18 + .../excalidraw/components/ToolPopover.tsx | 120 ++ packages/excalidraw/components/Toolbar.scss | 4 + .../components/dropdownMenu/DropdownMenu.scss | 30 +- .../components/dropdownMenu/DropdownMenu.tsx | 13 +- .../dropdownMenu/DropdownMenuContent.tsx | 3 + packages/excalidraw/components/icons.tsx | 14 +- .../components/main-menu/MainMenu.tsx | 2 + packages/excalidraw/components/shapes.tsx | 2 +- .../welcome-screen/WelcomeScreen.scss | 10 +- packages/excalidraw/css/styles.scss | 46 +- packages/excalidraw/css/theme.scss | 9 + packages/excalidraw/css/variables.module.scss | 16 + packages/excalidraw/index.tsx | 2 + .../__snapshots__/contextmenu.test.tsx.snap | 68 ++ .../tests/__snapshots__/history.test.tsx.snap | 258 +++- .../regressionTests.test.tsx.snap | 210 +++- packages/excalidraw/types.ts | 14 +- .../tests/__snapshots__/export.test.ts.snap | 4 + 47 files changed, 2407 insertions(+), 678 deletions(-) create mode 100644 packages/excalidraw/components/MobileToolBar.scss create mode 100644 packages/excalidraw/components/MobileToolBar.tsx create mode 100644 packages/excalidraw/components/ToolPopover.scss create mode 100644 packages/excalidraw/components/ToolPopover.tsx diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 3ac7a52b93..dfbb69aa97 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -543,3 +543,8 @@ export enum UserIdleState { export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const DOUBLE_TAP_POSITION_THRESHOLD = 35; + +// glass background for mobile action buttons +export const MOBILE_ACTION_BUTTON_BG = { + background: "var(--mobile-action-button-bg)", +} as const; diff --git a/packages/element/src/comparisons.ts b/packages/element/src/comparisons.ts index 75fac889dc..c15e1ca4bc 100644 --- a/packages/element/src/comparisons.ts +++ b/packages/element/src/comparisons.ts @@ -10,7 +10,13 @@ export const hasBackground = (type: ElementOrToolType) => type === "freedraw"; export const hasStrokeColor = (type: ElementOrToolType) => - type !== "image" && type !== "frame" && type !== "magicframe"; + type === "rectangle" || + type === "ellipse" || + type === "diamond" || + type === "freedraw" || + type === "arrow" || + type === "line" || + type === "text"; export const hasStrokeWidth = (type: ElementOrToolType) => type === "rectangle" || diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index d0039d1c29..b4aac19059 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -122,7 +122,10 @@ export const actionClearCanvas = register({ pasteDialog: appState.pasteDialog, activeTool: appState.activeTool.type === "image" - ? { ...appState.activeTool, type: app.defaultSelectionTool } + ? { + ...appState.activeTool, + type: app.state.preferredSelectionTool.type, + } : appState.activeTool, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, @@ -501,7 +504,7 @@ export const actionToggleEraserTool = register({ if (isEraserActive(appState)) { activeTool = updateActiveTool(appState, { ...(appState.activeTool.lastActiveTool || { - type: app.defaultSelectionTool, + type: app.state.preferredSelectionTool.type, }), lastActiveToolBeforeEraser: null, }); @@ -532,7 +535,7 @@ export const actionToggleLassoTool = register({ icon: LassoIcon, trackEvent: { category: "toolbar" }, predicate: (elements, appState, props, app) => { - return app.defaultSelectionTool !== "lasso"; + return app.state.preferredSelectionTool.type !== "lasso"; }, perform: (elements, appState, _, app) => { let activeTool: AppState["activeTool"]; diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 78a3465689..694f02b90c 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -1,4 +1,8 @@ -import { KEYS, updateActiveTool } from "@excalidraw/common"; +import { + KEYS, + MOBILE_ACTION_BUTTON_BG, + updateActiveTool, +} from "@excalidraw/common"; import { getNonDeletedElements } from "@excalidraw/element"; import { fixBindingsAfterDeletion } from "@excalidraw/element"; @@ -299,7 +303,7 @@ export const actionDeleteSelected = register({ appState: { ...nextAppState, activeTool: updateActiveTool(appState, { - type: app.defaultSelectionTool, + type: app.state.preferredSelectionTool.type, }), multiElement: null, activeEmbeddable: null, @@ -323,7 +327,15 @@ export const actionDeleteSelected = register({ title={t("labels.delete")} aria-label={t("labels.delete")} onClick={() => updateData(null)} - visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + disabled={ + !isSomeElementSelected(getNonDeletedElements(elements), appState) + } + style={{ + ...(appState.stylesPanelMode === "mobile" && + appState.openPopup !== "compactOtherProperties" + ? MOBILE_ACTION_BUTTON_BG + : {}), + }} /> ), }); diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index c1b2a9da42..daf1dbb3c6 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -1,6 +1,7 @@ import { DEFAULT_GRID_SIZE, KEYS, + MOBILE_ACTION_BUTTON_BG, arrayToMap, getShortcutKey, } from "@excalidraw/common"; @@ -115,7 +116,15 @@ export const actionDuplicateSelection = register({ )}`} aria-label={t("labels.duplicateSelection")} onClick={() => updateData(null)} - visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + disabled={ + !isSomeElementSelected(getNonDeletedElements(elements), appState) + } + style={{ + ...(appState.stylesPanelMode === "mobile" && + appState.openPopup !== "compactOtherProperties" + ? MOBILE_ACTION_BUTTON_BG + : {}), + }} /> ), }); diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 877c817ad4..4e7ae67919 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -261,13 +261,13 @@ export const actionFinalize = register({ if (appState.activeTool.type === "eraser") { activeTool = updateActiveTool(appState, { ...(appState.activeTool.lastActiveTool || { - type: app.defaultSelectionTool, + type: app.state.preferredSelectionTool.type, }), lastActiveToolBeforeEraser: null, }); } else { activeTool = updateActiveTool(appState, { - type: app.defaultSelectionTool, + type: app.state.preferredSelectionTool.type, }); } diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index b948fe7d49..a1971f527c 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -1,4 +1,10 @@ -import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common"; +import { + isWindows, + KEYS, + matchKey, + arrayToMap, + MOBILE_ACTION_BUTTON_BG, +} from "@excalidraw/common"; import { CaptureUpdateAction } from "@excalidraw/element"; @@ -67,7 +73,7 @@ export const createUndoAction: ActionCreator = (history) => ({ ), keyTest: (event) => event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey, - PanelComponent: ({ updateData, data }) => { + PanelComponent: ({ appState, updateData, data }) => { const { isUndoStackEmpty } = useEmitter( history.onHistoryChangedEmitter, new HistoryChangedEvent( @@ -85,6 +91,11 @@ export const createUndoAction: ActionCreator = (history) => ({ size={data?.size || "medium"} disabled={isUndoStackEmpty} data-testid="button-undo" + style={{ + ...(appState.stylesPanelMode === "mobile" + ? MOBILE_ACTION_BUTTON_BG + : {}), + }} /> ); }, @@ -103,7 +114,7 @@ export const createRedoAction: ActionCreator = (history) => ({ keyTest: (event) => (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)), - PanelComponent: ({ updateData, data }) => { + PanelComponent: ({ appState, updateData, data }) => { const { isRedoStackEmpty } = useEmitter( history.onHistoryChangedEmitter, new HistoryChangedEvent( @@ -121,6 +132,11 @@ export const createRedoAction: ActionCreator = (history) => ({ size={data?.size || "medium"} disabled={isRedoStackEmpty} data-testid="button-redo" + style={{ + ...(appState.stylesPanelMode === "mobile" + ? MOBILE_ACTION_BUTTON_BG + : {}), + }} /> ); }, diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index c03309e9cc..229b492533 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -348,7 +348,10 @@ export const actionChangeStrokeColor = register({ elements={elements} appState={appState} updateData={updateData} - compactMode={appState.stylesPanelMode === "compact"} + compactMode={ + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile" + } /> ), @@ -428,7 +431,10 @@ export const actionChangeBackgroundColor = register({ elements={elements} appState={appState} updateData={updateData} - compactMode={appState.stylesPanelMode === "compact"} + compactMode={ + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile" + } /> ), @@ -531,9 +537,7 @@ export const actionChangeStrokeWidth = register({ }, PanelComponent: ({ elements, appState, updateData, app, data }) => (
- {appState.stylesPanelMode === "full" && ( - {t("labels.strokeWidth")} - )} + {t("labels.strokeWidth")}
(
- {appState.stylesPanelMode === "full" && ( - {t("labels.sloppiness")} - )} + {t("labels.sloppiness")}
(
- {appState.stylesPanelMode === "full" && ( - {t("labels.strokeStyle")} - )} + {t("labels.strokeStyle")}
{ withCaretPositionPreservation( () => updateData(value), - appState.stylesPanelMode === "compact", + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile", !!appState.editingTextElement, data?.onPreventClose, ); @@ -1040,7 +1041,7 @@ export const actionChangeFontFamily = register({ return result; }, - PanelComponent: ({ elements, appState, app, updateData, data }) => { + PanelComponent: ({ elements, appState, app, updateData }) => { const cachedElementsRef = useRef(new Map()); const prevSelectedFontFamilyRef = useRef(null); // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them @@ -1117,7 +1118,7 @@ export const actionChangeFontFamily = register({ }, []); return ( -
+ <> {appState.stylesPanelMode === "full" && ( {t("labels.fontFamily")} )} @@ -1125,7 +1126,7 @@ export const actionChangeFontFamily = register({ isOpened={appState.openPopup === "fontFamily"} selectedFontFamily={selectedFontFamily} hoveredFontFamily={appState.currentHoveredFontFamily} - compactMode={appState.stylesPanelMode === "compact"} + compactMode={appState.stylesPanelMode !== "full"} onSelect={(fontFamily) => { withCaretPositionPreservation( () => { @@ -1137,7 +1138,8 @@ export const actionChangeFontFamily = register({ // defensive clear so immediate close won't abuse the cached elements cachedElementsRef.current.clear(); }, - appState.stylesPanelMode === "compact", + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile", !!appState.editingTextElement, ); }} @@ -1213,7 +1215,8 @@ export const actionChangeFontFamily = register({ // Refocus text editor when font picker closes if we were editing text if ( - appState.stylesPanelMode === "compact" && + (appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile") && appState.editingTextElement ) { restoreCaretPosition(null); // Just refocus without saved position @@ -1221,7 +1224,7 @@ export const actionChangeFontFamily = register({ } }} /> -
+ ); }, }); @@ -1314,7 +1317,8 @@ export const actionChangeTextAlign = register({ onChange={(value) => { withCaretPositionPreservation( () => updateData(value), - appState.stylesPanelMode === "compact", + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile", !!appState.editingTextElement, data?.onPreventClose, ); @@ -1413,7 +1417,8 @@ export const actionChangeVerticalAlign = register({ onChange={(value) => { withCaretPositionPreservation( () => updateData(value), - appState.stylesPanelMode === "compact", + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile", !!appState.editingTextElement, data?.onPreventClose, ); @@ -1678,8 +1683,8 @@ export const actionChangeArrowProperties = register({ PanelComponent: ({ elements, appState, updateData, app, renderAction }) => { return (
- {renderAction("changeArrowType")} {renderAction("changeArrowhead")} + {renderAction("changeArrowType")}
); }, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 2a37b138d8..96876e5854 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -55,6 +55,10 @@ export const getDefaultAppState = (): Omit< fromSelection: false, lastActiveTool: null, }, + preferredSelectionTool: { + type: "selection", + initialized: false, + }, penMode: false, penDetected: false, errorMessage: null, @@ -176,6 +180,7 @@ const APP_STATE_STORAGE_CONF = (< editingTextElement: { browser: false, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false }, + preferredSelectionTool: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false }, penDetected: { browser: true, export: false, server: false }, errorMessage: { browser: false, export: false, server: false }, @@ -248,7 +253,7 @@ const APP_STATE_STORAGE_CONF = (< searchMatches: { browser: false, export: false, server: false }, lockedMultiSelections: { browser: true, export: true, server: true }, activeLockedId: { browser: false, export: false, server: false }, - stylesPanelMode: { browser: true, export: false, server: false }, + stylesPanelMode: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index 93b5ef7c3e..f97f3c7b6f 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/packages/excalidraw/components/Actions.scss @@ -106,15 +106,15 @@ justify-content: center; align-items: center; min-height: 2.5rem; + pointer-events: auto; --default-button-size: 2rem; .compact-action-button { - width: 2rem; - height: 2rem; + width: var(--mobile-action-button-size); + height: var(--mobile-action-button-size); border: none; border-radius: var(--border-radius-lg); - background: transparent; color: var(--color-on-surface); cursor: pointer; display: flex; @@ -122,24 +122,20 @@ justify-content: center; transition: all 0.2s ease; + background: var(--mobile-action-button-bg); + svg { width: 1rem; height: 1rem; flex: 0 0 auto; } - &:hover { - background: var(--button-hover-bg, var(--island-bg-color)); - border-color: var( - --button-hover-border, - var(--button-border, var(--default-border-color)) + &.active { + background: var( + --color-surface-primary-container, + var(--mobile-action-button-bg) ); } - - &:active { - background: var(--button-active-bg, var(--island-bg-color)); - border-color: var(--button-active-border, var(--color-primary-darkest)); - } } .compact-popover-content { @@ -167,6 +163,19 @@ } } } + + .ToolIcon { + .ToolIcon__icon { + width: var(--mobile-action-button-size); + height: var(--mobile-action-button-size); + + background: var(--mobile-action-button-bg); + + &:hover { + background-color: transparent; + } + } + } } .compact-shape-actions-island { @@ -174,29 +183,18 @@ overflow-x: hidden; } -.compact-popover-content { - .popover-section { - margin-bottom: 1rem; - - &:last-child { - margin-bottom: 0; - } - - .popover-section-title { - font-size: 0.75rem; - font-weight: 600; - color: var(--color-text-secondary); - margin-bottom: 0.5rem; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .buttonList { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - } - } +.mobile-shape-actions { + z-index: 999; + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + background: transparent; + border-radius: var(--border-radius-lg); + box-shadow: none; + overflow: none; + scrollbar-width: none; + -ms-overflow-style: none; } .shape-actions-theme-scope { diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index ae44dafd04..ec95d40c3e 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { useState } from "react"; +import { useRef, useState } from "react"; import * as Popover from "@radix-ui/react-popover"; import { @@ -56,6 +56,7 @@ import "./Actions.scss"; import { useDevice, useExcalidrawContainer } from "./App"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; +import { ToolPopover } from "./ToolPopover"; import { Tooltip } from "./Tooltip"; import DropdownMenu from "./dropdownMenu/DropdownMenu"; import { PropertiesPopover } from "./PropertiesPopover"; @@ -73,8 +74,11 @@ import { TextSizeIcon, adjustmentsIcon, DotsHorizontalIcon, + SelectionIcon, } from "./icons"; +import { Island } from "./Island"; + import type { AppClassProperties, AppProps, @@ -302,6 +306,475 @@ export const SelectedShapeActions = ({ ); }; +const CombinedShapeProperties = ({ + appState, + renderAction, + setAppState, + targetElements, + container, +}: { + targetElements: ExcalidrawElement[]; + appState: UIAppState; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + container: HTMLDivElement | null; +}) => { + const showFillIcons = + (hasBackground(appState.activeTool.type) && + !isTransparent(appState.currentItemBackgroundColor)) || + targetElements.some( + (element) => + hasBackground(element.type) && !isTransparent(element.backgroundColor), + ); + + const shouldShowCombinedProperties = + targetElements.length > 0 || + (appState.activeTool.type !== "selection" && + appState.activeTool.type !== "eraser" && + appState.activeTool.type !== "hand" && + appState.activeTool.type !== "laser" && + appState.activeTool.type !== "lasso"); + const isOpen = appState.openPopup === "compactStrokeStyles"; + + if (!shouldShowCombinedProperties) { + return null; + } + + return ( +
+ { + if (open) { + setAppState({ openPopup: "compactStrokeStyles" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {isOpen && ( + {}} + > +
+ {showFillIcons && renderAction("changeFillStyle")} + {(hasStrokeWidth(appState.activeTool.type) || + targetElements.some((element) => + hasStrokeWidth(element.type), + )) && + renderAction("changeStrokeWidth")} + {(hasStrokeStyle(appState.activeTool.type) || + targetElements.some((element) => + hasStrokeStyle(element.type), + )) && ( + <> + {renderAction("changeStrokeStyle")} + {renderAction("changeSloppiness")} + + )} + {(canChangeRoundness(appState.activeTool.type) || + targetElements.some((element) => + canChangeRoundness(element.type), + )) && + renderAction("changeRoundness")} + {renderAction("changeOpacity")} +
+
+ )} +
+
+ ); +}; + +const CombinedArrowProperties = ({ + appState, + renderAction, + setAppState, + targetElements, + container, + app, +}: { + targetElements: ExcalidrawElement[]; + appState: UIAppState; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + container: HTMLDivElement | null; + app: AppClassProperties; +}) => { + const showShowArrowProperties = + toolIsArrow(appState.activeTool.type) || + targetElements.some((element) => toolIsArrow(element.type)); + const isOpen = appState.openPopup === "compactArrowProperties"; + + if (!showShowArrowProperties) { + return null; + } + + return ( +
+ { + if (open) { + setAppState({ openPopup: "compactArrowProperties" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {isOpen && ( + {}} + > + {renderAction("changeArrowProperties")} + + )} + +
+ ); +}; + +const CombinedTextProperties = ({ + appState, + renderAction, + setAppState, + targetElements, + container, + elementsMap, +}: { + appState: UIAppState; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + targetElements: ExcalidrawElement[]; + container: HTMLDivElement | null; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; +}) => { + const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(); + const isOpen = appState.openPopup === "compactTextProperties"; + + return ( +
+ { + if (open) { + if (appState.editingTextElement) { + saveCaretPosition(); + } + setAppState({ openPopup: "compactTextProperties" }); + } else { + setAppState({ openPopup: null }); + if (appState.editingTextElement) { + restoreCaretPosition(); + } + } + }} + > + + + + {appState.openPopup === "compactTextProperties" && ( + { + // Refocus text editor when popover closes with caret restoration + if (appState.editingTextElement) { + restoreCaretPosition(); + } + }} + > +
+ {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && + renderAction("changeFontSize")} + {(appState.activeTool.type === "text" || + suppportsHorizontalAlign(targetElements, elementsMap)) && + renderAction("changeTextAlign")} + {shouldAllowVerticalAlign(targetElements, elementsMap) && + renderAction("changeVerticalAlign")} +
+
+ )} +
+
+ ); +}; + +const CombinedExtraActions = ({ + appState, + renderAction, + targetElements, + setAppState, + container, + app, + showDuplicate, + showDelete, +}: { + appState: UIAppState; + targetElements: ExcalidrawElement[]; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + container: HTMLDivElement | null; + app: AppClassProperties; + showDuplicate?: boolean; + showDelete?: boolean; +}) => { + const isEditingTextOrNewElement = Boolean( + appState.editingTextElement || appState.newElement, + ); + const showCropEditorAction = + !appState.croppingElementId && + targetElements.length === 1 && + isImageElement(targetElements[0]); + const showLinkIcon = targetElements.length === 1; + const showAlignActions = alignActionsPredicate(appState, app); + let isSingleElementBoundContainer = false; + if ( + targetElements.length === 2 && + (hasBoundTextElement(targetElements[0]) || + hasBoundTextElement(targetElements[1])) + ) { + isSingleElementBoundContainer = true; + } + + const isRTL = document.documentElement.getAttribute("dir") === "rtl"; + const isOpen = appState.openPopup === "compactOtherProperties"; + + if (isEditingTextOrNewElement || targetElements.length === 0) { + return null; + } + + return ( +
+ { + if (open) { + setAppState({ openPopup: "compactOtherProperties" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {isOpen && ( + {}} + > +
+
+ {t("labels.layers")} +
+ {renderAction("sendToBack")} + {renderAction("sendBackward")} + {renderAction("bringForward")} + {renderAction("bringToFront")} +
+
+ + {showAlignActions && !isSingleElementBoundContainer && ( +
+ {t("labels.align")} +
+ {isRTL ? ( + <> + {renderAction("alignRight")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignLeft")} + + ) : ( + <> + {renderAction("alignLeft")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignRight")} + + )} + {targetElements.length > 2 && + renderAction("distributeHorizontally")} + {/* breaks the row ˇˇ */} +
+
+ {renderAction("alignTop")} + {renderAction("alignVerticallyCentered")} + {renderAction("alignBottom")} + {targetElements.length > 2 && + renderAction("distributeVertically")} +
+
+
+ )} +
+ {t("labels.actions")} +
+ {renderAction("group")} + {renderAction("ungroup")} + {showLinkIcon && renderAction("hyperlink")} + {showCropEditorAction && renderAction("cropEditor")} + {showDuplicate && renderAction("duplicateSelection")} + {showDelete && renderAction("deleteSelectedElements")} +
+
+
+
+ )} +
+
+ ); +}; + +const LinearEditorAction = ({ + appState, + renderAction, + targetElements, +}: { + appState: UIAppState; + targetElements: ExcalidrawElement[]; + renderAction: ActionManager["renderAction"]; +}) => { + const showLineEditorAction = + !appState.selectedLinearElement?.isEditing && + targetElements.length === 1 && + isLinearElement(targetElements[0]) && + !isElbowArrow(targetElements[0]); + + if (!showLineEditorAction) { + return null; + } + + return ( +
+ {renderAction("toggleLinearEditor")} +
+ ); +}; + export const CompactShapeActions = ({ appState, elementsMap, @@ -316,47 +789,18 @@ export const CompactShapeActions = ({ setAppState: React.Component["setState"]; }) => { const targetElements = getTargetElements(elementsMap, appState); - const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(); const { container } = useExcalidrawContainer(); const isEditingTextOrNewElement = Boolean( appState.editingTextElement || appState.newElement, ); - const showFillIcons = - (hasBackground(appState.activeTool.type) && - !isTransparent(appState.currentItemBackgroundColor)) || - targetElements.some( - (element) => - hasBackground(element.type) && !isTransparent(element.backgroundColor), - ); - - const showLinkIcon = targetElements.length === 1; - const showLineEditorAction = !appState.selectedLinearElement?.isEditing && targetElements.length === 1 && isLinearElement(targetElements[0]) && !isElbowArrow(targetElements[0]); - const showCropEditorAction = - !appState.croppingElementId && - targetElements.length === 1 && - isImageElement(targetElements[0]); - - const showAlignActions = alignActionsPredicate(appState, app); - - let isSingleElementBoundContainer = false; - if ( - targetElements.length === 2 && - (hasBoundTextElement(targetElements[0]) || - hasBoundTextElement(targetElements[1])) - ) { - isSingleElementBoundContainer = true; - } - - const isRTL = document.documentElement.getAttribute("dir") === "rtl"; - return (
{/* Stroke Color */} @@ -373,156 +817,22 @@ export const CompactShapeActions = ({
)} - {/* Combined Properties (Fill, Stroke, Opacity) */} - {(showFillIcons || - hasStrokeWidth(appState.activeTool.type) || - targetElements.some((element) => hasStrokeWidth(element.type)) || - hasStrokeStyle(appState.activeTool.type) || - targetElements.some((element) => hasStrokeStyle(element.type)) || - canChangeRoundness(appState.activeTool.type) || - targetElements.some((element) => canChangeRoundness(element.type))) && ( -
- { - if (open) { - setAppState({ openPopup: "compactStrokeStyles" }); - } else { - setAppState({ openPopup: null }); - } - }} - > - - - - {appState.openPopup === "compactStrokeStyles" && ( - {}} - > -
- {showFillIcons && renderAction("changeFillStyle")} - {(hasStrokeWidth(appState.activeTool.type) || - targetElements.some((element) => - hasStrokeWidth(element.type), - )) && - renderAction("changeStrokeWidth")} - {(hasStrokeStyle(appState.activeTool.type) || - targetElements.some((element) => - hasStrokeStyle(element.type), - )) && ( - <> - {renderAction("changeStrokeStyle")} - {renderAction("changeSloppiness")} - - )} - {(canChangeRoundness(appState.activeTool.type) || - targetElements.some((element) => - canChangeRoundness(element.type), - )) && - renderAction("changeRoundness")} - {renderAction("changeOpacity")} -
-
- )} -
-
- )} - - {/* Combined Arrow Properties */} - {(toolIsArrow(appState.activeTool.type) || - targetElements.some((element) => toolIsArrow(element.type))) && ( -
- { - if (open) { - setAppState({ openPopup: "compactArrowProperties" }); - } else { - setAppState({ openPopup: null }); - } - }} - > - - - - {appState.openPopup === "compactArrowProperties" && ( - {}} - > - {renderAction("changeArrowProperties")} - - )} - -
- )} + + {/* Linear Editor */} {showLineEditorAction && (
@@ -537,73 +847,14 @@ export const CompactShapeActions = ({
{renderAction("changeFontFamily")}
-
- { - if (open) { - if (appState.editingTextElement) { - saveCaretPosition(); - } - setAppState({ openPopup: "compactTextProperties" }); - } else { - setAppState({ openPopup: null }); - if (appState.editingTextElement) { - restoreCaretPosition(); - } - } - }} - > - - - - {appState.openPopup === "compactTextProperties" && ( - { - // Refocus text editor when popover closes with caret restoration - if (appState.editingTextElement) { - restoreCaretPosition(); - } - }} - > -
- {(appState.activeTool.type === "text" || - targetElements.some(isTextElement)) && - renderAction("changeFontSize")} - {(appState.activeTool.type === "text" || - suppportsHorizontalAlign(targetElements, elementsMap)) && - renderAction("changeTextAlign")} - {shouldAllowVerticalAlign(targetElements, elementsMap) && - renderAction("changeVerticalAlign")} -
-
- )} -
-
+ )} @@ -621,135 +872,195 @@ export const CompactShapeActions = ({
)} - {/* Combined Other Actions */} - {!isEditingTextOrNewElement && targetElements.length > 0 && ( -
- { - if (open) { - setAppState({ openPopup: "compactOtherProperties" }); - } else { - setAppState({ openPopup: null }); - } - }} - > - - - - {appState.openPopup === "compactOtherProperties" && ( - {}} - > -
-
- {t("labels.layers")} -
- {renderAction("sendToBack")} - {renderAction("sendBackward")} - {renderAction("bringForward")} - {renderAction("bringToFront")} -
-
- - {showAlignActions && !isSingleElementBoundContainer && ( -
- {t("labels.align")} -
- {isRTL ? ( - <> - {renderAction("alignRight")} - {renderAction("alignHorizontallyCentered")} - {renderAction("alignLeft")} - - ) : ( - <> - {renderAction("alignLeft")} - {renderAction("alignHorizontallyCentered")} - {renderAction("alignRight")} - - )} - {targetElements.length > 2 && - renderAction("distributeHorizontally")} - {/* breaks the row ˇˇ */} -
-
- {renderAction("alignTop")} - {renderAction("alignVerticallyCentered")} - {renderAction("alignBottom")} - {targetElements.length > 2 && - renderAction("distributeVertically")} -
-
-
- )} -
- {t("labels.actions")} -
- {renderAction("group")} - {renderAction("ungroup")} - {showLinkIcon && renderAction("hyperlink")} - {showCropEditorAction && renderAction("cropEditor")} -
-
-
-
- )} -
-
- )} +
); }; +export const MobileShapeActions = ({ + appState, + elementsMap, + renderAction, + app, + setAppState, +}: { + appState: UIAppState; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; + renderAction: ActionManager["renderAction"]; + app: AppClassProperties; + setAppState: React.Component["setState"]; +}) => { + const targetElements = getTargetElements(elementsMap, appState); + const { container } = useExcalidrawContainer(); + const mobileActionsRef = useRef(null); + + const ACTIONS_WIDTH = + mobileActionsRef.current?.getBoundingClientRect()?.width ?? 0; + + // 7 actions + 2 for undo/redo + const MIN_ACTIONS = 9; + + const GAP = 6; + const WIDTH = 32; + + const MIN_WIDTH = MIN_ACTIONS * WIDTH + (MIN_ACTIONS - 1) * GAP; + + const ADDITIONAL_WIDTH = WIDTH + GAP; + + const showDeleteOutside = ACTIONS_WIDTH >= MIN_WIDTH + ADDITIONAL_WIDTH; + const showDuplicateOutside = + ACTIONS_WIDTH >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH; + + return ( + +
+ {canChangeStrokeColor(appState, targetElements) && ( +
+ {renderAction("changeStrokeColor")} +
+ )} + {canChangeBackgroundColor(appState, targetElements) && ( +
+ {renderAction("changeBackgroundColor")} +
+ )} + + {/* Combined Arrow Properties */} + + {/* Linear Editor */} + + {/* Text Properties */} + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && ( + <> +
+ {renderAction("changeFontFamily")} +
+ + + )} + + {/* Combined Other Actions */} + +
+
+
{renderAction("undo")}
+
{renderAction("redo")}
+ {showDuplicateOutside && ( +
+ {renderAction("duplicateSelection")} +
+ )} + {showDeleteOutside && ( +
+ {renderAction("deleteSelectedElements")} +
+ )} +
+
+ ); +}; + export const ShapesSwitcher = ({ activeTool, - appState, + setAppState, app, UIOptions, }: { activeTool: UIAppState["activeTool"]; - appState: UIAppState; + setAppState: React.Component["setState"]; app: AppClassProperties; UIOptions: AppProps["UIOptions"]; }) => { const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); + const SELECTION_TOOLS = [ + { + type: "selection", + icon: SelectionIcon, + title: capitalizeString(t("toolBar.selection")), + }, + { + type: "lasso", + icon: LassoIcon, + title: capitalizeString(t("toolBar.lasso")), + }, + ] as const; + const frameToolSelected = activeTool.type === "frame"; const laserToolSelected = activeTool.type === "laser"; const lassoToolSelected = - activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso"; + app.state.stylesPanelMode === "full" && + activeTool.type === "lasso" && + app.state.preferredSelectionTool.type !== "lasso"; const embeddableToolSelected = activeTool.type === "embeddable"; @@ -776,6 +1087,40 @@ export const ShapesSwitcher = ({ const shortcut = letter ? `${letter} ${t("helpDialog.or")} ${numericKey}` : `${numericKey}`; + // when in compact styles panel mode (tablet) + // use a ToolPopover for selection/lasso toggle as well + if ( + (value === "selection" || value === "lasso") && + app.state.stylesPanelMode === "compact" + ) { + return ( + { + if (type === "selection" || type === "lasso") { + app.setActiveTool({ type }); + setAppState({ + preferredSelectionTool: { type, initialized: true }, + }); + } + }} + displayedOption={ + SELECTION_TOOLS.find( + (tool) => + tool.type === app.state.preferredSelectionTool.type, + ) || SELECTION_TOOLS[0] + } + fillable={activeTool.type === "selection"} + /> + ); + } return ( { - if (!appState.penDetected && pointerType === "pen") { + if (!app.state.penDetected && pointerType === "pen") { app.togglePenMode(true); } if (value === "selection") { - if (appState.activeTool.type === "selection") { + if (app.state.activeTool.type === "selection") { app.setActiveTool({ type: "lasso" }); } else { app.setActiveTool({ type: "selection" }); @@ -804,7 +1149,7 @@ export const ShapesSwitcher = ({ } }} onChange={({ pointerType }) => { - if (appState.activeTool.type !== value) { + if (app.state.activeTool.type !== value) { trackEvent("toolbar", value, "ui"); } if (value === "image") { @@ -877,7 +1222,7 @@ export const ShapesSwitcher = ({ > {t("toolBar.laser")} - {app.defaultSelectionTool !== "lasso" && ( + {app.state.stylesPanelMode === "full" && ( app.setActiveTool({ type: "lasso" })} icon={LassoIcon} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index af888b1921..c74ef73b52 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -666,14 +666,9 @@ class App extends React.Component { >(); onRemoveEventListenersEmitter = new Emitter<[]>(); - defaultSelectionTool: "selection" | "lasso" = "selection"; - constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); - this.defaultSelectionTool = isMobileOrTablet() - ? ("lasso" as const) - : ("selection" as const); const { excalidrawAPI, viewModeEnabled = false, @@ -1527,7 +1522,7 @@ class App extends React.Component { public render() { const selectedElements = this.scene.getSelectedElements(this.state); - const { renderTopRightUI, renderCustomStats } = this.props; + const { renderTopRightUI, renderTopLeftUI, renderCustomStats } = this.props; const sceneNonce = this.scene.getSceneNonce(); const { elementsMap, visibleElements } = @@ -1613,6 +1608,7 @@ class App extends React.Component { onPenModeToggle={this.togglePenMode} onHandToolToggle={this.onHandToolToggle} langCode={getLanguage().code} + renderTopLeftUI={renderTopLeftUI} renderTopRightUI={renderTopRightUI} renderCustomStats={renderCustomStats} showExitZenModeBtn={ @@ -1625,7 +1621,7 @@ class App extends React.Component { !this.state.isLoading && this.state.showWelcomeScreen && this.state.activeTool.type === - this.defaultSelectionTool && + this.state.preferredSelectionTool.type && !this.state.zenModeEnabled && !this.scene.getElementsIncludingDeleted().length } @@ -2370,6 +2366,14 @@ class App extends React.Component { deleteInvisibleElements: true, }); const activeTool = scene.appState.activeTool; + + if (!scene.appState.preferredSelectionTool.initialized) { + scene.appState.preferredSelectionTool = { + type: this.device.editor.isMobile ? "lasso" : "selection", + initialized: true, + }; + } + scene.appState = { ...scene.appState, theme: this.props.theme || scene.appState.theme, @@ -2384,12 +2388,13 @@ class App extends React.Component { activeTool.type === "selection" ? { ...activeTool, - type: this.defaultSelectionTool, + type: scene.appState.preferredSelectionTool.type, } : scene.appState.activeTool, isLoading: false, toast: this.state.toast, }; + if (initialData?.scrollToContent) { scene.appState = { ...scene.appState, @@ -2490,6 +2495,8 @@ class App extends React.Component { // but not too narrow (> MQ_MAX_WIDTH_MOBILE) this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet() ? "compact" + : this.isMobileBreakpoint(editorWidth, editorHeight) + ? "mobile" : "full", }); @@ -3289,7 +3296,10 @@ class App extends React.Component { await this.insertClipboardContent(data, filesList, isPlainPaste); - this.setActiveTool({ type: this.defaultSelectionTool }, true); + this.setActiveTool( + { type: this.state.preferredSelectionTool.type }, + true, + ); event?.preventDefault(); }, ); @@ -3435,7 +3445,7 @@ class App extends React.Component { } }, ); - this.setActiveTool({ type: this.defaultSelectionTool }, true); + this.setActiveTool({ type: this.state.preferredSelectionTool.type }, true); if (opts.fitToContent) { this.scrollToContent(duplicatedElements, { @@ -3647,7 +3657,7 @@ class App extends React.Component { ...updateActiveTool( this.state, prevState.activeTool.locked - ? { type: this.defaultSelectionTool } + ? { type: this.state.preferredSelectionTool.type } : prevState.activeTool, ), locked: !prevState.activeTool.locked, @@ -3989,7 +3999,12 @@ class App extends React.Component { } if (appState) { - this.setState(appState); + this.setState({ + ...appState, + // keep existing stylesPanelMode as it needs to be preserved + // or set at startup + stylesPanelMode: this.state.stylesPanelMode, + } as Pick | null); } if (elements) { @@ -4653,7 +4668,7 @@ class App extends React.Component { if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { if (this.state.activeTool.type === "laser") { - this.setActiveTool({ type: this.defaultSelectionTool }); + this.setActiveTool({ type: this.state.preferredSelectionTool.type }); } else { this.setActiveTool({ type: "laser" }); } @@ -5498,7 +5513,7 @@ class App extends React.Component { return; } // we should only be able to double click when mode is selection - if (this.state.activeTool.type !== this.defaultSelectionTool) { + if (this.state.activeTool.type !== this.state.preferredSelectionTool.type) { return; } @@ -6491,6 +6506,10 @@ class App extends React.Component { this.setAppState({ snapLines: [] }); } + if (this.state.openPopup) { + this.setState({ openPopup: null }); + } + this.updateGestureOnPointerDown(event); // if dragging element is freedraw and another pointerdown event occurs @@ -7695,7 +7714,7 @@ class App extends React.Component { if (!this.state.activeTool.locked) { this.setState({ activeTool: updateActiveTool(this.state, { - type: this.defaultSelectionTool, + type: this.state.preferredSelectionTool.type, }), }); } @@ -9409,7 +9428,7 @@ class App extends React.Component { this.setState((prevState) => ({ newElement: null, activeTool: updateActiveTool(this.state, { - type: this.defaultSelectionTool, + type: this.state.preferredSelectionTool.type, }), selectedElementIds: makeNextSelectedElementIds( { @@ -10026,7 +10045,7 @@ class App extends React.Component { newElement: null, suggestedBindings: [], activeTool: updateActiveTool(this.state, { - type: this.defaultSelectionTool, + type: this.state.preferredSelectionTool.type, }), }); } else { @@ -10256,7 +10275,7 @@ class App extends React.Component { { newElement: null, activeTool: updateActiveTool(this.state, { - type: this.defaultSelectionTool, + type: this.state.preferredSelectionTool.type, }), }, () => { @@ -10720,7 +10739,7 @@ class App extends React.Component { event.nativeEvent.pointerType === "pen" && // always allow if user uses a pen secondary button event.button !== POINTER_BUTTON.SECONDARY)) && - this.state.activeTool.type !== this.defaultSelectionTool + this.state.activeTool.type !== this.state.preferredSelectionTool.type ) { return; } diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.scss b/packages/excalidraw/components/ColorPicker/ColorPicker.scss index 0e3768dcc0..658a75dad7 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.scss +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.scss @@ -7,6 +7,12 @@ } } + .color-picker__title { + padding: 0 0.5rem; + font-size: 0.875rem; + text-align: left; + } + .color-picker__heading { padding: 0 0.5rem; font-size: 0.75rem; @@ -157,6 +163,15 @@ width: 1.625rem; height: 1.625rem; } + + &.compact-sizing { + width: var(--mobile-action-button-size); + height: var(--mobile-action-button-size); + } + + &.mobile-border { + border: 1px solid var(--mobile-color-border); + } } .color-picker__button__hotkey-label { diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index ad0bea3610..759ab9cad2 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -19,7 +19,7 @@ import { useExcalidrawContainer } from "../App"; import { ButtonSeparator } from "../ButtonSeparator"; import { activeEyeDropperAtom } from "../EyeDropper"; import { PropertiesPopover } from "../PropertiesPopover"; -import { backgroundIcon, slashIcon, strokeIcon } from "../icons"; +import { slashIcon, strokeIcon } from "../icons"; import { saveCaretPosition, restoreCaretPosition, @@ -216,6 +216,11 @@ const ColorPickerPopupContent = ({ type={type} elements={elements} updateData={updateData} + showTitle={ + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile" + } + showHotKey={appState.stylesPanelMode !== "mobile"} > {colorInputJSX} @@ -230,7 +235,7 @@ const ColorPickerTrigger = ({ label, color, type, - compactMode = false, + stylesPanelMode, mode = "background", onToggle, editingTextElement, @@ -238,7 +243,7 @@ const ColorPickerTrigger = ({ color: string | null; label: string; type: ColorPickerType; - compactMode?: boolean; + stylesPanelMode?: AppState["stylesPanelMode"]; mode?: "background" | "stroke"; onToggle: () => void; editingTextElement?: boolean; @@ -263,6 +268,9 @@ const ColorPickerTrigger = ({ "is-transparent": !color || color === "transparent", "has-outline": !color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD), + "compact-sizing": + stylesPanelMode === "compact" || stylesPanelMode === "mobile", + "mobile-border": stylesPanelMode === "mobile", })} aria-label={label} style={color ? { "--swatch-color": color } : undefined} @@ -275,20 +283,10 @@ const ColorPickerTrigger = ({ onClick={handleClick} >
{!color && slashIcon}
- {compactMode && color && ( -
- {mode === "background" ? ( - - {backgroundIcon} - - ) : ( + {(stylesPanelMode === "compact" || stylesPanelMode === "mobile") && + color && + mode === "stroke" && ( +
{strokeIcon} - )} -
- )} +
+ )} ); }; @@ -316,12 +313,15 @@ export const ColorPicker = ({ topPicks, updateData, appState, - compactMode = false, }: ColorPickerProps) => { const openRef = useRef(appState.openPopup); useEffect(() => { openRef.current = appState.openPopup; }, [appState.openPopup]); + const compactMode = + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile"; + return (
{ diff --git a/packages/excalidraw/components/ColorPicker/Picker.tsx b/packages/excalidraw/components/ColorPicker/Picker.tsx index f784912f4c..9c48c58075 100644 --- a/packages/excalidraw/components/ColorPicker/Picker.tsx +++ b/packages/excalidraw/components/ColorPicker/Picker.tsx @@ -37,8 +37,10 @@ interface PickerProps { palette: ColorPaletteCustom; updateData: (formData?: any) => void; children?: React.ReactNode; + showTitle?: boolean; onEyeDropperToggle: (force?: boolean) => void; onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; + showHotKey?: boolean; } export const Picker = React.forwardRef( @@ -51,11 +53,21 @@ export const Picker = React.forwardRef( palette, updateData, children, + showTitle, onEyeDropperToggle, onEscape, + showHotKey = true, }: PickerProps, ref, ) => { + const title = showTitle + ? type === "elementStroke" + ? t("labels.stroke") + : type === "elementBackground" + ? t("labels.background") + : null + : null; + const [customColors] = React.useState(() => { if (type === "canvasBackground") { return []; @@ -154,6 +166,8 @@ export const Picker = React.forwardRef( // to allow focusing by clicking but not by tabbing tabIndex={-1} > + {title &&
{title}
} + {!!customColors.length && (
@@ -175,12 +189,18 @@ export const Picker = React.forwardRef( palette={palette} onChange={onChange} activeShade={activeShade} + showHotKey={showHotKey} />
{t("colorPicker.shades")} - +
{children}
diff --git a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx index 4fd6815e44..13928f0239 100644 --- a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx +++ b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx @@ -20,6 +20,7 @@ interface PickerColorListProps { color: string | null; onChange: (color: string) => void; activeShade: number; + showHotKey?: boolean; } const PickerColorList = ({ @@ -27,6 +28,7 @@ const PickerColorList = ({ color, onChange, activeShade, + showHotKey = true, }: PickerColorListProps) => { const colorObj = getColorNameAndShadeFromColor({ color, @@ -82,7 +84,7 @@ const PickerColorList = ({ key={key} >
- + {showHotKey && } ); })} diff --git a/packages/excalidraw/components/ColorPicker/ShadeList.tsx b/packages/excalidraw/components/ColorPicker/ShadeList.tsx index db33402b0c..2c17c57ede 100644 --- a/packages/excalidraw/components/ColorPicker/ShadeList.tsx +++ b/packages/excalidraw/components/ColorPicker/ShadeList.tsx @@ -16,9 +16,15 @@ interface ShadeListProps { color: string | null; onChange: (color: string) => void; palette: ColorPaletteCustom; + showHotKey?: boolean; } -export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => { +export const ShadeList = ({ + color, + onChange, + palette, + showHotKey, +}: ShadeListProps) => { const colorObj = getColorNameAndShadeFromColor({ color: color || "transparent", palette, @@ -67,7 +73,9 @@ export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => { }} >
- + {showHotKey && ( + + )} ))}
diff --git a/packages/excalidraw/components/ExcalidrawLogo.scss b/packages/excalidraw/components/ExcalidrawLogo.scss index e59e8a90c0..d42f98a325 100644 --- a/packages/excalidraw/components/ExcalidrawLogo.scss +++ b/packages/excalidraw/components/ExcalidrawLogo.scss @@ -1,5 +1,8 @@ .excalidraw { .ExcalidrawLogo { + --logo-icon--mobile: 1rem; + --logo-text--mobile: 0.75rem; + --logo-icon--xs: 2rem; --logo-text--xs: 1.5rem; @@ -30,6 +33,17 @@ color: var(--color-logo-text); } + &.is-mobile { + .ExcalidrawLogo-icon { + height: var(--logo-icon--mobile); + } + + .ExcalidrawLogo-text { + height: var(--logo-text--mobile); + margin-left: 0.5rem; + } + } + &.is-xs { .ExcalidrawLogo-icon { height: var(--logo-icon--xs); diff --git a/packages/excalidraw/components/ExcalidrawLogo.tsx b/packages/excalidraw/components/ExcalidrawLogo.tsx index 01d07fc505..8610249ba1 100644 --- a/packages/excalidraw/components/ExcalidrawLogo.tsx +++ b/packages/excalidraw/components/ExcalidrawLogo.tsx @@ -41,7 +41,7 @@ const LogoText = () => ( ); -type LogoSize = "xs" | "small" | "normal" | "large" | "custom"; +type LogoSize = "xs" | "small" | "normal" | "large" | "custom" | "mobile"; interface LogoProps { size?: LogoSize; diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index 891ae49efd..c52286a173 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -106,6 +106,7 @@ export const FontPicker = React.memo( {isOpened && ( - + {app.state.stylesPanelMode === "full" && ( + + )} { const setAppState = useExcalidrawSetAppState(); + const compactStyle = compactMode + ? { + ...MOBILE_ACTION_BUTTON_BG, + width: "2rem", + height: "2rem", + } + : {}; + return (
@@ -37,6 +49,7 @@ export const FontPickerTrigger = ({ }} style={{ border: "none", + ...compactStyle, }} />
diff --git a/packages/excalidraw/components/HandButton.tsx b/packages/excalidraw/components/HandButton.tsx index 5ebfdf9d3f..db653a8103 100644 --- a/packages/excalidraw/components/HandButton.tsx +++ b/packages/excalidraw/components/HandButton.tsx @@ -18,7 +18,7 @@ type LockIconProps = { export const HandButton = (props: LockIconProps) => { return ( ({ ); }; + const isMobile = device.editor.isMobile; + return ( diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 6748095322..4fd6f6d269 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -91,6 +91,7 @@ interface LayerUIProps { onPenModeToggle: AppClassProperties["togglePenMode"]; showExitZenModeBtn: boolean; langCode: Language["code"]; + renderTopLeftUI?: ExcalidrawProps["renderTopLeftUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; UIOptions: AppProps["UIOptions"]; @@ -149,6 +150,7 @@ const LayerUI = ({ onHandToolToggle, onPenModeToggle, showExitZenModeBtn, + renderTopLeftUI, renderTopRightUI, renderCustomStats, UIOptions, @@ -366,7 +368,7 @@ const LayerUI = ({ /> diff --git a/packages/excalidraw/components/MobileMenu.tsx b/packages/excalidraw/components/MobileMenu.tsx index 454c0f64e5..8da02b30b3 100644 --- a/packages/excalidraw/components/MobileMenu.tsx +++ b/packages/excalidraw/components/MobileMenu.tsx @@ -1,32 +1,23 @@ import React from "react"; -import { showSelectedShapeActions } from "@excalidraw/element"; - import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; -import { isHandToolActive } from "../appState"; import { useTunnels } from "../context/tunnels"; import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; -import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; +import { MobileShapeActions } from "./Actions"; +import { MobileToolBar } from "./MobileToolBar"; import { FixedSideContainer } from "./FixedSideContainer"; -import { HandButton } from "./HandButton"; -import { HintViewer } from "./HintViewer"; + import { Island } from "./Island"; -import { LockButton } from "./LockButton"; -import { PenModeButton } from "./PenModeButton"; -import { Section } from "./Section"; -import Stack from "./Stack"; import type { ActionManager } from "../actions/manager"; import type { AppClassProperties, AppProps, AppState, - Device, - ExcalidrawProps, UIAppState, } from "../types"; import type { JSX } from "react"; @@ -38,7 +29,6 @@ type MobileMenuProps = { renderImageExportDialog: () => React.ReactNode; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; - onLockToggle: () => void; onHandToolToggle: () => void; onPenModeToggle: AppClassProperties["togglePenMode"]; @@ -46,9 +36,11 @@ type MobileMenuProps = { isMobile: boolean, appState: UIAppState, ) => JSX.Element | null; - renderCustomStats?: ExcalidrawProps["renderCustomStats"]; + renderTopLeftUI?: ( + isMobile: boolean, + appState: UIAppState, + ) => JSX.Element | null; renderSidebars: () => JSX.Element | null; - device: Device; renderWelcomeScreen: boolean; UIOptions: AppProps["UIOptions"]; app: AppClassProperties; @@ -59,14 +51,10 @@ export const MobileMenu = ({ elements, actionManager, setAppState, - onLockToggle, onHandToolToggle, - onPenModeToggle, - + renderTopLeftUI, renderTopRightUI, - renderCustomStats, renderSidebars, - device, renderWelcomeScreen, UIOptions, app, @@ -76,141 +64,98 @@ export const MobileMenu = ({ MainMenuTunnel, DefaultSidebarTriggerTunnel, } = useTunnels(); - const renderToolbar = () => { - return ( - - {renderWelcomeScreen && } -
- {(heading: React.ReactNode) => ( - - - - {heading} - - - - - {renderTopRightUI && renderTopRightUI(true, appState)} -
- {!appState.viewModeEnabled && - appState.openDialog?.name !== "elementLinkSelector" && ( - - )} - onPenModeToggle(null)} - title={t("toolBar.penMode")} - isMobile - penDetected={appState.penDetected} - /> - - onHandToolToggle()} - title={t("toolBar.hand")} - isMobile - /> -
-
-
- )} -
- -
+ const renderAppTopBar = () => { + const topRightUI = renderTopRightUI?.(true, appState) ?? ( + + ); + + const topLeftUI = ( +
+ {renderTopLeftUI?.(true, appState)} + +
); - }; - const renderAppToolbar = () => { if ( appState.viewModeEnabled || appState.openDialog?.name === "elementLinkSelector" ) { - return ( -
- -
- ); + return
{topLeftUI}
; } return ( -
- - {actionManager.renderAction("toggleEditMenu")} - {actionManager.renderAction( - appState.multiElement ? "finalize" : "duplicateSelection", - )} - {actionManager.renderAction("deleteSelectedElements")} -
- {actionManager.renderAction("undo")} - {actionManager.renderAction("redo")} -
+
+ {topLeftUI} + {topRightUI}
); }; + const renderToolbar = () => { + return ( + + ); + }; + return ( <> {renderSidebars()} - {!appState.viewModeEnabled && - appState.openDialog?.name !== "elementLinkSelector" && - renderToolbar()} + {/* welcome screen, bottom bar, and top bar all have the same z-index */} + {/* ordered in this reverse order so that top bar is on top */} +
+ {renderWelcomeScreen && } +
+
- - {appState.openMenu === "shape" && - !appState.viewModeEnabled && - appState.openDialog?.name !== "elementLinkSelector" && - showSelectedShapeActions(appState, elements) ? ( -
- -
- ) : null} -
- {renderAppToolbar()} - {appState.scrolledOutside && - !appState.openMenu && - !appState.openSidebar && ( - - )} -
+ + + + {!appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && + renderToolbar()} + {appState.scrolledOutside && + !appState.openMenu && + !appState.openSidebar && ( + + )}
+ + + {renderAppTopBar()} + ); }; diff --git a/packages/excalidraw/components/MobileToolBar.scss b/packages/excalidraw/components/MobileToolBar.scss new file mode 100644 index 0000000000..b936c70ebd --- /dev/null +++ b/packages/excalidraw/components/MobileToolBar.scss @@ -0,0 +1,78 @@ +@import "open-color/open-color.scss"; +@import "../css/variables.module.scss"; + +.excalidraw { + .mobile-toolbar { + display: flex; + flex: 1; + align-items: center; + padding: 0px; + gap: 4px; + border-radius: var(--space-factor); + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; + justify-content: space-between; + } + + .mobile-toolbar::-webkit-scrollbar { + display: none; + } + + .mobile-toolbar .ToolIcon { + min-width: 2rem; + min-height: 2rem; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + .ToolIcon__icon { + width: 2.25rem; + height: 2.25rem; + + &:hover { + background-color: transparent; + } + } + + &.active { + background: var( + --color-surface-primary-container, + var(--island-bg-color) + ); + border-color: var(--button-active-border, var(--color-primary-darkest)); + } + + svg { + width: 1rem; + height: 1rem; + } + } + + .mobile-toolbar .App-toolbar__extra-tools-dropdown { + min-width: 160px; + z-index: var(--zIndex-layerUI); + } + + .mobile-toolbar-separator { + width: 1px; + height: 24px; + background: var(--default-border-color); + margin: 0 2px; + flex-shrink: 0; + } + + .mobile-toolbar-undo { + display: flex; + align-items: center; + } + + .mobile-toolbar-undo .ToolIcon { + min-width: 32px; + min-height: 32px; + width: 32px; + height: 32px; + } +} diff --git a/packages/excalidraw/components/MobileToolBar.tsx b/packages/excalidraw/components/MobileToolBar.tsx new file mode 100644 index 0000000000..093cbd2630 --- /dev/null +++ b/packages/excalidraw/components/MobileToolBar.tsx @@ -0,0 +1,471 @@ +import { useState, useEffect, useRef } from "react"; +import clsx from "clsx"; + +import { KEYS, capitalizeString } from "@excalidraw/common"; + +import { trackEvent } from "../analytics"; + +import { t } from "../i18n"; + +import { isHandToolActive } from "../appState"; + +import { useTunnels } from "../context/tunnels"; + +import { HandButton } from "./HandButton"; +import { ToolButton } from "./ToolButton"; +import DropdownMenu from "./dropdownMenu/DropdownMenu"; +import { ToolPopover } from "./ToolPopover"; + +import { + SelectionIcon, + FreedrawIcon, + EraserIcon, + RectangleIcon, + ArrowIcon, + extraToolsIcon, + DiamondIcon, + EllipseIcon, + LineIcon, + TextIcon, + ImageIcon, + frameToolIcon, + EmbedIcon, + laserPointerToolIcon, + LassoIcon, + mermaidLogoIcon, + MagicIcon, +} from "./icons"; + +import "./ToolIcon.scss"; +import "./MobileToolBar.scss"; + +import type { AppClassProperties, ToolType, UIAppState } from "../types"; + +const SHAPE_TOOLS = [ + { + type: "rectangle", + icon: RectangleIcon, + title: capitalizeString(t("toolBar.rectangle")), + }, + { + type: "diamond", + icon: DiamondIcon, + title: capitalizeString(t("toolBar.diamond")), + }, + { + type: "ellipse", + icon: EllipseIcon, + title: capitalizeString(t("toolBar.ellipse")), + }, +] as const; + +const SELECTION_TOOLS = [ + { + type: "selection", + icon: SelectionIcon, + title: capitalizeString(t("toolBar.selection")), + }, + { + type: "lasso", + icon: LassoIcon, + title: capitalizeString(t("toolBar.lasso")), + }, +] as const; + +const LINEAR_ELEMENT_TOOLS = [ + { + type: "arrow", + icon: ArrowIcon, + title: capitalizeString(t("toolBar.arrow")), + }, + { type: "line", icon: LineIcon, title: capitalizeString(t("toolBar.line")) }, +] as const; + +type MobileToolBarProps = { + app: AppClassProperties; + onHandToolToggle: () => void; + setAppState: React.Component["setState"]; +}; + +export const MobileToolBar = ({ + app, + onHandToolToggle, + setAppState, +}: MobileToolBarProps) => { + const activeTool = app.state.activeTool; + const [isOtherShapesMenuOpen, setIsOtherShapesMenuOpen] = useState(false); + const [lastActiveGenericShape, setLastActiveGenericShape] = useState< + "rectangle" | "diamond" | "ellipse" + >("rectangle"); + const [lastActiveLinearElement, setLastActiveLinearElement] = useState< + "arrow" | "line" + >("arrow"); + + const toolbarRef = useRef(null); + + // keep lastActiveGenericShape in sync with active tool if user switches via other UI + useEffect(() => { + if ( + activeTool.type === "rectangle" || + activeTool.type === "diamond" || + activeTool.type === "ellipse" + ) { + setLastActiveGenericShape(activeTool.type); + } + }, [activeTool.type]); + + // keep lastActiveLinearElement in sync with active tool if user switches via other UI + useEffect(() => { + if (activeTool.type === "arrow" || activeTool.type === "line") { + setLastActiveLinearElement(activeTool.type); + } + }, [activeTool.type]); + + const frameToolSelected = activeTool.type === "frame"; + const laserToolSelected = activeTool.type === "laser"; + const embeddableToolSelected = activeTool.type === "embeddable"; + + const { TTDDialogTriggerTunnel } = useTunnels(); + + const handleToolChange = (toolType: string, pointerType?: string) => { + if (app.state.activeTool.type !== toolType) { + trackEvent("toolbar", toolType, "ui"); + } + + if (toolType === "selection") { + if (app.state.activeTool.type === "selection") { + // Toggle selection tool behavior if needed + } else { + app.setActiveTool({ type: "selection" }); + } + } else { + app.setActiveTool({ type: toolType as ToolType }); + } + }; + + const toolbarWidth = + toolbarRef.current?.getBoundingClientRect()?.width ?? 0 - 8; + const WIDTH = 36; + const GAP = 4; + + // hand, selection, freedraw, eraser, rectangle, arrow, others + const MIN_TOOLS = 7; + const MIN_WIDTH = MIN_TOOLS * WIDTH + (MIN_TOOLS - 1) * GAP; + const ADDITIONAL_WIDTH = WIDTH + GAP; + + const showTextToolOutside = toolbarWidth >= MIN_WIDTH + 1 * ADDITIONAL_WIDTH; + const showImageToolOutside = toolbarWidth >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH; + const showFrameToolOutside = toolbarWidth >= MIN_WIDTH + 3 * ADDITIONAL_WIDTH; + + const extraTools = [ + "text", + "frame", + "embeddable", + "laser", + "magicframe", + ].filter((tool) => { + if (showImageToolOutside && tool === "image") { + return false; + } + if (showFrameToolOutside && tool === "frame") { + return false; + } + return true; + }); + const extraToolSelected = extraTools.includes(activeTool.type); + const extraIcon = extraToolSelected + ? activeTool.type === "frame" + ? frameToolIcon + : activeTool.type === "embeddable" + ? EmbedIcon + : activeTool.type === "laser" + ? laserPointerToolIcon + : activeTool.type === "text" + ? TextIcon + : activeTool.type === "magicframe" + ? MagicIcon + : extraToolsIcon + : extraToolsIcon; + + return ( +
+ {/* Hand Tool */} + + + {/* Selection Tool */} + { + if (type === "selection" || type === "lasso") { + app.setActiveTool({ type }); + setAppState({ + preferredSelectionTool: { type, initialized: true }, + }); + } + }} + displayedOption={ + SELECTION_TOOLS.find( + (tool) => tool.type === app.state.preferredSelectionTool.type, + ) || SELECTION_TOOLS[0] + } + /> + + {/* Free Draw */} + handleToolChange("freedraw")} + /> + + {/* Eraser */} + handleToolChange("eraser")} + /> + + {/* Rectangle */} + { + if ( + type === "rectangle" || + type === "diamond" || + type === "ellipse" + ) { + setLastActiveGenericShape(type); + app.setActiveTool({ type }); + } + }} + displayedOption={ + SHAPE_TOOLS.find((tool) => tool.type === lastActiveGenericShape) || + SHAPE_TOOLS[0] + } + /> + + {/* Arrow/Line */} + { + if (type === "arrow" || type === "line") { + setLastActiveLinearElement(type); + app.setActiveTool({ type }); + } + }} + displayedOption={ + LINEAR_ELEMENT_TOOLS.find( + (tool) => tool.type === lastActiveLinearElement, + ) || LINEAR_ELEMENT_TOOLS[0] + } + /> + + {/* Text Tool */} + {showTextToolOutside && ( + handleToolChange("text")} + /> + )} + + {/* Image */} + {showImageToolOutside && ( + handleToolChange("image")} + /> + )} + + {/* Frame Tool */} + {showFrameToolOutside && ( + handleToolChange("frame")} + /> + )} + + {/* Other Shapes */} + + setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen)} + title={t("toolBar.extraTools")} + style={{ + width: WIDTH, + height: WIDTH, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + {extraIcon} + + setIsOtherShapesMenuOpen(false)} + onSelect={() => setIsOtherShapesMenuOpen(false)} + className="App-toolbar__extra-tools-dropdown" + > + {!showTextToolOutside && ( + app.setActiveTool({ type: "text" })} + icon={TextIcon} + shortcut={KEYS.T.toLocaleUpperCase()} + data-testid="toolbar-text" + selected={activeTool.type === "text"} + > + {t("toolBar.text")} + + )} + + {!showImageToolOutside && ( + app.setActiveTool({ type: "image" })} + icon={ImageIcon} + data-testid="toolbar-image" + selected={activeTool.type === "image"} + > + {t("toolBar.image")} + + )} + {!showFrameToolOutside && ( + app.setActiveTool({ type: "frame" })} + icon={frameToolIcon} + shortcut={KEYS.F.toLocaleUpperCase()} + data-testid="toolbar-frame" + selected={frameToolSelected} + > + {t("toolBar.frame")} + + )} + app.setActiveTool({ type: "embeddable" })} + icon={EmbedIcon} + data-testid="toolbar-embeddable" + selected={embeddableToolSelected} + > + {t("toolBar.embeddable")} + + app.setActiveTool({ type: "laser" })} + icon={laserPointerToolIcon} + data-testid="toolbar-laser" + selected={laserToolSelected} + shortcut={KEYS.K.toLocaleUpperCase()} + > + {t("toolBar.laser")} + +
+ Generate +
+ {app.props.aiEnabled !== false && } + app.setOpenDialog({ name: "ttd", tab: "mermaid" })} + icon={mermaidLogoIcon} + data-testid="toolbar-embeddable" + > + {t("toolBar.mermaidToExcalidraw")} + + {app.props.aiEnabled !== false && app.plugins.diagramToCode && ( + <> + app.onMagicframeToolSelect()} + icon={MagicIcon} + data-testid="toolbar-magicframe" + > + {t("toolBar.magicframe")} + AI + + + )} +
+
+
+ ); +}; diff --git a/packages/excalidraw/components/ToolPopover.scss b/packages/excalidraw/components/ToolPopover.scss new file mode 100644 index 0000000000..d049704bb7 --- /dev/null +++ b/packages/excalidraw/components/ToolPopover.scss @@ -0,0 +1,18 @@ +@import "../css/variables.module.scss"; + +.excalidraw { + .tool-popover-content { + display: flex; + flex-direction: row; + gap: 0.25rem; + border-radius: 0.5rem; + background: var(--island-bg-color); + box-shadow: var(--shadow-island); + padding: 0.5rem; + z-index: var(--zIndex-layerUI); + } + + &:focus { + outline: none; + } +} diff --git a/packages/excalidraw/components/ToolPopover.tsx b/packages/excalidraw/components/ToolPopover.tsx new file mode 100644 index 0000000000..81d5726d5a --- /dev/null +++ b/packages/excalidraw/components/ToolPopover.tsx @@ -0,0 +1,120 @@ +import React, { useEffect, useState } from "react"; +import clsx from "clsx"; + +import { capitalizeString } from "@excalidraw/common"; + +import * as Popover from "@radix-ui/react-popover"; + +import { trackEvent } from "../analytics"; + +import { ToolButton } from "./ToolButton"; + +import "./ToolPopover.scss"; + +import type { AppClassProperties } from "../types"; + +type ToolOption = { + type: string; + icon: React.ReactNode; + title?: string; +}; + +type ToolPopoverProps = { + app: AppClassProperties; + options: readonly ToolOption[]; + activeTool: { type: string }; + defaultOption: string; + className?: string; + namePrefix: string; + title: string; + "data-testid": string; + onToolChange: (type: string) => void; + displayedOption: ToolOption; + fillable?: boolean; +}; + +export const ToolPopover = ({ + app, + options, + activeTool, + defaultOption, + className = "Shape", + namePrefix, + title, + "data-testid": dataTestId, + onToolChange, + displayedOption, + fillable = false, +}: ToolPopoverProps) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + const currentType = activeTool.type; + const isActive = displayedOption.type === currentType; + const SIDE_OFFSET = 32 / 2 + 10; + + // if currentType is not in options, close popup + if (!options.some((o) => o.type === currentType) && isPopupOpen) { + setIsPopupOpen(false); + } + + // Close popover when user starts interacting with the canvas (pointer down) + useEffect(() => { + // app.onPointerDownEmitter emits when pointer down happens on canvas area + const unsubscribe = app.onPointerDownEmitter.on(() => { + setIsPopupOpen(false); + }); + return () => unsubscribe?.(); + }, [app]); + + return ( + + + o.type === activeTool.type), + })} + type="radio" + icon={displayedOption.icon} + checked={isActive} + name="editor-current-shape" + title={title} + aria-label={title} + data-testid={dataTestId} + onPointerDown={() => { + setIsPopupOpen((v) => !v); + onToolChange(defaultOption); + }} + /> + + + + {options.map(({ type, icon, title }) => ( + { + if (app.state.activeTool.type !== type) { + trackEvent("toolbar", type, "ui"); + } + app.setActiveTool({ type: type as any }); + onToolChange?.(type); + }} + /> + ))} + + + ); +}; diff --git a/packages/excalidraw/components/Toolbar.scss b/packages/excalidraw/components/Toolbar.scss index 14c4cc174b..3919176bbb 100644 --- a/packages/excalidraw/components/Toolbar.scss +++ b/packages/excalidraw/components/Toolbar.scss @@ -44,6 +44,10 @@ var(--button-active-border, var(--color-primary-darkest)) inset; } + &:hover { + background-color: transparent; + } + &--selected, &--selected:hover { background: var(--color-primary-light); diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index 95d258c46b..a0a230941d 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -3,24 +3,46 @@ .excalidraw { .dropdown-menu { position: absolute; - top: 100%; + top: 2.5rem; margin-top: 0.5rem; + &--placement-top { + top: auto; + bottom: 100%; + margin-top: 0; + margin-bottom: 0.5rem; + } + &--mobile { - left: 0; width: 100%; row-gap: 0.75rem; + // When main menu is in the top toolbar, position relative to trigger + &.main-menu-dropdown { + min-width: 232px; + max-width: calc(100vw - var(--editor-container-padding) * 2); + margin-top: 0; + margin-bottom: 0; + z-index: var(--zIndex-layerUI); + + @media screen and (orientation: landscape) { + max-width: 232px; + } + } + .dropdown-menu-container { padding: 8px 8px; box-sizing: border-box; - // background-color: var(--island-bg-color); + max-height: calc( + 100svh - var(--editor-container-padding) * 2 - 2.25rem + ); box-shadow: var(--shadow-island); border-radius: var(--border-radius-lg); position: relative; transition: box-shadow 0.5s ease-in-out; display: flex; flex-direction: column; + overflow-y: auto; &.zen-mode { box-shadow: none; @@ -30,7 +52,7 @@ .dropdown-menu-container { background-color: var(--island-bg-color); - max-height: calc(100vh - 150px); + overflow-y: auto; --gap: 2; } diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx index e1412e20b1..761d09b3f9 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx @@ -17,16 +17,27 @@ import "./DropdownMenu.scss"; const DropdownMenu = ({ children, open, + placement, }: { children?: React.ReactNode; open: boolean; + placement?: "top" | "bottom"; }) => { const MenuTriggerComp = getMenuTriggerComponent(children); const MenuContentComp = getMenuContentComponent(children); + + // clone the MenuContentComp to pass the placement prop + const MenuContentCompWithPlacement = + MenuContentComp && React.isValidElement(MenuContentComp) + ? React.cloneElement(MenuContentComp as React.ReactElement, { + placement, + }) + : MenuContentComp; + return ( <> {MenuTriggerComp} - {open && MenuContentComp} + {open && MenuContentCompWithPlacement} ); }; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx index de6fc31c18..291f857e80 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx @@ -17,6 +17,7 @@ const MenuContent = ({ className = "", onSelect, style, + placement = "bottom", }: { children?: React.ReactNode; onClickOutside?: () => void; @@ -26,6 +27,7 @@ const MenuContent = ({ */ onSelect?: (event: Event) => void; style?: React.CSSProperties; + placement?: "top" | "bottom"; }) => { const device = useDevice(); const menuRef = useRef(null); @@ -58,6 +60,7 @@ const MenuContent = ({ const classNames = clsx(`dropdown-menu ${className}`, { "dropdown-menu--mobile": device.editor.isMobile, + "dropdown-menu--placement-top": placement === "top", }).trim(); return ( diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 33e59380c7..3f6c4d1bb1 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -2319,22 +2319,10 @@ export const adjustmentsIcon = createIcon( tablerIconProps, ); -export const backgroundIcon = createIcon( - - - - - - - - , - tablerIconProps, -); - export const strokeIcon = createIcon( - + , tablerIconProps, ); diff --git a/packages/excalidraw/components/main-menu/MainMenu.tsx b/packages/excalidraw/components/main-menu/MainMenu.tsx index 7c2b5fb4a1..8ce2a5d69b 100644 --- a/packages/excalidraw/components/main-menu/MainMenu.tsx +++ b/packages/excalidraw/components/main-menu/MainMenu.tsx @@ -53,6 +53,8 @@ const MainMenu = Object.assign( onSelect={composeEventHandlers(onSelect, () => { setAppState({ openMenu: null }); })} + placement="bottom" + className={device.editor.isMobile ? "main-menu-dropdown" : ""} > {children} {device.editor.isMobile && appState.collaborators.size > 0 && ( diff --git a/packages/excalidraw/components/shapes.tsx b/packages/excalidraw/components/shapes.tsx index 56c85bcd42..d46f08a311 100644 --- a/packages/excalidraw/components/shapes.tsx +++ b/packages/excalidraw/components/shapes.tsx @@ -89,7 +89,7 @@ export const SHAPES = [ ] as const; export const getToolbarTools = (app: AppClassProperties) => { - return app.defaultSelectionTool === "lasso" + return app.state.preferredSelectionTool.type === "lasso" ? ([ { value: "lasso", diff --git a/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss b/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss index 8e3a010309..96f1ca2df3 100644 --- a/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss +++ b/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss @@ -252,16 +252,12 @@ } } - @media (max-height: 599px) { + &.excalidraw--mobile { .welcome-screen-center { - margin-top: 4rem; - } - } - @media (min-height: 600px) and (max-height: 900px) { - .welcome-screen-center { - margin-top: 8rem; + margin-bottom: 2rem; } } + @media (max-height: 500px), (max-width: 320px) { .welcome-screen-center { display: none; diff --git a/packages/excalidraw/css/styles.scss b/packages/excalidraw/css/styles.scss index 2169696ae0..679a5c4cd1 100644 --- a/packages/excalidraw/css/styles.scss +++ b/packages/excalidraw/css/styles.scss @@ -44,6 +44,11 @@ body.excalidraw-cursor-resize * { height: 100%; width: 100%; + button, + label { + @include buttonNoHighlight; + } + button { cursor: pointer; user-select: none; @@ -235,27 +240,32 @@ body.excalidraw-cursor-resize * { z-index: var(--zIndex-layerUI); display: flex; flex-direction: column; - align-items: center; + } + + .App-welcome-screen { + z-index: var(--zIndex-layerUI); } .App-bottom-bar { position: absolute; - top: 0; + // account for margins + width: calc(100% - 28px); + max-width: 450px; bottom: 0; - left: 0; - right: 0; + left: 50%; + transform: translateX(-50%); --bar-padding: calc(4 * var(--space-factor)); - z-index: 4; + z-index: var(--zIndex-layerUI); display: flex; - align-items: flex-end; + flex-direction: column; + pointer-events: none; + justify-content: center; > .Island { - width: 100%; - max-width: 100%; - min-width: 100%; box-sizing: border-box; max-height: 100%; + padding: 4px; display: flex; flex-direction: column; pointer-events: var(--ui-pointerEvents); @@ -263,7 +273,8 @@ body.excalidraw-cursor-resize * { } .App-toolbar { - width: 100%; + display: flex; + justify-content: center; .eraser { &.ToolIcon:hover { @@ -276,16 +287,15 @@ body.excalidraw-cursor-resize * { } } - .App-toolbar-content { + .excalidraw-ui-top-left { display: flex; align-items: center; - justify-content: space-between; - padding: 8px; + gap: 0.5rem; + } - .dropdown-menu--mobile { - bottom: 55px; - top: auto; - } + .App-toolbar-content { + display: flex; + flex-direction: column; } .App-mobile-menu { @@ -506,7 +516,7 @@ body.excalidraw-cursor-resize * { display: none; } .scroll-back-to-content { - bottom: calc(80px + var(--sab, 0)); + bottom: calc(100px + var(--sab, 0)); z-index: -1; } } diff --git a/packages/excalidraw/css/theme.scss b/packages/excalidraw/css/theme.scss index 1d6a569665..223cd8eb6e 100644 --- a/packages/excalidraw/css/theme.scss +++ b/packages/excalidraw/css/theme.scss @@ -8,6 +8,8 @@ --button-gray-1: #{$oc-gray-2}; --button-gray-2: #{$oc-gray-4}; --button-gray-3: #{$oc-gray-5}; + --mobile-action-button-bg: rgba(255, 255, 255, 0.35); + --mobile-color-border: var(--default-border-color); --button-special-active-bg-color: #{$oc-green-0}; --dialog-border-color: var(--color-gray-20); --dropdown-icon: url('data:image/svg+xml,'); @@ -42,6 +44,11 @@ --lg-button-size: 2.25rem; --lg-icon-size: 1rem; --editor-container-padding: 1rem; + --mobile-action-button-size: 2rem; + + @include isMobile { + --editor-container-padding: 0.75rem; + } @media screen and (min-device-width: 1921px) { --lg-button-size: 2.5rem; @@ -177,6 +184,8 @@ --button-gray-1: #363636; --button-gray-2: #272727; --button-gray-3: #222; + --mobile-action-button-bg: var(--island-bg-color); + --mobile-color-border: rgba(255, 255, 255, 0.85); --button-special-active-bg-color: #204624; --dialog-border-color: var(--color-gray-80); --dropdown-icon: url('data:image/svg+xml,'); diff --git a/packages/excalidraw/css/variables.module.scss b/packages/excalidraw/css/variables.module.scss index c360c0dc6b..15d0768adb 100644 --- a/packages/excalidraw/css/variables.module.scss +++ b/packages/excalidraw/css/variables.module.scss @@ -122,6 +122,17 @@ color: var(--button-color, var(--color-on-primary-container)); } } + + @include isMobile() { + width: var(--mobile-action-button-size, var(--default-button-size)); + height: var(--mobile-action-button-size, var(--default-button-size)); + } +} + +@mixin buttonNoHighlight { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + user-select: none; } @mixin outlineButtonIconStyles { @@ -187,4 +198,9 @@ &:active { box-shadow: 0 0 0 1px var(--color-brand-active); } + + @include isMobile() { + width: var(--mobile-action-button-size, 2rem); + height: var(--mobile-action-button-size, 2rem); + } } diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 1b1f830439..1d599a98ec 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -28,6 +28,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { excalidrawAPI, isCollaborating = false, onPointerUpdate, + renderTopLeftUI, renderTopRightUI, langCode = defaultLang.code, viewModeEnabled, @@ -120,6 +121,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { excalidrawAPI={excalidrawAPI} isCollaborating={isCollaborating} onPointerUpdate={onPointerUpdate} + renderTopLeftUI={renderTopLeftUI} renderTopRightUI={renderTopRightUI} langCode={langCode} viewModeEnabled={viewModeEnabled} diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index da94b4731f..6f4f6fd559 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -956,6 +956,10 @@ exports[`contextMenu element > right-clicking on a group should select whole gro }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -1151,6 +1155,10 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -1364,6 +1372,10 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -1694,6 +1706,10 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -2024,6 +2040,10 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -2237,6 +2257,10 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -2477,6 +2501,10 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -2774,6 +2802,10 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id3": true, }, @@ -3145,6 +3177,10 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -3637,6 +3673,10 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -3959,6 +3999,10 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -4281,6 +4325,10 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id3": true, }, @@ -5565,6 +5613,10 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -6781,6 +6833,10 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -7718,6 +7774,10 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -8714,6 +8774,10 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -9707,6 +9771,10 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 8e0b5dabe0..d436af1375 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -78,6 +78,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id4": true, }, @@ -693,6 +697,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id4": true, }, @@ -1181,6 +1189,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -1544,6 +1556,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -1910,6 +1926,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -2169,6 +2189,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -2613,6 +2637,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -2915,6 +2943,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -3233,6 +3265,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -3526,6 +3562,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -3811,6 +3851,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -4045,6 +4089,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -4301,6 +4349,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -4571,6 +4623,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -4799,6 +4855,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -5027,6 +5087,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -5273,6 +5337,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -5528,6 +5596,10 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -5782,6 +5854,10 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id1": true, }, @@ -6101,7 +6177,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "offsetTop": 0, "openDialog": null, "openMenu": null, - "openPopup": "elementBackground", + "openPopup": null, "openSidebar": null, "originSnapOffset": null, "pasteDialog": { @@ -6110,6 +6186,10 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id8": true, }, @@ -6536,6 +6616,10 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id1": true, }, @@ -6912,6 +6996,10 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -7220,6 +7308,10 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -7535,6 +7627,10 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -7764,6 +7860,10 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -8115,6 +8215,10 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -8466,6 +8570,10 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, "id3": true, @@ -8871,6 +8979,10 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -9157,6 +9269,10 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -9420,6 +9536,10 @@ exports[`history > multiplayer undo/redo > should not override remote changes on }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -9684,6 +9804,10 @@ exports[`history > multiplayer undo/redo > should not override remote changes on }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -9918,6 +10042,10 @@ exports[`history > multiplayer undo/redo > should override remotely added groups }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -10211,6 +10339,10 @@ exports[`history > multiplayer undo/redo > should override remotely added points }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -10559,6 +10691,10 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -10797,6 +10933,10 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -11241,6 +11381,10 @@ exports[`history > multiplayer undo/redo > should update history entries after r }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -11500,6 +11644,10 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -11734,6 +11882,10 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -11961,7 +12113,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "offsetTop": 0, "openDialog": null, "openMenu": null, - "openPopup": "elementStroke", + "openPopup": null, "openSidebar": null, "originSnapOffset": null, "pasteDialog": { @@ -11970,6 +12122,10 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -12375,6 +12531,10 @@ exports[`history > singleplayer undo/redo > should create new history entry on e }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -12581,6 +12741,10 @@ exports[`history > singleplayer undo/redo > should create new history entry on e }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -12790,6 +12954,10 @@ exports[`history > singleplayer undo/redo > should create new history entry on i }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -13087,6 +13255,10 @@ exports[`history > singleplayer undo/redo > should create new history entry on i }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -13387,6 +13559,10 @@ exports[`history > singleplayer undo/redo > should create new history entry on s }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": -50, @@ -13628,6 +13804,10 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -13864,6 +14044,10 @@ exports[`history > singleplayer undo/redo > should end up with no history entry }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -14100,6 +14284,10 @@ exports[`history > singleplayer undo/redo > should iterate through the history w }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -14346,6 +14534,10 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -14679,6 +14871,10 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -14845,6 +15041,10 @@ exports[`history > singleplayer undo/redo > should not end up with history entry }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -15131,6 +15331,10 @@ exports[`history > singleplayer undo/redo > should not end up with history entry }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -15393,6 +15597,10 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -15533,7 +15741,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "offsetTop": 0, "openDialog": null, "openMenu": null, - "openPopup": "elementBackground", + "openPopup": null, "openSidebar": null, "originSnapOffset": null, "pasteDialog": { @@ -15542,6 +15750,10 @@ exports[`history > singleplayer undo/redo > should not override appstate changes }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -15826,6 +16038,10 @@ exports[`history > singleplayer undo/redo > should support appstate name or view }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -15984,6 +16200,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -16688,6 +16908,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -17322,6 +17546,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -17956,6 +18184,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -18674,6 +18906,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -19424,6 +19660,10 @@ exports[`history > singleplayer undo/redo > should support changes in elements' }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -19903,6 +20143,10 @@ exports[`history > singleplayer undo/redo > should support duplication of groups }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id1": true, }, @@ -20413,6 +20657,10 @@ exports[`history > singleplayer undo/redo > should support element creation, del }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id3": true, }, @@ -20871,6 +21119,10 @@ exports[`history > singleplayer undo/redo > should support linear element creati }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 761fbc54d9..a33ca9c963 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -79,6 +79,10 @@ exports[`given element A and group of elements B and given both are selected whe }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, "id3": true, @@ -504,6 +508,10 @@ exports[`given element A and group of elements B and given both are selected whe }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, "id3": true, @@ -919,6 +927,10 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -1484,6 +1496,10 @@ exports[`regression tests > Drags selected element when hitting only bounding bo }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -1690,6 +1706,10 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -2073,6 +2093,10 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -2317,6 +2341,10 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -2496,6 +2524,10 @@ exports[`regression tests > can drag element that covers another element, while }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id6": true, }, @@ -2820,6 +2852,10 @@ exports[`regression tests > change the properties of a shape > [end of test] app }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -3074,6 +3110,10 @@ exports[`regression tests > click on an element and drag it > [dragged] appState }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -3314,6 +3354,10 @@ exports[`regression tests > click on an element and drag it > [end of test] appS }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -3549,6 +3593,10 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id3": true, }, @@ -3806,6 +3854,10 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id6": true, }, @@ -4119,6 +4171,10 @@ exports[`regression tests > deleting last but one element in editing group shoul }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -4554,6 +4610,10 @@ exports[`regression tests > deselects group of selected elements on pointer down }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, "id3": true, @@ -4836,6 +4896,10 @@ exports[`regression tests > deselects group of selected elements on pointer up w }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, "id3": true, @@ -5111,6 +5175,10 @@ exports[`regression tests > deselects selected element on pointer down when poin }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -5318,6 +5386,10 @@ exports[`regression tests > deselects selected element, on pointer up, when clic }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -5517,6 +5589,10 @@ exports[`regression tests > double click to edit a group > [end of test] appStat }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -5909,6 +5985,10 @@ exports[`regression tests > drags selected elements from point inside common bou }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, "id3": true, @@ -6205,6 +6285,10 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -7060,6 +7144,10 @@ exports[`regression tests > given a group of selected elements with an element t }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, "id6": true, @@ -7384,7 +7472,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "offsetTop": 0, "openDialog": null, "openMenu": null, - "openPopup": "elementBackground", + "openPopup": null, "openSidebar": null, "originSnapOffset": null, "pasteDialog": { @@ -7393,6 +7481,10 @@ exports[`regression tests > given a selected element A and a not selected elemen }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -7671,6 +7763,10 @@ exports[`regression tests > given selected element A with lower z-index than uns }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -7905,6 +8001,10 @@ exports[`regression tests > given selected element A with lower z-index than uns }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -8144,6 +8244,10 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -8323,6 +8427,10 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -8502,6 +8610,10 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -8681,6 +8793,10 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -8910,6 +9026,10 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -9137,6 +9257,10 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -9332,6 +9456,10 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -9561,6 +9689,10 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -9740,6 +9872,10 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -9967,6 +10103,10 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -10146,6 +10286,10 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -10341,6 +10485,10 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -10520,6 +10668,10 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, "id3": true, @@ -11050,6 +11202,10 @@ exports[`regression tests > noop interaction after undo shouldn't create history }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -11329,6 +11485,10 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": "-6.25000", @@ -11451,6 +11611,10 @@ exports[`regression tests > shift click on selected element should deselect it o }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -11650,6 +11814,10 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, "id3": true, @@ -11968,6 +12136,10 @@ exports[`regression tests > should group elements and ungroup them > [end of tes }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, "id3": true, @@ -12396,6 +12568,10 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, "id15": true, @@ -13038,6 +13214,10 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 60, @@ -13160,6 +13340,10 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -13790,6 +13974,10 @@ exports[`regression tests > switches from group of selected elements to another }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id3": true, "id6": true, @@ -14128,6 +14316,10 @@ exports[`regression tests > switches selected element on pointer down > [end of }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id3": true, }, @@ -14391,6 +14583,10 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 20, @@ -14513,6 +14709,10 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -14904,6 +15104,10 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -15029,6 +15233,10 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 65f330ae24..3b4d2eb478 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -316,6 +316,10 @@ export interface AppState { // indicates if the current tool is temporarily switched on from the selection tool fromSelection: boolean; } & ActiveTool; + preferredSelectionTool: { + type: "selection" | "lasso"; + initialized: boolean; + }; penMode: boolean; penDetected: boolean; exportBackground: boolean; @@ -364,7 +368,6 @@ export interface AppState { | { name: "ttd"; tab: "text-to-diagram" | "mermaid" } | { name: "commandPalette" } | { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] }; - /** * Reflects user preference for whether the default sidebar should be docked. * @@ -448,7 +451,7 @@ export interface AppState { lockedMultiSelections: { [groupId: string]: true }; /** properties sidebar mode - determines whether to show compact or complete sidebar */ - stylesPanelMode: "compact" | "full"; + stylesPanelMode: "compact" | "full" | "mobile"; } export type SearchMatch = { @@ -571,6 +574,10 @@ export interface ExcalidrawProps { /** excludes the duplicated elements */ prevElements: readonly ExcalidrawElement[], ) => ExcalidrawElement[] | void; + renderTopLeftUI?: ( + isMobile: boolean, + appState: UIAppState, + ) => JSX.Element | null; renderTopRightUI?: ( isMobile: boolean, appState: UIAppState, @@ -738,8 +745,7 @@ export type AppClassProperties = { onPointerUpEmitter: App["onPointerUpEmitter"]; updateEditorAtom: App["updateEditorAtom"]; - - defaultSelectionTool: "selection" | "lasso"; + onPointerDownEmitter: App["onPointerDownEmitter"]; }; export type PointerDownState = Readonly<{ diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index 20f3ee28d9..1f799501c9 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -80,6 +80,10 @@ exports[`exportToSvg > with default arguments 1`] = ` }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": false, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, From 19b03b4ca9d9dbbd8d3246d99490dadf57e4c507 Mon Sep 17 00:00:00 2001 From: Omar Brikaa Date: Fri, 10 Oct 2025 19:12:08 +0300 Subject: [PATCH 19/27] fix: remove redundant selectionStart/End resetting that causes scroll-reset bug on firefox (#8263) Remove redundant selectionStart/End resetting that causes scroll-reset bug on firefox --- packages/excalidraw/wysiwyg/textWysiwyg.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index adede07f87..149faf8987 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -226,22 +226,6 @@ export const textWysiwyg = ({ } } const [viewportX, viewportY] = getViewportCoords(coordX, coordY); - const initialSelectionStart = editable.selectionStart; - const initialSelectionEnd = editable.selectionEnd; - const initialLength = editable.value.length; - - // restore cursor position after value updated so it doesn't - // go to the end of text when container auto expanded - if ( - initialSelectionStart === initialSelectionEnd && - initialSelectionEnd !== initialLength - ) { - // get diff between length and selection end and shift - // the cursor by "diff" times to position correctly - const diff = initialLength - initialSelectionEnd; - editable.selectionStart = editable.value.length - diff; - editable.selectionEnd = editable.value.length - diff; - } if (!container) { maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; From 8608d7b2e000ac5f756c263e2c0d04e3f93485c4 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:33:02 +0200 Subject: [PATCH 20/27] fix: revert preferred selection to box once you switch to `full` UI (#10160) --- packages/excalidraw/components/App.tsx | 33 +++++++++++++++++--------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index c74ef73b52..574ec4eb91 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2487,18 +2487,29 @@ class App extends React.Component { canFitSidebar: editorWidth > sidebarBreakpoint, }); + const stylesPanelMode = + // NOTE: we could also remove the isMobileOrTablet check here and + // always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP) + // but not too narrow (> MQ_MAX_WIDTH_MOBILE) + this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet() + ? "compact" + : this.isMobileBreakpoint(editorWidth, editorHeight) + ? "mobile" + : "full"; + // also check if we need to update the app state - this.setState({ - stylesPanelMode: - // NOTE: we could also remove the isMobileOrTablet check here and - // always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP) - // but not too narrow (> MQ_MAX_WIDTH_MOBILE) - this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet() - ? "compact" - : this.isMobileBreakpoint(editorWidth, editorHeight) - ? "mobile" - : "full", - }); + this.setState((prevState) => ({ + stylesPanelMode, + // reset to box selection mode if the UI changes to full + // where you'd not be able to change the mode yourself currently + preferredSelectionTool: + stylesPanelMode === "full" + ? { + type: "selection", + initialized: true, + } + : prevState.preferredSelectionTool, + })); if (prevEditorState !== nextEditorState) { this.device = { ...this.device, editor: nextEditorState }; From 5fffc4743fecb89eeb549c8cf4fb4b9ffd05c703 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:34:49 +0200 Subject: [PATCH 21/27] fix: mobile UI and other fixes (#10177) * remove legacy openMenu=shape state and unused actions * close menus/popups in applicable cases when opening a different one * split ui z-indexes to account prefer different overlap * make top canvas area clickable on mobile * make mobile main menu closable by clicking outside and reduce width * offset picker popups from viewport border on mobile * reduce items gap in mobile main menu * show top picks for canvas bg colors in all ui modes * fix menu separator visibility on mobile * fix command palette items not being filtered --- packages/excalidraw/actions/actionMenu.tsx | 58 +------------------ packages/excalidraw/actions/index.ts | 6 +- packages/excalidraw/actions/types.ts | 2 - packages/excalidraw/components/Actions.tsx | 5 +- .../components/ColorPicker/ColorPicker.tsx | 5 +- .../CommandPalette/CommandPalette.tsx | 9 ++- packages/excalidraw/components/IconPicker.tsx | 2 +- .../excalidraw/components/LibraryMenu.scss | 1 + .../excalidraw/components/MobileToolBar.tsx | 5 +- .../components/PropertiesPopover.tsx | 3 +- .../components/Sidebar/Sidebar.scss | 2 +- .../components/Sidebar/SidebarTrigger.tsx | 6 +- .../components/dropdownMenu/DropdownMenu.scss | 3 +- .../dropdownMenu/DropdownMenuContent.tsx | 7 ++- .../dropdownMenu/DropdownMenuSeparator.tsx | 1 + .../components/main-menu/MainMenu.tsx | 7 +-- packages/excalidraw/css/styles.scss | 15 ++++- .../__snapshots__/excalidraw.test.tsx.snap | 4 +- packages/excalidraw/types.ts | 2 +- 19 files changed, 58 insertions(+), 85 deletions(-) diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx index 2c6a774456..4cb95c2f0f 100644 --- a/packages/excalidraw/actions/actionMenu.tsx +++ b/packages/excalidraw/actions/actionMenu.tsx @@ -1,65 +1,11 @@ import { KEYS } from "@excalidraw/common"; -import { getNonDeletedElements } from "@excalidraw/element"; - -import { showSelectedShapeActions } from "@excalidraw/element"; - import { CaptureUpdateAction } from "@excalidraw/element"; -import { ToolButton } from "../components/ToolButton"; -import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons"; -import { t } from "../i18n"; +import { HelpIconThin } from "../components/icons"; import { register } from "./register"; -export const actionToggleCanvasMenu = register({ - name: "toggleCanvasMenu", - label: "buttons.menu", - trackEvent: { category: "menu" }, - perform: (_, appState) => ({ - appState: { - ...appState, - openMenu: appState.openMenu === "canvas" ? null : "canvas", - }, - captureUpdate: CaptureUpdateAction.EVENTUALLY, - }), - PanelComponent: ({ appState, updateData }) => ( - - ), -}); - -export const actionToggleEditMenu = register({ - name: "toggleEditMenu", - label: "buttons.edit", - trackEvent: { category: "menu" }, - perform: (_elements, appState) => ({ - appState: { - ...appState, - openMenu: appState.openMenu === "shape" ? null : "shape", - }, - captureUpdate: CaptureUpdateAction.EVENTUALLY, - }), - PanelComponent: ({ elements, appState, updateData }) => ( - - ), -}); - export const actionShortcuts = register({ name: "toggleShortcuts", label: "welcomeScreen.defaults.helpHint", @@ -79,6 +25,8 @@ export const actionShortcuts = register({ : { name: "help", }, + openMenu: null, + openPopup: null, }, captureUpdate: CaptureUpdateAction.EVENTUALLY, }; diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index 2719a5d0a2..6b888e92d3 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -44,11 +44,7 @@ export { } from "./actionExport"; export { actionCopyStyles, actionPasteStyles } from "./actionStyles"; -export { - actionToggleCanvasMenu, - actionToggleEditMenu, - actionShortcuts, -} from "./actionMenu"; +export { actionShortcuts } from "./actionMenu"; export { actionGroup, actionUngroup } from "./actionGroup"; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 302a76fb4e..d533294d39 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -72,8 +72,6 @@ export type ActionName = | "changeArrowProperties" | "changeOpacity" | "changeFontSize" - | "toggleCanvasMenu" - | "toggleEditMenu" | "undo" | "redo" | "finalize" diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index ec95d40c3e..48ec4dc9a2 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1178,7 +1178,10 @@ export const ShapesSwitcher = ({ // on top of it (laserToolSelected && !app.props.isCollaborating), })} - onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)} + onToggle={() => { + setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen); + setAppState({ openMenu: null, openPopup: null }); + }} title={t("toolBar.extraTools")} > {frameToolSelected diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index 759ab9cad2..238960fa0b 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -319,8 +319,9 @@ export const ColorPicker = ({ openRef.current = appState.openPopup; }, [appState.openPopup]); const compactMode = - appState.stylesPanelMode === "compact" || - appState.stylesPanelMode === "mobile"; + type !== "canvasBackground" && + (appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile"); return (
diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 03f9c93cb8..e9f4c72d4c 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -476,7 +476,6 @@ function CommandPaletteInner({ }, perform: () => { setAppState((prevState) => ({ - openMenu: prevState.openMenu === "shape" ? null : "shape", openPopup: "elementStroke", })); }, @@ -496,7 +495,6 @@ function CommandPaletteInner({ }, perform: () => { setAppState((prevState) => ({ - openMenu: prevState.openMenu === "shape" ? null : "shape", openPopup: "elementBackground", })); }, @@ -838,7 +836,12 @@ function CommandPaletteInner({ let matchingCommands = commandSearch?.length > 1 - ? [...allCommands, ...libraryCommands] + ? [ + ...allCommands + .filter(isCommandAvailable) + .sort((a, b) => a.order - b.order), + ...libraryCommands, + ] : allCommands .filter(isCommandAvailable) .sort((a, b) => a.order - b.order); diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx index 2d7f8a0ba5..031d181eb0 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -159,7 +159,7 @@ function Picker({ side={isMobile ? "right" : "bottom"} align="start" sideOffset={isMobile ? 8 : 12} - style={{ zIndex: "var(--zIndex-popup)" }} + style={{ zIndex: "var(--zIndex-ui-styles-popup)" }} onKeyDown={handleKeyDown} >
setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen)} + onToggle={() => { + setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen); + setAppState({ openMenu: null, openPopup: null }); + }} title={t("toolBar.extraTools")} style={{ width: WIDTH, diff --git a/packages/excalidraw/components/PropertiesPopover.tsx b/packages/excalidraw/components/PropertiesPopover.tsx index d4437b3858..3c03c35b99 100644 --- a/packages/excalidraw/components/PropertiesPopover.tsx +++ b/packages/excalidraw/components/PropertiesPopover.tsx @@ -60,7 +60,8 @@ export const PropertiesPopover = React.forwardRef< alignOffset={-16} sideOffset={20} style={{ - zIndex: "var(--zIndex-popup)", + zIndex: "var(--zIndex-ui-styles-popup)", + marginLeft: device.editor.isMobile ? "0.5rem" : undefined, }} onPointerLeave={onPointerLeave} onKeyDown={onKeyDown} diff --git a/packages/excalidraw/components/Sidebar/Sidebar.scss b/packages/excalidraw/components/Sidebar/Sidebar.scss index c7776d1c69..2fba020ca9 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.scss +++ b/packages/excalidraw/components/Sidebar/Sidebar.scss @@ -9,7 +9,7 @@ top: 0; bottom: 0; right: 0; - z-index: 5; + z-index: var(--zIndex-ui-library); margin: 0; padding: 0; box-sizing: border-box; diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx index 6e8bf374ce..706a6abe52 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx @@ -30,7 +30,11 @@ export const SidebarTrigger = ({ .querySelector(".layer-ui__wrapper") ?.classList.remove("animate"); const isOpen = event.target.checked; - setAppState({ openSidebar: isOpen ? { name, tab } : null }); + setAppState({ + openSidebar: isOpen ? { name, tab } : null, + openMenu: null, + openPopup: null, + }); onToggle?.(isOpen); }} checked={appState.openSidebar?.name === name} diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index a0a230941d..f6c7d7dc24 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -5,6 +5,7 @@ position: absolute; top: 2.5rem; margin-top: 0.5rem; + max-width: 16rem; &--placement-top { top: auto; @@ -20,10 +21,8 @@ // When main menu is in the top toolbar, position relative to trigger &.main-menu-dropdown { min-width: 232px; - max-width: calc(100vw - var(--editor-container-padding) * 2); margin-top: 0; margin-bottom: 0; - z-index: var(--zIndex-layerUI); @media screen and (orientation: landscape) { max-width: 232px; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx index 291f857e80..5bbb41763b 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx @@ -74,7 +74,12 @@ const MenuContent = ({ {/* the zIndex ensures this menu has higher stacking order, see https://github.com/excalidraw/excalidraw/pull/1445 */} {device.editor.isMobile ? ( - {children} + + {children} + ) : ( ( height: "1px", backgroundColor: "var(--default-border-color)", margin: ".5rem 0", + flex: "0 0 auto", }} /> ); diff --git a/packages/excalidraw/components/main-menu/MainMenu.tsx b/packages/excalidraw/components/main-menu/MainMenu.tsx index 8ce2a5d69b..0098ebe526 100644 --- a/packages/excalidraw/components/main-menu/MainMenu.tsx +++ b/packages/excalidraw/components/main-menu/MainMenu.tsx @@ -30,9 +30,6 @@ const MainMenu = Object.assign( const device = useDevice(); const appState = useUIAppState(); const setAppState = useExcalidrawSetAppState(); - const onClickOutside = device.editor.isMobile - ? undefined - : () => setAppState({ openMenu: null }); return ( @@ -41,6 +38,8 @@ const MainMenu = Object.assign( onToggle={() => { setAppState({ openMenu: appState.openMenu === "canvas" ? null : "canvas", + openPopup: null, + openDialog: null, }); }} data-testid="main-menu-trigger" @@ -49,7 +48,7 @@ const MainMenu = Object.assign( {HamburgerMenuIcon} setAppState({ openMenu: null })} onSelect={composeEventHandlers(onSelect, () => { setAppState({ openMenu: null }); })} diff --git a/packages/excalidraw/css/styles.scss b/packages/excalidraw/css/styles.scss index 679a5c4cd1..72890f206d 100644 --- a/packages/excalidraw/css/styles.scss +++ b/packages/excalidraw/css/styles.scss @@ -12,6 +12,11 @@ --zIndex-eyeDropperPreview: 6; --zIndex-hyperlinkContainer: 7; + --zIndex-ui-styles-popup: 40; + --zIndex-ui-bottom: 60; + --zIndex-ui-library: 80; + --zIndex-ui-top: 100; + --zIndex-modal: 1000; --zIndex-popup: 1001; --zIndex-toast: 999999; @@ -237,7 +242,7 @@ body.excalidraw-cursor-resize * { } .App-top-bar { - z-index: var(--zIndex-layerUI); + z-index: var(--zIndex-ui-top); display: flex; flex-direction: column; } @@ -255,7 +260,7 @@ body.excalidraw-cursor-resize * { left: 50%; transform: translateX(-50%); --bar-padding: calc(4 * var(--space-factor)); - z-index: var(--zIndex-layerUI); + z-index: var(--zIndex-ui-bottom); display: flex; flex-direction: column; @@ -296,6 +301,12 @@ body.excalidraw-cursor-resize * { .App-toolbar-content { display: flex; flex-direction: column; + + pointer-events: none; + + & > * { + pointer-events: var(--ui-pointerEvents); + } } .App-mobile-menu { diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index f1a65130ea..d1a1ef77e0 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -414,7 +414,7 @@ exports[` > Test UIOptions prop > Test canvasActions > should rende