From f39ac4a653335efaaaf9834bf28e9ffc1452cb59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Mon, 2 Feb 2026 20:12:56 +0100 Subject: [PATCH 01/24] fix(editor): On focus drag only update other binding if it's orbit (#10730) fix(focus): Only update other binding if it's orbit Signed-off-by: Mark Tolmacs --- packages/element/src/arrows/focus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/element/src/arrows/focus.ts b/packages/element/src/arrows/focus.ts index 960524465c..ae02793f72 100644 --- a/packages/element/src/arrows/focus.ts +++ b/packages/element/src/arrows/focus.ts @@ -137,7 +137,7 @@ const focusPointUpdate = ( } // Also update the adjacent end if it has a binding - if (adjacentBinding) { + if (adjacentBinding && adjacentBinding.mode === "orbit") { const adjacentBindableElement = elementsMap.get( adjacentBinding.elementId, ) as ExcalidrawBindableElement; From 83d3943cd0973aa2fe6dc843a730bcb50e8a26ec Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 5 Feb 2026 12:00:56 +0100 Subject: [PATCH 02/24] feat(editor): reduce binding gap (#10739) * feat(editor): reduce binding gap to 7px * feat(editor): reduce binding gap to 5px * feat(editor): reduce binding gap to 3px * go back to 5px * update tests --- packages/element/src/binding.ts | 2 +- .../tests/linearElementEditor.test.tsx | 4 +- packages/element/tests/resize.test.tsx | 4 +- .../tests/__snapshots__/history.test.tsx.snap | 278 +++++++++--------- .../tests/__snapshots__/move.test.tsx.snap | 20 +- packages/excalidraw/tests/history.test.tsx | 4 +- packages/excalidraw/tests/move.test.tsx | 6 +- packages/excalidraw/tests/rotate.test.tsx | 4 +- 8 files changed, 161 insertions(+), 161 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 074fe2a21f..c763b6e21f 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -114,7 +114,7 @@ export type BindingStrategy = * * IMPORTANT: currently must be > 0 (this also applies to the computed gap) */ -export const BASE_BINDING_GAP = 10; +export const BASE_BINDING_GAP = 5; export const BASE_BINDING_GAP_ELBOW = 5; export const FOCUS_POINT_SIZE = 10 / 1.5; diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 4c9ab3825d..9485dcb222 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -1317,7 +1317,7 @@ describe("Test Linear Elements", () => { const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; expect(arrow.endBinding?.elementId).toBe(rect.id); - expect(arrow.width).toBeCloseTo(399); + expect(arrow.width).toBeCloseTo(404); expect(rect.x).toBe(400); expect(rect.y).toBe(0); expect( @@ -1336,7 +1336,7 @@ describe("Test Linear Elements", () => { mouse.downAt(rect.x, rect.y); mouse.moveTo(200, 0); mouse.upAt(200, 0); - expect(arrow.width).toBeCloseTo(199); + expect(arrow.width).toBeCloseTo(204); expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( diff --git a/packages/element/tests/resize.test.tsx b/packages/element/tests/resize.test.tsx index b51d537e37..470cc1fa96 100644 --- a/packages/element/tests/resize.test.tsx +++ b/packages/element/tests/resize.test.tsx @@ -1350,8 +1350,8 @@ describe("multiple selection", () => { expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.y).toBeCloseTo(240 * scaleY); - expect(boundArrow.points[1][0]).toBeCloseTo(59.7979); - expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305); + expect(boundArrow.points[1][0]).toBeCloseTo(63.4035); + expect(boundArrow.points[1][1]).toBeCloseTo(-84.538); expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( boundArrow.x + boundArrow.points[1][0] / 2, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 4e9d7bb568..6b8851c281 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -198,7 +198,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "106.79573", + "height": "112.79549", "id": "id4", "index": "a2", "isDeleted": false, @@ -212,8 +212,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "89.00000", - "106.79573", + "94.00000", + "112.79549", ], ], "roughness": 1, @@ -227,8 +227,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 33, - "width": "89.00000", + "version": 34, + "width": "94.00000", "x": 0, "y": 0, } @@ -334,15 +334,15 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "height": "65.91078", + "height": "74.35962", "points": [ [ 0, 0, ], [ - 78, - "65.91078", + 88, + "74.35962", ], ], "startBinding": { @@ -353,27 +353,27 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 32, - "width": 78, + "version": 33, + "width": 88, }, "inserted": { "endBinding": { "elementId": "id1", "fixedPoint": [ - "0.39512", - "0.60488", + "0.39746", + "0.60254", ], "mode": "orbit", }, - "height": "1.30876", + "height": "1.66245", "points": [ [ 0, 0, ], [ - 78, - "-1.30876", + 88, + "-1.66245", ], ], "startBinding": { @@ -384,8 +384,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 29, - "width": 78, + "version": 30, + "width": 88, }, }, }, @@ -428,33 +428,33 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "id4": { "deleted": { - "height": "106.79573", + "height": "112.79549", "points": [ [ 0, 0, ], [ - "89.00000", - "106.79573", + "94.00000", + "112.79549", ], ], "startBinding": null, - "version": 33, - "width": "89.00000", + "version": 34, + "width": "94.00000", "x": 0, "y": 0, }, "inserted": { - "height": "65.91078", + "height": "74.35962", "points": [ [ 0, 0, ], [ - 78, - "65.91078", + 88, + "74.35962", ], ], "startBinding": { @@ -465,10 +465,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 32, - "width": 78, - "x": 11, - "y": "49.49137", + "version": 33, + "width": 88, + "x": 6, + "y": "46.67801", }, }, }, @@ -854,7 +854,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 28, + "version": 26, "width": 100, "x": 150, "y": 0, @@ -904,15 +904,15 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "id4": { "deleted": { "endBinding": null, - "height": "4.68000", + "height": "5.28000", "points": [ [ 0, 0, ], [ - -39, - "-4.68000", + -44, + "-5.28000", ], ], "startBinding": { @@ -923,28 +923,28 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 27, - "width": 39, - "y": "4.68000", + "version": 25, + "width": 44, + "y": "5.28000", }, "inserted": { "endBinding": { "elementId": "id1", "fixedPoint": [ - "0.41019", - "0.58981", + "0.41067", + "0.58933", ], "mode": "orbit", }, - "height": "10.70742", + "height": "9.80848", "points": [ [ 0, 0, ], [ - "52.01909", - "10.70742", + "47.06697", + "9.80848", ], ], "startBinding": { @@ -955,9 +955,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 26, - "width": "52.01909", - "y": "-1.72651", + "version": 24, + "width": "47.06697", + "y": "-0.87545", }, }, }, @@ -1004,21 +1004,21 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], ], "startBinding": null, - "version": 28, + "version": 26, "width": 100, "x": 150, "y": 0, }, "inserted": { - "height": "4.68000", + "height": "5.28000", "points": [ [ 0, 0, ], [ - -39, - "-4.68000", + -44, + "-5.28000", ], ], "startBinding": { @@ -1029,10 +1029,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 27, - "width": 39, - "x": 139, - "y": "4.68000", + "version": 25, + "width": 44, + "x": 144, + "y": "5.28000", }, }, }, @@ -1335,7 +1335,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "26.16768", + "height": "29.36414", "id": "id4", "index": "Zz", "isDeleted": false, @@ -1349,8 +1349,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "78.00000", - "26.16768", + 88, + "29.36414", ], ], "roughness": 1, @@ -1370,9 +1370,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 10, - "width": "78.00000", - "x": 11, - "y": "3.67566", + "width": 88, + "x": 6, + "y": "2.00946", } `; @@ -1698,7 +1698,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "10.76674", + "height": "14.91372", "id": "id5", "index": "a0", "isDeleted": false, @@ -1712,8 +1712,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "78.00000", - "-10.76674", + "88.00000", + "-14.91372", ], ], "roughness": 1, @@ -1733,9 +1733,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 11, - "width": "78.00000", - "x": 11, - "y": "35.29320", + "width": "88.00000", + "x": 6, + "y": "37.05219", } `; @@ -1846,7 +1846,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "10.76674", + "height": "14.91372", "index": "a0", "isDeleted": false, "link": null, @@ -1858,8 +1858,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "78.00000", - "-10.76674", + "88.00000", + "-14.91372", ], ], "roughness": 1, @@ -1878,9 +1878,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "version": 11, - "width": "78.00000", - "x": 11, - "y": "35.29320", + "width": "88.00000", + "x": 6, + "y": "37.05219", }, "inserted": { "isDeleted": true, @@ -2398,7 +2398,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "390.11932", + "height": "398.76619", "id": "id4", "index": "a2", "isDeleted": false, @@ -2412,8 +2412,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "478.03878", - "-390.11932", + 488, + "-398.76619", ], ], "roughness": 1, @@ -2435,9 +2435,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 12, - "width": "478.03878", - "x": 11, - "y": "-8.96692", + "width": 488, + "x": 6, + "y": "-4.89286", } `; @@ -2566,7 +2566,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "390.11932", + "height": "398.76619", "index": "a2", "isDeleted": false, "link": null, @@ -2578,8 +2578,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "478.03878", - "-390.11932", + 488, + "-398.76619", ], ], "roughness": 1, @@ -2600,9 +2600,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "version": 12, - "width": "478.03878", - "x": 11, - "y": "-8.96692", + "width": 488, + "x": 6, + "y": "-4.89286", }, "inserted": { "isDeleted": true, @@ -16383,7 +16383,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00108", + "height": "0.00092", "id": "id13", "index": "a3", "isDeleted": false, @@ -16397,8 +16397,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "78.00000", - "0.00108", + "88.00000", + "0.00092", ], ], "roughness": 1, @@ -16420,9 +16420,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 12, - "width": "78.00000", - "x": 11, - "y": "0.00807", + "width": "88.00000", + "x": 6, + "y": "0.00849", } `; @@ -16801,7 +16801,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00561", + "height": "0.00611", "index": "a3", "isDeleted": false, "link": null, @@ -16813,8 +16813,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "78.00000", - "0.00561", + 88, + "0.00611", ], ], "roughness": 1, @@ -16835,8 +16835,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "version": 8, - "width": "78.00000", - "x": 11, + "width": 88, + "x": 6, "y": 0, }, "inserted": { @@ -17134,7 +17134,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00108", + "height": "0.00092", "id": "id13", "index": "a3", "isDeleted": false, @@ -17148,8 +17148,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "78.00000", - "0.00108", + "88.00000", + "0.00092", ], ], "roughness": 1, @@ -17171,9 +17171,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 12, - "width": "78.00000", - "x": 11, - "y": "0.00807", + "width": "88.00000", + "x": 6, + "y": "0.00849", } `; @@ -17442,7 +17442,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00108", + "height": "0.00092", "index": "a3", "isDeleted": false, "link": null, @@ -17454,8 +17454,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "78.00000", - "0.00108", + "88.00000", + "0.00092", ], ], "roughness": 1, @@ -17476,9 +17476,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "version": 12, - "width": "78.00000", - "x": 11, - "y": "0.00807", + "width": "88.00000", + "x": 6, + "y": "0.00849", }, "inserted": { "isDeleted": true, @@ -17783,7 +17783,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00108", + "height": "0.00092", "id": "id13", "index": "a3", "isDeleted": false, @@ -17797,8 +17797,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "78.00000", - "0.00108", + "88.00000", + "0.00092", ], ], "roughness": 1, @@ -17820,9 +17820,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 12, - "width": "78.00000", - "x": 11, - "y": "0.00807", + "width": "88.00000", + "x": 6, + "y": "0.00849", } `; @@ -18091,7 +18091,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00108", + "height": "0.00092", "index": "a3", "isDeleted": false, "link": null, @@ -18103,8 +18103,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "78.00000", - "0.00108", + "88.00000", + "0.00092", ], ], "roughness": 1, @@ -18125,9 +18125,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "version": 12, - "width": "78.00000", - "x": 11, - "y": "0.00807", + "width": "88.00000", + "x": 6, + "y": "0.00849", }, "inserted": { "isDeleted": true, @@ -18430,7 +18430,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00108", + "height": "0.00092", "id": "id13", "index": "a3", "isDeleted": false, @@ -18444,8 +18444,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "78.00000", - "0.00108", + "88.00000", + "0.00092", ], ], "roughness": 1, @@ -18467,9 +18467,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 12, - "width": "78.00000", - "x": 11, - "y": "0.00807", + "width": "88.00000", + "x": 6, + "y": "0.00849", } `; @@ -18824,7 +18824,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00561", + "height": "0.00611", "index": "a3", "isDeleted": false, "link": null, @@ -18836,8 +18836,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "78.00000", - "0.00561", + 88, + "0.00611", ], ], "roughness": 1, @@ -18858,8 +18858,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "version": 8, - "width": "78.00000", - "x": 11, + "width": 88, + "x": 6, "y": 0, }, "inserted": { @@ -19185,7 +19185,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00108", + "height": "0.00092", "id": "id13", "index": "a3", "isDeleted": false, @@ -19199,8 +19199,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "78.00000", - "0.00108", + "88.00000", + "0.00092", ], ], "roughness": 1, @@ -19222,9 +19222,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 13, - "width": "78.00000", - "x": 11, - "y": "0.00807", + "width": "88.00000", + "x": 6, + "y": "0.00849", } `; @@ -19575,7 +19575,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00561", + "height": "0.00611", "index": "a3", "isDeleted": false, "link": null, @@ -19587,8 +19587,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "78.00000", - "0.00561", + 88, + "0.00611", ], ], "roughness": 1, @@ -19609,8 +19609,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "version": 8, - "width": "78.00000", - "x": 11, + "width": 88, + "x": 6, "y": 0, }, "inserted": { diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 6678adef33..ed4531a9e7 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -181,15 +181,15 @@ exports[`move element > rectangles with binding arrow 7`] = ` "endBinding": { "elementId": "id3", "fixedPoint": [ - "-0.03667", - "0.43000", + "-0.02000", + "0.44667", ], "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "80.00000", + "height": "90.00000", "id": "id6", "index": "a2", "isDeleted": false, @@ -203,8 +203,8 @@ exports[`move element > rectangles with binding arrow 7`] = ` 0, ], [ - "79.00000", - "80.00000", + 89, + "90.00000", ], ], "roughness": 1, @@ -216,8 +216,8 @@ exports[`move element > rectangles with binding arrow 7`] = ` "startBinding": { "elementId": "id0", "fixedPoint": [ - "1.11000", - "0.51000", + "1.06000", + "0.46000", ], "mode": "orbit", }, @@ -228,8 +228,8 @@ exports[`move element > rectangles with binding arrow 7`] = ` "updated": 1, "version": 13, "versionNonce": 271613161, - "width": "79.00000", - "x": "111.00000", - "y": "51.00000", + "width": 89, + "x": 106, + "y": "46.00000", } `; diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 014d8608ed..0afaced0cb 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -4628,7 +4628,7 @@ describe("history", () => { }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: [0.41019091151895054, 0.5898090884810495], + fixedPoint: [0.4106696643494564, 0.5893303356505437], mode: "orbit", }), }), @@ -4772,7 +4772,7 @@ describe("history", () => { // rebound with previous rectangle endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: [0.39511653718091, 0.6048834628190899], + fixedPoint: [0.39746300211416496, 0.6025369978858351], mode: "orbit", }), }), diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 659917da8c..05e2951231 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -110,8 +110,8 @@ describe("move element", () => { expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectB.x, rectB.y]).toEqual([200, 0]); - expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[111, 51]], 0); - expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[78, 78]], 0); + expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[106, 46]], 0); + expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[88, 88]], 0); renderInteractiveScene.mockClear(); renderStaticScene.mockClear(); @@ -129,7 +129,7 @@ describe("move element", () => { expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectB.x, rectB.y]).toEqual([201, 2]); - expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[111, 51]], 0); + expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[106, 46]], 0); expect([[arrow.width, arrow.height]]).toCloselyEqualPoints( [[79, 124.1678]], 2, diff --git a/packages/excalidraw/tests/rotate.test.tsx b/packages/excalidraw/tests/rotate.test.tsx index 47f7e469e4..25597363d6 100644 --- a/packages/excalidraw/tests/rotate.test.tsx +++ b/packages/excalidraw/tests/rotate.test.tsx @@ -80,6 +80,6 @@ test("unselected bound arrows update when rotating their target elements", async expect(textArrow.x).toEqual(360); expect(textArrow.y).toEqual(300); expect(textArrow.points[0]).toEqual([0, 0]); - expect(textArrow.points[1][0]).toBeCloseTo(-95.74, 0); - expect(textArrow.points[1][1]).toBeCloseTo(-119.7354, 0); + expect(textArrow.points[1][0]).toBeCloseTo(-98.87, 0); + expect(textArrow.points[1][1]).toBeCloseTo(-123.65, 0); }); From b43260d97be0cd36faaa630595c299963de2cb6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Fri, 6 Feb 2026 15:01:07 +0100 Subject: [PATCH 03/24] fix: Other binding converted from fixed to orbit unconditionally (#10748) * fix: Other binding converted from fixed to orbit unconditionally Signed-off-by: Mark Tolmacs * fix: New arrow creation Signed-off-by: Mark Tolmacs * fix: Alt point setting on inside binding Signed-off-by: Mark Tolmacs * fix: Initial arrow creation with Alt Signed-off-by: Mark Tolmacs --------- Signed-off-by: Mark Tolmacs --- packages/element/src/binding.ts | 14 +++++++++----- packages/excalidraw/components/App.tsx | 1 + 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index c763b6e21f..afc67d7afd 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -810,10 +810,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( elementsMap, ); - const other: BindingStrategy = - otherBindableElement && - !otherFocusPointIsInElement && - appState.selectedLinearElement?.initialState.altFocusPoint + const otherNeverOverride = opts?.newArrow + ? appState.selectedLinearElement?.initialState.arrowStartIsInside + : otherBinding?.mode === "inside"; + const other: BindingStrategy = !otherNeverOverride + ? otherBindableElement && + !otherFocusPointIsInElement && + appState.selectedLinearElement?.initialState.altFocusPoint ? { mode: "orbit", element: otherBindableElement, @@ -832,7 +835,8 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( elementsMap, ) || otherEndpoint, } - : { mode: undefined }; + : { mode: undefined } + : { mode: undefined }; return { start: startDragged ? current : other, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index fa9a02b492..88871adde2 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -8743,6 +8743,7 @@ class App extends React.Component { selectedPointsIndices: [endIdx], initialState: { ...linearElementEditor.initialState, + arrowStartIsInside: event.altKey, lastClickedPoint: endIdx, origin: pointFrom( pointerDownState.origin.x, From 063533aede8365bf18b8f08ec949a1e93697c17d Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sun, 8 Feb 2026 22:27:34 +0100 Subject: [PATCH 04/24] feat(packages/excalidraw): support nested dropdown menu (#10749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Barnabás Molnár <38168628+barnabasmolnar@users.noreply.github.com> --- excalidraw-app/components/AppMainMenu.tsx | 2 +- packages/common/src/constants.ts | 1 + packages/excalidraw/components/Actions.tsx | 2 +- .../components/ColorPicker/ColorPicker.tsx | 2 +- .../components/FontPicker/FontPicker.test.tsx | 31 + .../components/FontPicker/FontPicker.tsx | 2 +- .../components/FontPicker/FontPickerList.tsx | 107 ++- .../FontPicker/FontPickerTrigger.tsx | 2 +- packages/excalidraw/components/IconPicker.tsx | 2 +- .../excalidraw/components/LibraryMenu.scss | 3 +- .../excalidraw/components/MobileToolBar.tsx | 3 +- .../components/PropertiesPopover.tsx | 2 +- .../components/Sidebar/SidebarTab.tsx | 2 +- .../components/Sidebar/SidebarTabTrigger.tsx | 2 +- .../components/Sidebar/SidebarTabTriggers.tsx | 2 +- .../components/Sidebar/SidebarTabs.tsx | 2 +- .../components/TTDDialog/Chat/Chat.scss | 2 +- .../TTDDialog/Chat/ChatHistoryMenu.tsx | 6 +- .../components/TTDDialog/TTDDialogTab.tsx | 2 +- .../TTDDialog/TTDDialogTabTrigger.tsx | 2 +- .../TTDDialog/TTDDialogTabTriggers.tsx | 2 +- .../components/TTDDialog/TTDDialogTabs.tsx | 2 +- .../excalidraw/components/ToolPopover.tsx | 2 +- packages/excalidraw/components/UserList.tsx | 2 +- .../components/dropdownMenu/DropdownMenu.scss | 54 +- .../components/dropdownMenu/DropdownMenu.tsx | 43 +- .../dropdownMenu/DropdownMenuContent.tsx | 57 +- .../dropdownMenu/DropdownMenuItem.tsx | 67 +- .../DropdownMenuItemContentRadio.tsx | 4 +- .../dropdownMenu/DropdownMenuItemLink.tsx | 35 +- .../dropdownMenu/DropdownMenuSeparator.tsx | 2 +- .../dropdownMenu/DropdownMenuSub.tsx | 26 + .../dropdownMenu/DropdownMenuSubContent.tsx | 71 ++ .../dropdownMenu/DropdownMenuSubTrigger.tsx | 38 + .../dropdownMenu/DropdownMenuTrigger.tsx | 6 +- .../components/dropdownMenu/common.ts | 25 +- .../dropdownMenu/dropdownMenuUtils.ts | 28 +- packages/excalidraw/components/icons.tsx | 12 + .../components/main-menu/DefaultItems.tsx | 8 +- .../components/main-menu/MainMenu.tsx | 10 +- packages/excalidraw/css/styles.scss | 16 + packages/excalidraw/package.json | 3 +- .../MermaidToExcalidraw.test.tsx.snap | 2 +- .../__snapshots__/excalidraw.test.tsx.snap | 128 ++- .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 1 - yarn.lock | 873 ++++++++++++++---- 46 files changed, 1265 insertions(+), 431 deletions(-) create mode 100644 packages/excalidraw/components/FontPicker/FontPicker.test.tsx create mode 100644 packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx create mode 100644 packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx create mode 100644 packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index cd0aca2683..e51d1763b8 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{ {isDevEnv() && ( { + onSelect={() => { if (window.visualDebug) { delete window.visualDebug; saveDebugState({ enabled: false }); diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 03978584cb..4ff50335ef 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -106,6 +106,7 @@ export const CLASSES = { CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope", FRAME_NAME: "frame-name", + DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper", }; export const FONT_SIZES = { diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 18791f8f9b..d9f3415d64 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { useRef, useState } from "react"; -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import { CLASSES, diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index 5de89f7590..f76c109280 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import { useRef, useEffect } from "react"; diff --git a/packages/excalidraw/components/FontPicker/FontPicker.test.tsx b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx new file mode 100644 index 0000000000..ab92464fd5 --- /dev/null +++ b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx @@ -0,0 +1,31 @@ +import { KEYS } from "@excalidraw/common"; + +import { Excalidraw } from "../.."; +import { Keyboard } from "../../tests/helpers/ui"; +import { act, render } from "../../tests/test-utils"; + +describe("FontPicker", () => { + it("should be able to open font picker", async () => { + (global as any).ResizeObserver = + (global as any).ResizeObserver || + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; + + const { queryByTestId } = await render( + , + ); + + Keyboard.keyPress(KEYS.T); + + const fontPickerTrigger = queryByTestId("font-family-show-fonts"); + + expect(fontPickerTrigger).not.toBeNull(); + + act(() => { + fontPickerTrigger!.click(); + }); + }); +}); diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index c52286a173..4325147e17 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import React, { useCallback, useMemo } from "react"; diff --git a/packages/excalidraw/components/FontPicker/FontPickerList.tsx b/packages/excalidraw/components/FontPicker/FontPickerList.tsx index 79dca68ce0..e5e7b85aea 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerList.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerList.tsx @@ -30,10 +30,12 @@ import { PropertiesPopover } from "../PropertiesPopover"; import { QuickSearch } from "../QuickSearch"; import { ScrollableList } from "../ScrollableList"; import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup"; -import DropdownMenuItem, { +import { DropDownMenuItemBadgeType, DropDownMenuItemBadge, } from "../dropdownMenu/DropdownMenuItem"; +import MenuItemContent from "../dropdownMenu/DropdownMenuItemContent"; +import { getDropdownMenuItemClassName } from "../dropdownMenu/common"; import { FontFamilyCodeIcon, FontFamilyHeadingIcon, @@ -269,45 +271,74 @@ export const FontPickerList = React.memo( [filteredFonts, sceneFamilies], ); - const renderFont = (font: FontDescriptor, index: number) => ( - { - wrappedOnSelect(Number(e.currentTarget.value)); - }} - onMouseMove={() => { - if (hoveredFont?.value !== font.value) { - onHover(font.value); - } - }} - badge={ - font.badge && ( - - {font.badge.placeholder} - - ) + const FontPickerListItem = ({ + font, + order, + }: { + font: FontDescriptor; + order: number; + }) => { + const ref = useRef(null); + const isHovered = font.value === hoveredFont?.value; + const isSelected = font.value === selectedFontFamily; + + useEffect(() => { + if (!isHovered) { + return; } - > - {font.text} - - ); + if (order === 0) { + // scroll into the first item differently, so it's visible what is above (i.e. group title) + ref.current?.scrollIntoView?.({ block: "end" }); + } else { + ref.current?.scrollIntoView?.({ block: "nearest" }); + } + }, [isHovered, order]); + + return ( + + ); + }; const groups = []; if (sceneFilteredFonts.length) { groups.push( - {sceneFilteredFonts.map(renderFont)} + {sceneFilteredFonts.map((font, index) => ( + + ))} , ); } @@ -315,9 +346,13 @@ export const FontPickerList = React.memo( if (availableFilteredFonts.length) { groups.push( - {availableFilteredFonts.map((font, index) => - renderFont(font, index + sceneFilteredFonts.length), - )} + {availableFilteredFonts.map((font, index) => ( + + ))} , ); } diff --git a/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx b/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx index ede3a50e06..eea1cdb8d1 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common"; diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx index fab4f109b8..0d644ca7e9 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import React, { useEffect } from "react"; diff --git a/packages/excalidraw/components/LibraryMenu.scss b/packages/excalidraw/components/LibraryMenu.scss index 6133bb2ecf..b0892a07f7 100644 --- a/packages/excalidraw/components/LibraryMenu.scss +++ b/packages/excalidraw/components/LibraryMenu.scss @@ -126,9 +126,10 @@ .dropdown-menu-container { width: 196px; - box-shadow: var(--library-dropdown-shadow); border-radius: var(--border-radius-lg); padding: 0.25rem 0.5rem; + + --box-shadow: var(--library-dropdown-shadow); } } diff --git a/packages/excalidraw/components/MobileToolBar.tsx b/packages/excalidraw/components/MobileToolBar.tsx index 9cd351d2ee..e439061217 100644 --- a/packages/excalidraw/components/MobileToolBar.tsx +++ b/packages/excalidraw/components/MobileToolBar.tsx @@ -375,7 +375,7 @@ export const MobileToolBar = ({ )} {/* Other Shapes */} - + setIsOtherShapesMenuOpen(false)} onSelect={() => setIsOtherShapesMenuOpen(false)} className="App-toolbar__extra-tools-dropdown" + align="start" > {!showTextToolOutside && ( {historyIcon} - + <> {savedChats.map((chat) => ( { const MenuTriggerComp = getMenuTriggerComponent(children); const MenuContentComp = getMenuContentComponent(children); - - // clone the MenuContentComp to pass the placement prop - const MenuContentCompWithPlacement = + const MenuContentWithState = MenuContentComp && React.isValidElement(MenuContentComp) - ? React.cloneElement(MenuContentComp as React.ReactElement, { - placement, - }) + ? React.cloneElement( + MenuContentComp as React.ReactElement< + React.ComponentProps + >, + { open }, + ) : MenuContentComp; return ( -
- {MenuTriggerComp} - {open && MenuContentCompWithPlacement} -
+ +
+ {MenuTriggerComp} + {MenuContentWithState} +
+
); }; @@ -55,6 +61,7 @@ DropdownMenu.ItemLink = DropdownMenuItemLink; DropdownMenu.ItemCustom = DropdownMenuItemCustom; DropdownMenu.Group = DropdownMenuGroup; DropdownMenu.Separator = MenuSeparator; +DropdownMenu.Sub = DropdownMenuSub; export default DropdownMenu; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx index f92c9df327..79be24b969 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx @@ -1,7 +1,9 @@ import clsx from "clsx"; -import React, { useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; -import { EVENT, KEYS } from "@excalidraw/common"; +import { CLASSES, EVENT, KEYS } from "@excalidraw/common"; + +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import { useOutsideClick } from "../../hooks/useOutsideClick"; import { useStable } from "../../hooks/useStable"; @@ -16,8 +18,9 @@ const MenuContent = ({ onClickOutside, className = "", onSelect, + open = true, + align = "end", style, - placement = "bottom", }: { children?: React.ReactNode; onClickOutside?: () => void; @@ -26,26 +29,36 @@ const MenuContent = ({ * Called when any menu item is selected (clicked on). */ onSelect?: (event: Event) => void; + open?: boolean; style?: React.CSSProperties; - placement?: "top" | "bottom"; + align?: "start" | "center" | "end"; }) => { const editorInterface = useEditorInterface(); const menuRef = useRef(null); const callbacksRef = useStable({ onClickOutside }); - useOutsideClick(menuRef, (event) => { - // prevents closing if clicking on the trigger button - if ( - !menuRef.current - ?.closest(".dropdown-menu-container") - ?.contains(event.target) - ) { - callbacksRef.onClickOutside?.(); - } - }); + useOutsideClick( + menuRef, + useCallback( + (event) => { + // prevents closing if clicking on the trigger button + if ( + !menuRef.current + ?.closest(`.${CLASSES.DROPDOWN_MENU_EVENT_WRAPPER}`) + ?.contains(event.target) + ) { + callbacksRef.onClickOutside?.(); + } + }, + [callbacksRef], + ), + ); useEffect(() => { + if (!open) { + return; + } const onKeyDown = (event: KeyboardEvent) => { if (event.key === KEYS.ESCAPE) { event.stopImmediatePropagation(); @@ -63,35 +76,33 @@ const MenuContent = ({ return () => { document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option); }; - }, [callbacksRef]); + }, [callbacksRef, open]); const classNames = clsx(`dropdown-menu ${className}`, { "dropdown-menu--mobile": editorInterface.formFactor === "phone", - "dropdown-menu--placement-top": placement === "top", }).trim(); return ( -
event.preventDefault()} > {/* the zIndex ensures this menu has higher stacking order, see https://github.com/excalidraw/excalidraw/pull/1445 */} {editorInterface.formFactor === "phone" ? ( {children} ) : ( - + {children} )} -
+
); }; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx index 0f227a0bbd..7cc10b95d4 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx @@ -1,78 +1,63 @@ -import React, { useEffect, useRef } from "react"; +import React from "react"; import { THEME } from "@excalidraw/common"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + import type { ValueOf } from "@excalidraw/common/utility-types"; import { useExcalidrawAppState } from "../App"; -import MenuItemContent from "./DropdownMenuItemContent"; import { getDropdownMenuItemClassName, - useHandleDropdownMenuItemClick, + useHandleDropdownMenuItemSelect, } from "./common"; +import MenuItemContent from "./DropdownMenuItemContent"; import type { JSX } from "react"; const DropdownMenuItem = ({ icon, + badge, value, - order, children, shortcut, className, - hovered, selected, - textStyle, onSelect, - onClick, - badge, ...rest }: { icon?: JSX.Element; + badge?: React.ReactNode; value?: string | number | undefined; - order?: number; onSelect?: (event: Event) => void; children: React.ReactNode; shortcut?: string; - hovered?: boolean; selected?: boolean; - textStyle?: React.CSSProperties; className?: string; - badge?: React.ReactNode; -} & Omit, "onSelect">) => { - const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect); - const ref = useRef(null); - - useEffect(() => { - if (hovered) { - if (order === 0) { - // scroll into the first item differently, so it's visible what is above (i.e. group title) - ref.current?.scrollIntoView({ block: "end" }); - } else { - ref.current?.scrollIntoView({ block: "nearest" }); - } - } - }, [hovered, order]); +} & Omit< + React.ButtonHTMLAttributes, + "onSelect" | "onClick" +>) => { + const handleSelect = useHandleDropdownMenuItemSelect(onSelect); return ( - + + {children} + + + ); }; DropdownMenuItem.displayName = "DropdownMenuItem"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx index d8177c50e0..4f2986c30e 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx @@ -27,9 +27,7 @@ const DropdownMenuItemContentRadio = ({ return ( <>
- + void; rel?: string; } & React.AnchorHTMLAttributes) => { - const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); + const handleSelect = useHandleDropdownMenuItemSelect(onSelect); return ( // eslint-disable-next-line react/jsx-no-target-blank - - - {children} - - + + + {children} + + + ); }; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx index 1c6d19521d..4f880c691a 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx @@ -5,7 +5,7 @@ const MenuSeparator = () => ( style={{ height: "1px", backgroundColor: "var(--default-border-color)", - margin: ".5rem 0", + margin: "6px 0", flex: "0 0 auto", }} /> diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx new file mode 100644 index 0000000000..0ba328b189 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx @@ -0,0 +1,26 @@ +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import DropdownMenuSubContent from "./DropdownMenuSubContent"; +import DropdownMenuSubTrigger from "./DropdownMenuSubTrigger"; +import { + getSubMenuContentComponent, + getSubMenuTriggerComponent, +} from "./dropdownMenuUtils"; + +const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => { + const MenuTriggerComp = getSubMenuTriggerComponent(children); + const MenuContentComp = getSubMenuContentComponent(children); + return ( + + {MenuTriggerComp} + {MenuContentComp} + + ); +}; + +DropdownMenuSub.Trigger = DropdownMenuSubTrigger; +DropdownMenuSub.Content = DropdownMenuSubContent; + +DropdownMenuSub.displayName = "DropdownMenuSub"; + +export default DropdownMenuSub; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx new file mode 100644 index 0000000000..ef5debef57 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx @@ -0,0 +1,71 @@ +import clsx from "clsx"; + +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import { useCallback, useState } from "react"; + +import { useEditorInterface } from "../App"; +import { Island } from "../Island"; +import Stack from "../Stack"; + +const BASE_ALIGN_OFFSET = -4; +const BASE_SIDE_OFFSET = 4; + +const DropdownMenuSubContent = ({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) => { + const editorInterface = useEditorInterface(); + + const classNames = clsx(`dropdown-menu dropdown-submenu ${className}`, { + "dropdown-menu--mobile": editorInterface.formFactor === "phone", + }).trim(); + + const callbacksRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + const parentContainer = node.closest(".dropdown-menu-container"); + const parentRect = parentContainer?.getBoundingClientRect(); + if (parentRect) { + const menuWidth = node.getBoundingClientRect().width; + + const viewportWidth = window.innerWidth; + const spaceRemaining = viewportWidth - parentRect.right; + if (spaceRemaining < menuWidth + 20) { + setSideOffset(spaceRemaining - menuWidth + BASE_ALIGN_OFFSET); + setAlignOffset(BASE_ALIGN_OFFSET + 8); + } + } + } + }, []); + + const [sideOffset, setSideOffset] = useState(BASE_SIDE_OFFSET); + const [alignOffset, setAlignOffset] = useState(BASE_ALIGN_OFFSET); + + return ( + + {editorInterface.formFactor === "phone" ? ( + {children} + ) : ( + + {children} + + )} + + ); +}; + +export default DropdownMenuSubContent; +DropdownMenuSubContent.displayName = "DropdownMenuSubContent"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx new file mode 100644 index 0000000000..579d4979a7 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import { chevronRight } from "../icons"; + +import { getDropdownMenuItemClassName } from "./common"; +import MenuItemContent from "./DropdownMenuItemContent"; + +import type { JSX } from "react"; + +const DropdownMenuSubTrigger = ({ + children, + icon, + shortcut, + className, +}: { + children: React.ReactNode; + icon?: JSX.Element; + shortcut?: string; + className?: string; +}) => { + return ( + + + {children} + +
{chevronRight}
+
+ ); +}; + +export default DropdownMenuSubTrigger; +DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx index e1f3ef202f..469eb002b8 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx @@ -1,5 +1,7 @@ import clsx from "clsx"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + import { useEditorInterface } from "../App"; const MenuTrigger = ({ @@ -23,7 +25,7 @@ const MenuTrigger = ({ }, ).trim(); return ( - + ); }; diff --git a/packages/excalidraw/components/dropdownMenu/common.ts b/packages/excalidraw/components/dropdownMenu/common.ts index feca5c4060..27342a28f6 100644 --- a/packages/excalidraw/components/dropdownMenu/common.ts +++ b/packages/excalidraw/components/dropdownMenu/common.ts @@ -1,6 +1,6 @@ import React, { useContext } from "react"; -import { EVENT, composeEventHandlers } from "@excalidraw/common"; +import { composeEventHandlers } from "@excalidraw/common"; export const DropdownMenuContentPropsContext = React.createContext<{ onSelect?: (event: Event) => void; @@ -11,28 +11,17 @@ export const getDropdownMenuItemClassName = ( selected = false, hovered = false, ) => { - return `dropdown-menu-item dropdown-menu-item-base ${className} - ${selected ? "dropdown-menu-item--selected" : ""} ${ - hovered ? "dropdown-menu-item--hovered" : "" - }`.trim(); + return `dropdown-menu-item dropdown-menu-item-base ${className} ${ + selected ? "dropdown-menu-item--selected" : "" + } ${hovered ? "dropdown-menu-item--hovered" : ""}`.trim(); }; -export const useHandleDropdownMenuItemClick = ( - origOnClick: - | React.MouseEventHandler - | undefined, +export const useHandleDropdownMenuItemSelect = ( onSelect: ((event: Event) => void) | undefined, ) => { const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext); - return composeEventHandlers(origOnClick, (event) => { - const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, { - bubbles: true, - cancelable: true, - }); - onSelect?.(itemSelectEvent); - if (!itemSelectEvent.defaultPrevented) { - DropdownMenuContentProps.onSelect?.(itemSelectEvent); - } + return composeEventHandlers(onSelect, (event) => { + DropdownMenuContentProps.onSelect?.(event); }); }; diff --git a/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts b/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts index 10d91fb856..82e8ccf591 100644 --- a/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts +++ b/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts @@ -1,6 +1,6 @@ import React from "react"; -export const getMenuTriggerComponent = (children: React.ReactNode) => { +const getMenuComponent = (component: string) => (children: React.ReactNode) => { const comp = React.Children.toArray(children).find( (child) => React.isValidElement(child) && @@ -8,7 +8,7 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => { //@ts-ignore child?.type.displayName && //@ts-ignore - child.type.displayName === "DropdownMenuTrigger", + child.type.displayName === component, ); if (!comp) { return null; @@ -17,19 +17,11 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => { return comp; }; -export const getMenuContentComponent = (children: React.ReactNode) => { - const comp = React.Children.toArray(children).find( - (child) => - React.isValidElement(child) && - typeof child.type !== "string" && - //@ts-ignore - child?.type.displayName && - //@ts-ignore - child.type.displayName === "DropdownMenuContent", - ); - if (!comp) { - return null; - } - //@ts-ignore - return comp; -}; +export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger"); +export const getMenuContentComponent = getMenuComponent("DropdownMenuContent"); +export const getSubMenuTriggerComponent = getMenuComponent( + "DropdownMenuSubTrigger", +); +export const getSubMenuContentComponent = getMenuComponent( + "DropdownMenuSubContent", +); diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 8caf6cc163..f5ce947d7a 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -2396,3 +2396,15 @@ export const presentationIcon = createIcon( , tablerIconProps, ); + +// empty placeholder icon (used for alignment in menus) +export const emptyIcon =
; + +//tabler-icons: chevron-right +export const chevronRight = createIcon( + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index 29a2761a10..2a00a79a71 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -306,10 +306,14 @@ export const ChangeCanvasBackground = () => { return null; } return ( -
+
{t("labels.canvasBackground")}
diff --git a/packages/excalidraw/components/main-menu/MainMenu.tsx b/packages/excalidraw/components/main-menu/MainMenu.tsx index d028231328..3755f1f0d4 100644 --- a/packages/excalidraw/components/main-menu/MainMenu.tsx +++ b/packages/excalidraw/components/main-menu/MainMenu.tsx @@ -8,6 +8,7 @@ import { t } from "../../i18n"; import { useEditorInterface, useExcalidrawSetAppState } from "../App"; import { UserList } from "../UserList"; import DropdownMenu from "../dropdownMenu/DropdownMenu"; +import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub"; import { withInternalFallback } from "../hoc/withInternalFallback"; import { HamburgerMenuIcon } from "../icons"; @@ -52,12 +53,8 @@ const MainMenu = Object.assign( onSelect={composeEventHandlers(onSelect, () => { setAppState({ openMenu: null }); })} - placement="bottom" - className={ - editorInterface.formFactor === "phone" - ? "main-menu-dropdown" - : "" - } + className="main-menu" + align="start" > {children} {editorInterface.formFactor === "phone" && @@ -84,6 +81,7 @@ const MainMenu = Object.assign( ItemCustom: DropdownMenu.ItemCustom, Group: DropdownMenu.Group, Separator: DropdownMenu.Separator, + Sub: DropdownMenuSub, DefaultItems, }, ); diff --git a/packages/excalidraw/css/styles.scss b/packages/excalidraw/css/styles.scss index 5557d50313..e0d42fa716 100644 --- a/packages/excalidraw/css/styles.scss +++ b/packages/excalidraw/css/styles.scss @@ -16,6 +16,7 @@ --zIndex-ui-context-menu: 90; --zIndex-ui-styles-popup: 100; --zIndex-ui-top: 100; + --zIndex-ui-main-menu: 110; --zIndex-ui-library: 120; --zIndex-modal: 1000; @@ -223,6 +224,18 @@ body.excalidraw-cursor-resize * { box-shadow: 0 0 0 1px var(--color-brand-hover); } + // radix doesn't allow differntiating between hover and keyboard active + // states (it's forcing :focus on both). + // + // proper handling would be to disable :focus-visible by default, and enable + // on keyboard arrows (it'd then have to be disabled again, e.g. on keydown + // or container focus) + // + // alas, that is left for another day + [data-radix-collection-item]:focus-visible { + box-shadow: none !important; + } + .buttonList { .ToolIcon__icon { all: unset !important; @@ -670,6 +683,9 @@ body.excalidraw-cursor-resize * { } } + .main-menu { + z-index: var(--zIndex-ui-main-menu); + } .main-menu-trigger { @include filledButtonOnCanvas; } diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index c7d8693d86..2f12bf6041 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -85,8 +85,7 @@ "@excalidraw/math": "0.18.0", "@excalidraw/mermaid-to-excalidraw": "2.0.0-rfc3", "@excalidraw/random-username": "1.1.0", - "@radix-ui/react-popover": "1.1.6", - "@radix-ui/react-tabs": "1.1.3", + "radix-ui": "1.4.3", "browser-fs-access": "0.29.1", "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", diff --git a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap index 1d92ee37e0..f07f054977 100644 --- a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Test > should open mermaid popup when active tool is mermaid 1`] = ` -"