From df25de7e6816cccc72187aca7b1b865da7803fca Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Thu, 7 Aug 2025 15:38:58 +0200 Subject: [PATCH 01/48] feat: fix delta apply to issues (#9830) --- packages/element/src/delta.ts | 83 +++++++++---- packages/element/src/store.ts | 4 +- .../tests/__snapshots__/history.test.tsx.snap | 110 +++++++++++++++++- scripts/release.js | 4 +- 4 files changed, 174 insertions(+), 27 deletions(-) diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index bd428d8560..a86b5b523d 100644 --- a/packages/element/src/delta.ts +++ b/packages/element/src/delta.ts @@ -151,6 +151,16 @@ export class Delta { ); } + /** + * Merges two deltas into a new one. + */ + public static merge(delta1: Delta, delta2: Delta) { + return Delta.create( + { ...delta1.deleted, ...delta2.deleted }, + { ...delta1.inserted, ...delta2.inserted }, + ); + } + /** * Merges deleted and inserted object partials. */ @@ -497,6 +507,11 @@ export interface DeltaContainer { */ applyTo(previous: T, ...options: unknown[]): [T, boolean]; + /** + * Squashes the current delta with the given one. + */ + squash(delta: DeltaContainer): this; + /** * Checks whether all `Delta`s are empty. */ @@ -504,7 +519,7 @@ export interface DeltaContainer { } export class AppStateDelta implements DeltaContainer { - private constructor(public readonly delta: Delta) {} + private constructor(public delta: Delta) {} public static calculate( prevAppState: T, @@ -535,6 +550,11 @@ export class AppStateDelta implements DeltaContainer { return new AppStateDelta(inversedDelta); } + public squash(delta: AppStateDelta): this { + this.delta = Delta.merge(this.delta, delta.delta); + return this; + } + public applyTo( appState: AppState, nextElements: SceneElementsMap, @@ -1196,8 +1216,8 @@ export class ElementsDelta implements DeltaContainer { const inverseInternal = (deltas: Record>) => { const inversedDeltas: Record> = {}; - for (const [id, delta] of Object.entries(deltas)) { - inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted); + for (const [id, { inserted, deleted }] of Object.entries(deltas)) { + inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted }); } return inversedDeltas; @@ -1395,6 +1415,42 @@ export class ElementsDelta implements DeltaContainer { } } + public squash(delta: ElementsDelta): this { + const { added, removed, updated } = delta; + + for (const [id, nextDelta] of Object.entries(added)) { + const prevDelta = this.added[id]; + + if (!prevDelta) { + this.added[id] = nextDelta; + } else { + this.added[id] = Delta.merge(prevDelta, nextDelta); + } + } + + for (const [id, nextDelta] of Object.entries(removed)) { + const prevDelta = this.removed[id]; + + if (!prevDelta) { + this.removed[id] = nextDelta; + } else { + this.removed[id] = Delta.merge(prevDelta, nextDelta); + } + } + + for (const [id, nextDelta] of Object.entries(updated)) { + const prevDelta = this.updated[id]; + + if (!prevDelta) { + this.updated[id] = nextDelta; + } else { + this.updated[id] = Delta.merge(prevDelta, nextDelta); + } + } + + return this; + } + private static createApplier = ( nextElements: SceneElementsMap, @@ -1624,25 +1680,12 @@ export class ElementsDelta implements DeltaContainer { Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)), ); - // calculate complete deltas for affected elements, and assign them back to all the deltas - // technically we could do better here if perf. would become an issue - const { added, removed, updated } = ElementsDelta.calculate( - prevAffectedElements, - nextAffectedElements, + // calculate complete deltas for affected elements, and squash them back to the current deltas + this.squash( + // technically we could do better here if perf. would become an issue + ElementsDelta.calculate(prevAffectedElements, nextAffectedElements), ); - for (const [id, delta] of Object.entries(added)) { - this.added[id] = delta; - } - - for (const [id, delta] of Object.entries(removed)) { - this.removed[id] = delta; - } - - for (const [id, delta] of Object.entries(updated)) { - this.updated[id] = delta; - } - return nextAffectedElements; } diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index 2bf70f5814..56316c5e39 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -76,8 +76,9 @@ type MicroActionsQueue = (() => void)[]; * Store which captures the observed changes and emits them as `StoreIncrement` events. */ export class Store { - // internally used by history + // for internal use by history public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>(); + // for public use as part of onIncrement API public readonly onStoreIncrementEmitter = new Emitter< [DurableIncrement | EphemeralIncrement] >(); @@ -239,7 +240,6 @@ export class Store { if (!storeDelta.isEmpty()) { const increment = new DurableIncrement(storeChange, storeDelta); - // Notify listeners with the increment this.onDurableIncrementEmitter.trigger(increment); this.onStoreIncrementEmitter.trigger(increment); } diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 70269263de..9d7be4e0d0 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -2924,7 +2924,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 11, "width": 100, "x": 10, "y": 10, @@ -3001,7 +3001,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "textAlign": "left", "type": "text", "updated": 1, - "version": 5, + "version": 11, "verticalAlign": "top", "width": 30, "x": 15, @@ -3031,14 +3031,67 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "version": 9, }, "inserted": { + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, "containerId": null, + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 100, + "index": "a0", "isDeleted": false, + "lineHeight": "1.25000", + "link": null, + "locked": false, + "opacity": 100, + "originalText": "que pasa", + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "que pasa", + "textAlign": "left", + "type": "text", "version": 8, + "verticalAlign": "top", + "width": 100, + "x": 15, + "y": 15, }, }, }, "removed": {}, - "updated": {}, + "updated": { + "id0": { + "deleted": { + "boundElements": [], + "version": 11, + }, + "inserted": { + "boundElements": [ + { + "id": "id1", + "type": "text", + }, + ], + "version": 10, + }, + }, + "id5": { + "deleted": { + "version": 11, + }, + "inserted": { + "version": 9, + }, + }, + }, }, "id": "id9", }, @@ -5036,9 +5089,29 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "removed": { "id0": { "deleted": { + "angle": 0, + "backgroundColor": "transparent", "boundElements": [], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "index": "a0", "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", "version": 8, + "width": 100, + "x": 10, + "y": 10, }, "inserted": { "boundElements": [ @@ -5266,9 +5339,38 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "removed": { "id1": { "deleted": { + "angle": 0, + "autoResize": true, + "backgroundColor": "transparent", + "boundElements": null, "containerId": null, + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 5, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 100, + "index": "a0", "isDeleted": false, + "lineHeight": "1.25000", + "link": null, + "locked": false, + "opacity": 100, + "originalText": "que pasa", + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "que pasa", + "textAlign": "left", + "type": "text", "version": 8, + "verticalAlign": "top", + "width": 100, + "x": 15, + "y": 15, }, "inserted": { "containerId": "id0", @@ -5525,9 +5627,11 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "updated": { "id1": { "deleted": { + "frameId": null, "version": 10, }, "inserted": { + "frameId": null, "version": 8, }, }, diff --git a/scripts/release.js b/scripts/release.js index 3c75523411..f45afc66d2 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -144,7 +144,7 @@ const askToCommit = (tag, nextVersion) => { }); rl.question( - "Do you want to commit these changes to git? (Y/n): ", + "Would you like to commit these changes to git? (Y/n): ", (answer) => { rl.close(); @@ -189,7 +189,7 @@ const askToPublish = (tag, version) => { }); rl.question( - "Do you want to publish these changes to npm? (Y/n): ", + "Would you like to publish these changes to npm? (Y/n): ", (answer) => { rl.close(); From 9036812b6d6d7c38d092405b312114e9fd9fcb49 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Fri, 8 Aug 2025 09:30:11 +0200 Subject: [PATCH 02/48] fix: editing linear element (#9839) --- packages/element/src/delta.ts | 120 +++----- packages/element/src/store.ts | 17 +- packages/element/tests/delta.test.tsx | 21 +- packages/excalidraw/data/reconcile.ts | 4 +- packages/excalidraw/data/restore.ts | 1 + .../tests/__snapshots__/history.test.tsx.snap | 285 ++++++++---------- .../regressionTests.test.tsx.snap | 109 ++++--- packages/excalidraw/tests/history.test.tsx | 9 +- packages/excalidraw/types.ts | 6 +- 9 files changed, 264 insertions(+), 308 deletions(-) diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index a86b5b523d..87a23d0c2f 100644 --- a/packages/element/src/delta.ts +++ b/packages/element/src/delta.ts @@ -2,7 +2,6 @@ import { arrayToMap, arrayToObject, assertNever, - invariant, isDevEnv, isShallowEqual, isTestEnv, @@ -561,70 +560,50 @@ export class AppStateDelta implements DeltaContainer { ): [AppState, boolean] { try { const { - selectedElementIds: removedSelectedElementIds = {}, - selectedGroupIds: removedSelectedGroupIds = {}, + selectedElementIds: deletedSelectedElementIds = {}, + selectedGroupIds: deletedSelectedGroupIds = {}, } = this.delta.deleted; const { - selectedElementIds: addedSelectedElementIds = {}, - selectedGroupIds: addedSelectedGroupIds = {}, - selectedLinearElementId, - selectedLinearElementIsEditing, + selectedElementIds: insertedSelectedElementIds = {}, + selectedGroupIds: insertedSelectedGroupIds = {}, + selectedLinearElement: insertedSelectedLinearElement, ...directlyApplicablePartial } = this.delta.inserted; const mergedSelectedElementIds = Delta.mergeObjects( appState.selectedElementIds, - addedSelectedElementIds, - removedSelectedElementIds, + insertedSelectedElementIds, + deletedSelectedElementIds, ); const mergedSelectedGroupIds = Delta.mergeObjects( appState.selectedGroupIds, - addedSelectedGroupIds, - removedSelectedGroupIds, + insertedSelectedGroupIds, + deletedSelectedGroupIds, ); - let selectedLinearElement = appState.selectedLinearElement; - - if (selectedLinearElementId === null) { - // Unselect linear element (visible change) - selectedLinearElement = null; - } else if ( - selectedLinearElementId && - nextElements.has(selectedLinearElementId) - ) { - selectedLinearElement = new LinearElementEditor( - nextElements.get( - selectedLinearElementId, - ) as NonDeleted, - nextElements, - selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false - ); - } - - if ( - // Value being 'null' is equivaluent to unknown in this case because it only gets set - // to null when 'selectedLinearElementId' is set to null - selectedLinearElementIsEditing != null - ) { - invariant( - selectedLinearElement, - `selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`, - ); - - selectedLinearElement = { - ...selectedLinearElement, - isEditing: selectedLinearElementIsEditing, - }; - } + const selectedLinearElement = + insertedSelectedLinearElement && + nextElements.has(insertedSelectedLinearElement.elementId) + ? new LinearElementEditor( + nextElements.get( + insertedSelectedLinearElement.elementId, + ) as NonDeleted, + nextElements, + insertedSelectedLinearElement.isEditing, + ) + : null; const nextAppState = { ...appState, ...directlyApplicablePartial, selectedElementIds: mergedSelectedElementIds, selectedGroupIds: mergedSelectedGroupIds, - selectedLinearElement, + selectedLinearElement: + typeof insertedSelectedLinearElement !== "undefined" + ? selectedLinearElement + : appState.selectedLinearElement, }; const constainsVisibleChanges = this.filterInvisibleChanges( @@ -753,64 +732,53 @@ export class AppStateDelta implements DeltaContainer { } break; - case "selectedLinearElementId": { - const appStateKey = AppStateDelta.convertToAppStateKey(key); - const linearElement = nextAppState[appStateKey]; + case "selectedLinearElement": + const nextLinearElement = nextAppState[key]; - if (!linearElement) { + if (!nextLinearElement) { // previously there was a linear element (assuming visible), now there is none visibleDifferenceFlag.value = true; } else { - const element = nextElements.get(linearElement.elementId); + const element = nextElements.get(nextLinearElement.elementId); if (element && !element.isDeleted) { // previously there wasn't a linear element, now there is one which is visible visibleDifferenceFlag.value = true; } else { // there was assigned a linear element now, but it's deleted - nextAppState[appStateKey] = null; + nextAppState[key] = null; } } break; - } - case "selectedLinearElementIsEditing": { - // Changes in editing state are always visible - const prevIsEditing = - prevAppState.selectedLinearElement?.isEditing ?? false; - const nextIsEditing = - nextAppState.selectedLinearElement?.isEditing ?? false; - - if (prevIsEditing !== nextIsEditing) { - visibleDifferenceFlag.value = true; - } - break; - } - case "lockedMultiSelections": { + case "lockedMultiSelections": const prevLockedUnits = prevAppState[key] || {}; const nextLockedUnits = nextAppState[key] || {}; + // TODO: this seems wrong, we are already doing this comparison generically above, + // hence instead we should check whether elements are actually visible, + // so that once these changes are applied they actually result in a visible change to the user if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) { visibleDifferenceFlag.value = true; } break; - } - case "activeLockedId": { + case "activeLockedId": const prevHitLockedId = prevAppState[key] || null; const nextHitLockedId = nextAppState[key] || null; + // TODO: this seems wrong, we are already doing this comparison generically above, + // hence instead we should check whether elements are actually visible, + // so that once these changes are applied they actually result in a visible change to the user if (prevHitLockedId !== nextHitLockedId) { visibleDifferenceFlag.value = true; } break; - } - default: { + default: assertNever( key, `Unknown ObservedElementsAppState's key "${key}"`, true, ); - } } } } @@ -818,15 +786,6 @@ export class AppStateDelta implements DeltaContainer { return visibleDifferenceFlag.value; } - private static convertToAppStateKey( - key: keyof Pick, - ): keyof Pick { - switch (key) { - case "selectedLinearElementId": - return "selectedLinearElement"; - } - } - private static filterSelectedElements( selectedElementIds: AppState["selectedElementIds"], elements: SceneElementsMap, @@ -891,8 +850,7 @@ export class AppStateDelta implements DeltaContainer { editingGroupId, selectedGroupIds, selectedElementIds, - selectedLinearElementId, - selectedLinearElementIsEditing, + selectedLinearElement, croppingElementId, lockedMultiSelections, activeLockedId, diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index 56316c5e39..e304486ffa 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -978,8 +978,7 @@ const getDefaultObservedAppState = (): ObservedAppState => { viewBackgroundColor: COLOR_PALETTE.white, selectedElementIds: {}, selectedGroupIds: {}, - selectedLinearElementId: null, - selectedLinearElementIsEditing: null, + selectedLinearElement: null, croppingElementId: null, activeLockedId: null, lockedMultiSelections: {}, @@ -998,14 +997,12 @@ export const getObservedAppState = ( croppingElementId: appState.croppingElementId, activeLockedId: appState.activeLockedId, lockedMultiSelections: appState.lockedMultiSelections, - selectedLinearElementId: - (appState as AppState).selectedLinearElement?.elementId ?? - (appState as ObservedAppState).selectedLinearElementId ?? - null, - selectedLinearElementIsEditing: - (appState as AppState).selectedLinearElement?.isEditing ?? - (appState as ObservedAppState).selectedLinearElementIsEditing ?? - null, + selectedLinearElement: appState.selectedLinearElement + ? { + elementId: appState.selectedLinearElement.elementId, + isEditing: !!appState.selectedLinearElement.isEditing, + } + : null, }; Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { diff --git a/packages/element/tests/delta.test.tsx b/packages/element/tests/delta.test.tsx index 4d56aac834..89501c8663 100644 --- a/packages/element/tests/delta.test.tsx +++ b/packages/element/tests/delta.test.tsx @@ -7,7 +7,10 @@ describe("AppStateDelta", () => { describe("ensure stable delta properties order", () => { it("should maintain stable order for root properties", () => { const name = "untitled scene"; - const selectedLinearElementId = "id1" as LinearElementEditor["elementId"]; + const selectedLinearElement = { + elementId: "id1" as LinearElementEditor["elementId"], + isEditing: false, + }; const commonAppState = { viewBackgroundColor: "#ffffff", @@ -24,23 +27,23 @@ describe("AppStateDelta", () => { const prevAppState1: ObservedAppState = { ...commonAppState, name: "", - selectedLinearElementId: null, + selectedLinearElement: null, }; const nextAppState1: ObservedAppState = { ...commonAppState, name, - selectedLinearElementId, + selectedLinearElement, }; const prevAppState2: ObservedAppState = { - selectedLinearElementId: null, + selectedLinearElement: null, name: "", ...commonAppState, }; const nextAppState2: ObservedAppState = { - selectedLinearElementId, + selectedLinearElement, name, ...commonAppState, }; @@ -58,9 +61,7 @@ describe("AppStateDelta", () => { selectedGroupIds: {}, editingGroupId: null, croppingElementId: null, - selectedLinearElementId: null, - selectedLinearElementIsEditing: null, - editingLinearElementId: null, + selectedLinearElement: null, activeLockedId: null, lockedMultiSelections: {}, }; @@ -106,9 +107,7 @@ describe("AppStateDelta", () => { selectedElementIds: {}, editingGroupId: null, croppingElementId: null, - selectedLinearElementId: null, - selectedLinearElementIsEditing: null, - editingLinearElementId: null, + selectedLinearElement: null, activeLockedId: null, lockedMultiSelections: {}, }; diff --git a/packages/excalidraw/data/reconcile.ts b/packages/excalidraw/data/reconcile.ts index d758796a78..a93f951003 100644 --- a/packages/excalidraw/data/reconcile.ts +++ b/packages/excalidraw/data/reconcile.ts @@ -20,7 +20,7 @@ export type ReconciledExcalidrawElement = OrderedExcalidrawElement & export type RemoteExcalidrawElement = OrderedExcalidrawElement & MakeBrand<"RemoteExcalidrawElement">; -const shouldDiscardRemoteElement = ( +export const shouldDiscardRemoteElement = ( localAppState: AppState, local: OrderedExcalidrawElement | undefined, remote: RemoteExcalidrawElement, @@ -30,7 +30,7 @@ const shouldDiscardRemoteElement = ( // local element is being edited (local.id === localAppState.editingTextElement?.id || local.id === localAppState.resizingElement?.id || - local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array + local.id === localAppState.newElement?.id || // local element is newer local.version > remote.version || // resolve conflicting edits deterministically by taking the one with diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index a609c0a0eb..c7ec8d728e 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -291,6 +291,7 @@ const restoreElement = ( // if empty text, mark as deleted. We keep in array // for data integrity purposes (collab etc.) if (!text && !element.isDeleted) { + // TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded) element = { ...element, originalText: text, isDeleted: true }; element = bumpVersion(element); } diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 9d7be4e0d0..50f08c4c23 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -543,13 +543,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "selectedElementIds": { "id4": true, }, - "selectedLinearElementId": "id4", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id4", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -1026,13 +1027,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "selectedElementIds": { "id4": true, }, - "selectedLinearElementId": "id4", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id4", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -2414,13 +2416,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "selectedElementIds": { "id4": true, }, - "selectedLinearElementId": "id4", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id4", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -7028,9 +7031,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "scrollX": 0, "scrollY": 0, "searchMatches": null, - "selectedElementIds": { - "id0": true, - }, + "selectedElementIds": {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, "selectionElement": null, @@ -7131,74 +7132,70 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "elements": { "added": {}, - "removed": { - "id0": { - "deleted": { - "angle": 0, - "backgroundColor": "transparent", - "boundElements": null, - "customData": undefined, - "elbowed": false, - "endArrowhead": "arrow", - "endBinding": null, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 10, - "index": "a0", - "isDeleted": false, - "lastCommittedPoint": [ - 10, - 10, - ], - "link": null, - "locked": false, - "opacity": 100, - "points": [ - [ - 0, - 0, - ], - [ - 10, - 10, - ], - ], - "roughness": 1, - "roundness": { - "type": 2, - }, - "startArrowhead": null, - "startBinding": null, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "arrow", - "version": 6, - "width": 10, - "x": 0, - "y": 0, + "removed": {}, + "updated": {}, + }, + "id": "id13", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, }, - "inserted": { - "isDeleted": true, - "version": 5, + }, + "inserted": { + "selectedLinearElement": null, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id14", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedLinearElement": { + "elementId": "id0", + "isEditing": true, + }, + }, + "inserted": { + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, }, }, }, + }, + "elements": { + "added": {}, + "removed": {}, "updated": {}, }, - "id": "id2", + "id": "id15", }, { "appState": AppStateDelta { "delta": Delta { "deleted": { - "selectedLinearElementId": "id0", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": true, + }, }, }, }, @@ -7207,43 +7204,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "removed": {}, "updated": {}, }, - "id": "id4", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElementIsEditing": true, - }, - "inserted": { - "selectedLinearElementIsEditing": false, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id6", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElementIsEditing": false, - }, - "inserted": { - "selectedLinearElementIsEditing": true, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id10", + "id": "id16", }, ] `; @@ -10210,12 +10171,13 @@ exports[`history > multiplayer undo/redo > should override remotely added points "appState": AppStateDelta { "delta": Delta { "deleted": { - "selectedLinearElementId": "id0", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -15770,13 +15732,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "selectedElementIds": { "id13": true, }, - "selectedLinearElementId": "id13", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id13", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -16064,15 +16027,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "selectedElementIds": { "id13": true, }, - "selectedLinearElementId": "id13", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id13", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": { "id0": true, }, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -16688,15 +16652,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "selectedElementIds": { "id13": true, }, - "selectedLinearElementId": "id13", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id13", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": { "id0": true, }, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -17320,15 +17285,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "selectedElementIds": { "id13": true, }, - "selectedLinearElementId": "id13", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id13", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": { "id0": true, }, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -18017,15 +17983,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "selectedElementIds": { "id13": true, }, - "selectedLinearElementId": "id13", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id13", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": { "id0": true, }, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -18132,15 +18099,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "selectedElementIds": { "id0": true, }, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, "inserted": { "selectedElementIds": { "id13": true, }, - "selectedLinearElementId": "id13", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id13", + "isEditing": false, + }, }, }, }, @@ -18744,15 +18712,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "selectedElementIds": { "id13": true, }, - "selectedLinearElementId": "id13", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id13", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": { "id0": true, }, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -18859,15 +18828,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "selectedElementIds": { "id0": true, }, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, "inserted": { "selectedElementIds": { "id13": true, }, - "selectedLinearElementId": "id13", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id13", + "isEditing": false, + }, }, }, }, @@ -20656,12 +20626,13 @@ exports[`history > singleplayer undo/redo > should support linear element creati "appState": AppStateDelta { "delta": Delta { "deleted": { - "selectedLinearElementId": "id0", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -20676,10 +20647,16 @@ exports[`history > singleplayer undo/redo > should support linear element creati "appState": AppStateDelta { "delta": Delta { "deleted": { - "selectedLinearElementIsEditing": true, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": true, + }, }, "inserted": { - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, }, }, @@ -20747,10 +20724,16 @@ exports[`history > singleplayer undo/redo > should support linear element creati "appState": AppStateDelta { "delta": Delta { "deleted": { - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { - "selectedLinearElementIsEditing": true, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": true, + }, }, }, }, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index f22cfd28cb..a895eb6366 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -6394,15 +6394,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "selectedElementIds": { "id9": true, }, - "selectedLinearElementId": "id9", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id9", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": { "id6": true, }, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -6470,13 +6471,19 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "selectedElementIds": { "id12": true, }, - "selectedLinearElementId": "id12", + "selectedLinearElement": { + "elementId": "id12", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": { "id9": true, }, - "selectedLinearElementId": "id9", + "selectedLinearElement": { + "elementId": "id9", + "isEditing": false, + }, }, }, }, @@ -6542,15 +6549,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "selectedElementIds": { "id15": true, }, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, "inserted": { "selectedElementIds": { "id12": true, }, - "selectedLinearElementId": "id12", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id12", + "isEditing": false, + }, }, }, }, @@ -6677,12 +6685,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "appState": AppStateDelta { "delta": Delta { "deleted": { - "selectedLinearElementId": "id15", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id15", + "isEditing": false, + }, }, "inserted": { - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -6700,15 +6709,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "selectedElementIds": { "id22": true, }, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, "inserted": { "selectedElementIds": { "id15": true, }, - "selectedLinearElementId": "id15", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id15", + "isEditing": false, + }, }, }, }, @@ -6833,12 +6843,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "appState": AppStateDelta { "delta": Delta { "deleted": { - "selectedLinearElementId": "id22", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id22", + "isEditing": false, + }, }, "inserted": { - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -6873,12 +6884,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "appState": AppStateDelta { "delta": Delta { "deleted": { - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, "inserted": { - "selectedLinearElementId": "id22", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id22", + "isEditing": false, + }, }, }, }, @@ -8719,13 +8731,14 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack "selectedElementIds": { "id0": true, }, - "selectedLinearElementId": "id0", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -8946,13 +8959,14 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1 "selectedElementIds": { "id0": true, }, - "selectedLinearElementId": "id0", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -9365,13 +9379,14 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack "selectedElementIds": { "id0": true, }, - "selectedLinearElementId": "id0", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -9770,13 +9785,14 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1 "selectedElementIds": { "id0": true, }, - "selectedLinearElementId": "id0", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, }, }, @@ -14494,12 +14510,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "appState": AppStateDelta { "delta": Delta { "deleted": { - "selectedLinearElementId": null, - "selectedLinearElementIsEditing": null, + "selectedLinearElement": null, }, "inserted": { - "selectedLinearElementId": "id6", - "selectedLinearElementIsEditing": false, + "selectedLinearElement": { + "elementId": "id6", + "isEditing": false, + }, }, }, }, diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index a0953b8d47..707fe4e48c 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -3043,15 +3043,14 @@ describe("history", () => { }); Keyboard.undo(); - expect(API.getUndoStack().length).toBe(3); - expect(API.getRedoStack().length).toBe(1); - expect(h.state.selectedLinearElement).not.toBeNull(); - expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(API.getUndoStack().length).toBe(0); + expect(API.getRedoStack().length).toBe(4); + expect(h.state.selectedLinearElement).toBeNull(); Keyboard.redo(); expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(0); - expect(h.state.selectedLinearElement).not.toBeNull(); + expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index c265d7ca61..e321b34cba 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -248,8 +248,10 @@ export type ObservedElementsAppState = { editingGroupId: AppState["editingGroupId"]; selectedElementIds: AppState["selectedElementIds"]; selectedGroupIds: AppState["selectedGroupIds"]; - selectedLinearElementId: LinearElementEditor["elementId"] | null; - selectedLinearElementIsEditing: boolean | null; + selectedLinearElement: { + elementId: LinearElementEditor["elementId"]; + isEditing: boolean; + } | null; croppingElementId: AppState["croppingElementId"]; lockedMultiSelections: AppState["lockedMultiSelections"]; activeLockedId: AppState["activeLockedId"]; From cc8e490c7507cfa6810bae0be3fdc873e0317b3d Mon Sep 17 00:00:00 2001 From: zsviczian Date: Mon, 11 Aug 2025 11:52:44 +0200 Subject: [PATCH 03/48] fix: do not auto-add elements to locked frame (#9851) * Do not return locked frames when filtering for top level frame * lint * lint * lint --- packages/excalidraw/components/App.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b381ad0f13..548df6f9d7 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5708,8 +5708,9 @@ class App extends React.Component { const elementsMap = this.scene.getNonDeletedElementsMap(); const frames = this.scene .getNonDeletedFramesLikes() - .filter((frame): frame is ExcalidrawFrameLikeElement => - isCursorInFrame(sceneCoords, frame, elementsMap), + .filter( + (frame): frame is ExcalidrawFrameLikeElement => + !frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap), ); return frames.length ? frames[frames.length - 1] : null; From 54c148f3902de857c813a0e7fdd598c21fc67214 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Tue, 12 Aug 2025 09:27:04 +0200 Subject: [PATCH 04/48] fix: text restore & deletion issues (#9853) --- excalidraw-app/data/firebase.ts | 4 +- excalidraw-app/data/index.ts | 7 +- packages/element/src/delta.ts | 39 +++++- packages/element/tests/delta.test.tsx | 69 +++++++++- packages/excalidraw/components/App.tsx | 27 ++-- packages/excalidraw/data/blob.ts | 6 +- packages/excalidraw/data/restore.ts | 26 +++- packages/excalidraw/index.tsx | 1 + .../tests/__snapshots__/history.test.tsx.snap | 128 +++++++----------- .../regressionTests.test.tsx.snap | 38 +----- .../excalidraw/tests/data/restore.test.ts | 24 +++- packages/excalidraw/tests/history.test.tsx | 4 +- .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 8 +- packages/utils/src/export.ts | 2 + 14 files changed, 229 insertions(+), 154 deletions(-) diff --git a/excalidraw-app/data/firebase.ts b/excalidraw-app/data/firebase.ts index 568054f7ef..d05966df51 100644 --- a/excalidraw-app/data/firebase.ts +++ b/excalidraw-app/data/firebase.ts @@ -259,7 +259,9 @@ export const loadFromFirebase = async ( } const storedScene = docSnap.data() as FirebaseStoredScene; const elements = getSyncableElements( - restoreElements(await decryptElements(storedScene, roomKey), null), + restoreElements(await decryptElements(storedScene, roomKey), null, { + deleteEmptyTextElements: true, + }), ); if (socket) { diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 75aa278779..73512f3786 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -258,11 +258,16 @@ export const loadScene = async ( await importFromBackend(id, privateKey), localDataState?.appState, localDataState?.elements, - { repairBindings: true, refreshDimensions: false }, + { + repairBindings: true, + refreshDimensions: false, + deleteEmptyTextElements: true, + }, ); } else { data = restore(localDataState || null, null, null, { repairBindings: true, + deleteEmptyTextElements: true, }); } diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index 87a23d0c2f..38839fa84c 100644 --- a/packages/element/src/delta.ts +++ b/packages/element/src/delta.ts @@ -1088,7 +1088,7 @@ export class ElementsDelta implements DeltaContainer { const nextElement = nextElements.get(prevElement.id); if (!nextElement) { - const deleted = { ...prevElement, isDeleted: false } as ElementPartial; + const deleted = { ...prevElement } as ElementPartial; const inserted = { isDeleted: true, @@ -1102,7 +1102,10 @@ export class ElementsDelta implements DeltaContainer { ElementsDelta.stripIrrelevantProps, ); - removed[prevElement.id] = delta; + // ignore updates which would "delete" already deleted element + if (!prevElement.isDeleted) { + removed[prevElement.id] = delta; + } } } @@ -1118,7 +1121,6 @@ export class ElementsDelta implements DeltaContainer { const inserted = { ...nextElement, - isDeleted: false, } as ElementPartial; const delta = Delta.create( @@ -1127,7 +1129,10 @@ export class ElementsDelta implements DeltaContainer { ElementsDelta.stripIrrelevantProps, ); - added[nextElement.id] = delta; + // ignore updates which would "delete" already deleted element + if (!nextElement.isDeleted) { + added[nextElement.id] = delta; + } continue; } @@ -1156,8 +1161,13 @@ export class ElementsDelta implements DeltaContainer { continue; } - // making sure there are at least some changes - if (!Delta.isEmpty(delta)) { + const strippedDeleted = ElementsDelta.stripVersionProps(delta.deleted); + const strippedInserted = ElementsDelta.stripVersionProps( + delta.inserted, + ); + + // making sure there are at least some changes and only changed version & versionNonce does not count! + if (Delta.isInnerDifferent(strippedDeleted, strippedInserted, true)) { updated[nextElement.id] = delta; } } @@ -1273,8 +1283,15 @@ export class ElementsDelta implements DeltaContainer { latestDelta = delta; } + const strippedDeleted = ElementsDelta.stripVersionProps( + latestDelta.deleted, + ); + const strippedInserted = ElementsDelta.stripVersionProps( + latestDelta.inserted, + ); + // it might happen that after applying latest changes the delta itself does not contain any changes - if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) { + if (Delta.isInnerDifferent(strippedDeleted, strippedInserted)) { modifiedDeltas[id] = latestDelta; } } @@ -1854,4 +1871,12 @@ export class ElementsDelta implements DeltaContainer { return strippedPartial; } + + private static stripVersionProps( + partial: Partial, + ): ElementPartial { + const { version, versionNonce, ...strippedPartial } = partial; + + return strippedPartial; + } } diff --git a/packages/element/tests/delta.test.tsx b/packages/element/tests/delta.test.tsx index 89501c8663..81c9d4591c 100644 --- a/packages/element/tests/delta.test.tsx +++ b/packages/element/tests/delta.test.tsx @@ -1,7 +1,74 @@ +import { API } from "@excalidraw/excalidraw/tests/helpers/api"; + import type { ObservedAppState } from "@excalidraw/excalidraw/types"; import type { LinearElementEditor } from "@excalidraw/element"; +import type { SceneElementsMap } from "@excalidraw/element/types"; -import { AppStateDelta } from "../src/delta"; +import { AppStateDelta, ElementsDelta } from "../src/delta"; + +describe("ElementsDelta", () => { + describe("elements delta calculation", () => { + it("should not create removed delta when element gets removed but was already deleted", () => { + const element = API.createElement({ + type: "rectangle", + x: 100, + y: 100, + isDeleted: true, + }); + + const prevElements = new Map([[element.id, element]]); + const nextElements = new Map(); + + const delta = ElementsDelta.calculate(prevElements, nextElements); + + expect(delta.isEmpty()).toBeTruthy(); + }); + + it("should not create added delta when adding element as already deleted", () => { + const element = API.createElement({ + type: "rectangle", + x: 100, + y: 100, + isDeleted: true, + }); + + const prevElements = new Map(); + const nextElements = new Map([[element.id, element]]); + + const delta = ElementsDelta.calculate(prevElements, nextElements); + + expect(delta.isEmpty()).toBeTruthy(); + }); + + it("should not create updated delta when there is only version and versionNonce change", () => { + const baseElement = API.createElement({ + type: "rectangle", + x: 100, + y: 100, + strokeColor: "#000000", + backgroundColor: "#ffffff", + }); + + const modifiedElement = { + ...baseElement, + version: baseElement.version + 1, + versionNonce: baseElement.versionNonce + 1, + }; + + // Create maps for the delta calculation + const prevElements = new Map([[baseElement.id, baseElement]]); + const nextElements = new Map([[modifiedElement.id, modifiedElement]]); + + // Calculate the delta + const delta = ElementsDelta.calculate( + prevElements as SceneElementsMap, + nextElements as SceneElementsMap, + ); + + expect(delta.isEmpty()).toBeTruthy(); + }); + }); +}); describe("AppStateDelta", () => { describe("ensure stable delta properties order", () => { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 548df6f9d7..40a942e96b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2342,7 +2342,10 @@ class App extends React.Component { }, }; } - const scene = restore(initialData, null, null, { repairBindings: true }); + const scene = restore(initialData, null, null, { + repairBindings: true, + deleteEmptyTextElements: true, + }); scene.appState = { ...scene.appState, theme: this.props.theme || scene.appState.theme, @@ -3200,7 +3203,9 @@ class App extends React.Component { retainSeed?: boolean; fitToContent?: boolean; }) => { - const elements = restoreElements(opts.elements, null, undefined); + const elements = restoreElements(opts.elements, null, { + deleteEmptyTextElements: true, + }); const [minX, minY, maxX, maxY] = getCommonBounds(elements); const elementsCenterX = distance(minX, maxX) / 2; @@ -4927,17 +4932,8 @@ class App extends React.Component { }), onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { const isDeleted = !nextOriginalText.trim(); + updateElement(nextOriginalText, isDeleted); - if (isDeleted && !isExistingElement) { - // let's just remove the element from the scene, as it's an empty just created text element - this.scene.replaceAllElements( - this.scene - .getElementsIncludingDeleted() - .filter((x) => x.id !== element.id), - ); - } else { - updateElement(nextOriginalText, isDeleted); - } // select the created text element only if submitting via keyboard // (when submitting via click it should act as signal to deselect) if (!isDeleted && viaKeyboard) { @@ -4961,15 +4957,16 @@ class App extends React.Component { })); }); } + if (isDeleted) { fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [ element, ]); } - // we need to record either way, whether the text element was added or removed - // since we need to sync this delta to other clients, otherwise it would end up with inconsistencies - this.store.scheduleCapture(); + if (!isDeleted || isExistingElement) { + this.store.scheduleCapture(); + } flushSync(() => { this.setState({ diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index 3e5db7c29e..5db4c6cfe0 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -170,7 +170,11 @@ export const loadSceneOrLibraryFromBlob = async ( }, localAppState, localElements, - { repairBindings: true, refreshDimensions: false }, + { + repairBindings: true, + refreshDimensions: false, + deleteEmptyTextElements: true, + }, ), }; } else if (isValidLibrary(data)) { diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index c7ec8d728e..10addb47e5 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -241,8 +241,9 @@ const restoreElementWithProperties = < return ret; }; -const restoreElement = ( +export const restoreElement = ( element: Exclude, + opts?: { deleteEmptyTextElements?: boolean }, ): typeof element | null => { element = { ...element }; @@ -290,7 +291,7 @@ const restoreElement = ( // if empty text, mark as deleted. We keep in array // for data integrity purposes (collab etc.) - if (!text && !element.isDeleted) { + if (opts?.deleteEmptyTextElements && !text && !element.isDeleted) { // TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded) element = { ...element, originalText: text, isDeleted: true }; element = bumpVersion(element); @@ -524,7 +525,13 @@ export const restoreElements = ( elements: ImportedDataState["elements"], /** NOTE doesn't serve for reconciliation */ localElements: readonly ExcalidrawElement[] | null | undefined, - opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined, + opts?: + | { + refreshDimensions?: boolean; + repairBindings?: boolean; + deleteEmptyTextElements?: boolean; + } + | undefined, ): OrderedExcalidrawElement[] => { // used to detect duplicate top-level element ids const existingIds = new Set(); @@ -534,7 +541,12 @@ export const restoreElements = ( // filtering out selection, which is legacy, no longer kept in elements, // and causing issues if retained if (element.type !== "selection" && !isInvisiblySmallElement(element)) { - let migratedElement: ExcalidrawElement | null = restoreElement(element); + let migratedElement: ExcalidrawElement | null = restoreElement( + element, + { + deleteEmptyTextElements: opts?.deleteEmptyTextElements, + }, + ); if (migratedElement) { const localElement = localElementsMap?.get(element.id); if (localElement && localElement.version > migratedElement.version) { @@ -791,7 +803,11 @@ export const restore = ( */ localAppState: Partial | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined, - elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean }, + elementsConfig?: { + refreshDimensions?: boolean; + repairBindings?: boolean; + deleteEmptyTextElements?: boolean; + }, ): RestoredDataState => { return { elements: restoreElements(data?.elements, localElements, elementsConfig), diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index a592e2ea91..1b1f830439 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -229,6 +229,7 @@ export { defaultLang, useI18n, languages } from "./i18n"; export { restore, restoreAppState, + restoreElement, restoreElements, restoreLibraryItems, } from "./data/restore"; diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 50f08c4c23..c9a2992838 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -282,14 +282,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "added": {}, "removed": {}, "updated": { - "id0": { - "deleted": { - "version": 17, - }, - "inserted": { - "version": 15, - }, - }, "id1": { "deleted": { "boundElements": [], @@ -404,14 +396,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "version": 17, }, }, - "id15": { - "deleted": { - "version": 14, - }, - "inserted": { - "version": 12, - }, - }, "id4": { "deleted": { "height": "99.19972", @@ -853,14 +837,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "added": {}, "removed": {}, "updated": { - "id0": { - "deleted": { - "version": 18, - }, - "inserted": { - "version": 16, - }, - }, "id1": { "deleted": { "boundElements": [], @@ -2656,7 +2632,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "height": 100, "id": "id0", "index": "a0", - "isDeleted": false, + "isDeleted": true, "link": null, "locked": false, "opacity": 100, @@ -2667,7 +2643,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 6, + "version": 9, "width": 100, "x": 10, "y": 10, @@ -2719,7 +2695,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id0", + "containerId": null, "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -2744,7 +2720,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "textAlign": "left", "type": "text", "updated": 1, - "version": 7, + "version": 10, "verticalAlign": "top", "width": 30, "x": 15, @@ -2766,15 +2742,49 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, }, "elements": { - "added": {}, + "added": { + "id0": { + "deleted": { + "isDeleted": true, + "version": 9, + }, + "inserted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 8, + "width": 100, + "x": 10, + "y": 10, + }, + }, + }, "removed": {}, "updated": { "id5": { "deleted": { - "version": 7, + "containerId": null, + "version": 10, }, "inserted": { - "version": 5, + "containerId": "id0", + "version": 9, }, }, }, @@ -3086,14 +3096,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "version": 10, }, }, - "id5": { - "deleted": { - "version": 11, - }, - "inserted": { - "version": 9, - }, - }, }, }, "id": "id9", @@ -4643,15 +4645,15 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "id1": { "deleted": { "angle": 0, - "version": 5, + "version": 4, "x": 15, "y": 15, }, "inserted": { - "angle": 0, - "version": 7, - "x": 15, - "y": 15, + "angle": 90, + "version": 3, + "x": 205, + "y": 205, }, }, }, @@ -5630,12 +5632,12 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "updated": { "id1": { "deleted": { - "frameId": null, - "version": 10, + "frameId": "id0", + "version": 5, }, "inserted": { "frameId": null, - "version": 8, + "version": 6, }, }, }, @@ -15773,14 +15775,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 5, }, }, - "id1": { - "deleted": { - "version": 6, - }, - "inserted": { - "version": 4, - }, - }, "id2": { "deleted": { "boundElements": [ @@ -16742,14 +16736,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 5, }, }, - "id1": { - "deleted": { - "version": 8, - }, - "inserted": { - "version": 6, - }, - }, "id2": { "deleted": { "boundElements": [ @@ -17375,14 +17361,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 9, }, }, - "id1": { - "deleted": { - "version": 12, - }, - "inserted": { - "version": 10, - }, - }, "id2": { "deleted": { "boundElements": [ @@ -17744,14 +17722,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 7, }, }, - "id2": { - "deleted": { - "version": 5, - }, - "inserted": { - "version": 3, - }, - }, }, }, "id": "id21", diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index a895eb6366..c16cd9884f 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -2216,16 +2216,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] undo }, }, }, - "updated": { - "id0": { - "deleted": { - "version": 5, - }, - "inserted": { - "version": 3, - }, - }, - }, + "updated": {}, }, "id": "id6", }, @@ -10901,32 +10892,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] undo s }, }, }, - "updated": { - "id0": { - "deleted": { - "version": 6, - }, - "inserted": { - "version": 4, - }, - }, - "id3": { - "deleted": { - "version": 6, - }, - "inserted": { - "version": 4, - }, - }, - "id6": { - "deleted": { - "version": 6, - }, - "inserted": { - "version": 4, - }, - }, - }, + "updated": {}, }, "id": "id21", }, diff --git a/packages/excalidraw/tests/data/restore.test.ts b/packages/excalidraw/tests/data/restore.test.ts index cc38073821..1cae91416b 100644 --- a/packages/excalidraw/tests/data/restore.test.ts +++ b/packages/excalidraw/tests/data/restore.test.ts @@ -85,6 +85,23 @@ describe("restoreElements", () => { }); }); + it("should not delete empty text element when deleteEmptyTextElements is not defined", () => { + const textElement = API.createElement({ + type: "text", + text: "", + isDeleted: false, + }); + + const restoredElements = restore.restoreElements([textElement], null); + + expect(restoredElements).toEqual([ + expect.objectContaining({ + id: textElement.id, + isDeleted: false, + }), + ]); + }); + it("should restore text element correctly with unknown font family, null text and undefined alignment", () => { const textElement: any = API.createElement({ type: "text", @@ -97,10 +114,9 @@ describe("restoreElements", () => { textElement.font = "10 unknown"; expect(textElement.isDeleted).toBe(false); - const restoredText = restore.restoreElements( - [textElement], - null, - )[0] as ExcalidrawTextElement; + const restoredText = restore.restoreElements([textElement], null, { + deleteEmptyTextElements: true, + })[0] as ExcalidrawTextElement; expect(restoredText.isDeleted).toBe(true); expect(restoredText).toMatchSnapshot({ seed: expect.any(Number), diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 707fe4e48c..09510e5ebb 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -4055,7 +4055,7 @@ describe("history", () => { expect.objectContaining({ id: container.id, boundElements: [{ id: remoteText.id, type: "text" }], - isDeleted: false, // isDeleted got remotely updated to false + isDeleted: true, }), expect.objectContaining({ id: text.id, @@ -4065,7 +4065,7 @@ describe("history", () => { expect.objectContaining({ id: remoteText.id, // unbound - containerId: container.id, + containerId: null, isDeleted: false, }), ]); diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx index 08301a3042..b03fab7391 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx @@ -704,7 +704,7 @@ describe("textWysiwyg", () => { rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ); - expect(h.elements.length).toBe(2); + expect(h.elements.length).toBe(3); text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); @@ -1198,7 +1198,11 @@ describe("textWysiwyg", () => { updateTextEditor(editor, " "); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([]); - expect(h.elements[1]).toBeUndefined(); + expect(h.elements[1]).toEqual( + expect.objectContaining({ + isDeleted: true, + }), + ); }); it("should restore original container height and clear cache once text is unbind", async () => { diff --git a/packages/utils/src/export.ts b/packages/utils/src/export.ts index 4559fe1af8..7821b51608 100644 --- a/packages/utils/src/export.ts +++ b/packages/utils/src/export.ts @@ -49,6 +49,7 @@ export const exportToCanvas = ({ { elements, appState }, null, null, + { deleteEmptyTextElements: true }, ); const { exportBackground, viewBackgroundColor } = restoredAppState; return _exportToCanvas( @@ -179,6 +180,7 @@ export const exportToSvg = async ({ { elements, appState }, null, null, + { deleteEmptyTextElements: true }, ); const exportAppState = { From dda3affcb081c40e2333cc2019dddadd3a37609f Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:56:11 +0200 Subject: [PATCH 05/48] fix: do not strip invisible elements from array (#9844) --- excalidraw-app/data/firebase.ts | 2 +- excalidraw-app/data/index.ts | 4 +- .../excalidraw/actions/actionFinalize.tsx | 29 +++++++-- packages/excalidraw/components/App.tsx | 4 +- packages/excalidraw/data/blob.ts | 2 +- packages/excalidraw/data/restore.ts | 61 +++++++++++-------- .../excalidraw/tests/data/restore.test.ts | 30 +++++++-- packages/excalidraw/tests/dragCreate.test.tsx | 14 ++++- packages/utils/src/export.ts | 4 +- 9 files changed, 104 insertions(+), 46 deletions(-) diff --git a/excalidraw-app/data/firebase.ts b/excalidraw-app/data/firebase.ts index d05966df51..4e4c60b291 100644 --- a/excalidraw-app/data/firebase.ts +++ b/excalidraw-app/data/firebase.ts @@ -260,7 +260,7 @@ export const loadFromFirebase = async ( const storedScene = docSnap.data() as FirebaseStoredScene; const elements = getSyncableElements( restoreElements(await decryptElements(storedScene, roomKey), null, { - deleteEmptyTextElements: true, + deleteInvisibleElements: true, }), ); diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 73512f3786..cc7d5e8cc9 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -261,13 +261,13 @@ export const loadScene = async ( { repairBindings: true, refreshDimensions: false, - deleteEmptyTextElements: true, + deleteInvisibleElements: true, }, ); } else { data = restore(localDataState || null, null, null, { repairBindings: true, - deleteEmptyTextElements: true, + deleteInvisibleElements: true, }); } diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 9baeb0b6f0..f9ff6e79f7 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -5,7 +5,11 @@ import { bindOrUnbindLinearElement, isBindingEnabled, } from "@excalidraw/element/binding"; -import { isValidPolygon, LinearElementEditor } from "@excalidraw/element"; +import { + isValidPolygon, + LinearElementEditor, + newElementWith, +} from "@excalidraw/element"; import { isBindingElement, @@ -78,7 +82,14 @@ export const actionFinalize = register({ let newElements = elements; if (element && isInvisiblySmallElement(element)) { // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want - newElements = newElements.filter((el) => el.id !== element!.id); + newElements = newElements.map((el) => { + if (el.id === element.id) { + return newElementWith(el, { + isDeleted: true, + }); + } + return el; + }); } return { elements: newElements, @@ -117,7 +128,12 @@ export const actionFinalize = register({ return { elements: element.points.length < 2 || isInvisiblySmallElement(element) - ? elements.filter((el) => el.id !== element.id) + ? elements.map((el) => { + if (el.id === element.id) { + return newElementWith(el, { isDeleted: true }); + } + return el; + }) : undefined, appState: { ...appState, @@ -172,7 +188,12 @@ export const actionFinalize = register({ if (element && isInvisiblySmallElement(element)) { // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want - newElements = newElements.filter((el) => el.id !== element!.id); + newElements = newElements.map((el) => { + if (el.id === element?.id) { + return newElementWith(el, { isDeleted: true }); + } + return el; + }); } if (isLinearElement(element) || isFreeDrawElement(element)) { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 40a942e96b..5d0ce49e24 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2344,7 +2344,7 @@ class App extends React.Component { } const scene = restore(initialData, null, null, { repairBindings: true, - deleteEmptyTextElements: true, + deleteInvisibleElements: true, }); scene.appState = { ...scene.appState, @@ -3204,7 +3204,7 @@ class App extends React.Component { fitToContent?: boolean; }) => { const elements = restoreElements(opts.elements, null, { - deleteEmptyTextElements: true, + deleteInvisibleElements: true, }); const [minX, minY, maxX, maxY] = getCommonBounds(elements); diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index 5db4c6cfe0..d990fd0500 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -173,7 +173,7 @@ export const loadSceneOrLibraryFromBlob = async ( { repairBindings: true, refreshDimensions: false, - deleteEmptyTextElements: true, + deleteInvisibleElements: true, }, ), }; diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 10addb47e5..40c57b9cf6 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -243,7 +243,7 @@ const restoreElementWithProperties = < export const restoreElement = ( element: Exclude, - opts?: { deleteEmptyTextElements?: boolean }, + opts?: { deleteInvisibleElements?: boolean }, ): typeof element | null => { element = { ...element }; @@ -291,7 +291,7 @@ export const restoreElement = ( // if empty text, mark as deleted. We keep in array // for data integrity purposes (collab etc.) - if (opts?.deleteEmptyTextElements && !text && !element.isDeleted) { + if (opts?.deleteInvisibleElements && !text && !element.isDeleted) { // TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded) element = { ...element, originalText: text, isDeleted: true }; element = bumpVersion(element); @@ -529,7 +529,7 @@ export const restoreElements = ( | { refreshDimensions?: boolean; repairBindings?: boolean; - deleteEmptyTextElements?: boolean; + deleteInvisibleElements?: boolean; } | undefined, ): OrderedExcalidrawElement[] => { @@ -540,29 +540,38 @@ export const restoreElements = ( (elements || []).reduce((elements, element) => { // filtering out selection, which is legacy, no longer kept in elements, // and causing issues if retained - if (element.type !== "selection" && !isInvisiblySmallElement(element)) { - let migratedElement: ExcalidrawElement | null = restoreElement( - element, - { - deleteEmptyTextElements: opts?.deleteEmptyTextElements, - }, - ); - if (migratedElement) { - const localElement = localElementsMap?.get(element.id); - if (localElement && localElement.version > migratedElement.version) { - migratedElement = bumpVersion( - migratedElement, - localElement.version, - ); - } - if (existingIds.has(migratedElement.id)) { - migratedElement = { ...migratedElement, id: randomId() }; - } - existingIds.add(migratedElement.id); - - elements.push(migratedElement); - } + if (element.type === "selection") { + return elements; } + + let migratedElement: ExcalidrawElement | null = restoreElement(element, { + deleteInvisibleElements: opts?.deleteInvisibleElements, + }); + if (migratedElement) { + const localElement = localElementsMap?.get(element.id); + + const shouldMarkAsDeleted = + opts?.deleteInvisibleElements && isInvisiblySmallElement(element); + + if ( + shouldMarkAsDeleted || + (localElement && localElement.version > migratedElement.version) + ) { + migratedElement = bumpVersion(migratedElement, localElement?.version); + } + + if (shouldMarkAsDeleted) { + migratedElement = { ...migratedElement, isDeleted: true }; + } + + if (existingIds.has(migratedElement.id)) { + migratedElement = { ...migratedElement, id: randomId() }; + } + existingIds.add(migratedElement.id); + + elements.push(migratedElement); + } + return elements; }, [] as ExcalidrawElement[]), ); @@ -806,7 +815,7 @@ export const restore = ( elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean; - deleteEmptyTextElements?: boolean; + deleteInvisibleElements?: boolean; }, ): RestoredDataState => { return { diff --git a/packages/excalidraw/tests/data/restore.test.ts b/packages/excalidraw/tests/data/restore.test.ts index 1cae91416b..3eee14d4a4 100644 --- a/packages/excalidraw/tests/data/restore.test.ts +++ b/packages/excalidraw/tests/data/restore.test.ts @@ -60,7 +60,11 @@ describe("restoreElements", () => { const rectElement = API.createElement({ type: "rectangle" }); mockSizeHelper.mockImplementation(() => true); - expect(restore.restoreElements([rectElement], null).length).toBe(0); + expect( + restore.restoreElements([rectElement], null, { + deleteInvisibleElements: true, + }), + ).toEqual([expect.objectContaining({ isDeleted: true })]); }); it("should restore text element correctly passing value for each attribute", () => { @@ -85,7 +89,7 @@ describe("restoreElements", () => { }); }); - it("should not delete empty text element when deleteEmptyTextElements is not defined", () => { + it("should not delete empty text element when opts.deleteInvisibleElements is not defined", () => { const textElement = API.createElement({ type: "text", text: "", @@ -115,7 +119,7 @@ describe("restoreElements", () => { expect(textElement.isDeleted).toBe(false); const restoredText = restore.restoreElements([textElement], null, { - deleteEmptyTextElements: true, + deleteInvisibleElements: true, })[0] as ExcalidrawTextElement; expect(restoredText.isDeleted).toBe(true); expect(restoredText).toMatchSnapshot({ @@ -193,13 +197,16 @@ describe("restoreElements", () => { y: 0, }); - const restoredElements = restore.restoreElements([arrowElement], null); + const restoredElements = restore.restoreElements([arrowElement], null, { + deleteInvisibleElements: true, + }); const restoredArrow = restoredElements[0] as | ExcalidrawArrowElement | undefined; - expect(restoredArrow).toBeUndefined(); + expect(restoredArrow).not.toBeUndefined(); + expect(restoredArrow?.isDeleted).toBe(true); }); it("should keep 'imperceptibly' small freedraw/line elements", () => { @@ -864,6 +871,7 @@ describe("repairing bindings", () => { let restoredElements = restore.restoreElements( [container, invisibleBoundElement, boundElement], null, + { deleteInvisibleElements: true }, ); expect(restoredElements).toEqual([ @@ -871,6 +879,11 @@ describe("repairing bindings", () => { id: container.id, boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding], }), + expect.objectContaining({ + id: invisibleBoundElement.id, + containerId: container.id, + isDeleted: true, + }), expect.objectContaining({ id: boundElement.id, containerId: container.id, @@ -880,7 +893,7 @@ describe("repairing bindings", () => { restoredElements = restore.restoreElements( [container, invisibleBoundElement, boundElement], null, - { repairBindings: true }, + { repairBindings: true, deleteInvisibleElements: true }, ); expect(restoredElements).toEqual([ @@ -888,6 +901,11 @@ describe("repairing bindings", () => { id: container.id, boundElements: [], }), + expect.objectContaining({ + id: invisibleBoundElement.id, + containerId: container.id, + isDeleted: true, + }), expect.objectContaining({ id: boundElement.id, containerId: container.id, diff --git a/packages/excalidraw/tests/dragCreate.test.tsx b/packages/excalidraw/tests/dragCreate.test.tsx index 810efa973e..566c839050 100644 --- a/packages/excalidraw/tests/dragCreate.test.tsx +++ b/packages/excalidraw/tests/dragCreate.test.tsx @@ -315,7 +315,12 @@ describe("Test dragCreate", () => { ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); expect(h.state.selectionElement).toBeNull(); - expect(h.elements.length).toEqual(0); + expect(h.elements).toEqual([ + expect.objectContaining({ + type: "arrow", + isDeleted: true, + }), + ]); }); it("line", async () => { @@ -344,7 +349,12 @@ describe("Test dragCreate", () => { ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); expect(h.state.selectionElement).toBeNull(); - expect(h.elements.length).toEqual(0); + expect(h.elements).toEqual([ + expect.objectContaining({ + type: "line", + isDeleted: true, + }), + ]); }); }); }); diff --git a/packages/utils/src/export.ts b/packages/utils/src/export.ts index 7821b51608..fefd0db2e0 100644 --- a/packages/utils/src/export.ts +++ b/packages/utils/src/export.ts @@ -49,7 +49,7 @@ export const exportToCanvas = ({ { elements, appState }, null, null, - { deleteEmptyTextElements: true }, + { deleteInvisibleElements: true }, ); const { exportBackground, viewBackgroundColor } = restoredAppState; return _exportToCanvas( @@ -180,7 +180,7 @@ export const exportToSvg = async ({ { elements, appState }, null, null, - { deleteEmptyTextElements: true }, + { deleteInvisibleElements: true }, ); const exportAppState = { From 2535d7305485a032b5a860a4538870a0c2d09c5a Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Fri, 15 Aug 2025 15:25:56 +0200 Subject: [PATCH 06/48] feat: apply deltas API (#9869) --- packages/element/src/Scene.ts | 20 +- packages/element/src/delta.ts | 304 +++++++++++++--- packages/element/src/store.ts | 26 +- packages/element/tests/delta.test.tsx | 340 +++++++++++++++++- packages/excalidraw/components/App.tsx | 25 ++ packages/excalidraw/history.ts | 2 +- .../tests/__snapshots__/history.test.tsx.snap | 184 +++++----- packages/excalidraw/types.ts | 1 + 8 files changed, 750 insertions(+), 152 deletions(-) diff --git a/packages/element/src/Scene.ts b/packages/element/src/Scene.ts index df0fd3e2d4..eaef257960 100644 --- a/packages/element/src/Scene.ts +++ b/packages/element/src/Scene.ts @@ -164,9 +164,14 @@ export class Scene { return this.frames; } - constructor(elements: ElementsMapOrArray | null = null) { + constructor( + elements: ElementsMapOrArray | null = null, + options?: { + skipValidation?: true; + }, + ) { if (elements) { - this.replaceAllElements(elements); + this.replaceAllElements(elements, options); } } @@ -263,12 +268,19 @@ export class Scene { return didChange; } - replaceAllElements(nextElements: ElementsMapOrArray) { + replaceAllElements( + nextElements: ElementsMapOrArray, + options?: { + skipValidation?: true; + }, + ) { // we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices const _nextElements = toArray(nextElements); const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; - validateIndicesThrottled(_nextElements); + if (!options?.skipValidation) { + validateIndicesThrottled(_nextElements); + } this.elements = syncInvalidIndices(_nextElements); this.elementsMap.clear(); diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index 38839fa84c..d7b242d60f 100644 --- a/packages/element/src/delta.ts +++ b/packages/element/src/delta.ts @@ -55,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; -import { Scene } from "./Scene"; - import { StoreSnapshot } from "./store"; +import { Scene } from "./Scene"; + import type { BindableProp, BindingProp } from "./binding"; import type { ElementUpdate } from "./mutateElement"; @@ -153,10 +153,14 @@ export class Delta { /** * Merges two deltas into a new one. */ - public static merge(delta1: Delta, delta2: Delta) { + public static merge( + delta1: Delta, + delta2: Delta, + delta3: Delta = Delta.empty(), + ) { return Delta.create( - { ...delta1.deleted, ...delta2.deleted }, - { ...delta1.inserted, ...delta2.inserted }, + { ...delta1.deleted, ...delta2.deleted, ...delta3.deleted }, + { ...delta1.inserted, ...delta2.inserted, ...delta3.inserted }, ); } @@ -166,7 +170,7 @@ export class Delta { public static mergeObjects( prev: T, added: T, - removed: T, + removed: T = {} as T, ) { const cloned = { ...prev }; @@ -520,6 +524,10 @@ export interface DeltaContainer { export class AppStateDelta implements DeltaContainer { private constructor(public delta: Delta) {} + public static create(delta: Delta): AppStateDelta { + return new AppStateDelta(delta); + } + public static calculate( prevAppState: T, nextAppState: T, @@ -550,7 +558,74 @@ export class AppStateDelta implements DeltaContainer { } public squash(delta: AppStateDelta): this { - this.delta = Delta.merge(this.delta, delta.delta); + if (delta.isEmpty()) { + return this; + } + + const mergedDeletedSelectedElementIds = Delta.mergeObjects( + this.delta.deleted.selectedElementIds ?? {}, + delta.delta.deleted.selectedElementIds ?? {}, + ); + + const mergedInsertedSelectedElementIds = Delta.mergeObjects( + this.delta.inserted.selectedElementIds ?? {}, + delta.delta.inserted.selectedElementIds ?? {}, + ); + + const mergedDeletedSelectedGroupIds = Delta.mergeObjects( + this.delta.deleted.selectedGroupIds ?? {}, + delta.delta.deleted.selectedGroupIds ?? {}, + ); + + const mergedInsertedSelectedGroupIds = Delta.mergeObjects( + this.delta.inserted.selectedGroupIds ?? {}, + delta.delta.inserted.selectedGroupIds ?? {}, + ); + + const mergedDeletedLockedMultiSelections = Delta.mergeObjects( + this.delta.deleted.lockedMultiSelections ?? {}, + delta.delta.deleted.lockedMultiSelections ?? {}, + ); + + const mergedInsertedLockedMultiSelections = Delta.mergeObjects( + this.delta.inserted.lockedMultiSelections ?? {}, + delta.delta.inserted.lockedMultiSelections ?? {}, + ); + + const mergedInserted: Partial = {}; + const mergedDeleted: Partial = {}; + + if ( + Object.keys(mergedDeletedSelectedElementIds).length || + Object.keys(mergedInsertedSelectedElementIds).length + ) { + mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds; + mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds; + } + + if ( + Object.keys(mergedDeletedSelectedGroupIds).length || + Object.keys(mergedInsertedSelectedGroupIds).length + ) { + mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds; + mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds; + } + + if ( + Object.keys(mergedDeletedLockedMultiSelections).length || + Object.keys(mergedInsertedLockedMultiSelections).length + ) { + mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections; + mergedInserted.lockedMultiSelections = + mergedInsertedLockedMultiSelections; + } + + this.delta = Delta.merge( + this.delta, + delta.delta, + Delta.create(mergedDeleted, mergedInserted), + ); + return this; } @@ -562,11 +637,13 @@ export class AppStateDelta implements DeltaContainer { const { selectedElementIds: deletedSelectedElementIds = {}, selectedGroupIds: deletedSelectedGroupIds = {}, + lockedMultiSelections: deletedLockedMultiSelections = {}, } = this.delta.deleted; const { selectedElementIds: insertedSelectedElementIds = {}, selectedGroupIds: insertedSelectedGroupIds = {}, + lockedMultiSelections: insertedLockedMultiSelections = {}, selectedLinearElement: insertedSelectedLinearElement, ...directlyApplicablePartial } = this.delta.inserted; @@ -583,6 +660,12 @@ export class AppStateDelta implements DeltaContainer { deletedSelectedGroupIds, ); + const mergedLockedMultiSelections = Delta.mergeObjects( + appState.lockedMultiSelections, + insertedLockedMultiSelections, + deletedLockedMultiSelections, + ); + const selectedLinearElement = insertedSelectedLinearElement && nextElements.has(insertedSelectedLinearElement.elementId) @@ -600,6 +683,7 @@ export class AppStateDelta implements DeltaContainer { ...directlyApplicablePartial, selectedElementIds: mergedSelectedElementIds, selectedGroupIds: mergedSelectedGroupIds, + lockedMultiSelections: mergedLockedMultiSelections, selectedLinearElement: typeof insertedSelectedLinearElement !== "undefined" ? selectedLinearElement @@ -904,12 +988,6 @@ export class AppStateDelta implements DeltaContainer { "lockedMultiSelections", (prevValue) => (prevValue ?? {}) as ValueOf, ); - Delta.diffObjects( - deleted, - inserted, - "activeLockedId", - (prevValue) => (prevValue ?? null) as ValueOf, - ); } catch (e) { // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it console.error(`Couldn't postprocess appstate change deltas.`); @@ -938,12 +1016,13 @@ type ElementPartial = Omit>, "id" | "updated" | "seed">; export type ApplyToOptions = { - excludedProperties: Set; + excludedProperties?: Set; }; type ApplyToFlags = { containsVisibleDifference: boolean; containsZindexDifference: boolean; + applyDirection: "forward" | "backward" | undefined; }; /** @@ -1044,6 +1123,15 @@ export class ElementsDelta implements DeltaContainer { deleted.version !== inserted.version ); + private static satisfiesUniqueInvariants = ( + elementsDelta: ElementsDelta, + id: string, + ) => { + const { added, removed, updated } = elementsDelta; + // it's required that there is only one unique delta type per element + return [added[id], removed[id], updated[id]].filter(Boolean).length === 1; + }; + private static validate( elementsDelta: ElementsDelta, type: "added" | "removed" | "updated", @@ -1052,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer { for (const [id, delta] of Object.entries(elementsDelta[type])) { if ( !this.satisfiesCommmonInvariants(delta) || + !this.satisfiesUniqueInvariants(elementsDelta, id) || !satifiesSpecialInvariants(delta) ) { console.error( @@ -1311,9 +1400,7 @@ export class ElementsDelta implements DeltaContainer { public applyTo( elements: SceneElementsMap, snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements, - options: ApplyToOptions = { - excludedProperties: new Set(), - }, + options?: ApplyToOptions, ): [SceneElementsMap, boolean] { let nextElements = new Map(elements) as SceneElementsMap; let changedElements: Map; @@ -1321,22 +1408,28 @@ export class ElementsDelta implements DeltaContainer { const flags: ApplyToFlags = { containsVisibleDifference: false, containsZindexDifference: false, + applyDirection: undefined, }; // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) try { const applyDeltas = ElementsDelta.createApplier( + elements, nextElements, snapshot, - options, flags, + options, ); const addedElements = applyDeltas(this.added); const removedElements = applyDeltas(this.removed); const updatedElements = applyDeltas(this.updated); - const affectedElements = this.resolveConflicts(elements, nextElements); + const affectedElements = this.resolveConflicts( + elements, + nextElements, + flags.applyDirection, + ); // TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues changedElements = new Map([ @@ -1360,22 +1453,15 @@ export class ElementsDelta implements DeltaContainer { } try { - // the following reorder performs also mutations, but only on new instances of changed elements - // (unless something goes really bad and it fallbacks to fixing all invalid indices) + // the following reorder performs mutations, but only on new instances of changed elements, + // unless something goes really bad and it fallbacks to fixing all invalid indices nextElements = ElementsDelta.reorderElements( nextElements, changedElements, flags, ); - // we don't have an up-to-date scene, as we can be just in the middle of applying history entry - // we also don't have a scene on the server - // so we are creating a temp scene just to query and mutate elements - const tempScene = new Scene(nextElements); - - ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements); - // Need ordered nextElements to avoid z-index binding issues - ElementsDelta.redrawBoundArrows(tempScene, changedElements); + ElementsDelta.redrawElements(nextElements, changedElements); } catch (e) { console.error( `Couldn't mutate elements after applying elements change`, @@ -1391,47 +1477,112 @@ export class ElementsDelta implements DeltaContainer { } public squash(delta: ElementsDelta): this { + if (delta.isEmpty()) { + return this; + } + const { added, removed, updated } = delta; + const mergeBoundElements = ( + prevDelta: Delta, + nextDelta: Delta, + ) => { + const mergedDeletedBoundElements = + Delta.mergeArrays( + prevDelta.deleted.boundElements ?? [], + nextDelta.deleted.boundElements ?? [], + undefined, + (x) => x.id, + ) ?? []; + + const mergedInsertedBoundElements = + Delta.mergeArrays( + prevDelta.inserted.boundElements ?? [], + nextDelta.inserted.boundElements ?? [], + undefined, + (x) => x.id, + ) ?? []; + + if ( + !mergedDeletedBoundElements.length && + !mergedInsertedBoundElements.length + ) { + return; + } + + return Delta.create( + { + boundElements: mergedDeletedBoundElements, + }, + { + boundElements: mergedInsertedBoundElements, + }, + ); + }; + for (const [id, nextDelta] of Object.entries(added)) { - const prevDelta = this.added[id]; + const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id]; if (!prevDelta) { this.added[id] = nextDelta; } else { - this.added[id] = Delta.merge(prevDelta, nextDelta); + const mergedDelta = mergeBoundElements(prevDelta, nextDelta); + delete this.removed[id]; + delete this.updated[id]; + + this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta); } } for (const [id, nextDelta] of Object.entries(removed)) { - const prevDelta = this.removed[id]; + const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id]; if (!prevDelta) { this.removed[id] = nextDelta; } else { - this.removed[id] = Delta.merge(prevDelta, nextDelta); + const mergedDelta = mergeBoundElements(prevDelta, nextDelta); + delete this.added[id]; + delete this.updated[id]; + + this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta); } } for (const [id, nextDelta] of Object.entries(updated)) { - const prevDelta = this.updated[id]; + const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id]; if (!prevDelta) { this.updated[id] = nextDelta; } else { - this.updated[id] = Delta.merge(prevDelta, nextDelta); + const mergedDelta = mergeBoundElements(prevDelta, nextDelta); + const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta); + + if (prevDelta === this.added[id]) { + this.added[id] = updatedDelta; + } else if (prevDelta === this.removed[id]) { + this.removed[id] = updatedDelta; + } else { + this.updated[id] = updatedDelta; + } } } + if (isTestEnv() || isDevEnv()) { + ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition); + ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval); + ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate); + } + return this; } private static createApplier = ( + prevElements: SceneElementsMap, nextElements: SceneElementsMap, snapshot: StoreSnapshot["elements"], - options: ApplyToOptions, flags: ApplyToFlags, + options?: ApplyToOptions, ) => (deltas: Record>) => { const getElement = ElementsDelta.createGetter( @@ -1444,15 +1595,26 @@ export class ElementsDelta implements DeltaContainer { const element = getElement(id, delta.inserted); if (element) { - const newElement = ElementsDelta.applyDelta( + const nextElement = ElementsDelta.applyDelta( element, delta, - options, flags, + options, ); - nextElements.set(newElement.id, newElement); - acc.set(newElement.id, newElement); + nextElements.set(nextElement.id, nextElement); + acc.set(nextElement.id, nextElement); + + if (!flags.applyDirection) { + const prevElement = prevElements.get(id); + + if (prevElement) { + flags.applyDirection = + prevElement.version > nextElement.version + ? "backward" + : "forward"; + } + } } return acc; @@ -1497,8 +1659,8 @@ export class ElementsDelta implements DeltaContainer { private static applyDelta( element: OrderedExcalidrawElement, delta: Delta, - options: ApplyToOptions, flags: ApplyToFlags, + options?: ApplyToOptions, ) { const directlyApplicablePartial: Mutable = {}; @@ -1512,7 +1674,7 @@ export class ElementsDelta implements DeltaContainer { continue; } - if (options.excludedProperties.has(key)) { + if (options?.excludedProperties?.has(key)) { continue; } @@ -1552,7 +1714,7 @@ export class ElementsDelta implements DeltaContainer { delta.deleted.index !== delta.inserted.index; } - return newElementWith(element, directlyApplicablePartial); + return newElementWith(element, directlyApplicablePartial, true); } /** @@ -1592,6 +1754,7 @@ export class ElementsDelta implements DeltaContainer { private resolveConflicts( prevElements: SceneElementsMap, nextElements: SceneElementsMap, + applyDirection: "forward" | "backward" = "forward", ) { const nextAffectedElements = new Map(); const updater = ( @@ -1603,21 +1766,36 @@ export class ElementsDelta implements DeltaContainer { return; } + const prevElement = prevElements.get(element.id); + const nextVersion = + applyDirection === "forward" + ? nextElement.version + 1 + : nextElement.version - 1; + + const elementUpdates = updates as ElementUpdate; + let affectedElement: OrderedExcalidrawElement; - if (prevElements.get(element.id) === nextElement) { + if (prevElement === nextElement) { // create the new element instance in case we didn't modify the element yet // so that we won't end up in an incosistent state in case we would fail in the middle of mutations affectedElement = newElementWith( nextElement, - updates as ElementUpdate, + { + ...elementUpdates, + version: nextVersion, + }, + true, ); } else { - affectedElement = mutateElement( - nextElement, - nextElements, - updates as ElementUpdate, - ); + affectedElement = mutateElement(nextElement, nextElements, { + ...elementUpdates, + // don't modify the version further, if it's already different + version: + prevElement?.version !== nextElement.version + ? nextElement.version + : nextVersion, + }); } nextAffectedElements.set(affectedElement.id, affectedElement); @@ -1722,6 +1900,31 @@ export class ElementsDelta implements DeltaContainer { BindableElement.rebindAffected(nextElements, nextElement(), updater); } + public static redrawElements( + nextElements: SceneElementsMap, + changedElements: Map, + ) { + try { + // we don't have an up-to-date scene, as we can be just in the middle of applying history entry + // we also don't have a scene on the server + // so we are creating a temp scene just to query and mutate elements + const tempScene = new Scene(nextElements, { skipValidation: true }); + + ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements); + + // needs ordered nextElements to avoid z-index binding issues + ElementsDelta.redrawBoundArrows(tempScene, changedElements); + } catch (e) { + console.error(`Couldn't redraw elements`, e); + + if (isTestEnv() || isDevEnv()) { + throw e; + } + } finally { + return nextElements; + } + } + private static redrawTextBoundingBoxes( scene: Scene, changed: Map, @@ -1776,6 +1979,7 @@ export class ElementsDelta implements DeltaContainer { ) { for (const element of changed.values()) { if (!element.isDeleted && isBindableElement(element)) { + // TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds updateBoundElements(element, scene, { changedElements: changed, }); diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index e304486ffa..38235e752c 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -552,10 +552,26 @@ export class StoreDelta { public static load({ id, elements: { added, removed, updated }, + appState: { delta: appStateDelta }, }: DTO) { const elements = ElementsDelta.create(added, removed, updated); + const appState = AppStateDelta.create(appStateDelta); - return new this(id, elements, AppStateDelta.empty()); + return new this(id, elements, appState); + } + + /** + * Squash the passed deltas into the aggregated delta instance. + */ + public static squash(...deltas: StoreDelta[]) { + const aggregatedDelta = StoreDelta.empty(); + + for (const delta of deltas) { + aggregatedDelta.elements.squash(delta.elements); + aggregatedDelta.appState.squash(delta.appState); + } + + return aggregatedDelta; } /** @@ -572,9 +588,7 @@ export class StoreDelta { delta: StoreDelta, elements: SceneElementsMap, appState: AppState, - options: ApplyToOptions = { - excludedProperties: new Set(), - }, + options?: ApplyToOptions, ): [SceneElementsMap, AppState, boolean] { const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( elements, @@ -613,6 +627,10 @@ export class StoreDelta { ); } + public static empty() { + return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty()); + } + public isEmpty() { return this.elements.isEmpty() && this.appState.isEmpty(); } diff --git a/packages/element/tests/delta.test.tsx b/packages/element/tests/delta.test.tsx index 81c9d4591c..e9a19d850a 100644 --- a/packages/element/tests/delta.test.tsx +++ b/packages/element/tests/delta.test.tsx @@ -4,7 +4,7 @@ import type { ObservedAppState } from "@excalidraw/excalidraw/types"; import type { LinearElementEditor } from "@excalidraw/element"; import type { SceneElementsMap } from "@excalidraw/element/types"; -import { AppStateDelta, ElementsDelta } from "../src/delta"; +import { AppStateDelta, Delta, ElementsDelta } from "../src/delta"; describe("ElementsDelta", () => { describe("elements delta calculation", () => { @@ -68,6 +68,251 @@ describe("ElementsDelta", () => { expect(delta.isEmpty()).toBeTruthy(); }); }); + + describe("squash", () => { + it("should not squash when second delta is empty", () => { + const updatedDelta = Delta.create( + { x: 100, version: 1, versionNonce: 1 }, + { x: 200, version: 2, versionNonce: 2 }, + ); + + const elementsDelta1 = ElementsDelta.create( + {}, + {}, + { id1: updatedDelta }, + ); + const elementsDelta2 = ElementsDelta.empty(); + const elementsDelta = elementsDelta1.squash(elementsDelta2); + + expect(elementsDelta.isEmpty()).toBeFalsy(); + expect(elementsDelta).toBe(elementsDelta1); + expect(elementsDelta.updated.id1).toBe(updatedDelta); + }); + + it("should squash mutually exclusive delta types", () => { + const addedDelta = Delta.create( + { x: 100, version: 1, versionNonce: 1, isDeleted: true }, + { x: 200, version: 2, versionNonce: 2, isDeleted: false }, + ); + + const removedDelta = Delta.create( + { x: 100, version: 1, versionNonce: 1, isDeleted: false }, + { x: 200, version: 2, versionNonce: 2, isDeleted: true }, + ); + + const updatedDelta = Delta.create( + { x: 100, version: 1, versionNonce: 1 }, + { x: 200, version: 2, versionNonce: 2 }, + ); + + const elementsDelta1 = ElementsDelta.create( + { id1: addedDelta }, + { id2: removedDelta }, + {}, + ); + + const elementsDelta2 = ElementsDelta.create( + {}, + {}, + { id3: updatedDelta }, + ); + + const elementsDelta = elementsDelta1.squash(elementsDelta2); + + expect(elementsDelta.isEmpty()).toBeFalsy(); + expect(elementsDelta).toBe(elementsDelta1); + expect(elementsDelta.added.id1).toBe(addedDelta); + expect(elementsDelta.removed.id2).toBe(removedDelta); + expect(elementsDelta.updated.id3).toBe(updatedDelta); + }); + + it("should squash the same delta types", () => { + const elementsDelta1 = ElementsDelta.create( + { + id1: Delta.create( + { x: 100, version: 1, versionNonce: 1, isDeleted: true }, + { x: 200, version: 2, versionNonce: 2, isDeleted: false }, + ), + }, + { + id2: Delta.create( + { x: 100, version: 1, versionNonce: 1, isDeleted: false }, + { x: 200, version: 2, versionNonce: 2, isDeleted: true }, + ), + }, + { + id3: Delta.create( + { x: 100, version: 1, versionNonce: 1 }, + { x: 200, version: 2, versionNonce: 2 }, + ), + }, + ); + + const elementsDelta2 = ElementsDelta.create( + { + id1: Delta.create( + { y: 100, version: 2, versionNonce: 2, isDeleted: true }, + { y: 200, version: 3, versionNonce: 3, isDeleted: false }, + ), + }, + { + id2: Delta.create( + { y: 100, version: 2, versionNonce: 2, isDeleted: false }, + { y: 200, version: 3, versionNonce: 3, isDeleted: true }, + ), + }, + { + id3: Delta.create( + { y: 100, version: 2, versionNonce: 2 }, + { y: 200, version: 3, versionNonce: 3 }, + ), + }, + ); + + const elementsDelta = elementsDelta1.squash(elementsDelta2); + + expect(elementsDelta.isEmpty()).toBeFalsy(); + expect(elementsDelta).toBe(elementsDelta1); + expect(elementsDelta.added.id1).toEqual( + Delta.create( + { x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true }, + { x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false }, + ), + ); + expect(elementsDelta.removed.id2).toEqual( + Delta.create( + { x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false }, + { x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true }, + ), + ); + expect(elementsDelta.updated.id3).toEqual( + Delta.create( + { x: 100, y: 100, version: 2, versionNonce: 2 }, + { x: 200, y: 200, version: 3, versionNonce: 3 }, + ), + ); + }); + + it("should squash different delta types ", () => { + // id1: added -> updated => added + // id2: removed -> added => added + // id3: updated -> removed => removed + const elementsDelta1 = ElementsDelta.create( + { + id1: Delta.create( + { x: 100, version: 1, versionNonce: 1, isDeleted: true }, + { x: 101, version: 2, versionNonce: 2, isDeleted: false }, + ), + }, + { + id2: Delta.create( + { x: 200, version: 1, versionNonce: 1, isDeleted: false }, + { x: 201, version: 2, versionNonce: 2, isDeleted: true }, + ), + }, + { + id3: Delta.create( + { x: 300, version: 1, versionNonce: 1 }, + { x: 301, version: 2, versionNonce: 2 }, + ), + }, + ); + + const elementsDelta2 = ElementsDelta.create( + { + id2: Delta.create( + { y: 200, version: 2, versionNonce: 2, isDeleted: true }, + { y: 201, version: 3, versionNonce: 3, isDeleted: false }, + ), + }, + { + id3: Delta.create( + { y: 300, version: 2, versionNonce: 2, isDeleted: false }, + { y: 301, version: 3, versionNonce: 3, isDeleted: true }, + ), + }, + { + id1: Delta.create( + { y: 100, version: 2, versionNonce: 2 }, + { y: 101, version: 3, versionNonce: 3 }, + ), + }, + ); + + const elementsDelta = elementsDelta1.squash(elementsDelta2); + + expect(elementsDelta.isEmpty()).toBeFalsy(); + expect(elementsDelta).toBe(elementsDelta1); + expect(elementsDelta.added).toEqual({ + id1: Delta.create( + { x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true }, + { x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false }, + ), + id2: Delta.create( + { x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true }, + { x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false }, + ), + }); + expect(elementsDelta.removed).toEqual({ + id3: Delta.create( + { x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false }, + { x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true }, + ), + }); + expect(elementsDelta.updated).toEqual({}); + }); + + it("should squash bound elements", () => { + const elementsDelta1 = ElementsDelta.create( + {}, + {}, + { + id1: Delta.create( + { + version: 1, + versionNonce: 1, + boundElements: [{ id: "t1", type: "text" }], + }, + { + version: 2, + versionNonce: 2, + boundElements: [{ id: "t2", type: "text" }], + }, + ), + }, + ); + + const elementsDelta2 = ElementsDelta.create( + {}, + {}, + { + id1: Delta.create( + { + version: 2, + versionNonce: 2, + boundElements: [{ id: "a1", type: "arrow" }], + }, + { + version: 3, + versionNonce: 3, + boundElements: [{ id: "a2", type: "arrow" }], + }, + ), + }, + ); + + const elementsDelta = elementsDelta1.squash(elementsDelta2); + + expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([ + { id: "t1", type: "text" }, + { id: "a1", type: "arrow" }, + ]); + expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([ + { id: "t2", type: "text" }, + { id: "a2", type: "arrow" }, + ]); + }); + }); }); describe("AppStateDelta", () => { @@ -215,4 +460,97 @@ describe("AppStateDelta", () => { expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2)); }); }); + + describe("squash", () => { + it("should not squash when second delta is empty", () => { + const delta = Delta.create( + { name: "untitled scene" }, + { name: "titled scene" }, + ); + + const appStateDelta1 = AppStateDelta.create(delta); + const appStateDelta2 = AppStateDelta.empty(); + const appStateDelta = appStateDelta1.squash(appStateDelta2); + + expect(appStateDelta.isEmpty()).toBeFalsy(); + expect(appStateDelta).toBe(appStateDelta1); + expect(appStateDelta.delta).toBe(delta); + }); + + it("should squash exclusive properties", () => { + const delta1 = Delta.create( + { name: "untitled scene" }, + { name: "titled scene" }, + ); + const delta2 = Delta.create( + { viewBackgroundColor: "#ffffff" }, + { viewBackgroundColor: "#000000" }, + ); + + const appStateDelta1 = AppStateDelta.create(delta1); + const appStateDelta2 = AppStateDelta.create(delta2); + const appStateDelta = appStateDelta1.squash(appStateDelta2); + + expect(appStateDelta.isEmpty()).toBeFalsy(); + expect(appStateDelta).toBe(appStateDelta1); + expect(appStateDelta.delta).toEqual( + Delta.create( + { name: "untitled scene", viewBackgroundColor: "#ffffff" }, + { name: "titled scene", viewBackgroundColor: "#000000" }, + ), + ); + }); + + it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => { + const delta1 = Delta.create>( + { + name: "untitled scene", + selectedElementIds: { id1: true }, + selectedGroupIds: {}, + lockedMultiSelections: { g1: true }, + }, + { + name: "titled scene", + selectedElementIds: { id2: true }, + selectedGroupIds: { g1: true }, + lockedMultiSelections: {}, + }, + ); + const delta2 = Delta.create>( + { + selectedElementIds: { id3: true }, + selectedGroupIds: { g1: true }, + lockedMultiSelections: {}, + }, + { + selectedElementIds: { id2: true }, + selectedGroupIds: { g2: true, g3: true }, + lockedMultiSelections: { g3: true }, + }, + ); + + const appStateDelta1 = AppStateDelta.create(delta1); + const appStateDelta2 = AppStateDelta.create(delta2); + const appStateDelta = appStateDelta1.squash(appStateDelta2); + + expect(appStateDelta.isEmpty()).toBeFalsy(); + expect(appStateDelta).toBe(appStateDelta1); + expect(appStateDelta.delta).toEqual( + Delta.create>( + { + name: "untitled scene", + selectedElementIds: { id1: true, id3: true }, + selectedGroupIds: { g1: true }, + lockedMultiSelections: { g1: true }, + }, + { + name: "titled scene", + selectedElementIds: { id2: true }, + selectedGroupIds: { g1: true, g2: true, g3: true }, + lockedMultiSelections: { g3: true }, + }, + ), + ); + }); + }); }); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5d0ce49e24..65635cd60d 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -233,6 +233,8 @@ import { hitElementBoundingBox, isLineElement, isSimpleArrow, + StoreDelta, + type ApplyToOptions, } from "@excalidraw/element"; import type { LocalPoint, Radians } from "@excalidraw/math"; @@ -259,6 +261,7 @@ import type { MagicGenerationData, ExcalidrawArrowElement, ExcalidrawElbowArrowElement, + SceneElementsMap, } from "@excalidraw/element/types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; @@ -697,6 +700,7 @@ class App extends React.Component { if (excalidrawAPI) { const api: ExcalidrawImperativeAPI = { updateScene: this.updateScene, + applyDeltas: this.applyDeltas, mutateElement: this.mutateElement, updateLibrary: this.library.updateLibrary, addFiles: this.addFiles, @@ -3938,6 +3942,27 @@ class App extends React.Component { }, ); + public applyDeltas = ( + deltas: StoreDelta[], + options?: ApplyToOptions, + ): [SceneElementsMap, AppState, boolean] => { + // squash all deltas together, starting with a fresh new delta instance + const aggregatedDelta = StoreDelta.squash(...deltas); + + // create new instance of elements map & appState, so we don't accidentaly mutate existing ones + const nextAppState = { ...this.state }; + const nextElements = new Map( + this.scene.getElementsMapIncludingDeleted(), + ) as SceneElementsMap; + + return StoreDelta.applyTo( + aggregatedDelta, + nextElements, + nextAppState, + options, + ); + }; + public mutateElement = >( element: TElement, updates: ElementUpdate, diff --git a/packages/excalidraw/history.ts b/packages/excalidraw/history.ts index 7250dd600a..482065be45 100644 --- a/packages/excalidraw/history.ts +++ b/packages/excalidraw/history.ts @@ -175,7 +175,7 @@ export class History { let nextAppState = appState; let containsVisibleChange = false; - // iterate through the history entries in case ;they result in no visible changes + // iterate through the history entries in case they result in no visible changes while (historyDelta) { try { [nextElements, nextAppState, containsVisibleChange] = diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index c9a2992838..60d7e5ed6b 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -137,7 +137,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 18, + "version": 13, "width": 100, "x": -100, "y": -50, @@ -258,7 +258,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 14, + "version": 10, "width": 50, "x": 100, "y": 100, @@ -305,11 +305,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", }, ], - "version": 12, + "version": 9, }, "inserted": { "boundElements": [], - "version": 11, + "version": 8, }, }, "id4": { @@ -384,7 +384,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "id0": { "deleted": { "boundElements": [], - "version": 18, + "version": 13, }, "inserted": { "boundElements": [ @@ -393,7 +393,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", }, ], - "version": 17, + "version": 12, }, }, "id4": { @@ -735,7 +735,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 19, + "version": 14, "width": 100, "x": 150, "y": -50, @@ -884,7 +884,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "id0": { "deleted": { "boundElements": [], - "version": 19, + "version": 14, }, "inserted": { "boundElements": [ @@ -893,7 +893,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", }, ], - "version": 18, + "version": 13, }, }, "id4": { @@ -1242,7 +1242,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, + "version": 10, "width": 98, "x": 1, "y": 0, @@ -1421,12 +1421,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "focus": 0, "gap": 1, }, - "version": 11, + "version": 10, }, "inserted": { "endBinding": null, "startBinding": null, - "version": 8, + "version": 7, }, }, }, @@ -1639,7 +1639,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 12, + "version": 8, "width": 100, "x": -100, "y": -50, @@ -1674,7 +1674,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 11, + "version": 7, "width": 100, "x": 100, "y": -50, @@ -1772,11 +1772,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", }, ], - "version": 12, + "version": 8, }, "inserted": { "boundElements": [], - "version": 9, + "version": 7, }, }, "id1": { @@ -1787,11 +1787,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", }, ], - "version": 11, + "version": 7, }, "inserted": { "boundElements": [], - "version": 8, + "version": 6, }, }, }, @@ -2202,7 +2202,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 5, "width": 100, "x": -100, "y": -50, @@ -2237,7 +2237,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 6, "width": 100, "x": 500, "y": -500, @@ -2473,7 +2473,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", }, ], - "version": 7, + "version": 5, }, "inserted": { "boundElements": [], @@ -2488,7 +2488,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", }, ], - "version": 8, + "version": 6, }, "inserted": { "boundElements": [], @@ -2720,7 +2720,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "textAlign": "left", "type": "text", "updated": 1, - "version": 10, + "version": 7, "verticalAlign": "top", "width": 30, "x": 15, @@ -2780,11 +2780,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "id5": { "deleted": { "containerId": null, - "version": 10, + "version": 7, }, "inserted": { "containerId": "id0", - "version": 9, + "version": 6, }, }, }, @@ -2937,7 +2937,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 11, + "version": 9, "width": 100, "x": 10, "y": 10, @@ -2975,7 +2975,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "textAlign": "left", "type": "text", "updated": 1, - "version": 9, + "version": 8, "verticalAlign": "top", "width": 100, "x": 15, @@ -3014,7 +3014,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "textAlign": "left", "type": "text", "updated": 1, - "version": 11, + "version": 7, "verticalAlign": "top", "width": 30, "x": 15, @@ -3041,7 +3041,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "deleted": { "containerId": "id0", "isDeleted": true, - "version": 9, + "version": 8, }, "inserted": { "angle": 0, @@ -3071,7 +3071,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "text": "que pasa", "textAlign": "left", "type": "text", - "version": 8, + "version": 7, "verticalAlign": "top", "width": 100, "x": 15, @@ -3084,7 +3084,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "id0": { "deleted": { "boundElements": [], - "version": 11, + "version": 9, }, "inserted": { "boundElements": [ @@ -3093,7 +3093,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "text", }, ], - "version": 10, + "version": 8, }, }, }, @@ -3246,7 +3246,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 10, + "version": 9, "width": 100, "x": 10, "y": 10, @@ -3356,7 +3356,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "text", }, ], - "version": 10, + "version": 9, }, "inserted": { "boundElements": [ @@ -3365,7 +3365,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "text", }, ], - "version": 9, + "version": 8, }, }, "id1": { @@ -4093,7 +4093,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "textAlign": "left", "type": "text", "updated": 1, - "version": 12, + "version": 8, "verticalAlign": "top", "width": 80, "x": 15, @@ -4155,11 +4155,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "id1": { "deleted": { "containerId": "id0", - "version": 12, + "version": 8, }, "inserted": { "containerId": null, - "version": 9, + "version": 7, }, }, }, @@ -4310,7 +4310,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 11, + "version": 7, "width": 100, "x": 10, "y": 10, @@ -4424,11 +4424,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "text", }, ], - "version": 11, + "version": 7, }, "inserted": { "boundElements": [], - "version": 8, + "version": 6, }, }, }, @@ -4617,7 +4617,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "textAlign": "left", "type": "text", "updated": 1, - "version": 7, + "version": 8, "verticalAlign": "top", "width": 80, "x": 15, @@ -5028,7 +5028,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 7, "width": 100, "x": 10, "y": 10, @@ -5113,7 +5113,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", - "version": 8, + "version": 7, "width": 100, "x": 10, "y": 10, @@ -5126,7 +5126,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, ], "isDeleted": true, - "version": 7, + "version": 6, }, }, }, @@ -5316,7 +5316,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "textAlign": "left", "type": "text", "updated": 1, - "version": 8, + "version": 7, "verticalAlign": "top", "width": 100, "x": 15, @@ -5371,7 +5371,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "text": "que pasa", "textAlign": "left", "type": "text", - "version": 8, + "version": 7, "verticalAlign": "top", "width": 100, "x": 15, @@ -5380,7 +5380,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "inserted": { "containerId": "id0", "isDeleted": true, - "version": 7, + "version": 6, }, }, }, @@ -5527,7 +5527,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 10, + "version": 9, "width": 100, "x": 10, "y": 10, @@ -5784,7 +5784,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 4, + "version": 5, "width": 100, "x": 0, "y": 0, @@ -5816,7 +5816,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 3, + "version": 4, "width": 100, "x": 100, "y": 100, @@ -6072,7 +6072,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 8, "width": 10, "x": 20, "y": 0, @@ -6102,7 +6102,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 8, "width": 10, "x": 50, "y": 50, @@ -6205,11 +6205,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "id3": { "deleted": { "backgroundColor": "#ffc9c9", - "version": 7, + "version": 8, }, "inserted": { "backgroundColor": "transparent", - "version": 6, + "version": 7, }, }, }, @@ -6251,12 +6251,12 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "updated": { "id8": { "deleted": { - "version": 7, + "version": 8, "x": 50, "y": 50, }, "inserted": { - "version": 6, + "version": 7, "x": 30, "y": 30, }, @@ -7104,7 +7104,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 7, + "version": 8, "width": 10, "x": 0, "y": 0, @@ -7344,7 +7344,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 8, "width": 10, "x": 10, "y": 0, @@ -7393,11 +7393,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "id0": { "deleted": { "backgroundColor": "#ffec99", - "version": 7, + "version": 8, }, "inserted": { "backgroundColor": "transparent", - "version": 6, + "version": 7, }, }, }, @@ -10326,7 +10326,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 8, "width": 10, "x": 10, "y": 0, @@ -10378,14 +10378,14 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", - "version": 7, + "version": 8, "width": 10, "x": 10, "y": 0, }, "inserted": { "isDeleted": true, - "version": 6, + "version": 7, }, }, }, @@ -15584,7 +15584,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 6, "width": 100, "x": -100, "y": -50, @@ -15622,7 +15622,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 6, + "version": 5, "verticalAlign": "middle", "width": 30, "x": -65, @@ -15658,7 +15658,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 5, "width": 100, "x": 100, "y": -50, @@ -15768,7 +15768,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 8, + "version": 6, }, "inserted": { "boundElements": [], @@ -15783,7 +15783,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 7, + "version": 5, }, "inserted": { "boundElements": [], @@ -16279,7 +16279,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 6, "width": 100, "x": -100, "y": -50, @@ -16317,7 +16317,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 8, + "version": 6, "verticalAlign": "middle", "width": 30, "x": -65, @@ -16353,7 +16353,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 5, "width": 100, "x": 100, "y": -50, @@ -16729,7 +16729,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 8, + "version": 6, }, "inserted": { "boundElements": [], @@ -16744,7 +16744,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 7, + "version": 5, }, "inserted": { "boundElements": [], @@ -16904,7 +16904,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 12, + "version": 10, "width": 100, "x": -100, "y": -50, @@ -16942,7 +16942,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 12, + "version": 10, "verticalAlign": "middle", "width": 30, "x": -65, @@ -16978,7 +16978,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 9, + "version": 7, "width": 100, "x": 100, "y": -50, @@ -17119,7 +17119,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "text": "ola", "textAlign": "left", "type": "text", - "version": 9, + "version": 8, "verticalAlign": "top", "width": 100, "x": -200, @@ -17127,7 +17127,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "inserted": { "isDeleted": true, - "version": 8, + "version": 7, }, }, "id2": { @@ -17243,7 +17243,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "containerId": "id0", "height": 25, "textAlign": "center", - "version": 10, + "version": 9, "verticalAlign": "middle", "width": 30, "x": -65, @@ -17253,7 +17253,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "containerId": null, "height": 100, "textAlign": "left", - "version": 9, + "version": 8, "verticalAlign": "top", "width": 100, "x": -200, @@ -17354,7 +17354,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 12, + "version": 10, }, "inserted": { "boundElements": [], @@ -17369,7 +17369,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 9, + "version": 7, }, "inserted": { "boundElements": [], @@ -17527,7 +17527,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 6, "width": 100, "x": -100, "y": -50, @@ -17565,7 +17565,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 8, + "version": 6, "verticalAlign": "middle", "width": 30, "x": -65, @@ -17601,7 +17601,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 4, "width": 100, "x": 100, "y": -50, @@ -17689,7 +17689,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id0": { "deleted": { "isDeleted": false, - "version": 8, + "version": 6, }, "inserted": { "isDeleted": true, @@ -17699,7 +17699,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id1": { "deleted": { "isDeleted": false, - "version": 8, + "version": 6, }, "inserted": { "isDeleted": true, @@ -18239,7 +18239,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 6, "width": 100, "x": -100, "y": -50, @@ -18277,7 +18277,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 8, + "version": 6, "verticalAlign": "middle", "width": 30, "x": -65, @@ -18402,7 +18402,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id0": { "deleted": { "isDeleted": false, - "version": 8, + "version": 6, }, "inserted": { "isDeleted": true, @@ -18412,7 +18412,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id1": { "deleted": { "isDeleted": false, - "version": 8, + "version": 6, }, "inserted": { "isDeleted": true, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index e321b34cba..6694c8810e 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -801,6 +801,7 @@ export type UnsubscribeCallback = () => void; export interface ExcalidrawImperativeAPI { updateScene: InstanceType["updateScene"]; + applyDeltas: InstanceType["applyDeltas"]; mutateElement: InstanceType["mutateElement"]; updateLibrary: InstanceType["updateLibrary"]; resetScene: InstanceType["resetScene"]; From c6f8ef9ad26fdfc4cc535e161819dda70c4e3a72 Mon Sep 17 00:00:00 2001 From: zsviczian Date: Mon, 18 Aug 2025 11:45:05 +0200 Subject: [PATCH 07/48] fix: Scene deleted after pica image resize failure (#9879) Revert change in private updateImageCache --- packages/excalidraw/components/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 65635cd60d..c91f1c1b9b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -10144,7 +10144,7 @@ class App extends React.Component { if (erroredFiles.size) { this.store.scheduleAction(CaptureUpdateAction.NEVER); this.scene.replaceAllElements( - elements.map((element) => { + this.scene.getElementsIncludingDeleted().map((element) => { if ( isInitializedImageElement(element) && erroredFiles.has(element.fileId) From b4903a7eab2330ce127509977472f6c50f52327b Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Wed, 20 Aug 2025 08:03:02 +1000 Subject: [PATCH 08/48] feat: drag, resize, and rotate after selecting in lasso (#9732) * feat: drag, resize, and rotate after selecting in lasso * alternative ux: drag with lasso right away * fix: lasso dragging should snap too * fix: alt+cmd getting stuck * test: snapshots * alternatvie: keep lasso drag to only mobile * alternative: drag after selection on PCs * improve mobile dection * add mobile lasso icon * add default selection tool * render according to default selection tool * return to default selection tool after deletion * reset to default tool after clearing out the canvas * return to default tool after eraser toggle * if default lasso, close lasso toggle * finalize to default selection tool * toggle between laser and default selection * return to default selection tool after creation * double click to add text when using default selection tool * set to default selection tool after unlocking tool * paste to center on touch screen * switch to default selection tool after pasting * lint * fix tests * show welcome screen when using default selection tool * fix tests * fix snapshots * fix context menu not opening * prevent potential displacement issue * prevent element jumping during lasso selection * fix dragging on mobile * use same selection icon * fix alt+cmd lasso getting cut off * fix: shortcut handling * lint --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/common/src/constants.ts | 11 +- packages/excalidraw/actions/actionCanvas.tsx | 9 +- .../actions/actionDeleteSelected.tsx | 4 +- .../excalidraw/actions/actionFinalize.tsx | 4 +- packages/excalidraw/components/Actions.tsx | 136 +++++----- packages/excalidraw/components/App.tsx | 253 ++++++++++++++++-- packages/excalidraw/components/shapes.tsx | 21 +- packages/excalidraw/snapping.ts | 8 +- .../__snapshots__/contextmenu.test.tsx.snap | 8 +- packages/excalidraw/types.ts | 6 + 10 files changed, 353 insertions(+), 107 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index c797c6e8c2..4ccf723f89 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -18,13 +18,22 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1; export const isSafari = !isChrome && navigator.userAgent.indexOf("Safari") !== -1; export const isIOS = - /iPad|iPhone/.test(navigator.platform) || + /iPad|iPhone/i.test(navigator.platform) || // iPadOS 13+ (navigator.userAgent.includes("Mac") && "ontouchend" in document); // keeping function so it can be mocked in test export const isBrave = () => (navigator as any).brave?.isBrave?.name === "isBrave"; +export const isMobile = + isIOS || + /android|webos|ipod|blackberry|iemobile|opera mini/i.test( + navigator.userAgent.toLowerCase(), + ) || + /android|ios|ipod|blackberry|windows phone/i.test( + navigator.platform.toLowerCase(), + ); + export const supportsResizeObserver = typeof window !== "undefined" && "ResizeObserver" in window; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 80a9eedaac..535d96c7d3 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -121,7 +121,7 @@ export const actionClearCanvas = register({ pasteDialog: appState.pasteDialog, activeTool: appState.activeTool.type === "image" - ? { ...appState.activeTool, type: "selection" } + ? { ...appState.activeTool, type: app.defaultSelectionTool } : appState.activeTool, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, @@ -494,13 +494,13 @@ export const actionToggleEraserTool = register({ name: "toggleEraserTool", label: "toolBar.eraser", trackEvent: { category: "toolbar" }, - perform: (elements, appState) => { + perform: (elements, appState, _, app) => { let activeTool: AppState["activeTool"]; if (isEraserActive(appState)) { activeTool = updateActiveTool(appState, { ...(appState.activeTool.lastActiveTool || { - type: "selection", + type: app.defaultSelectionTool, }), lastActiveToolBeforeEraser: null, }); @@ -530,6 +530,9 @@ export const actionToggleLassoTool = register({ label: "toolBar.lasso", icon: LassoIcon, trackEvent: { category: "toolbar" }, + predicate: (elements, appState, props, app) => { + return app.defaultSelectionTool !== "lasso"; + }, perform: (elements, appState, _, app) => { let activeTool: AppState["activeTool"]; diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index a9281ce84e..78a3465689 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -298,7 +298,9 @@ export const actionDeleteSelected = register({ elements: nextElements, appState: { ...nextAppState, - activeTool: updateActiveTool(appState, { type: "selection" }), + activeTool: updateActiveTool(appState, { + type: app.defaultSelectionTool, + }), multiElement: null, activeEmbeddable: null, selectedLinearElement: null, diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index f9ff6e79f7..877c817ad4 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: "selection", + type: app.defaultSelectionTool, }), lastActiveToolBeforeEraser: null, }); } else { activeTool = updateActiveTool(appState, { - type: "selection", + type: app.defaultSelectionTool, }); } diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 5c9d59ada3..91bef0e057 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -46,7 +46,7 @@ import { hasStrokeWidth, } from "../scene"; -import { SHAPES } from "./shapes"; +import { getToolbarTools } from "./shapes"; import "./Actions.scss"; @@ -295,7 +295,8 @@ export const ShapesSwitcher = ({ const frameToolSelected = activeTool.type === "frame"; const laserToolSelected = activeTool.type === "laser"; - const lassoToolSelected = activeTool.type === "lasso"; + const lassoToolSelected = + activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso"; const embeddableToolSelected = activeTool.type === "embeddable"; @@ -303,63 +304,68 @@ export const ShapesSwitcher = ({ return ( <> - {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { - if ( - UIOptions.tools?.[ - value as Extract - ] === false - ) { - return null; - } + {getToolbarTools(app).map( + ({ value, icon, key, numericKey, fillable }, index) => { + if ( + UIOptions.tools?.[ + value as Extract< + typeof value, + keyof AppProps["UIOptions"]["tools"] + > + ] === false + ) { + return null; + } - const label = t(`toolBar.${value}`); - const letter = - key && capitalizeString(typeof key === "string" ? key : key[0]); - const shortcut = letter - ? `${letter} ${t("helpDialog.or")} ${numericKey}` - : `${numericKey}`; + const label = t(`toolBar.${value}`); + const letter = + key && capitalizeString(typeof key === "string" ? key : key[0]); + const shortcut = letter + ? `${letter} ${t("helpDialog.or")} ${numericKey}` + : `${numericKey}`; - return ( - { - if (!appState.penDetected && pointerType === "pen") { - app.togglePenMode(true); - } - - if (value === "selection") { - if (appState.activeTool.type === "selection") { - app.setActiveTool({ type: "lasso" }); - } else { - app.setActiveTool({ type: "selection" }); + return ( + { + if (!appState.penDetected && pointerType === "pen") { + app.togglePenMode(true); } - } - }} - onChange={({ pointerType }) => { - if (appState.activeTool.type !== value) { - trackEvent("toolbar", value, "ui"); - } - if (value === "image") { - app.setActiveTool({ - type: value, - }); - } else { - app.setActiveTool({ type: value }); - } - }} - /> - ); - })} + + if (value === "selection") { + if (appState.activeTool.type === "selection") { + app.setActiveTool({ type: "lasso" }); + } else { + app.setActiveTool({ type: "selection" }); + } + } + }} + onChange={({ pointerType }) => { + if (appState.activeTool.type !== value) { + trackEvent("toolbar", value, "ui"); + } + if (value === "image") { + app.setActiveTool({ + type: value, + }); + } else { + app.setActiveTool({ type: value }); + } + }} + /> + ); + }, + )}
@@ -418,14 +424,16 @@ export const ShapesSwitcher = ({ > {t("toolBar.laser")} - app.setActiveTool({ type: "lasso" })} - icon={LassoIcon} - data-testid="toolbar-lasso" - selected={lassoToolSelected} - > - {t("toolBar.lasso")} - + {app.defaultSelectionTool !== "lasso" && ( + app.setActiveTool({ type: "lasso" })} + icon={LassoIcon} + data-testid="toolbar-lasso" + selected={lassoToolSelected} + > + {t("toolBar.lasso")} + + )}
Generate
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index c91f1c1b9b..d0fe01db99 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -100,6 +100,7 @@ import { randomInteger, CLASSES, Emitter, + isMobile, MINIMUM_ARROW_SIZE, } from "@excalidraw/common"; @@ -653,9 +654,14 @@ class App extends React.Component { >(); onRemoveEventListenersEmitter = new Emitter<[]>(); + defaultSelectionTool: "selection" | "lasso" = "selection"; + constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); + this.defaultSelectionTool = this.isMobileOrTablet() + ? ("lasso" as const) + : ("selection" as const); const { excalidrawAPI, viewModeEnabled = false, @@ -1606,7 +1612,8 @@ class App extends React.Component { renderWelcomeScreen={ !this.state.isLoading && this.state.showWelcomeScreen && - this.state.activeTool.type === "selection" && + this.state.activeTool.type === + this.defaultSelectionTool && !this.state.zenModeEnabled && !this.scene.getElementsIncludingDeleted().length } @@ -2350,6 +2357,7 @@ class App extends React.Component { repairBindings: true, deleteInvisibleElements: true, }); + const activeTool = scene.appState.activeTool; scene.appState = { ...scene.appState, theme: this.props.theme || scene.appState.theme, @@ -2359,8 +2367,13 @@ class App extends React.Component { // with a library install link, which should auto-open the library) openSidebar: scene.appState?.openSidebar || this.state.openSidebar, activeTool: - scene.appState.activeTool.type === "image" - ? { ...scene.appState.activeTool, type: "selection" } + activeTool.type === "image" || + activeTool.type === "lasso" || + activeTool.type === "selection" + ? { + ...activeTool, + type: this.defaultSelectionTool, + } : scene.appState.activeTool, isLoading: false, toast: this.state.toast, @@ -2399,6 +2412,16 @@ class App extends React.Component { } }; + private isMobileOrTablet = (): boolean => { + const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0; + const hasCoarsePointer = + "matchMedia" in window && + window?.matchMedia("(pointer: coarse)")?.matches; + const isTouchMobile = hasTouch && hasCoarsePointer; + + return isMobile || isTouchMobile; + }; + private isMobileBreakpoint = (width: number, height: number) => { return ( width < MQ_MAX_WIDTH_PORTRAIT || @@ -3117,7 +3140,7 @@ class App extends React.Component { this.addElementsFromPasteOrLibrary({ elements, files: data.files || null, - position: "cursor", + position: this.isMobileOrTablet() ? "center" : "cursor", retainSeed: isPlainPaste, }); } else if (data.text) { @@ -3135,7 +3158,7 @@ class App extends React.Component { this.addElementsFromPasteOrLibrary({ elements, files, - position: "cursor", + position: this.isMobileOrTablet() ? "center" : "cursor", }); return; @@ -3195,7 +3218,7 @@ class App extends React.Component { } this.addTextFromPaste(data.text, isPlainPaste); } - this.setActiveTool({ type: "selection" }); + this.setActiveTool({ type: this.defaultSelectionTool }, true); event?.preventDefault(); }, ); @@ -3341,7 +3364,7 @@ class App extends React.Component { } }, ); - this.setActiveTool({ type: "selection" }); + this.setActiveTool({ type: this.defaultSelectionTool }, true); if (opts.fitToContent) { this.scrollToContent(duplicatedElements, { @@ -3587,7 +3610,7 @@ class App extends React.Component { ...updateActiveTool( this.state, prevState.activeTool.locked - ? { type: "selection" } + ? { type: this.defaultSelectionTool } : prevState.activeTool, ), locked: !prevState.activeTool.locked, @@ -4500,7 +4523,7 @@ class App extends React.Component { !this.state.selectionElement && !this.state.selectedElementsAreBeingDragged ) { - const shape = findShapeByKey(event.key); + const shape = findShapeByKey(event.key, this); if (shape) { if (this.state.activeTool.type !== shape) { trackEvent( @@ -4593,7 +4616,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: "selection" }); + this.setActiveTool({ type: this.defaultSelectionTool }); } else { this.setActiveTool({ type: "laser" }); } @@ -5438,7 +5461,7 @@ class App extends React.Component { return; } // we should only be able to double click when mode is selection - if (this.state.activeTool.type !== "selection") { + if (this.state.activeTool.type !== this.defaultSelectionTool) { return; } @@ -6050,6 +6073,7 @@ class App extends React.Component { if ( hasDeselectedButton || (this.state.activeTool.type !== "selection" && + this.state.activeTool.type !== "lasso" && this.state.activeTool.type !== "text" && this.state.activeTool.type !== "eraser") ) { @@ -6212,7 +6236,12 @@ class App extends React.Component { !isElbowArrow(hitElement) || !(hitElement.startBinding || hitElement.endBinding) ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + if ( + this.state.activeTool.type !== "lasso" || + selectedElements.length > 0 + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + } if (this.state.activeEmbeddable?.state === "hover") { this.setState({ activeEmbeddable: null }); } @@ -6329,7 +6358,12 @@ class App extends React.Component { !isElbowArrow(element) || !(element.startBinding || element.endBinding) ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + if ( + this.state.activeTool.type !== "lasso" || + Object.keys(this.state.selectedElementIds).length > 0 + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + } } } } else if (this.hitElement(scenePointerX, scenePointerY, element)) { @@ -6338,7 +6372,12 @@ class App extends React.Component { !isElbowArrow(element) || !(element.startBinding || element.endBinding) ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + if ( + this.state.activeTool.type !== "lasso" || + Object.keys(this.state.selectedElementIds).length > 0 + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + } } } @@ -6600,11 +6639,119 @@ class App extends React.Component { } if (this.state.activeTool.type === "lasso") { - this.lassoTrail.startPath( - pointerDownState.origin.x, - pointerDownState.origin.y, - event.shiftKey, - ); + const hitSelectedElement = + pointerDownState.hit.element && + this.isASelectedElement(pointerDownState.hit.element); + + const isMobileOrTablet = this.isMobileOrTablet(); + + if ( + !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && + !pointerDownState.resize.handleType && + !hitSelectedElement + ) { + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + event.shiftKey, + ); + + // block dragging after lasso selection on PCs until the next pointer down + // (on mobile or tablet, we want to allow user to drag immediately) + pointerDownState.drag.blockDragging = !isMobileOrTablet; + } + + // only for mobile or tablet, if we hit an element, select it immediately like normal selection + if ( + isMobileOrTablet && + pointerDownState.hit.element && + !hitSelectedElement + ) { + this.setState((prevState) => { + const nextSelectedElementIds: { [id: string]: true } = { + ...prevState.selectedElementIds, + [pointerDownState.hit.element!.id]: true, + }; + + const previouslySelectedElements: ExcalidrawElement[] = []; + + Object.keys(prevState.selectedElementIds).forEach((id) => { + const element = this.scene.getElement(id); + element && previouslySelectedElements.push(element); + }); + + const hitElement = pointerDownState.hit.element!; + + // if hitElement is frame-like, deselect all of its elements + // if they are selected + if (isFrameLikeElement(hitElement)) { + getFrameChildren(previouslySelectedElements, hitElement.id).forEach( + (element) => { + delete nextSelectedElementIds[element.id]; + }, + ); + } else if (hitElement.frameId) { + // if hitElement is in a frame and its frame has been selected + // disable selection for the given element + if (nextSelectedElementIds[hitElement.frameId]) { + delete nextSelectedElementIds[hitElement.id]; + } + } else { + // hitElement is neither a frame nor an element in a frame + // but since hitElement could be in a group with some frames + // this means selecting hitElement will have the frames selected as well + // because we want to keep the invariant: + // - frames and their elements are not selected at the same time + // we deselect elements in those frames that were previously selected + + const groupIds = hitElement.groupIds; + const framesInGroups = new Set( + groupIds + .flatMap((gid) => + getElementsInGroup(this.scene.getNonDeletedElements(), gid), + ) + .filter((element) => isFrameLikeElement(element)) + .map((frame) => frame.id), + ); + + if (framesInGroups.size > 0) { + previouslySelectedElements.forEach((element) => { + if (element.frameId && framesInGroups.has(element.frameId)) { + // deselect element and groups containing the element + delete nextSelectedElementIds[element.id]; + element.groupIds + .flatMap((gid) => + getElementsInGroup( + this.scene.getNonDeletedElements(), + gid, + ), + ) + .forEach((element) => { + delete nextSelectedElementIds[element.id]; + }); + } + }); + } + } + + return { + ...selectGroupsForSelectedElements( + { + editingGroupId: prevState.editingGroupId, + selectedElementIds: nextSelectedElementIds, + }, + this.scene.getNonDeletedElements(), + prevState, + this, + ), + showHyperlinkPopup: + hitElement.link || isEmbeddableElement(hitElement) + ? "info" + : false, + }; + }); + pointerDownState.hit.wasAddedToSelection = true; + } } else if (this.state.activeTool.type === "text") { this.handleTextOnPointerDown(event, pointerDownState); } else if ( @@ -6984,6 +7131,7 @@ class App extends React.Component { hasOccurred: false, offset: null, origin: { ...origin }, + blockDragging: false, }, eventListeners: { onMove: null, @@ -7059,7 +7207,10 @@ class App extends React.Component { event: React.PointerEvent, pointerDownState: PointerDownState, ): boolean => { - if (this.state.activeTool.type === "selection") { + if ( + this.state.activeTool.type === "selection" || + this.state.activeTool.type === "lasso" + ) { const elements = this.scene.getNonDeletedElements(); const elementsMap = this.scene.getNonDeletedElementsMap(); const selectedElements = this.scene.getSelectedElements(this.state); @@ -7266,7 +7417,18 @@ class App extends React.Component { // on CMD/CTRL, drill down to hit element regardless of groups etc. if (event[KEYS.CTRL_OR_CMD]) { if (event.altKey) { - // ctrl + alt means we're lasso selecting + // ctrl + alt means we're lasso selecting - start lasso trail and switch to lasso tool + + // Close any open dialogs that might interfere with lasso selection + if (this.state.openDialog?.name === "elementLinkSelector") { + this.setOpenDialog(null); + } + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + event.shiftKey, + ); + this.setActiveTool({ type: "lasso", fromSelection: true }); return false; } if (!this.state.selectedElementIds[hitElement.id]) { @@ -7487,7 +7649,9 @@ class App extends React.Component { resetCursor(this.interactiveCanvas); if (!this.state.activeTool.locked) { this.setState({ - activeTool: updateActiveTool(this.state, { type: "selection" }), + activeTool: updateActiveTool(this.state, { + type: this.defaultSelectionTool, + }), }); } }; @@ -8271,15 +8435,18 @@ class App extends React.Component { event.shiftKey && this.state.selectedLinearElement.elementId === pointerDownState.hit.element?.id; + if ( (hasHitASelectedElement || pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) && !isSelectingPointsInLineEditor && - this.state.activeTool.type !== "lasso" + !pointerDownState.drag.blockDragging ) { const selectedElements = this.scene.getSelectedElements(this.state); - - if (selectedElements.every((element) => element.locked)) { + if ( + selectedElements.length > 0 && + selectedElements.every((element) => element.locked) + ) { return; } @@ -8300,6 +8467,29 @@ class App extends React.Component { // if elements should be deselected on pointerup pointerDownState.drag.hasOccurred = true; + // prevent immediate dragging during lasso selection to avoid element displacement + // only allow dragging if we're not in the middle of lasso selection + // (on mobile, allow dragging if we hit an element) + if ( + this.state.activeTool.type === "lasso" && + this.lassoTrail.hasCurrentTrail && + !(this.isMobileOrTablet() && pointerDownState.hit.element) && + !this.state.activeTool.fromSelection + ) { + return; + } + + // Clear lasso trail when starting to drag selected elements with lasso tool + // Only clear if we're actually dragging (not during lasso selection) + if ( + this.state.activeTool.type === "lasso" && + selectedElements.length > 0 && + pointerDownState.drag.hasOccurred && + !this.state.activeTool.fromSelection + ) { + this.lassoTrail.endPath(); + } + // prevent dragging even if we're no longer holding cmd/ctrl otherwise // it would have weird results (stuff jumping all over the screen) // Checking for editingTextElement to avoid jump while editing on mobile #6503 @@ -8894,6 +9084,7 @@ class App extends React.Component { ): (event: PointerEvent) => void { return withBatchedUpdates((childEvent: PointerEvent) => { this.removePointer(childEvent); + pointerDownState.drag.blockDragging = false; if (pointerDownState.eventListeners.onMove) { pointerDownState.eventListeners.onMove.flush(); } @@ -9182,7 +9373,7 @@ class App extends React.Component { this.setState((prevState) => ({ newElement: null, activeTool: updateActiveTool(this.state, { - type: "selection", + type: this.defaultSelectionTool, }), selectedElementIds: makeNextSelectedElementIds( { @@ -9798,7 +9989,9 @@ class App extends React.Component { this.setState({ newElement: null, suggestedBindings: [], - activeTool: updateActiveTool(this.state, { type: "selection" }), + activeTool: updateActiveTool(this.state, { + type: this.defaultSelectionTool, + }), }); } else { this.setState({ @@ -10092,7 +10285,9 @@ class App extends React.Component { this.setState( { newElement: null, - activeTool: updateActiveTool(this.state, { type: "selection" }), + activeTool: updateActiveTool(this.state, { + type: this.defaultSelectionTool, + }), }, () => { this.actionManager.executeAction(actionFinalize); @@ -10465,7 +10660,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 !== "selection" + this.state.activeTool.type !== this.defaultSelectionTool ) { return; } diff --git a/packages/excalidraw/components/shapes.tsx b/packages/excalidraw/components/shapes.tsx index 7411a9e252..56c85bcd42 100644 --- a/packages/excalidraw/components/shapes.tsx +++ b/packages/excalidraw/components/shapes.tsx @@ -13,6 +13,8 @@ import { EraserIcon, } from "./icons"; +import type { AppClassProperties } from "../types"; + export const SHAPES = [ { icon: SelectionIcon, @@ -86,8 +88,23 @@ export const SHAPES = [ }, ] as const; -export const findShapeByKey = (key: string) => { - const shape = SHAPES.find((shape, index) => { +export const getToolbarTools = (app: AppClassProperties) => { + return app.defaultSelectionTool === "lasso" + ? ([ + { + value: "lasso", + icon: SelectionIcon, + key: KEYS.V, + numericKey: KEYS["1"], + fillable: true, + }, + ...SHAPES.slice(1), + ] as const) + : SHAPES; +}; + +export const findShapeByKey = (key: string, app: AppClassProperties) => { + const shape = getToolbarTools(app).find((shape, index) => { return ( (shape.numericKey != null && key === shape.numericKey.toString()) || (shape.key && diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index 5f1ba06d5d..cb4e8af6bb 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -169,8 +169,14 @@ export const isSnappingEnabled = ({ selectedElements: NonDeletedExcalidrawElement[]; }) => { if (event) { + // Allow snapping for lasso tool when dragging selected elements + // but not during lasso selection phase + const isLassoDragging = + app.state.activeTool.type === "lasso" && + app.state.selectedElementsAreBeingDragged; + return ( - app.state.activeTool.type !== "lasso" && + (app.state.activeTool.type !== "lasso" || isLassoDragging) && ((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) || (!app.state.objectsSnapModeEnabled && event[KEYS.CTRL_OR_CMD] && diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index e7c3c68d32..a7fe596441 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -3682,14 +3682,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "opacity": 100, "roughness": 1, "roundness": null, - "seed": 1116226695, + "seed": 400692809, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 23633383, + "versionNonce": 81784553, "width": 20, "x": 20, "y": 30, @@ -3714,14 +3714,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "opacity": 100, "roughness": 1, "roundness": null, - "seed": 1278240551, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 401146281, + "versionNonce": 1150084233, "width": 20, "x": -10, "y": 0, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 6694c8810e..5f62999e07 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -731,6 +731,8 @@ export type AppClassProperties = { onPointerUpEmitter: App["onPointerUpEmitter"]; updateEditorAtom: App["updateEditorAtom"]; + + defaultSelectionTool: "selection" | "lasso"; }; export type PointerDownState = Readonly<{ @@ -780,6 +782,10 @@ export type PointerDownState = Readonly<{ // by default same as PointerDownState.origin. On alt-duplication, reset // to current pointer position at time of duplication. origin: { x: number; y: number }; + // Whether to block drag after lasso selection + // this is meant to be used to block dragging after lasso selection on PCs + // until the next pointer down + blockDragging: boolean; }; // We need to have these in the state so that we can unsubscribe them eventListeners: { From c78e4aab7f29114f677855b122ac6fa39fe61911 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:09:20 +0200 Subject: [PATCH 09/48] chore: tweak title & remove timeout (#9883) --- excalidraw-app/App.tsx | 7 ------- excalidraw-app/index.html | 4 +--- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 932743ddfd..b972e6e5b0 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -20,7 +20,6 @@ import { APP_NAME, EVENT, THEME, - TITLE_TIMEOUT, VERSION_TIMEOUT, debounce, getVersion, @@ -499,11 +498,6 @@ const ExcalidrawWrapper = () => { } }; - const titleTimeout = setTimeout( - () => (document.title = APP_NAME), - TITLE_TIMEOUT, - ); - const syncData = debounce(() => { if (isTestEnv()) { return; @@ -594,7 +588,6 @@ const ExcalidrawWrapper = () => { visibilityChange, false, ); - clearTimeout(titleTimeout); }; }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]); diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 7eac3e39a2..1c29d7220a 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -2,9 +2,7 @@ - - Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw - + Excalidraw Whiteboard Date: Thu, 21 Aug 2025 16:09:19 +0200 Subject: [PATCH 10/48] fix: even deltas with version & version nonce are valid (#9897) --- packages/element/src/delta.ts | 50 +-- packages/element/tests/delta.test.tsx | 37 +- .../tests/__snapshots__/history.test.tsx.snap | 372 ++++++++++++++++-- .../regressionTests.test.tsx.snap | 38 +- packages/excalidraw/tests/history.test.tsx | 5 +- 5 files changed, 415 insertions(+), 87 deletions(-) diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index d7b242d60f..97b9403bcc 100644 --- a/packages/element/src/delta.ts +++ b/packages/element/src/delta.ts @@ -1111,16 +1111,16 @@ export class ElementsDelta implements DeltaContainer { inserted, }: Delta) => !!( - deleted.version && - inserted.version && // versions are required integers - Number.isInteger(deleted.version) && - Number.isInteger(inserted.version) && - // versions should be positive, zero included - deleted.version >= 0 && - inserted.version >= 0 && - // versions should never be the same - deleted.version !== inserted.version + ( + Number.isInteger(deleted.version) && + Number.isInteger(inserted.version) && + // versions should be positive, zero included + deleted.version! >= 0 && + inserted.version! >= 0 && + // versions should never be the same + deleted.version !== inserted.version + ) ); private static satisfiesUniqueInvariants = ( @@ -1191,9 +1191,10 @@ export class ElementsDelta implements DeltaContainer { ElementsDelta.stripIrrelevantProps, ); - // ignore updates which would "delete" already deleted element if (!prevElement.isDeleted) { removed[prevElement.id] = delta; + } else { + updated[prevElement.id] = delta; } } } @@ -1221,6 +1222,8 @@ export class ElementsDelta implements DeltaContainer { // ignore updates which would "delete" already deleted element if (!nextElement.isDeleted) { added[nextElement.id] = delta; + } else { + updated[nextElement.id] = delta; } continue; @@ -1250,15 +1253,7 @@ export class ElementsDelta implements DeltaContainer { continue; } - const strippedDeleted = ElementsDelta.stripVersionProps(delta.deleted); - const strippedInserted = ElementsDelta.stripVersionProps( - delta.inserted, - ); - - // making sure there are at least some changes and only changed version & versionNonce does not count! - if (Delta.isInnerDifferent(strippedDeleted, strippedInserted, true)) { - updated[nextElement.id] = delta; - } + updated[nextElement.id] = delta; } } @@ -1372,15 +1367,8 @@ export class ElementsDelta implements DeltaContainer { latestDelta = delta; } - const strippedDeleted = ElementsDelta.stripVersionProps( - latestDelta.deleted, - ); - const strippedInserted = ElementsDelta.stripVersionProps( - latestDelta.inserted, - ); - // it might happen that after applying latest changes the delta itself does not contain any changes - if (Delta.isInnerDifferent(strippedDeleted, strippedInserted)) { + if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) { modifiedDeltas[id] = latestDelta; } } @@ -2075,12 +2063,4 @@ export class ElementsDelta implements DeltaContainer { return strippedPartial; } - - private static stripVersionProps( - partial: Partial, - ): ElementPartial { - const { version, versionNonce, ...strippedPartial } = partial; - - return strippedPartial; - } } diff --git a/packages/element/tests/delta.test.tsx b/packages/element/tests/delta.test.tsx index e9a19d850a..2b39b32df7 100644 --- a/packages/element/tests/delta.test.tsx +++ b/packages/element/tests/delta.test.tsx @@ -8,7 +8,7 @@ import { AppStateDelta, Delta, ElementsDelta } from "../src/delta"; describe("ElementsDelta", () => { describe("elements delta calculation", () => { - it("should not create removed delta when element gets removed but was already deleted", () => { + it("should not throw when element gets removed but was already deleted", () => { const element = API.createElement({ type: "rectangle", x: 100, @@ -19,12 +19,12 @@ describe("ElementsDelta", () => { const prevElements = new Map([[element.id, element]]); const nextElements = new Map(); - const delta = ElementsDelta.calculate(prevElements, nextElements); - - expect(delta.isEmpty()).toBeTruthy(); + expect(() => + ElementsDelta.calculate(prevElements, nextElements), + ).not.toThrow(); }); - it("should not create added delta when adding element as already deleted", () => { + it("should not throw when adding element as already deleted", () => { const element = API.createElement({ type: "rectangle", x: 100, @@ -35,12 +35,12 @@ describe("ElementsDelta", () => { const prevElements = new Map(); const nextElements = new Map([[element.id, element]]); - const delta = ElementsDelta.calculate(prevElements, nextElements); - - expect(delta.isEmpty()).toBeTruthy(); + expect(() => + ElementsDelta.calculate(prevElements, nextElements), + ).not.toThrow(); }); - it("should not create updated delta when there is only version and versionNonce change", () => { + it("should create updated delta even when there is only version and versionNonce change", () => { const baseElement = API.createElement({ type: "rectangle", x: 100, @@ -65,7 +65,24 @@ describe("ElementsDelta", () => { nextElements as SceneElementsMap, ); - expect(delta.isEmpty()).toBeTruthy(); + expect(delta).toEqual( + ElementsDelta.create( + {}, + {}, + { + [baseElement.id]: Delta.create( + { + version: baseElement.version, + versionNonce: baseElement.versionNonce, + }, + { + version: baseElement.version + 1, + versionNonce: baseElement.versionNonce + 1, + }, + ), + }, + ), + ); }); }); diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 60d7e5ed6b..2f9e04d562 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -282,6 +282,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "added": {}, "removed": {}, "updated": { + "id0": { + "deleted": { + "version": 12, + }, + "inserted": { + "version": 11, + }, + }, "id1": { "deleted": { "boundElements": [], @@ -396,6 +404,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "version": 12, }, }, + "id15": { + "deleted": { + "version": 10, + }, + "inserted": { + "version": 9, + }, + }, "id4": { "deleted": { "height": "99.19972", @@ -837,6 +853,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "added": {}, "removed": {}, "updated": { + "id0": { + "deleted": { + "version": 13, + }, + "inserted": { + "version": 12, + }, + }, "id1": { "deleted": { "boundElements": [], @@ -2632,7 +2656,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "height": 100, "id": "id0", "index": "a0", - "isDeleted": true, + "isDeleted": false, "link": null, "locked": false, "opacity": 100, @@ -2681,7 +2705,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "textAlign": "left", "type": "text", "updated": 1, - "version": 6, + "version": 8, "verticalAlign": "top", "width": 100, "x": 15, @@ -2695,7 +2719,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": null, + "containerId": "id0", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -2742,10 +2766,12 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, }, "elements": { - "added": { + "added": {}, + "removed": {}, + "updated": { "id0": { "deleted": { - "isDeleted": true, + "isDeleted": false, "version": 9, }, "inserted": { @@ -2774,16 +2800,21 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "y": 10, }, }, - }, - "removed": {}, - "updated": { - "id5": { + "id1": { "deleted": { + "containerId": null, + "version": 8, + }, + "inserted": { "containerId": null, "version": 7, }, + }, + "id5": { + "deleted": { + "version": 7, + }, "inserted": { - "containerId": "id0", "version": 6, }, }, @@ -3096,6 +3127,14 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "version": 8, }, }, + "id5": { + "deleted": { + "version": 7, + }, + "inserted": { + "version": 6, + }, + }, }, }, "id": "id9", @@ -4645,15 +4684,15 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "id1": { "deleted": { "angle": 0, - "version": 4, + "version": 8, "x": 15, "y": 15, }, "inserted": { - "angle": 90, - "version": 3, - "x": 205, - "y": 205, + "angle": 0, + "version": 7, + "x": 15, + "y": 15, }, }, }, @@ -5632,12 +5671,12 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "updated": { "id1": { "deleted": { - "frameId": "id0", - "version": 5, + "frameId": null, + "version": 9, }, "inserted": { "frameId": null, - "version": 6, + "version": 8, }, }, }, @@ -5784,7 +5823,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 6, "width": 100, "x": 0, "y": 0, @@ -5816,7 +5855,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 4, + "version": 5, "width": 100, "x": 100, "y": 100, @@ -5852,7 +5891,74 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "elements": { "added": {}, "removed": {}, - "updated": {}, + "updated": { + "id0": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "A", + ], + "height": 100, + "index": "a0", + "isDeleted": true, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 5, + "width": 100, + "x": 0, + "y": 0, + }, + "inserted": { + "isDeleted": true, + "version": 4, + }, + }, + "id1": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "A", + ], + "height": 100, + "index": "a1", + "isDeleted": true, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 5, + "width": 100, + "x": 100, + "y": 100, + }, + "inserted": { + "isDeleted": true, + "version": 4, + }, + }, + }, }, "id": "id13", }, @@ -6072,7 +6178,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 9, "width": 10, "x": 20, "y": 0, @@ -6102,7 +6208,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 9, "width": 10, "x": 50, "y": 50, @@ -6187,7 +6293,39 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "elements": { "added": {}, "removed": {}, - "updated": {}, + "updated": { + "id3": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 10, + "index": "a1", + "isDeleted": true, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 8, + "width": 10, + "x": 20, + "y": 0, + }, + "inserted": { + "isDeleted": true, + "version": 7, + }, + }, + }, }, "id": "id18", }, @@ -6205,11 +6343,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "id3": { "deleted": { "backgroundColor": "#ffc9c9", - "version": 8, + "version": 9, }, "inserted": { "backgroundColor": "transparent", - "version": 7, + "version": 8, }, }, }, @@ -6234,7 +6372,39 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "elements": { "added": {}, "removed": {}, - "updated": {}, + "updated": { + "id8": { + "deleted": { + "angle": 0, + "backgroundColor": "#ffc9c9", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 10, + "index": "a2", + "isDeleted": true, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 8, + "width": 10, + "x": 30, + "y": 30, + }, + "inserted": { + "isDeleted": true, + "version": 7, + }, + }, + }, }, "id": "id20", }, @@ -6251,12 +6421,12 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "updated": { "id8": { "deleted": { - "version": 8, + "version": 9, "x": 50, "y": 50, }, "inserted": { - "version": 7, + "version": 8, "x": 30, "y": 30, }, @@ -7104,7 +7274,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 8, + "version": 9, "width": 10, "x": 0, "y": 0, @@ -7135,7 +7305,60 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "elements": { "added": {}, "removed": {}, - "updated": {}, + "updated": { + "id0": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": null, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 10, + "index": "a0", + "isDeleted": true, + "lastCommittedPoint": [ + 10, + 10, + ], + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0, + 0, + ], + [ + 10, + 10, + ], + ], + "roughness": 1, + "roundness": { + "type": 2, + }, + "startArrowhead": null, + "startBinding": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "version": 9, + "width": 10, + "x": 0, + "y": 0, + }, + "inserted": { + "isDeleted": true, + "version": 8, + }, + }, + }, }, "id": "id13", }, @@ -7344,7 +7567,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 9, "width": 10, "x": 10, "y": 0, @@ -7375,7 +7598,39 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "elements": { "added": {}, "removed": {}, - "updated": {}, + "updated": { + "id0": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 10, + "index": "a0", + "isDeleted": true, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "version": 8, + "width": 10, + "x": 10, + "y": 0, + }, + "inserted": { + "isDeleted": true, + "version": 7, + }, + }, + }, }, "id": "id7", }, @@ -7393,11 +7648,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "id0": { "deleted": { "backgroundColor": "#ffec99", - "version": 8, + "version": 9, }, "inserted": { "backgroundColor": "transparent", - "version": 7, + "version": 8, }, }, }, @@ -10326,7 +10581,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 9, "width": 10, "x": 10, "y": 0, @@ -10409,7 +10664,18 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "elements": { "added": {}, "removed": {}, - "updated": {}, + "updated": { + "id0": { + "deleted": { + "isDeleted": false, + "version": 9, + }, + "inserted": { + "isDeleted": false, + "version": 8, + }, + }, + }, }, "id": "id8", }, @@ -15775,6 +16041,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 5, }, }, + "id1": { + "deleted": { + "version": 5, + }, + "inserted": { + "version": 4, + }, + }, "id2": { "deleted": { "boundElements": [ @@ -16736,6 +17010,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 5, }, }, + "id1": { + "deleted": { + "version": 6, + }, + "inserted": { + "version": 5, + }, + }, "id2": { "deleted": { "boundElements": [ @@ -17361,6 +17643,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 9, }, }, + "id1": { + "deleted": { + "version": 10, + }, + "inserted": { + "version": 9, + }, + }, "id2": { "deleted": { "boundElements": [ @@ -17722,6 +18012,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 7, }, }, + "id2": { + "deleted": { + "version": 4, + }, + "inserted": { + "version": 3, + }, + }, }, }, "id": "id21", diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index c16cd9884f..a895eb6366 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -2216,7 +2216,16 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] undo }, }, }, - "updated": {}, + "updated": { + "id0": { + "deleted": { + "version": 5, + }, + "inserted": { + "version": 3, + }, + }, + }, }, "id": "id6", }, @@ -10892,7 +10901,32 @@ exports[`regression tests > make a group and duplicate it > [end of test] undo s }, }, }, - "updated": {}, + "updated": { + "id0": { + "deleted": { + "version": 6, + }, + "inserted": { + "version": 4, + }, + }, + "id3": { + "deleted": { + "version": 6, + }, + "inserted": { + "version": 4, + }, + }, + "id6": { + "deleted": { + "version": 6, + }, + "inserted": { + "version": 4, + }, + }, + }, }, "id": "id21", }, diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 09510e5ebb..9ef8198569 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -4055,7 +4055,7 @@ describe("history", () => { expect.objectContaining({ id: container.id, boundElements: [{ id: remoteText.id, type: "text" }], - isDeleted: true, + isDeleted: false, }), expect.objectContaining({ id: text.id, @@ -4064,8 +4064,7 @@ describe("history", () => { }), expect.objectContaining({ id: remoteText.id, - // unbound - containerId: null, + containerId: container.id, isDeleted: false, }), ]); From f29e9df72d4f87ed492420d90bed5c3e7b3c20f6 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 21 Aug 2025 20:58:04 +0200 Subject: [PATCH 11/48] chore: bump mermaid-to-excalidraw to 1.1.3 (#9898) --- packages/excalidraw/package.json | 2 +- yarn.lock | 34 ++++++++++++++++++++++---------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 019a828c61..845efc15c8 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -83,7 +83,7 @@ "@excalidraw/element": "0.18.0", "@excalidraw/math": "0.18.0", "@excalidraw/laser-pointer": "1.3.1", - "@excalidraw/mermaid-to-excalidraw": "1.1.2", + "@excalidraw/mermaid-to-excalidraw": "1.1.3", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.1.6", "@radix-ui/react-tabs": "1.1.3", diff --git a/yarn.lock b/yarn.lock index 21374749a6..446297a280 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1452,14 +1452,15 @@ resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.2.tgz#74d9507971976a7d3d960a1b2e8fb49a9f1f0d22" - integrity sha512-hAFv/TTIsOdoy0dL5v+oBd297SQ+Z88gZ5u99fCIFuEMHfQuPgLhU/ztKhFSTs7fISwVo6fizny/5oQRR3d4tQ== +"@excalidraw/mermaid-to-excalidraw@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.3.tgz#3204642c99f3d49c2ad41108217a5d493ef7fd09" + integrity sha512-/50GUWlGotc+FCMX7nM1P1kWm9vNd3fuq38v7upBp9IHqlw6Zmfyj79eG/0vz1heifuYrSW9yzzv0q9jVALzxg== dependencies: "@excalidraw/markdown-to-text" "0.1.2" - mermaid "10.9.3" + mermaid "10.9.4" nanoid "4.0.2" + react-split "^2.0.14" "@excalidraw/prettier-config@1.0.2": version "1.0.2" @@ -7057,10 +7058,10 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -mermaid@10.9.3: - version "10.9.3" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.3.tgz#90bc6f15c33dbe5d9507fed31592cc0d88fee9f7" - integrity sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw== +mermaid@10.9.4: + version "10.9.4" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.4.tgz#985fd4b6d73ae795b87f0b32f620a56d3d6bf1f8" + integrity sha512-VIG2B0R9ydvkS+wShA8sXqkzfpYglM2Qwj7VyUeqzNVqSGPoP/tcaUr3ub4ESykv8eqQJn3p99bHNvYdg3gCHQ== dependencies: "@braintree/sanitize-url" "^6.0.1" "@types/d3-scale" "^4.0.3" @@ -7963,7 +7964,7 @@ progress@2.0.3, progress@^2.0.0: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== -prop-types@^15.8.1: +prop-types@^15.5.7, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -8108,6 +8109,14 @@ react-remove-scroll@^2.6.3: use-callback-ref "^1.3.3" use-sidecar "^1.1.3" +react-split@^2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/react-split/-/react-split-2.0.14.tgz#ef198259bf43264d605f792fb3384f15f5b34432" + integrity sha512-bKWydgMgaKTg/2JGQnaJPg51T6dmumTWZppFgEbbY0Fbme0F5TuatAScCLaqommbGQQf/ZT1zaejuPDriscISA== + dependencies: + prop-types "^15.5.7" + split.js "^1.6.0" + react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" @@ -8747,6 +8756,11 @@ sourcemap-codec@^1.4.8: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== +split.js@^1.6.0: + version "1.6.5" + resolved "https://registry.yarnpkg.com/split.js/-/split.js-1.6.5.tgz#f7f61da1044c9984cb42947df4de4fadb5a3f300" + integrity sha512-mPTnGCiS/RiuTNsVhCm9De9cCAUsrNFFviRbADdKiiV+Kk8HKp/0fWu7Kr8pi3/yBmsqLFHuXGT9UUZ+CNLwFw== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" From 90ec2739aea65222f235dd7a2c0c929dce7bdbee Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 22 Aug 2025 17:37:16 +0200 Subject: [PATCH 12/48] fix: calling toLowerCase on potentially undefined `navigator.*` values (#9901) --- packages/common/src/constants.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 4ccf723f89..d6f23c69cb 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -28,11 +28,9 @@ export const isBrave = () => export const isMobile = isIOS || /android|webos|ipod|blackberry|iemobile|opera mini/i.test( - navigator.userAgent.toLowerCase(), + navigator.userAgent, ) || - /android|ios|ipod|blackberry|windows phone/i.test( - navigator.platform.toLowerCase(), - ); + /android|ios|ipod|blackberry|windows phone/i.test(navigator.platform); export const supportsResizeObserver = typeof window !== "undefined" && "ResizeObserver" in window; From 531f3e55247a0be184cb4bc1f037f76c97213965 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 22 Aug 2025 17:45:58 +0200 Subject: [PATCH 13/48] fix: restore from invalid fixedSegments & type-safer point updates (#9899) * fix: restore from invalid fixedSegments & type-safer point updates * fix: Type updates and throw for invalid point states --------- Co-authored-by: Mark Tolmacs --- packages/element/src/elbowArrow.ts | 47 ++++++++++++++++++----------- packages/excalidraw/data/restore.ts | 5 ++- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 0021851645..b988eb25bb 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -359,6 +359,12 @@ const handleSegmentRelease = ( null, ); + if (!restoredPoints || restoredPoints.length < 2) { + throw new Error( + "Property 'points' is required in the update returned by normalizeArrowElementUpdate()", + ); + } + const nextPoints: GlobalPoint[] = []; // First part of the arrow are the old points @@ -706,7 +712,7 @@ const handleEndpointDrag = ( endGlobalPoint: GlobalPoint, hoveredStartElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null, -) => { +): ElementUpdate => { let startIsSpecial = arrow.startIsSpecial ?? null; let endIsSpecial = arrow.endIsSpecial ?? null; const globalUpdatedPoints = updatedPoints.map((p, i) => @@ -741,8 +747,15 @@ const handleEndpointDrag = ( // Calculate the moving second point connection and add the start point { - const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1]; - const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2]; + const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1); + const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2); + + if (!secondPoint || !thirdPoint) { + throw new Error( + `Second and third points must exist when handling endpoint drag (${startIsSpecial})`, + ); + } + const startIsHorizontal = headingIsHorizontal(startHeading); const secondIsHorizontal = headingIsHorizontal( vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)), @@ -801,10 +814,19 @@ const handleEndpointDrag = ( // Calculate the moving second to last point connection { - const secondToLastPoint = - globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)]; - const thirdToLastPoint = - globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)]; + const secondToLastPoint = globalUpdatedPoints.at( + globalUpdatedPoints.length - (endIsSpecial ? 3 : 2), + ); + const thirdToLastPoint = globalUpdatedPoints.at( + globalUpdatedPoints.length - (endIsSpecial ? 4 : 3), + ); + + if (!secondToLastPoint || !thirdToLastPoint) { + throw new Error( + `Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`, + ); + } + const endIsHorizontal = headingIsHorizontal(endHeading); const secondIsHorizontal = headingForPointIsHorizontal( thirdToLastPoint, @@ -2071,16 +2093,7 @@ const normalizeArrowElementUpdate = ( nextFixedSegments: readonly FixedSegment[] | null, startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], -): { - points: LocalPoint[]; - x: number; - y: number; - width: number; - height: number; - fixedSegments: readonly FixedSegment[] | null; - startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"]; - endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"]; -} => { +): ElementUpdate => { const offsetX = global[0][0]; const offsetY = global[0][1]; let points = global.map((p) => diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 40c57b9cf6..34bdc8f57f 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -387,7 +387,10 @@ export const restoreElement = ( elbowed: true, startBinding: repairBinding(element, element.startBinding), endBinding: repairBinding(element, element.endBinding), - fixedSegments: element.fixedSegments, + fixedSegments: + element.fixedSegments?.length && base.points.length >= 4 + ? element.fixedSegments + : null, startIsSpecial: element.startIsSpecial, endIsSpecial: element.endIsSpecial, }) From 3085f4af81b514639b6aad954742db6fac78571b Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Sat, 23 Aug 2025 02:12:51 +1000 Subject: [PATCH 14/48] fix: tighten distance for double tap text creation (#9889) --- packages/common/src/constants.ts | 2 ++ packages/excalidraw/components/App.tsx | 38 +++++++++++++++++++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index d6f23c69cb..aef2fda9f5 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -522,3 +522,5 @@ export enum UserIdleState { * the start and end points) */ export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; + +export const DOUBLE_TAP_POSITION_THRESHOLD = 35; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d0fe01db99..337fe180ac 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -102,6 +102,7 @@ import { Emitter, isMobile, MINIMUM_ARROW_SIZE, + DOUBLE_TAP_POSITION_THRESHOLD, } from "@excalidraw/common"; import { @@ -531,6 +532,7 @@ export const useExcalidrawActionManager = () => let didTapTwice: boolean = false; let tappedTwiceTimer = 0; +let firstTapPosition: { x: number; y: number } | null = null; let isHoldingSpace: boolean = false; let isPanning: boolean = false; let isDraggingScrollBar: boolean = false; @@ -2989,6 +2991,7 @@ class App extends React.Component { private static resetTapTwice() { didTapTwice = false; + firstTapPosition = null; } private onTouchStart = (event: TouchEvent) => { @@ -2999,6 +3002,13 @@ class App extends React.Component { if (!didTapTwice) { didTapTwice = true; + + if (event.touches.length === 1) { + firstTapPosition = { + x: event.touches[0].clientX, + y: event.touches[0].clientY, + }; + } clearTimeout(tappedTwiceTimer); tappedTwiceTimer = window.setTimeout( App.resetTapTwice, @@ -3006,15 +3016,29 @@ class App extends React.Component { ); return; } - // insert text only if we tapped twice with a single finger + + // insert text only if we tapped twice with a single finger at approximately the same position // event.touches.length === 1 will also prevent inserting text when user's zooming - if (didTapTwice && event.touches.length === 1) { + if (didTapTwice && event.touches.length === 1 && firstTapPosition) { const touch = event.touches[0]; - // @ts-ignore - this.handleCanvasDoubleClick({ - clientX: touch.clientX, - clientY: touch.clientY, - }); + const distance = pointDistance( + pointFrom(touch.clientX, touch.clientY), + pointFrom(firstTapPosition.x, firstTapPosition.y), + ); + + // only create text if the second tap is within the threshold of the first tap + // this prevents accidental text creation during dragging/selection + if (distance <= DOUBLE_TAP_POSITION_THRESHOLD) { + // end lasso trail and deselect elements just in case + this.lassoTrail.endPath(); + this.deselectElements(); + + // @ts-ignore + this.handleCanvasDoubleClick({ + clientX: touch.clientX, + clientY: touch.clientY, + }); + } didTapTwice = false; clearTimeout(tappedTwiceTimer); } From ae89608985dfe5975b5fa0103d5aec64cb4fb83f Mon Sep 17 00:00:00 2001 From: Christopher Tangonan <161169629+cTangonan123@users.noreply.github.com> Date: Fri, 29 Aug 2025 03:31:23 -0700 Subject: [PATCH 15/48] fix: bound text rotation across alignments (#9914) Co-authored-by: A-Mundanilkunathil --- packages/element/src/resizeElements.ts | 22 ++- packages/element/src/textElement.ts | 24 ++- packages/element/tests/textElement.test.ts | 172 +++++++++++++++++- .../tests/__snapshots__/history.test.tsx.snap | 4 +- packages/excalidraw/tests/history.test.tsx | 8 +- packages/excalidraw/wysiwyg/textWysiwyg.tsx | 3 +- 6 files changed, 220 insertions(+), 13 deletions(-) diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index acb72b299b..8cfd807855 100644 --- a/packages/element/src/resizeElements.ts +++ b/packages/element/src/resizeElements.ts @@ -35,6 +35,7 @@ import { getContainerElement, handleBindTextResize, getBoundTextMaxWidth, + computeBoundTextPosition, } from "./textElement"; import { getMinTextElementWidth, @@ -225,7 +226,16 @@ const rotateSingleElement = ( scene.getElement(boundTextElementId); if (textElement && !isArrowElement(element)) { - scene.mutateElement(textElement, { angle }); + const { x, y } = computeBoundTextPosition( + element, + textElement, + scene.getNonDeletedElementsMap(), + ); + scene.mutateElement(textElement, { + angle, + x, + y, + }); } } }; @@ -416,9 +426,15 @@ const rotateMultipleElements = ( const boundText = getBoundTextElement(element, elementsMap); if (boundText && !isArrowElement(element)) { + const { x, y } = computeBoundTextPosition( + element, + boundText, + elementsMap, + ); + scene.mutateElement(boundText, { - x: boundText.x + (rotatedCX - cx), - y: boundText.y + (rotatedCY - cy), + x, + y, angle: normalizeRadians((centerAngle + origAngle) as Radians), }); } diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index 31347db240..523a8b8804 100644 --- a/packages/element/src/textElement.ts +++ b/packages/element/src/textElement.ts @@ -10,12 +10,12 @@ import { invariant, } from "@excalidraw/common"; +import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math"; + import type { AppState } from "@excalidraw/excalidraw/types"; import type { ExtractSetType } from "@excalidraw/common/utility-types"; -import type { Radians } from "@excalidraw/math"; - import { resetOriginalContainerCache, updateOriginalContainerCache, @@ -254,6 +254,26 @@ export const computeBoundTextPosition = ( x = containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2); } + const angle = (container.angle ?? 0) as Radians; + + if (angle !== 0) { + const contentCenter = pointFrom( + containerCoords.x + maxContainerWidth / 2, + containerCoords.y + maxContainerHeight / 2, + ); + const textCenter = pointFrom( + x + boundTextElement.width / 2, + y + boundTextElement.height / 2, + ); + + const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle); + + return { + x: rx - boundTextElement.width / 2, + y: ry - boundTextElement.height / 2, + }; + } + return { x, y }; }; diff --git a/packages/element/tests/textElement.test.ts b/packages/element/tests/textElement.test.ts index 5c10681a70..986854b985 100644 --- a/packages/element/tests/textElement.test.ts +++ b/packages/element/tests/textElement.test.ts @@ -1,13 +1,14 @@ import { getLineHeight } from "@excalidraw/common"; import { API } from "@excalidraw/excalidraw/tests/helpers/api"; -import { FONT_FAMILY } from "@excalidraw/common"; +import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common"; import { computeContainerDimensionForBoundText, getContainerCoords, getBoundTextMaxWidth, getBoundTextMaxHeight, + computeBoundTextPosition, } from "../src/textElement"; import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements"; @@ -207,3 +208,172 @@ describe("Test getDefaultLineHeight", () => { expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); }); }); + +describe("Test computeBoundTextPosition", () => { + const createMockElementsMap = () => new Map(); + + // Helper function to create rectangle test case with 90-degree rotation + const createRotatedRectangleTestCase = ( + textAlign: string, + verticalAlign: string, + ) => { + const container = API.createElement({ + type: "rectangle", + x: 100, + y: 100, + width: 200, + height: 100, + angle: (Math.PI / 2) as any, // 90 degrees + }); + + const boundTextElement = API.createElement({ + type: "text", + width: 80, + height: 40, + text: "hello darkness my old friend", + textAlign: textAlign as any, + verticalAlign: verticalAlign as any, + containerId: container.id, + }) as ExcalidrawTextElementWithContainer; + + const elementsMap = createMockElementsMap(); + + return { container, boundTextElement, elementsMap }; + }; + + describe("90-degree rotation with all alignment combinations", () => { + // Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment + + it("should position text with LEFT + TOP alignment at 90-degree rotation", () => { + const { container, boundTextElement, elementsMap } = + createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP); + + const result = computeBoundTextPosition( + container, + boundTextElement, + elementsMap, + ); + + expect(result.x).toBeCloseTo(185, 1); + expect(result.y).toBeCloseTo(75, 1); + }); + + it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => { + const { container, boundTextElement, elementsMap } = + createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE); + + const result = computeBoundTextPosition( + container, + boundTextElement, + elementsMap, + ); + + expect(result.x).toBeCloseTo(160, 1); + expect(result.y).toBeCloseTo(75, 1); + }); + + it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => { + const { container, boundTextElement, elementsMap } = + createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM); + + const result = computeBoundTextPosition( + container, + boundTextElement, + elementsMap, + ); + + expect(result.x).toBeCloseTo(135, 1); + expect(result.y).toBeCloseTo(75, 1); + }); + + it("should position text with CENTER + TOP alignment at 90-degree rotation", () => { + const { container, boundTextElement, elementsMap } = + createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP); + + const result = computeBoundTextPosition( + container, + boundTextElement, + elementsMap, + ); + + expect(result.x).toBeCloseTo(185, 1); + expect(result.y).toBeCloseTo(130, 1); + }); + + it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => { + const { container, boundTextElement, elementsMap } = + createRotatedRectangleTestCase( + TEXT_ALIGN.CENTER, + VERTICAL_ALIGN.MIDDLE, + ); + + const result = computeBoundTextPosition( + container, + boundTextElement, + elementsMap, + ); + + expect(result.x).toBeCloseTo(160, 1); + expect(result.y).toBeCloseTo(130, 1); + }); + + it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => { + const { container, boundTextElement, elementsMap } = + createRotatedRectangleTestCase( + TEXT_ALIGN.CENTER, + VERTICAL_ALIGN.BOTTOM, + ); + + const result = computeBoundTextPosition( + container, + boundTextElement, + elementsMap, + ); + + expect(result.x).toBeCloseTo(135, 1); + expect(result.y).toBeCloseTo(130, 1); + }); + + it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => { + const { container, boundTextElement, elementsMap } = + createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP); + + const result = computeBoundTextPosition( + container, + boundTextElement, + elementsMap, + ); + + expect(result.x).toBeCloseTo(185, 1); + expect(result.y).toBeCloseTo(185, 1); + }); + + it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => { + const { container, boundTextElement, elementsMap } = + createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE); + + const result = computeBoundTextPosition( + container, + boundTextElement, + elementsMap, + ); + + expect(result.x).toBeCloseTo(160, 1); + expect(result.y).toBeCloseTo(185, 1); + }); + + it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => { + const { container, boundTextElement, elementsMap } = + createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM); + + const result = computeBoundTextPosition( + container, + boundTextElement, + elementsMap, + ); + + expect(result.x).toBeCloseTo(135, 1); + expect(result.y).toBeCloseTo(185, 1); + }); + }); +}); diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 2f9e04d562..c31b9ea7ce 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -4886,8 +4886,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "version": 6, "verticalAlign": "top", "width": 80, - "x": 205, - "y": 205, + "x": "241.29526", + "y": "247.59241", } `; diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 9ef8198569..47d87ce6d0 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -4354,8 +4354,8 @@ describe("history", () => { expect.objectContaining({ ...textProps, // text element got redrawn! - x: 205, - y: 205, + x: 241.295259647664, + y: 247.59240920619527, angle: 90, id: text.id, containerId: container.id, @@ -4398,8 +4398,8 @@ describe("history", () => { }), expect.objectContaining({ ...textProps, - x: 205, - y: 205, + x: 241.295259647664, + y: 247.59240920619527, angle: 90, id: text.id, containerId: container.id, diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index 2255d8a5a2..73f3d7171b 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -215,11 +215,12 @@ export const textWysiwyg = ({ ); app.scene.mutateElement(container, { height: targetContainerHeight }); } else { - const { y } = computeBoundTextPosition( + const { x, y } = computeBoundTextPosition( container, updatedTextElement as ExcalidrawTextElementWithContainer, elementsMap, ); + coordX = x; coordY = y; } } From 3bdaafe4b5ecde577fe9b5c69a5d1819e37c4ccb Mon Sep 17 00:00:00 2001 From: Omar Brikaa Date: Mon, 1 Sep 2025 18:31:24 +0300 Subject: [PATCH 16/48] feat: [cont.] support inserting multiple images (#9875) * feat: support inserting multiple images * Initial * handleAppOnDrop, onImageToolbarButtonClick, pasteFromClipboard * Initial get history working * insertMultipleImages -> insertImages * Bug fixes, improvements * Remove redundant branch * Refactor addElementsFromMixedContentPaste * History, drag & drop bug fixes * Update snapshots * Remove redundant try-catch * Refactor pasteFromClipboard * Plain paste check in mermaid paste * Move comment * processClipboardData -> insertClipboardContent * Redundant variable * Redundant variable * Refactor insertImages * createImagePlaceholder -> newImagePlaceholder * Get rid of unneeded NEVER schedule, filter out failed images * Trigger CI * Position placeholders before initializing * Don't mutate scene with positionElementsOnGrid, captureUpdate: CaptureUpdateAction.IMMEDIATELY * Comment * Move positionOnGrid out of file * Rename file * Get rid of generic * Initial tests * More asserts, test paste * Test image tool * De-duplicate * Stricter assert, move rest of logic outside of waitFor * Modify history tests * De-duplicate update snapshots * Trigger CI * Fix package build * Make setupImageTest more explicit * Re-introduce generic to use latest placeholder versions * newElementWith instead of mutateElement to delete failed placeholder * Insert failed images separately with CaptureUpdateAction.NEVER * Refactor * Don't re-order elements * WIP * Get rid of 'never' for failed * refactor type check * align max file size constant * make grid padding scale to zoom --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/app_constants.ts | 3 +- packages/element/src/bounds.ts | 4 +- packages/element/src/index.ts | 1 + .../element/src/positionElementsOnGrid.ts | 112 ++++ packages/excalidraw/clipboard.ts | 14 +- packages/excalidraw/components/App.tsx | 587 +++++++++--------- packages/excalidraw/data/blob.ts | 49 +- .../tests/__snapshots__/history.test.tsx.snap | 216 ++++++- .../excalidraw/tests/fixtures/constants.ts | 9 + packages/excalidraw/tests/flip.test.tsx | 10 +- packages/excalidraw/tests/helpers/api.ts | 44 +- .../excalidraw/tests/helpers/constants.ts | 6 + packages/excalidraw/tests/helpers/mocks.ts | 32 + packages/excalidraw/tests/history.test.tsx | 195 ++---- packages/excalidraw/tests/image.test.tsx | 115 ++++ 15 files changed, 895 insertions(+), 502 deletions(-) create mode 100644 packages/element/src/positionElementsOnGrid.ts create mode 100644 packages/excalidraw/tests/fixtures/constants.ts create mode 100644 packages/excalidraw/tests/helpers/constants.ts create mode 100644 packages/excalidraw/tests/image.test.tsx diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index 1dc6c6f462..52c1ad7ba2 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50; export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day -export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB +// should be aligned with MAX_ALLOWED_FILE_BYTES +export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB // 1 year (https://stackoverflow.com/a/25201898/927631) export const FILE_CACHE_MAX_AGE_SEC = 31536000; diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index 2c07631a7a..6b190de1b7 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -1126,7 +1126,9 @@ export interface BoundingBox { } export const getCommonBoundingBox = ( - elements: ExcalidrawElement[] | readonly NonDeleted[], + elements: + | readonly ExcalidrawElement[] + | readonly NonDeleted[], ): BoundingBox => { const [minX, minY, maxX, maxY] = getCommonBounds(elements); return { diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 9bf5214d0f..4fc1ef5579 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -97,6 +97,7 @@ export * from "./image"; export * from "./linearElementEditor"; export * from "./mutateElement"; export * from "./newElement"; +export * from "./positionElementsOnGrid"; export * from "./renderElement"; export * from "./resizeElements"; export * from "./resizeTest"; diff --git a/packages/element/src/positionElementsOnGrid.ts b/packages/element/src/positionElementsOnGrid.ts new file mode 100644 index 0000000000..017ee1fd99 --- /dev/null +++ b/packages/element/src/positionElementsOnGrid.ts @@ -0,0 +1,112 @@ +import { getCommonBounds } from "./bounds"; +import { type ElementUpdate, newElementWith } from "./mutateElement"; + +import type { ExcalidrawElement } from "./types"; + +// TODO rewrite (mostly vibe-coded) +export const positionElementsOnGrid = ( + elements: TElement[] | TElement[][], + centerX: number, + centerY: number, + padding = 50, +): TElement[] => { + // Ensure there are elements to position + if (!elements || elements.length === 0) { + return []; + } + + const res: TElement[] = []; + // Normalize input to work with atomic units (groups of elements) + // If elements is a flat array, treat each element as its own atomic unit + const atomicUnits: TElement[][] = Array.isArray(elements[0]) + ? (elements as TElement[][]) + : (elements as TElement[]).map((element) => [element]); + + // Determine the number of columns for atomic units + // A common approach for a "grid-like" layout without specific column constraints + // is to aim for a roughly square arrangement. + const numUnits = atomicUnits.length; + const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits))); + + // Group atomic units into rows based on the calculated number of columns + const rows: TElement[][][] = []; + for (let i = 0; i < numUnits; i += numColumns) { + rows.push(atomicUnits.slice(i, i + numColumns)); + } + + // Calculate properties for each row (total width, max height) + // and the total actual height of all row content. + let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding + const rowProperties = rows.map((rowUnits) => { + let rowWidth = 0; + let maxUnitHeightInRow = 0; + + const unitBounds = rowUnits.map((unit) => { + const [minX, minY, maxX, maxY] = getCommonBounds(unit); + return { + elements: unit, + bounds: [minX, minY, maxX, maxY] as const, + width: maxX - minX, + height: maxY - minY, + }; + }); + + unitBounds.forEach((unitBound, index) => { + rowWidth += unitBound.width; + // Add padding between units in the same row, but not after the last one + if (index < unitBounds.length - 1) { + rowWidth += padding; + } + if (unitBound.height > maxUnitHeightInRow) { + maxUnitHeightInRow = unitBound.height; + } + }); + + totalGridActualHeight += maxUnitHeightInRow; + return { + unitBounds, + width: rowWidth, + maxHeight: maxUnitHeightInRow, + }; + }); + + // Calculate the total height of the grid including padding between rows + const totalGridHeightWithPadding = + totalGridActualHeight + Math.max(0, rows.length - 1) * padding; + + // Calculate the starting Y position to center the entire grid vertically around centerY + let currentY = centerY - totalGridHeightWithPadding / 2; + + // Position atomic units row by row + rowProperties.forEach((rowProp) => { + const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp; + + // Calculate the starting X for the current row to center it horizontally around centerX + let currentX = centerX - rowWidth / 2; + + unitBounds.forEach((unitBound) => { + // Calculate the offset needed to position this atomic unit + const [originalMinX, originalMinY] = unitBound.bounds; + const offsetX = currentX - originalMinX; + const offsetY = currentY - originalMinY; + + // Apply the offset to all elements in this atomic unit + unitBound.elements.forEach((element) => { + res.push( + newElementWith(element, { + x: element.x + offsetX, + y: element.y + offsetY, + } as ElementUpdate), + ); + }); + + // Move X for the next unit in the row + currentX += unitBound.width + padding; + }); + + // Move Y to the starting position for the next row + // This accounts for the tallest unit in the current row and the inter-row padding + currentY += rowMaxHeight + padding; + }); + return res; +}; diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 99b7d41f4a..ceaee38c80 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -5,6 +5,7 @@ import { arrayToMap, isMemberOf, isPromiseLike, + EVENT, } from "@excalidraw/common"; import { mutateElement } from "@excalidraw/element"; @@ -92,7 +93,7 @@ export const createPasteEvent = ({ console.warn("createPasteEvent: no types or files provided"); } - const event = new ClipboardEvent("paste", { + const event = new ClipboardEvent(EVENT.PASTE, { clipboardData: new DataTransfer(), }); @@ -519,3 +520,14 @@ const copyTextViaExecCommand = (text: string | null) => { return success; }; + +export const isClipboardEvent = ( + event: React.SyntheticEvent | Event, +): event is ClipboardEvent => { + /** not using instanceof ClipboardEvent due to tests (jsdom) */ + return ( + event.type === EVENT.PASTE || + event.type === EVENT.COPY || + event.type === EVENT.CUT + ); +}; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 337fe180ac..bf838b1c39 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -237,6 +237,7 @@ import { isSimpleArrow, StoreDelta, type ApplyToOptions, + positionElementsOnGrid, } from "@excalidraw/element"; import type { LocalPoint, Radians } from "@excalidraw/math"; @@ -345,7 +346,7 @@ import { generateIdFromFile, getDataURL, getDataURL_sync, - getFileFromEvent, + getFilesFromEvent, ImageURLToFile, isImageFileHandle, isSupportedImageFile, @@ -432,7 +433,7 @@ import type { ScrollBars, } from "../scene/types"; -import type { PastedMixedContent } from "../clipboard"; +import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { ExportedElements } from "../data"; import type { ContextMenuItems } from "./ContextMenu"; import type { FileSystemHandle } from "../data/filesystem"; @@ -3066,7 +3067,168 @@ class App extends React.Component { } }; - // TODO: this is so spaghetti, we should refactor it and cover it with tests + // TODO: Cover with tests + private async insertClipboardContent( + data: ClipboardData, + filesData: Awaited>, + isPlainPaste: boolean, + ) { + const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( + { + clientX: this.lastViewportPosition.x, + clientY: this.lastViewportPosition.y, + }, + this.state, + ); + + // ------------------- Error ------------------- + if (data.errorMessage) { + this.setState({ errorMessage: data.errorMessage }); + return; + } + + // ------------------- Mixed content with no files ------------------- + if (filesData.length === 0 && !isPlainPaste && data.mixedContent) { + await this.addElementsFromMixedContentPaste(data.mixedContent, { + isPlainPaste, + sceneX, + sceneY, + }); + return; + } + + // ------------------- Spreadsheet ------------------- + if (data.spreadsheet && !isPlainPaste) { + this.setState({ + pasteDialog: { + data: data.spreadsheet, + shown: true, + }, + }); + return; + } + + // ------------------- Images or SVG code ------------------- + const imageFiles = filesData + .map((data) => data.file) + .filter((file): file is File => isSupportedImageFile(file)); + + if (imageFiles.length === 0 && data.text && !isPlainPaste) { + const trimmedText = data.text.trim(); + if (trimmedText.startsWith("")) { + // ignore SVG validation/normalization which will be done during image + // initialization + imageFiles.push(SVGStringToFile(trimmedText)); + } + } + + if (imageFiles.length > 0) { + if (this.isToolSupported("image")) { + await this.insertImages(imageFiles, sceneX, sceneY); + } else { + this.setState({ errorMessage: t("errors.imageToolNotSupported") }); + } + return; + } + + // ------------------- Elements ------------------- + if (data.elements) { + const elements = ( + data.programmaticAPI + ? convertToExcalidrawElements( + data.elements as ExcalidrawElementSkeleton[], + ) + : data.elements + ) as readonly ExcalidrawElement[]; + // TODO: remove formatting from elements if isPlainPaste + this.addElementsFromPasteOrLibrary({ + elements, + files: data.files || null, + position: this.isMobileOrTablet() ? "center" : "cursor", + retainSeed: isPlainPaste, + }); + return; + } + + // ------------------- Only textual stuff remaining ------------------- + if (!data.text) { + return; + } + + // ------------------- Successful Mermaid ------------------- + if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) { + const api = await import("@excalidraw/mermaid-to-excalidraw"); + try { + const { elements: skeletonElements, files } = + await api.parseMermaidToExcalidraw(data.text); + + const elements = convertToExcalidrawElements(skeletonElements, { + regenerateIds: true, + }); + + this.addElementsFromPasteOrLibrary({ + elements, + files, + position: this.isMobileOrTablet() ? "center" : "cursor", + }); + + return; + } catch (err: any) { + console.warn( + `parsing pasted text as mermaid definition failed: ${err.message}`, + ); + } + } + + // ------------------- Pure embeddable URLs ------------------- + const nonEmptyLines = normalizeEOL(data.text) + .split(/\n+/) + .map((s) => s.trim()) + .filter(Boolean); + const embbeddableUrls = nonEmptyLines + .map((str) => maybeParseEmbedSrc(str)) + .filter( + (string) => + embeddableURLValidator(string, this.props.validateEmbeddable) && + (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) || + getEmbedLink(string)?.type === "video"), + ); + + if ( + !isPlainPaste && + embbeddableUrls.length > 0 && + embbeddableUrls.length === nonEmptyLines.length + ) { + const embeddables: NonDeleted[] = []; + for (const url of embbeddableUrls) { + const prevEmbeddable: ExcalidrawEmbeddableElement | undefined = + embeddables[embeddables.length - 1]; + const embeddable = this.insertEmbeddableElement({ + sceneX: prevEmbeddable + ? prevEmbeddable.x + prevEmbeddable.width + 20 + : sceneX, + sceneY, + link: normalizeLink(url), + }); + if (embeddable) { + embeddables.push(embeddable); + } + } + if (embeddables.length) { + this.store.scheduleCapture(); + this.setState({ + selectedElementIds: Object.fromEntries( + embeddables.map((embeddable) => [embeddable.id, true]), + ), + }); + } + return; + } + + // ------------------- Text ------------------- + this.addTextFromPaste(data.text, isPlainPaste); + } + public pasteFromClipboard = withBatchedUpdates( async (event: ClipboardEvent) => { const isPlainPaste = !!IS_PLAIN_PASTE; @@ -3091,47 +3253,11 @@ class App extends React.Component { return; } - const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( - { - clientX: this.lastViewportPosition.x, - clientY: this.lastViewportPosition.y, - }, - this.state, - ); - // must be called in the same frame (thus before any awaits) as the paste // event else some browsers (FF...) will clear the clipboardData // (something something security) - let file = event?.clipboardData?.files[0]; + const filesData = await getFilesFromEvent(event); const data = await parseClipboard(event, isPlainPaste); - if (!file && !isPlainPaste) { - if (data.mixedContent) { - return this.addElementsFromMixedContentPaste(data.mixedContent, { - isPlainPaste, - sceneX, - sceneY, - }); - } else if (data.text) { - const string = data.text.trim(); - if (string.startsWith("")) { - // ignore SVG validation/normalization which will be done during image - // initialization - file = SVGStringToFile(string); - } - } - } - - // prefer spreadsheet data over image file (MS Office/Libre Office) - if (isSupportedImageFile(file) && !data.spreadsheet) { - if (!this.isToolSupported("image")) { - this.setState({ errorMessage: t("errors.imageToolNotSupported") }); - return; - } - - this.createImageElement({ sceneX, sceneY, imageFile: file }); - - return; - } if (this.props.onPaste) { try { @@ -3143,105 +3269,7 @@ class App extends React.Component { } } - if (data.errorMessage) { - this.setState({ errorMessage: data.errorMessage }); - } else if (data.spreadsheet && !isPlainPaste) { - this.setState({ - pasteDialog: { - data: data.spreadsheet, - shown: true, - }, - }); - } else if (data.elements) { - const elements = ( - data.programmaticAPI - ? convertToExcalidrawElements( - data.elements as ExcalidrawElementSkeleton[], - ) - : data.elements - ) as readonly ExcalidrawElement[]; - // TODO remove formatting from elements if isPlainPaste - this.addElementsFromPasteOrLibrary({ - elements, - files: data.files || null, - position: this.isMobileOrTablet() ? "center" : "cursor", - retainSeed: isPlainPaste, - }); - } else if (data.text) { - if (data.text && isMaybeMermaidDefinition(data.text)) { - const api = await import("@excalidraw/mermaid-to-excalidraw"); - - try { - const { elements: skeletonElements, files } = - await api.parseMermaidToExcalidraw(data.text); - - const elements = convertToExcalidrawElements(skeletonElements, { - regenerateIds: true, - }); - - this.addElementsFromPasteOrLibrary({ - elements, - files, - position: this.isMobileOrTablet() ? "center" : "cursor", - }); - - return; - } catch (err: any) { - console.warn( - `parsing pasted text as mermaid definition failed: ${err.message}`, - ); - } - } - - const nonEmptyLines = normalizeEOL(data.text) - .split(/\n+/) - .map((s) => s.trim()) - .filter(Boolean); - - const embbeddableUrls = nonEmptyLines - .map((str) => maybeParseEmbedSrc(str)) - .filter((string) => { - return ( - embeddableURLValidator(string, this.props.validateEmbeddable) && - (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) || - getEmbedLink(string)?.type === "video") - ); - }); - - if ( - !IS_PLAIN_PASTE && - embbeddableUrls.length > 0 && - // if there were non-embeddable text (lines) mixed in with embeddable - // urls, ignore and paste as text - embbeddableUrls.length === nonEmptyLines.length - ) { - const embeddables: NonDeleted[] = []; - for (const url of embbeddableUrls) { - const prevEmbeddable: ExcalidrawEmbeddableElement | undefined = - embeddables[embeddables.length - 1]; - const embeddable = this.insertEmbeddableElement({ - sceneX: prevEmbeddable - ? prevEmbeddable.x + prevEmbeddable.width + 20 - : sceneX, - sceneY, - link: normalizeLink(url), - }); - if (embeddable) { - embeddables.push(embeddable); - } - } - if (embeddables.length) { - this.store.scheduleCapture(); - this.setState({ - selectedElementIds: Object.fromEntries( - embeddables.map((embeddable) => [embeddable.id, true]), - ), - }); - } - return; - } - this.addTextFromPaste(data.text, isPlainPaste); - } + await this.insertClipboardContent(data, filesData, isPlainPaste); this.setActiveTool({ type: this.defaultSelectionTool }, true); event?.preventDefault(); }, @@ -3431,45 +3459,11 @@ class App extends React.Component { } }), ); - let y = sceneY; - let firstImageYOffsetDone = false; - const nextSelectedIds: Record = {}; - for (const response of responses) { - if (response.file) { - const initializedImageElement = await this.createImageElement({ - sceneX, - sceneY: y, - imageFile: response.file, - }); - - if (initializedImageElement) { - // vertically center first image in the batch - if (!firstImageYOffsetDone) { - firstImageYOffsetDone = true; - y -= initializedImageElement.height / 2; - } - // hack to reset the `y` coord because we vertically center during - // insertImageElement - this.scene.mutateElement( - initializedImageElement, - { y }, - { informMutation: false, isDragging: false }, - ); - - y = initializedImageElement.y + initializedImageElement.height + 25; - - nextSelectedIds[initializedImageElement.id] = true; - } - } - } - - this.setState({ - selectedElementIds: makeNextSelectedElementIds( - nextSelectedIds, - this.state, - ), - }); + const imageFiles = responses + .filter((response): response is { file: File } => !!response.file) + .map((response) => response.file); + await this.insertImages(imageFiles, sceneX, sceneY); const error = responses.find((response) => !!response.errorMessage); if (error && error.errorMessage) { this.setState({ errorMessage: error.errorMessage }); @@ -4806,7 +4800,7 @@ class App extends React.Component { this.setState({ suggestedBindings: [] }); } if (nextActiveTool.type === "image") { - this.onImageAction(); + this.onImageToolbarButtonClick(); } this.setState((prevState) => { @@ -7842,16 +7836,14 @@ class App extends React.Component { return element; }; - private createImageElement = async ({ + private newImagePlaceholder = ({ sceneX, sceneY, addToFrameUnderCursor = true, - imageFile, }: { sceneX: number; sceneY: number; addToFrameUnderCursor?: boolean; - imageFile: File; }) => { const [gridX, gridY] = getGridPoint( sceneX, @@ -7870,7 +7862,7 @@ class App extends React.Component { const placeholderSize = 100 / this.state.zoom.value; - const placeholderImageElement = newImageElement({ + return newImageElement({ type: "image", strokeColor: this.state.currentItemStrokeColor, backgroundColor: this.state.currentItemBackgroundColor, @@ -7887,13 +7879,6 @@ class App extends React.Component { width: placeholderSize, height: placeholderSize, }); - - const initializedImageElement = await this.insertImageElement( - placeholderImageElement, - imageFile, - ); - - return initializedImageElement; }; private handleLinearElementOnPointerDown = ( @@ -10215,64 +10200,7 @@ class App extends React.Component { ); }; - /** - * inserts image into elements array and rerenders - */ - private insertImageElement = async ( - placeholderImageElement: ExcalidrawImageElement, - imageFile: File, - ) => { - // we should be handling all cases upstream, but in case we forget to handle - // a future case, let's throw here - if (!this.isToolSupported("image")) { - this.setState({ errorMessage: t("errors.imageToolNotSupported") }); - return; - } - - this.scene.insertElement(placeholderImageElement); - - try { - const initializedImageElement = await this.initializeImage( - placeholderImageElement, - imageFile, - ); - - const nextElements = this.scene - .getElementsIncludingDeleted() - .map((element) => { - if (element.id === initializedImageElement.id) { - return initializedImageElement; - } - - return element; - }); - - this.updateScene({ - captureUpdate: CaptureUpdateAction.IMMEDIATELY, - elements: nextElements, - appState: { - selectedElementIds: makeNextSelectedElementIds( - { [initializedImageElement.id]: true }, - this.state, - ), - }, - }); - - return initializedImageElement; - } catch (error: any) { - this.store.scheduleAction(CaptureUpdateAction.NEVER); - this.scene.mutateElement(placeholderImageElement, { - isDeleted: true, - }); - this.actionManager.executeAction(actionFinalize); - this.setState({ - errorMessage: error.message || t("errors.imageInsertError"), - }); - return null; - } - }; - - private onImageAction = async () => { + private onImageToolbarButtonClick = async () => { try { const clientX = this.state.width / 2 + this.state.offsetLeft; const clientY = this.state.height / 2 + this.state.offsetTop; @@ -10282,24 +10210,15 @@ class App extends React.Component { this.state, ); - const imageFile = await fileOpen({ + const imageFiles = await fileOpen({ description: "Image", extensions: Object.keys( IMAGE_MIME_TYPES, ) as (keyof typeof IMAGE_MIME_TYPES)[], + multiple: true, }); - await this.createImageElement({ - sceneX: x, - sceneY: y, - addToFrameUnderCursor: false, - imageFile, - }); - - // avoid being batched (just in case) - this.setState({}, () => { - this.actionManager.executeAction(actionFinalize); - }); + this.insertImages(imageFiles, x, y); } catch (error: any) { if (error.name !== "AbortError") { console.error(error); @@ -10496,60 +10415,113 @@ class App extends React.Component { } }; + private insertImages = async ( + imageFiles: File[], + sceneX: number, + sceneY: number, + ) => { + const gridPadding = 50 / this.state.zoom.value; + // Create, position, and insert placeholders + const placeholders = positionElementsOnGrid( + imageFiles.map(() => this.newImagePlaceholder({ sceneX, sceneY })), + sceneX, + sceneY, + gridPadding, + ); + placeholders.forEach((el) => this.scene.insertElement(el)); + + // Create, position, insert and select initialized (replacing placeholders) + const initialized = await Promise.all( + placeholders.map(async (placeholder, i) => { + try { + return await this.initializeImage(placeholder, imageFiles[i]); + } catch (error: any) { + this.setState({ + errorMessage: error.message || t("errors.imageInsertError"), + }); + return newElementWith(placeholder, { isDeleted: true }); + } + }), + ); + const initializedMap = arrayToMap(initialized); + + const positioned = positionElementsOnGrid( + initialized.filter((el) => !el.isDeleted), + sceneX, + sceneY, + gridPadding, + ); + const positionedMap = arrayToMap(positioned); + + const nextElements = this.scene + .getElementsIncludingDeleted() + .map((el) => positionedMap.get(el.id) ?? initializedMap.get(el.id) ?? el); + + this.updateScene({ + appState: { + selectedElementIds: makeNextSelectedElementIds( + Object.fromEntries(positioned.map((el) => [el.id, true])), + this.state, + ), + }, + elements: nextElements, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + + this.setState({}, () => { + // actionFinalize after all state values have been updated + this.actionManager.executeAction(actionFinalize); + }); + }; + private handleAppOnDrop = async (event: React.DragEvent) => { - // must be retrieved first, in the same frame - const { file, fileHandle } = await getFileFromEvent(event); const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( event, this.state, ); - try { - // if image tool not supported, don't show an error here and let it fall - // through so we still support importing scene data from images. If no - // scene data encoded, we'll show an error then - if (isSupportedImageFile(file) && this.isToolSupported("image")) { - // first attempt to decode scene from the image if it's embedded - // --------------------------------------------------------------------- + // must be retrieved first, in the same frame + const filesData = await getFilesFromEvent(event); - if (file?.type === MIME_TYPES.png || file?.type === MIME_TYPES.svg) { - try { - const scene = await loadFromBlob( - file, - this.state, - this.scene.getElementsIncludingDeleted(), - fileHandle, - ); - this.syncActionResult({ - ...scene, - appState: { - ...(scene.appState || this.state), - isLoading: false, - }, - replaceFiles: true, - captureUpdate: CaptureUpdateAction.IMMEDIATELY, - }); - return; - } catch (error: any) { - // Don't throw for image scene daa - if (error.name !== "EncodingError") { - throw new Error(t("alerts.couldNotLoadInvalidFile")); - } + if (filesData.length === 1) { + const { file, fileHandle } = filesData[0]; + + if ( + file && + (file.type === MIME_TYPES.png || file.type === MIME_TYPES.svg) + ) { + try { + const scene = await loadFromBlob( + file, + this.state, + this.scene.getElementsIncludingDeleted(), + fileHandle, + ); + this.syncActionResult({ + ...scene, + appState: { + ...(scene.appState || this.state), + isLoading: false, + }, + replaceFiles: true, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + return; + } catch (error: any) { + if (error.name !== "EncodingError") { + throw new Error(t("alerts.couldNotLoadInvalidFile")); } + // if EncodingError, fall through to insert as regular image } - - // if no scene is embedded or we fail for whatever reason, fall back - // to importing as regular image - // --------------------------------------------------------------------- - this.createImageElement({ sceneX, sceneY, imageFile: file }); - - return; } - } catch (error: any) { - return this.setState({ - isLoading: false, - errorMessage: error.message, - }); + } + + const imageFiles = filesData + .map((data) => data.file) + .filter((file): file is File => isSupportedImageFile(file)); + + if (imageFiles.length > 0 && this.isToolSupported("image")) { + return this.insertImages(imageFiles, sceneX, sceneY); } const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib); @@ -10567,9 +10539,12 @@ class App extends React.Component { return; } - if (file) { - // Attempt to parse an excalidraw/excalidrawlib file - await this.loadFileToCanvas(file, fileHandle); + if (filesData.length > 0) { + const { file, fileHandle } = filesData[0]; + if (file) { + // Attempt to parse an excalidraw/excalidrawlib file + await this.loadFileToCanvas(file, fileHandle); + } } if (event.dataTransfer?.types?.includes("text/plain")) { diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index d990fd0500..dc65cf0d3b 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -18,6 +18,8 @@ import { CanvasError, ImageSceneDataError } from "../errors"; import { calculateScrollCenter } from "../scene"; import { decodeSvgBase64Payload } from "../scene/export"; +import { isClipboardEvent } from "../clipboard"; + import { base64ToString, stringToBase64, toByteString } from "./encode"; import { nativeFileSystemSupported } from "./filesystem"; import { isValidExcalidrawData, isValidLibrary } from "./json"; @@ -389,23 +391,54 @@ export const ImageURLToFile = async ( throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); }; -export const getFileFromEvent = async ( - event: React.DragEvent, +export const getFilesFromEvent = async ( + event: React.DragEvent | ClipboardEvent, ) => { - const file = event.dataTransfer.files.item(0); - const fileHandle = await getFileHandle(event); + let fileList: FileList | undefined = undefined; + let items: DataTransferItemList | undefined = undefined; - return { file: file ? await normalizeFile(file) : null, fileHandle }; + if (isClipboardEvent(event)) { + fileList = event.clipboardData?.files; + items = event.clipboardData?.items; + } else { + const dragEvent = event as React.DragEvent; + fileList = dragEvent.dataTransfer?.files; + items = dragEvent.dataTransfer?.items; + } + + const files: (File | null)[] = Array.from(fileList || []); + + return await Promise.all( + files.map(async (file, idx) => { + const dataTransferItem = items?.[idx]; + const fileHandle = dataTransferItem + ? getFileHandle(dataTransferItem) + : null; + return file + ? { + file: await normalizeFile(file), + fileHandle: await fileHandle, + } + : { + file: null, + fileHandle: null, + }; + }), + ); }; export const getFileHandle = async ( - event: React.DragEvent, + event: DragEvent | React.DragEvent | DataTransferItem, ): Promise => { if (nativeFileSystemSupported) { try { - const item = event.dataTransfer.items[0]; + const dataTransferItem = + event instanceof DataTransferItem + ? event + : (event as DragEvent).dataTransfer?.items?.[0]; + const handle: FileSystemHandle | null = - (await (item as any).getAsFileSystemHandle()) || null; + (await (dataTransferItem as any).getAsFileSystemHandle()) || null; return handle; } catch (error: any) { diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index c31b9ea7ce..dbe5e38584 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -12534,10 +12534,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "openMenu": null, "openPopup": null, "openSidebar": null, - "originSnapOffset": { - "x": 0, - "y": 0, - }, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -12759,6 +12756,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "searchMatches": null, "selectedElementIds": { "id0": true, + "id1": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -12793,7 +12791,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "boundElements": null, "crop": null, "customData": undefined, - "fileId": "fileId", + "fileId": "id2", "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12816,16 +12814,53 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "strokeWidth": 2, "type": "image", "updated": 1, - "version": 5, + "version": 7, "width": 318, - "x": -159, + "x": -212, "y": "-167.50000", } `; -exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of elements 1`] = `1`; +exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] element 1 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "crop": null, + "customData": undefined, + "fileId": "id3", + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 77, + "id": "id1", + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "scale": [ + 1, + 1, + ], + "status": "pending", + "strokeColor": "transparent", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "image", + "updated": 1, + "version": 7, + "width": 56, + "x": 156, + "y": "-167.50000", +} +`; -exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of renders 1`] = `7`; +exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of elements 1`] = `2`; + +exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of renders 1`] = `8`; exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] redo stack 1`] = `[]`; @@ -12837,6 +12872,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "deleted": { "selectedElementIds": { "id0": true, + "id1": true, }, }, "inserted": { @@ -12854,7 +12890,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "boundElements": null, "crop": null, "customData": undefined, - "fileId": "fileId", + "fileId": "id2", "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12875,20 +12911,58 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "strokeStyle": "solid", "strokeWidth": 2, "type": "image", - "version": 5, + "version": 7, "width": 318, - "x": -159, + "x": -212, "y": "-167.50000", }, "inserted": { "isDeleted": true, - "version": 4, + "version": 6, + }, + }, + "id1": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "crop": null, + "customData": undefined, + "fileId": "id3", + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 77, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "scale": [ + 1, + 1, + ], + "status": "pending", + "strokeColor": "transparent", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "image", + "version": 7, + "width": 56, + "x": 156, + "y": "-167.50000", + }, + "inserted": { + "isDeleted": true, + "version": 6, }, }, }, "updated": {}, }, - "id": "id4", + "id": "id7", }, ] `; @@ -12964,10 +13038,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "openMenu": null, "openPopup": null, "openSidebar": null, - "originSnapOffset": { - "x": 0, - "y": 0, - }, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -12981,6 +13052,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "searchMatches": null, "selectedElementIds": { "id0": true, + "id1": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -13015,11 +13087,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "boundElements": null, "crop": null, "customData": undefined, - "fileId": "fileId", + "fileId": "id2", "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 77, + "height": 335, "id": "id0", "index": "a0", "isDeleted": false, @@ -13038,16 +13110,53 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "strokeWidth": 2, "type": "image", "updated": 1, - "version": 5, - "width": 56, - "x": -28, - "y": "-38.50000", + "version": 7, + "width": 318, + "x": -212, + "y": "-167.50000", } `; -exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of elements 1`] = `1`; +exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] element 1 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "crop": null, + "customData": undefined, + "fileId": "id3", + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 77, + "id": "id1", + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "scale": [ + 1, + 1, + ], + "status": "pending", + "strokeColor": "transparent", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "image", + "updated": 1, + "version": 7, + "width": 56, + "x": 156, + "y": "-167.50000", +} +`; -exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of renders 1`] = `7`; +exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of elements 1`] = `2`; + +exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of renders 1`] = `8`; exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] redo stack 1`] = `[]`; @@ -13059,6 +13168,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "deleted": { "selectedElementIds": { "id0": true, + "id1": true, }, }, "inserted": { @@ -13076,11 +13186,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "boundElements": null, "crop": null, "customData": undefined, - "fileId": "fileId", + "fileId": "id2", "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 77, + "height": 335, "index": "a0", "isDeleted": false, "link": null, @@ -13097,20 +13207,58 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "strokeStyle": "solid", "strokeWidth": 2, "type": "image", - "version": 5, - "width": 56, - "x": -28, - "y": "-38.50000", + "version": 7, + "width": 318, + "x": -212, + "y": "-167.50000", }, "inserted": { "isDeleted": true, - "version": 4, + "version": 6, + }, + }, + "id1": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "crop": null, + "customData": undefined, + "fileId": "id3", + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 77, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "scale": [ + 1, + 1, + ], + "status": "pending", + "strokeColor": "transparent", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "image", + "version": 7, + "width": 56, + "x": 156, + "y": "-167.50000", + }, + "inserted": { + "isDeleted": true, + "version": 6, }, }, }, "updated": {}, }, - "id": "id4", + "id": "id7", }, ] `; diff --git a/packages/excalidraw/tests/fixtures/constants.ts b/packages/excalidraw/tests/fixtures/constants.ts new file mode 100644 index 0000000000..04a246fd02 --- /dev/null +++ b/packages/excalidraw/tests/fixtures/constants.ts @@ -0,0 +1,9 @@ +export const DEER_IMAGE_DIMENSIONS = { + width: 318, + height: 335, +}; + +export const SMILEY_IMAGE_DIMENSIONS = { + width: 56, + height: 77, +}; diff --git a/packages/excalidraw/tests/flip.test.tsx b/packages/excalidraw/tests/flip.test.tsx index e965a00686..7e175e6144 100644 --- a/packages/excalidraw/tests/flip.test.tsx +++ b/packages/excalidraw/tests/flip.test.tsx @@ -25,6 +25,7 @@ import { Excalidraw } from "../index"; // Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason) import * as blobModule from "../data/blob"; +import { SMILEY_IMAGE_DIMENSIONS } from "./fixtures/constants"; import { API } from "./helpers/api"; import { UI, Pointer, Keyboard } from "./helpers/ui"; import { @@ -744,11 +745,6 @@ describe("freedraw", () => { //image //TODO: currently there is no test for pixel colors at flipped positions. describe("image", () => { - const smileyImageDimensions = { - width: 56, - height: 77, - }; - beforeEach(() => { // it's necessary to specify the height in order to calculate natural dimensions of the image h.state.height = 1000; @@ -756,8 +752,8 @@ describe("image", () => { beforeAll(() => { mockHTMLImageElement( - smileyImageDimensions.width, - smileyImageDimensions.height, + SMILEY_IMAGE_DIMENSIONS.width, + SMILEY_IMAGE_DIMENSIONS.height, ); }); diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index f378079534..2de2a2890f 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -478,33 +478,43 @@ export class API { }); }; - static drop = async (blob: Blob) => { + static drop = async (_blobs: Blob[] | Blob) => { + const blobs = Array.isArray(_blobs) ? _blobs : [_blobs]; const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas); - const text = await new Promise((resolve, reject) => { - try { - const reader = new FileReader(); - reader.onload = () => { - resolve(reader.result as string); - }; - reader.readAsText(blob); - } catch (error: any) { - reject(error); - } - }); + const texts = await Promise.all( + blobs.map( + (blob) => + new Promise((resolve, reject) => { + try { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.readAsText(blob); + } catch (error: any) { + reject(error); + } + }), + ), + ); - const files = [blob] as File[] & { item: (index: number) => File }; + const files = blobs as File[] & { item: (index: number) => File }; files.item = (index: number) => files[index]; Object.defineProperty(fileDropEvent, "dataTransfer", { value: { files, getData: (type: string) => { - if (type === blob.type || type === "text") { - return text; + const idx = blobs.findIndex((b) => b.type === type); + if (idx >= 0) { + return texts[idx]; + } + if (type === "text") { + return texts.join("\n"); } return ""; }, - types: [blob.type], + types: Array.from(new Set(blobs.map((b) => b.type))), }, }); Object.defineProperty(fileDropEvent, "clientX", { @@ -513,7 +523,7 @@ export class API { Object.defineProperty(fileDropEvent, "clientY", { value: 0, }); - + await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent); }; diff --git a/packages/excalidraw/tests/helpers/constants.ts b/packages/excalidraw/tests/helpers/constants.ts new file mode 100644 index 0000000000..c12b8b8d83 --- /dev/null +++ b/packages/excalidraw/tests/helpers/constants.ts @@ -0,0 +1,6 @@ +export const INITIALIZED_IMAGE_PROPS = { + type: "image", + fileId: expect.any(String), + x: expect.toBeNonNaNNumber(), + y: expect.toBeNonNaNNumber(), +}; diff --git a/packages/excalidraw/tests/helpers/mocks.ts b/packages/excalidraw/tests/helpers/mocks.ts index e7fa629111..a2f3e8f3f4 100644 --- a/packages/excalidraw/tests/helpers/mocks.ts +++ b/packages/excalidraw/tests/helpers/mocks.ts @@ -58,3 +58,35 @@ export const mockHTMLImageElement = ( }, ); }; + +// Mocks for multiple HTMLImageElements (dimensions are assigned in the order of image initialization) +export const mockMultipleHTMLImageElements = ( + sizes: (readonly [number, number])[], +) => { + const _sizes = [...sizes]; + + vi.stubGlobal( + "Image", + class extends Image { + constructor() { + super(); + + const size = _sizes.shift(); + if (!size) { + throw new Error("Insufficient sizes"); + } + + Object.defineProperty(this, "naturalWidth", { + value: size[0], + }); + Object.defineProperty(this, "naturalHeight", { + value: size[1], + }); + + queueMicrotask(() => { + this.onload?.({} as Event); + }); + } + }, + ); +}; diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 47d87ce6d0..ed9d5137a2 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -20,6 +20,7 @@ import { DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, DEFAULT_ELEMENT_STROKE_COLOR_INDEX, reseed, + randomId, } from "@excalidraw/common"; import "@excalidraw/utils/test-utils"; @@ -58,9 +59,13 @@ import { createPasteEvent } from "../clipboard"; import * as blobModule from "../data/blob"; +import { + DEER_IMAGE_DIMENSIONS, + SMILEY_IMAGE_DIMENSIONS, +} from "./fixtures/constants"; import { API } from "./helpers/api"; import { Keyboard, Pointer, UI } from "./helpers/ui"; -import { mockHTMLImageElement } from "./helpers/mocks"; +import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants"; import { GlobalTestState, act, @@ -71,6 +76,7 @@ import { checkpointHistory, unmountComponent, } from "./test-utils"; +import { setupImageTest as _setupImageTest } from "./image.test"; import type { AppState } from "../types"; @@ -123,7 +129,9 @@ describe("history", () => { const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile"); const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile"); - generateIdSpy.mockImplementation(() => Promise.resolve("fileId" as FileId)); + generateIdSpy.mockImplementation(() => + Promise.resolve(randomId() as FileId), + ); resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file)); Object.assign(document, { @@ -612,80 +620,6 @@ describe("history", () => { ]); }); - it("should create new history entry on image drag&drop", async () => { - await render(); - - // it's necessary to specify the height in order to calculate natural dimensions of the image - h.state.height = 1000; - - const deerImageDimensions = { - width: 318, - height: 335, - }; - - mockHTMLImageElement( - deerImageDimensions.width, - deerImageDimensions.height, - ); - - await API.drop(await API.loadFile("./fixtures/deer.png")); - - await waitFor(() => { - expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(0); - expect(h.elements).toEqual([ - expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), - ...deerImageDimensions, - }), - ]); - - // need to check that delta actually contains initialized image element (with fileId & natural dimensions) - expect( - Object.values(h.history.undoStack[0].elements.removed)[0].deleted, - ).toEqual( - expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), - ...deerImageDimensions, - }), - ); - }); - - Keyboard.undo(); - expect(API.getUndoStack().length).toBe(0); - expect(API.getRedoStack().length).toBe(1); - expect(h.elements).toEqual([ - expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), - isDeleted: true, - ...deerImageDimensions, - }), - ]); - - Keyboard.redo(); - expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(0); - expect(h.elements).toEqual([ - expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), - isDeleted: false, - ...deerImageDimensions, - }), - ]); - }); - it("should create new history entry on embeddable link drag&drop", async () => { await render(); @@ -730,54 +664,29 @@ describe("history", () => { ]); }); - it("should create new history entry on image paste", async () => { - await render( - , - ); - - // it's necessary to specify the height in order to calculate natural dimensions of the image - h.state.height = 1000; - - const smileyImageDimensions = { - width: 56, - height: 77, - }; - - mockHTMLImageElement( - smileyImageDimensions.width, - smileyImageDimensions.height, - ); - - document.dispatchEvent( - createPasteEvent({ - files: [await API.loadFile("./fixtures/smiley_embedded_v2.png")], - }), - ); + const setupImageTest = () => + _setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]); + const assertImageTest = async () => { await waitFor(() => { expect(API.getUndoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(0); - expect(h.elements).toEqual([ + + // need to check that delta actually contains initialized image elements (with fileId & natural dimensions) + expect( + Object.values(h.history.undoStack[0].elements.removed).map( + (val) => val.deleted, + ), + ).toEqual([ expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), - ...smileyImageDimensions, + ...INITIALIZED_IMAGE_PROPS, + ...DEER_IMAGE_DIMENSIONS, + }), + expect.objectContaining({ + ...INITIALIZED_IMAGE_PROPS, + ...SMILEY_IMAGE_DIMENSIONS, }), ]); - // need to check that delta actually contains initialized image element (with fileId & natural dimensions) - expect( - Object.values(h.history.undoStack[0].elements.removed)[0].deleted, - ).toEqual( - expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), - ...smileyImageDimensions, - }), - ); }); Keyboard.undo(); @@ -785,12 +694,14 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(h.elements).toEqual([ expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), + ...INITIALIZED_IMAGE_PROPS, isDeleted: true, - ...smileyImageDimensions, + ...DEER_IMAGE_DIMENSIONS, + }), + expect.objectContaining({ + ...INITIALIZED_IMAGE_PROPS, + isDeleted: true, + ...SMILEY_IMAGE_DIMENSIONS, }), ]); @@ -799,14 +710,44 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(h.elements).toEqual([ expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), + ...INITIALIZED_IMAGE_PROPS, isDeleted: false, - ...smileyImageDimensions, + ...DEER_IMAGE_DIMENSIONS, + }), + expect.objectContaining({ + ...INITIALIZED_IMAGE_PROPS, + isDeleted: false, + ...SMILEY_IMAGE_DIMENSIONS, }), ]); + }; + + it("should create new history entry on image drag&drop", async () => { + await setupImageTest(); + + await API.drop( + await Promise.all([ + API.loadFile("./fixtures/deer.png"), + API.loadFile("./fixtures/smiley.png"), + ]), + ); + + await assertImageTest(); + }); + + it("should create new history entry on image paste", async () => { + await setupImageTest(); + + document.dispatchEvent( + createPasteEvent({ + files: await Promise.all([ + API.loadFile("./fixtures/deer.png"), + API.loadFile("./fixtures/smiley.png"), + ]), + }), + ); + + await assertImageTest(); }); it("should create new history entry on embeddable link paste", async () => { diff --git a/packages/excalidraw/tests/image.test.tsx b/packages/excalidraw/tests/image.test.tsx new file mode 100644 index 0000000000..f9a372ed63 --- /dev/null +++ b/packages/excalidraw/tests/image.test.tsx @@ -0,0 +1,115 @@ +import { randomId, reseed } from "@excalidraw/common"; + +import type { FileId } from "@excalidraw/element/types"; + +import * as blobModule from "../data/blob"; +import * as filesystemModule from "../data/filesystem"; +import { Excalidraw } from "../index"; +import { createPasteEvent } from "../clipboard"; + +import { API } from "./helpers/api"; +import { mockMultipleHTMLImageElements } from "./helpers/mocks"; +import { UI } from "./helpers/ui"; +import { GlobalTestState, render, waitFor } from "./test-utils"; +import { + DEER_IMAGE_DIMENSIONS, + SMILEY_IMAGE_DIMENSIONS, +} from "./fixtures/constants"; +import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants"; + +const { h } = window; + +export const setupImageTest = async ( + sizes: { width: number; height: number }[], +) => { + await render(); + + h.state.height = 1000; + + mockMultipleHTMLImageElements(sizes.map((size) => [size.width, size.height])); +}; + +describe("image insertion", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + + reseed(7); + + const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile"); + const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile"); + + generateIdSpy.mockImplementation(() => + Promise.resolve(randomId() as FileId), + ); + resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file)); + + Object.assign(document, { + elementFromPoint: () => GlobalTestState.canvas, + }); + }); + + const setup = () => + setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]); + + const assert = async () => { + await waitFor(() => { + expect(h.elements).toEqual([ + expect.objectContaining({ + ...INITIALIZED_IMAGE_PROPS, + ...DEER_IMAGE_DIMENSIONS, + }), + expect.objectContaining({ + ...INITIALIZED_IMAGE_PROPS, + ...SMILEY_IMAGE_DIMENSIONS, + }), + ]); + }); + // Not placed on top of each other + const dimensionsSet = new Set(h.elements.map((el) => `${el.x}-${el.y}`)); + expect(dimensionsSet.size).toEqual(h.elements.length); + }; + + it("should eventually initialize all dropped images", async () => { + await setup(); + + const files = await Promise.all([ + API.loadFile("./fixtures/deer.png"), + API.loadFile("./fixtures/smiley.png"), + ]); + await API.drop(files); + + await assert(); + }); + + it("should eventually initialize all pasted images", async () => { + await setup(); + + document.dispatchEvent( + createPasteEvent({ + files: await Promise.all([ + API.loadFile("./fixtures/deer.png"), + API.loadFile("./fixtures/smiley.png"), + ]), + }), + ); + + await assert(); + }); + + it("should eventually initialize all images added through image tool", async () => { + await setup(); + + const fileOpenSpy = vi.spyOn(filesystemModule, "fileOpen"); + fileOpenSpy.mockImplementation( + async () => + await Promise.all([ + API.loadFile("./fixtures/deer.png"), + API.loadFile("./fixtures/smiley.png"), + ]), + ); + UI.clickTool("image"); + + await assert(); + }); +}); From b9d27d308ead33419ebada746933b6081ebe88ad Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 6 Sep 2025 22:51:23 +0200 Subject: [PATCH 17/48] fix: pasting not working in firefox (#9947) --- packages/common/src/constants.ts | 6 +- packages/excalidraw/clipboard.test.ts | 149 ++++++++------- packages/excalidraw/clipboard.ts | 171 ++++++++++++++++-- packages/excalidraw/components/App.tsx | 50 +++-- packages/excalidraw/data/blob.ts | 40 +--- .../tests/__snapshots__/history.test.tsx.snap | 2 +- packages/excalidraw/tests/appState.test.tsx | 31 ++-- packages/excalidraw/tests/export.test.tsx | 30 ++- packages/excalidraw/tests/helpers/api.ts | 58 +++--- .../excalidraw/tests/helpers/polyfills.ts | 25 +-- packages/excalidraw/tests/history.test.tsx | 56 +++--- packages/excalidraw/tests/image.test.tsx | 2 +- packages/excalidraw/tests/library.test.tsx | 71 ++++---- packages/excalidraw/wysiwyg/textWysiwyg.tsx | 15 +- 14 files changed, 445 insertions(+), 261 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index aef2fda9f5..88a1027720 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -259,13 +259,17 @@ export const IMAGE_MIME_TYPES = { jfif: "image/jfif", } as const; -export const MIME_TYPES = { +export const STRING_MIME_TYPES = { text: "text/plain", html: "text/html", json: "application/json", // excalidraw data excalidraw: "application/vnd.excalidraw+json", excalidrawlib: "application/vnd.excalidrawlib+json", +} as const; + +export const MIME_TYPES = { + ...STRING_MIME_TYPES, // image-encoded excalidraw data "excalidraw.svg": "image/svg+xml", "excalidraw.png": "image/png", diff --git a/packages/excalidraw/clipboard.test.ts b/packages/excalidraw/clipboard.test.ts index 770bcc90e7..2115c3eff2 100644 --- a/packages/excalidraw/clipboard.test.ts +++ b/packages/excalidraw/clipboard.test.ts @@ -1,6 +1,7 @@ import { createPasteEvent, parseClipboard, + parseDataTransferEvent, serializeAsClipboardJSON, } from "./clipboard"; import { API } from "./tests/helpers/api"; @@ -13,7 +14,9 @@ describe("parseClipboard()", () => { text = "123"; clipboardData = await parseClipboard( - createPasteEvent({ types: { "text/plain": text } }), + await parseDataTransferEvent( + createPasteEvent({ types: { "text/plain": text } }), + ), ); expect(clipboardData.text).toBe(text); @@ -21,7 +24,9 @@ describe("parseClipboard()", () => { text = "[123]"; clipboardData = await parseClipboard( - createPasteEvent({ types: { "text/plain": text } }), + await parseDataTransferEvent( + createPasteEvent({ types: { "text/plain": text } }), + ), ); expect(clipboardData.text).toBe(text); @@ -29,7 +34,9 @@ describe("parseClipboard()", () => { text = JSON.stringify({ val: 42 }); clipboardData = await parseClipboard( - createPasteEvent({ types: { "text/plain": text } }), + await parseDataTransferEvent( + createPasteEvent({ types: { "text/plain": text } }), + ), ); expect(clipboardData.text).toBe(text); }); @@ -39,11 +46,13 @@ describe("parseClipboard()", () => { const json = serializeAsClipboardJSON({ elements: [rect], files: null }); const clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/plain": json, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/plain": json, + }, + }), + ), ); expect(clipboardData.elements).toEqual([rect]); }); @@ -56,21 +65,25 @@ describe("parseClipboard()", () => { // ------------------------------------------------------------------------- json = serializeAsClipboardJSON({ elements: [rect], files: null }); clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": json, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": json, + }, + }), + ), ); expect(clipboardData.elements).toEqual([rect]); // ------------------------------------------------------------------------- json = serializeAsClipboardJSON({ elements: [rect], files: null }); clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": `
${json}
`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": `
${json}
`, + }, + }), + ), ); expect(clipboardData.elements).toEqual([rect]); // ------------------------------------------------------------------------- @@ -80,11 +93,13 @@ describe("parseClipboard()", () => { let clipboardData; // ------------------------------------------------------------------------- clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": ``, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": ``, + }, + }), + ), ); expect(clipboardData.mixedContent).toEqual([ { @@ -94,11 +109,13 @@ describe("parseClipboard()", () => { ]); // ------------------------------------------------------------------------- clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": `
`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": `
`, + }, + }), + ), ); expect(clipboardData.mixedContent).toEqual([ { @@ -114,11 +131,13 @@ describe("parseClipboard()", () => { it("should parse text content alongside `src` urls out of text/html", async () => { const clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": `hello
my friend!`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": `hello
my friend!`, + }, + }), + ), ); expect(clipboardData.mixedContent).toEqual([ { @@ -141,14 +160,16 @@ describe("parseClipboard()", () => { let clipboardData; // ------------------------------------------------------------------------- clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/plain": `a b + 1 2 + 4 5 + 7 10`, + }, + }), + ), ); expect(clipboardData.spreadsheet).toEqual({ title: "b", @@ -157,14 +178,16 @@ describe("parseClipboard()", () => { }); // ------------------------------------------------------------------------- clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": `a b - 1 2 - 4 5 - 7 10`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": `a b + 1 2 + 4 5 + 7 10`, + }, + }), + ), ); expect(clipboardData.spreadsheet).toEqual({ title: "b", @@ -173,19 +196,21 @@ describe("parseClipboard()", () => { }); // ------------------------------------------------------------------------- clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": ` - -
ab
12
45
710
- - `, - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": ` + +
ab
12
45
710
+ + `, + "text/plain": `a b + 1 2 + 4 5 + 7 10`, + }, + }), + ), ); expect(clipboardData.spreadsheet).toEqual({ title: "b", diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index ceaee38c80..9eb1014223 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -17,15 +17,25 @@ import { import { getContainingFrame } from "@excalidraw/element"; +import type { ValueOf } from "@excalidraw/common/utility-types"; + +import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common"; import type { ExcalidrawElement, NonDeletedExcalidrawElement, } from "@excalidraw/element/types"; import { ExcalidrawError } from "./errors"; -import { createFile, isSupportedImageFileType } from "./data/blob"; +import { + createFile, + getFileHandle, + isSupportedImageFileType, +} from "./data/blob"; + import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; +import type { FileSystemHandle } from "./data/filesystem"; + import type { Spreadsheet } from "./charts"; import type { BinaryFiles } from "./types"; @@ -102,10 +112,11 @@ export const createPasteEvent = ({ if (typeof value !== "string") { files = files || []; files.push(value); + event.clipboardData?.items.add(value); continue; } try { - event.clipboardData?.setData(type, value); + event.clipboardData?.items.add(value, type); if (event.clipboardData?.getData(type) !== value) { throw new Error(`Failed to set "${type}" as clipboardData item`); } @@ -230,14 +241,10 @@ function parseHTMLTree(el: ChildNode) { return result; } -const maybeParseHTMLPaste = ( - event: ClipboardEvent, +const maybeParseHTMLDataItem = ( + dataItem: ParsedDataTransferItemType, ): { type: "mixedContent"; value: PastedMixedContent } | null => { - const html = event.clipboardData?.getData(MIME_TYPES.html); - - if (!html) { - return null; - } + const html = dataItem.value; try { const doc = new DOMParser().parseFromString(html, MIME_TYPES.html); @@ -333,18 +340,21 @@ export const readSystemClipboard = async () => { * Parses "paste" ClipboardEvent. */ const parseClipboardEventTextData = async ( - event: ClipboardEvent, + dataList: ParsedDataTranferList, isPlainPaste = false, ): Promise => { try { - const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event); + const htmlItem = dataList.findByType(MIME_TYPES.html); + + const mixedContent = + !isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem); if (mixedContent) { if (mixedContent.value.every((item) => item.type === "text")) { return { type: "text", value: - event.clipboardData?.getData(MIME_TYPES.text) || + dataList.getData(MIME_TYPES.text) ?? mixedContent.value .map((item) => item.value) .join("\n") @@ -355,23 +365,150 @@ const parseClipboardEventTextData = async ( return mixedContent; } - const text = event.clipboardData?.getData(MIME_TYPES.text); - - return { type: "text", value: (text || "").trim() }; + return { + type: "text", + value: (dataList.getData(MIME_TYPES.text) || "").trim(), + }; } catch { return { type: "text", value: "" }; } }; +type AllowedParsedDataTransferItem = + | { + type: ValueOf; + kind: "file"; + file: File; + fileHandle: FileSystemHandle | null; + } + | { type: ValueOf; kind: "string"; value: string }; + +type ParsedDataTransferItem = + | { + type: string; + kind: "file"; + file: File; + fileHandle: FileSystemHandle | null; + } + | { type: string; kind: "string"; value: string }; + +type ParsedDataTransferItemType< + T extends AllowedParsedDataTransferItem["type"], +> = AllowedParsedDataTransferItem & { type: T }; + +export type ParsedDataTransferFile = Extract< + AllowedParsedDataTransferItem, + { kind: "file" } +>; + +type ParsedDataTranferList = ParsedDataTransferItem[] & { + /** + * Only allows filtering by known `string` data types, since `file` + * types can have multiple items of the same type (e.g. multiple image files) + * unlike `string` data transfer items. + */ + findByType: typeof findDataTransferItemType; + /** + * Only allows filtering by known `string` data types, since `file` + * types can have multiple items of the same type (e.g. multiple image files) + * unlike `string` data transfer items. + */ + getData: typeof getDataTransferItemData; + getFiles: typeof getDataTransferFiles; +}; + +const findDataTransferItemType = function < + T extends ValueOf, +>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType | null { + return ( + this.find( + (item): item is ParsedDataTransferItemType => item.type === type, + ) || null + ); +}; +const getDataTransferItemData = function < + T extends ValueOf, +>( + this: ParsedDataTranferList, + type: T, +): + | ParsedDataTransferItemType>["value"] + | null { + const item = this.find( + ( + item, + ): item is ParsedDataTransferItemType> => + item.type === type, + ); + + return item?.value ?? null; +}; + +const getDataTransferFiles = function ( + this: ParsedDataTranferList, +): ParsedDataTransferFile[] { + return this.filter( + (item): item is ParsedDataTransferFile => item.kind === "file", + ); +}; + +export const parseDataTransferEvent = async ( + event: ClipboardEvent | DragEvent | React.DragEvent, +): Promise => { + let items: DataTransferItemList | undefined = undefined; + + if (isClipboardEvent(event)) { + items = event.clipboardData?.items; + } else { + const dragEvent = event; + items = dragEvent.dataTransfer?.items; + } + + const dataItems = ( + await Promise.all( + Array.from(items || []).map( + async (item): Promise => { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + const fileHandle = await getFileHandle(item); + return { type: file.type, kind: "file", file, fileHandle }; + } + } else if (item.kind === "string") { + const { type } = item; + let value: string; + if ("clipboardData" in event && event.clipboardData) { + value = event.clipboardData?.getData(type); + } else { + value = await new Promise((resolve) => { + item.getAsString((str) => resolve(str)); + }); + } + return { type, kind: "string", value }; + } + + return null; + }, + ), + ) + ).filter((data): data is ParsedDataTransferItem => data != null); + + return Object.assign(dataItems, { + findByType: findDataTransferItemType, + getData: getDataTransferItemData, + getFiles: getDataTransferFiles, + }); +}; + /** * Attempts to parse clipboard event. */ export const parseClipboard = async ( - event: ClipboardEvent, + dataList: ParsedDataTranferList, isPlainPaste = false, ): Promise => { const parsedEventData = await parseClipboardEventTextData( - event, + dataList, isPlainPaste, ); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index bf838b1c39..788600749d 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -324,7 +324,13 @@ import { isEraserActive, isHandToolActive, } from "../appState"; -import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; +import { + copyTextToSystemClipboard, + parseClipboard, + parseDataTransferEvent, + type ParsedDataTransferFile, +} from "../clipboard"; + import { exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { restore, restoreElements } from "../data/restore"; @@ -346,7 +352,6 @@ import { generateIdFromFile, getDataURL, getDataURL_sync, - getFilesFromEvent, ImageURLToFile, isImageFileHandle, isSupportedImageFile, @@ -3070,7 +3075,7 @@ class App extends React.Component { // TODO: Cover with tests private async insertClipboardContent( data: ClipboardData, - filesData: Awaited>, + dataTransferFiles: ParsedDataTransferFile[], isPlainPaste: boolean, ) { const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( @@ -3088,7 +3093,7 @@ class App extends React.Component { } // ------------------- Mixed content with no files ------------------- - if (filesData.length === 0 && !isPlainPaste && data.mixedContent) { + if (dataTransferFiles.length === 0 && !isPlainPaste && data.mixedContent) { await this.addElementsFromMixedContentPaste(data.mixedContent, { isPlainPaste, sceneX, @@ -3109,9 +3114,7 @@ class App extends React.Component { } // ------------------- Images or SVG code ------------------- - const imageFiles = filesData - .map((data) => data.file) - .filter((file): file is File => isSupportedImageFile(file)); + const imageFiles = dataTransferFiles.map((data) => data.file); if (imageFiles.length === 0 && data.text && !isPlainPaste) { const trimmedText = data.text.trim(); @@ -3256,8 +3259,11 @@ class App extends React.Component { // must be called in the same frame (thus before any awaits) as the paste // event else some browsers (FF...) will clear the clipboardData // (something something security) - const filesData = await getFilesFromEvent(event); - const data = await parseClipboard(event, isPlainPaste); + const dataTransferList = await parseDataTransferEvent(event); + + const filesList = dataTransferList.getFiles(); + + const data = await parseClipboard(dataTransferList, isPlainPaste); if (this.props.onPaste) { try { @@ -3269,7 +3275,8 @@ class App extends React.Component { } } - await this.insertClipboardContent(data, filesData, isPlainPaste); + await this.insertClipboardContent(data, filesList, isPlainPaste); + this.setActiveTool({ type: this.defaultSelectionTool }, true); event?.preventDefault(); }, @@ -10479,12 +10486,13 @@ class App extends React.Component { event, this.state, ); + const dataTransferList = await parseDataTransferEvent(event); // must be retrieved first, in the same frame - const filesData = await getFilesFromEvent(event); + const fileItems = dataTransferList.getFiles(); - if (filesData.length === 1) { - const { file, fileHandle } = filesData[0]; + if (fileItems.length === 1) { + const { file, fileHandle } = fileItems[0]; if ( file && @@ -10516,15 +10524,15 @@ class App extends React.Component { } } - const imageFiles = filesData + const imageFiles = fileItems .map((data) => data.file) - .filter((file): file is File => isSupportedImageFile(file)); + .filter((file) => isSupportedImageFile(file)); if (imageFiles.length > 0 && this.isToolSupported("image")) { return this.insertImages(imageFiles, sceneX, sceneY); } - const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib); + const libraryJSON = dataTransferList.getData(MIME_TYPES.excalidrawlib); if (libraryJSON && typeof libraryJSON === "string") { try { const libraryItems = parseLibraryJSON(libraryJSON); @@ -10539,16 +10547,18 @@ class App extends React.Component { return; } - if (filesData.length > 0) { - const { file, fileHandle } = filesData[0]; + if (fileItems.length > 0) { + const { file, fileHandle } = fileItems[0]; if (file) { // Attempt to parse an excalidraw/excalidrawlib file await this.loadFileToCanvas(file, fileHandle); } } - if (event.dataTransfer?.types?.includes("text/plain")) { - const text = event.dataTransfer?.getData("text"); + const textItem = dataTransferList.findByType(MIME_TYPES.text); + + if (textItem) { + const text = textItem.value; if ( text && embeddableURLValidator(text, this.props.validateEmbeddable) && diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index dc65cf0d3b..2b6829a938 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -18,8 +18,6 @@ import { CanvasError, ImageSceneDataError } from "../errors"; import { calculateScrollCenter } from "../scene"; import { decodeSvgBase64Payload } from "../scene/export"; -import { isClipboardEvent } from "../clipboard"; - import { base64ToString, stringToBase64, toByteString } from "./encode"; import { nativeFileSystemSupported } from "./filesystem"; import { isValidExcalidrawData, isValidLibrary } from "./json"; @@ -98,6 +96,8 @@ export const getMimeType = (blob: Blob | string): string => { return MIME_TYPES.jpg; } else if (/\.svg$/.test(name)) { return MIME_TYPES.svg; + } else if (/\.excalidrawlib$/.test(name)) { + return MIME_TYPES.excalidrawlib; } return ""; }; @@ -391,42 +391,6 @@ export const ImageURLToFile = async ( throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); }; -export const getFilesFromEvent = async ( - event: React.DragEvent | ClipboardEvent, -) => { - let fileList: FileList | undefined = undefined; - let items: DataTransferItemList | undefined = undefined; - - if (isClipboardEvent(event)) { - fileList = event.clipboardData?.files; - items = event.clipboardData?.items; - } else { - const dragEvent = event as React.DragEvent; - fileList = dragEvent.dataTransfer?.files; - items = dragEvent.dataTransfer?.items; - } - - const files: (File | null)[] = Array.from(fileList || []); - - return await Promise.all( - files.map(async (file, idx) => { - const dataTransferItem = items?.[idx]; - const fileHandle = dataTransferItem - ? getFileHandle(dataTransferItem) - : null; - return file - ? { - file: await normalizeFile(file), - fileHandle: await fileHandle, - } - : { - file: null, - fileHandle: null, - }; - }), - ); -}; - export const getFileHandle = async ( event: DragEvent | React.DragEvent | DataTransferItem, ): Promise => { diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index dbe5e38584..c2284567ce 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -12291,7 +12291,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "editingGroupId": null, "editingTextElement": null, "elementsToHighlight": null, - "errorMessage": "Couldn't load invalid file", + "errorMessage": null, "exportBackground": true, "exportEmbedScene": false, "exportScale": 1, diff --git a/packages/excalidraw/tests/appState.test.tsx b/packages/excalidraw/tests/appState.test.tsx index abb7ac1762..39b3f238a2 100644 --- a/packages/excalidraw/tests/appState.test.tsx +++ b/packages/excalidraw/tests/appState.test.tsx @@ -35,20 +35,23 @@ describe("appState", () => { expect(h.state.viewBackgroundColor).toBe("#F00"); }); - await API.drop( - new Blob( - [ - JSON.stringify({ - type: EXPORT_DATA_TYPES.excalidraw, - appState: { - viewBackgroundColor: "#000", - }, - elements: [API.createElement({ type: "rectangle", id: "A" })], - }), - ], - { type: MIME_TYPES.json }, - ), - ); + await API.drop([ + { + kind: "file", + file: new Blob( + [ + JSON.stringify({ + type: EXPORT_DATA_TYPES.excalidraw, + appState: { + viewBackgroundColor: "#000", + }, + elements: [API.createElement({ type: "rectangle", id: "A" })], + }), + ], + { type: MIME_TYPES.json }, + ), + }, + ]); await waitFor(() => { expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); diff --git a/packages/excalidraw/tests/export.test.tsx b/packages/excalidraw/tests/export.test.tsx index a42e56b90c..365db0c6b1 100644 --- a/packages/excalidraw/tests/export.test.tsx +++ b/packages/excalidraw/tests/export.test.tsx @@ -57,7 +57,7 @@ describe("export", () => { blob: pngBlob, metadata: serializeAsJSON(testElements, h.state, {}, "local"), }); - await API.drop(pngBlobEmbedded); + await API.drop([{ kind: "file", file: pngBlobEmbedded }]); await waitFor(() => { expect(h.elements).toEqual([ @@ -94,7 +94,12 @@ describe("export", () => { }); it("import embedded png (legacy v1)", async () => { - await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png")); + await API.drop([ + { + kind: "file", + file: await API.loadFile("./fixtures/test_embedded_v1.png"), + }, + ]); await waitFor(() => { expect(h.elements).toEqual([ expect.objectContaining({ type: "text", text: "test" }), @@ -103,7 +108,12 @@ describe("export", () => { }); it("import embedded png (v2)", async () => { - await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.png")); + await API.drop([ + { + kind: "file", + file: await API.loadFile("./fixtures/smiley_embedded_v2.png"), + }, + ]); await waitFor(() => { expect(h.elements).toEqual([ expect.objectContaining({ type: "text", text: "😀" }), @@ -112,7 +122,12 @@ describe("export", () => { }); it("import embedded svg (legacy v1)", async () => { - await API.drop(await API.loadFile("./fixtures/test_embedded_v1.svg")); + await API.drop([ + { + kind: "file", + file: await API.loadFile("./fixtures/test_embedded_v1.svg"), + }, + ]); await waitFor(() => { expect(h.elements).toEqual([ expect.objectContaining({ type: "text", text: "test" }), @@ -121,7 +136,12 @@ describe("export", () => { }); it("import embedded svg (v2)", async () => { - await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.svg")); + await API.drop([ + { + kind: "file", + file: await API.loadFile("./fixtures/smiley_embedded_v2.svg"), + }, + ]); await waitFor(() => { expect(h.elements).toEqual([ expect.objectContaining({ type: "text", text: "😀" }), diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index 2de2a2890f..68b0813160 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -478,43 +478,43 @@ export class API { }); }; - static drop = async (_blobs: Blob[] | Blob) => { - const blobs = Array.isArray(_blobs) ? _blobs : [_blobs]; - const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas); - const texts = await Promise.all( - blobs.map( - (blob) => - new Promise((resolve, reject) => { - try { - const reader = new FileReader(); - reader.onload = () => { - resolve(reader.result as string); - }; - reader.readAsText(blob); - } catch (error: any) { - reject(error); - } - }), - ), - ); + static drop = async (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => { - const files = blobs as File[] & { item: (index: number) => File }; + const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas); + + const dataTransferFileItems = items.filter(i => i.kind === "file") as {kind: "file", file: File | Blob, type: string }[]; + + const files = dataTransferFileItems.map(item => item.file) as File[] & { item: (index: number) => File }; + // https://developer.mozilla.org/en-US/docs/Web/API/FileList/item files.item = (index: number) => files[index]; Object.defineProperty(fileDropEvent, "dataTransfer", { value: { + // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/files files, + // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items + items: items.map((item, idx) => { + if (item.kind === "string") { + return { + kind: "string", + type: item.type, + // https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsString + getAsString: (cb: (text: string) => any) => cb(item.value), + }; + } + return { + kind: "file", + type: item.type || item.file.type, + // https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsFile + getAsFile: () => item.file, + }; + }), + // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/getData getData: (type: string) => { - const idx = blobs.findIndex((b) => b.type === type); - if (idx >= 0) { - return texts[idx]; - } - if (type === "text") { - return texts.join("\n"); - } - return ""; + return items.find((item) => item.type === "string" && item.type === type) || ""; }, - types: Array.from(new Set(blobs.map((b) => b.type))), + // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types + types: Array.from(new Set(items.map((item) => item.kind === "file" ? "Files" : item.type))), }, }); Object.defineProperty(fileDropEvent, "clientX", { diff --git a/packages/excalidraw/tests/helpers/polyfills.ts b/packages/excalidraw/tests/helpers/polyfills.ts index 967e8d4e82..d02c7290be 100644 --- a/packages/excalidraw/tests/helpers/polyfills.ts +++ b/packages/excalidraw/tests/helpers/polyfills.ts @@ -47,42 +47,43 @@ class DataTransferItem { } } -class DataTransferList { - items: DataTransferItem[] = []; - +class DataTransferItemList extends Array { add(data: string | File, type: string = ""): void { if (typeof data === "string") { - this.items.push(new DataTransferItem("string", type, data)); + this.push(new DataTransferItem("string", type, data)); } else if (data instanceof File) { - this.items.push(new DataTransferItem("file", type, data)); + this.push(new DataTransferItem("file", type, data)); } } clear(): void { - this.items = []; + this.clear(); } } class DataTransfer { - public items: DataTransferList = new DataTransferList(); - private _types: Record = {}; + public items: DataTransferItemList = new DataTransferItemList(); get files() { - return this.items.items + return this.items .filter((item) => item.kind === "file") .map((item) => item.getAsFile()!); } add(data: string | File, type: string = ""): void { - this.items.add(data, type); + if (typeof data === "string") { + this.items.add(data, type); + } else { + this.items.add(data); + } } setData(type: string, value: string) { - this._types[type] = value; + this.items.add(value, type); } getData(type: string) { - return this._types[type] || ""; + return this.items.find((item) => item.type === type)?.data || ""; } } diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index ed9d5137a2..aca5530d4c 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -568,21 +568,24 @@ describe("history", () => { expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]), ); - await API.drop( - new Blob( - [ - JSON.stringify({ - type: EXPORT_DATA_TYPES.excalidraw, - appState: { - ...getDefaultAppState(), - viewBackgroundColor: "#000", - }, - elements: [API.createElement({ type: "rectangle", id: "B" })], - }), - ], - { type: MIME_TYPES.json }, - ), - ); + await API.drop([ + { + kind: "file", + file: new Blob( + [ + JSON.stringify({ + type: EXPORT_DATA_TYPES.excalidraw, + appState: { + ...getDefaultAppState(), + viewBackgroundColor: "#000", + }, + elements: [API.createElement({ type: "rectangle", id: "B" })], + }), + ], + { type: MIME_TYPES.json }, + ), + }, + ]); await waitFor(() => expect(API.getUndoStack().length).toBe(1)); expect(h.state.viewBackgroundColor).toBe("#000"); @@ -624,11 +627,13 @@ describe("history", () => { await render(); const link = "https://www.youtube.com/watch?v=gkGMXY0wekg"; - await API.drop( - new Blob([link], { + await API.drop([ + { + kind: "string", + value: link, type: MIME_TYPES.text, - }), - ); + }, + ]); await waitFor(() => { expect(API.getUndoStack().length).toBe(1); @@ -726,10 +731,15 @@ describe("history", () => { await setupImageTest(); await API.drop( - await Promise.all([ - API.loadFile("./fixtures/deer.png"), - API.loadFile("./fixtures/smiley.png"), - ]), + ( + await Promise.all([ + API.loadFile("./fixtures/deer.png"), + API.loadFile("./fixtures/smiley.png"), + ]) + ).map((file) => ({ + kind: "file", + file, + })), ); await assertImageTest(); diff --git a/packages/excalidraw/tests/image.test.tsx b/packages/excalidraw/tests/image.test.tsx index f9a372ed63..23b4fda6fc 100644 --- a/packages/excalidraw/tests/image.test.tsx +++ b/packages/excalidraw/tests/image.test.tsx @@ -77,7 +77,7 @@ describe("image insertion", () => { API.loadFile("./fixtures/deer.png"), API.loadFile("./fixtures/smiley.png"), ]); - await API.drop(files); + await API.drop(files.map((file) => ({ kind: "file", file }))); await assert(); }); diff --git a/packages/excalidraw/tests/library.test.tsx b/packages/excalidraw/tests/library.test.tsx index 1c9b7a53ac..f1c0f0a457 100644 --- a/packages/excalidraw/tests/library.test.tsx +++ b/packages/excalidraw/tests/library.test.tsx @@ -56,9 +56,13 @@ describe("library", () => { it("import library via drag&drop", async () => { expect(await h.app.library.getLatestLibrary()).toEqual([]); - await API.drop( - await API.loadFile("./fixtures/fixture_library.excalidrawlib"), - ); + 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([ { @@ -75,11 +79,13 @@ describe("library", () => { it("drop library item onto canvas", async () => { expect(h.elements).toEqual([]); const libraryItems = parseLibraryJSON(await libraryJSONPromise); - await API.drop( - new Blob([serializeLibraryAsJSON(libraryItems)], { + await API.drop([ + { + kind: "string", + value: serializeLibraryAsJSON(libraryItems), type: MIME_TYPES.excalidrawlib, - }), - ); + }, + ]); await waitFor(() => { expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]); }); @@ -111,23 +117,20 @@ describe("library", () => { }, }); - await API.drop( - new Blob( - [ - serializeLibraryAsJSON([ - { - id: "item1", - status: "published", - elements: [rectangle, text, arrow], - created: 1, - }, - ]), - ], - { - type: MIME_TYPES.excalidrawlib, - }, - ), - ); + await API.drop([ + { + kind: "string", + value: serializeLibraryAsJSON([ + { + id: "item1", + status: "published", + elements: [rectangle, text, arrow], + created: 1, + }, + ]), + type: MIME_TYPES.excalidrawlib, + }, + ]); await waitFor(() => { expect(h.elements).toEqual( @@ -170,11 +173,13 @@ describe("library", () => { created: 1, }; - await API.drop( - new Blob([serializeLibraryAsJSON([item1, item1])], { + await API.drop([ + { + kind: "string", + value: serializeLibraryAsJSON([item1, item1]), type: MIME_TYPES.excalidrawlib, - }), - ); + }, + ]); await waitFor(() => { expect(h.elements).toEqual([ @@ -193,11 +198,13 @@ describe("library", () => { UI.clickTool("rectangle"); expect(h.elements).toEqual([]); const libraryItems = parseLibraryJSON(await libraryJSONPromise); - await API.drop( - new Blob([serializeLibraryAsJSON(libraryItems)], { + await API.drop([ + { + kind: "string", + value: serializeLibraryAsJSON(libraryItems), type: MIME_TYPES.excalidrawlib, - }), - ); + }, + ]); await waitFor(() => { expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]); }); diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index 73f3d7171b..054be43e99 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -7,6 +7,7 @@ import { getFontString, getFontFamilyString, isTestEnv, + MIME_TYPES, } from "@excalidraw/common"; import { @@ -45,7 +46,7 @@ import type { import { actionSaveToActiveFile } from "../actions"; -import { parseClipboard } from "../clipboard"; +import { parseDataTransferEvent } from "../clipboard"; import { actionDecreaseFontSize, actionIncreaseFontSize, @@ -332,12 +333,14 @@ export const textWysiwyg = ({ if (onChange) { editable.onpaste = async (event) => { - const clipboardData = await parseClipboard(event, true); - if (!clipboardData.text) { + const textItem = (await parseDataTransferEvent(event)).findByType( + MIME_TYPES.text, + ); + if (!textItem) { return; } - const data = normalizeText(clipboardData.text); - if (!data) { + const text = normalizeText(textItem.value); + if (!text) { return; } const container = getContainerElement( @@ -355,7 +358,7 @@ export const textWysiwyg = ({ app.scene.getNonDeletedElementsMap(), ); const wrappedText = wrapText( - `${editable.value}${data}`, + `${editable.value}${text}`, font, getBoundTextMaxWidth(container, boundTextElement), ); From 414182f5997be12646f2fe9881f62e277fc4627e Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 10 Sep 2025 17:59:02 +0200 Subject: [PATCH 18/48] fix: normalize file on paste/drop (#9959) --- packages/excalidraw/clipboard.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 9eb1014223..007a02161b 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -30,6 +30,7 @@ import { createFile, getFileHandle, isSupportedImageFileType, + normalizeFile, } from "./data/blob"; import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; @@ -472,7 +473,12 @@ export const parseDataTransferEvent = async ( const file = item.getAsFile(); if (file) { const fileHandle = await getFileHandle(item); - return { type: file.type, kind: "file", file, fileHandle }; + return { + type: file.type, + kind: "file", + file: await normalizeFile(file), + fileHandle, + }; } } else if (item.kind === "string") { const { type } = item; From 204e06b77bf7b592e29891160b775df33399ff4d Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 12 Sep 2025 10:18:31 +1000 Subject: [PATCH 19/48] feat: compact layout for tablets (#9910) * feat: allow the hiding of top picks * feat: allow the hiding of default fonts * refactor: rename to compactMode * feat: introduce layout (incomplete) * tweak icons * do not show border * lint * add isTouchMobile to device * add isTouchMobile to device * refactor to use showCompactSidebar instead * hide library label in compact * fix icon color in dark theme * fix library and share btns getting hidden in smaller tablet widths * update tests * use a smaller gap between shapes * proper fix of range * quicker switching between different popovers * to not show properties panel at all when editing text * fix switching between different popovers for texts * fix popover not closable and font search auto focus * change properties for a new or editing text * change icon for more style settings * use bolt icon for extra actions * fix breakpoints * use rem for icon sizes * fix tests * improve switching between triggers (incomplete) * improve trigger switching (complete) * clean up code * put compact into app state * fix button size * remove redundant PanelComponentProps["compactMode"] * move fontSize UI on top * mobile detection (breakpoints incomplete) * tweak compact mode detection * rename appState prop & values * update snapshots --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/common/src/constants.ts | 13 +- packages/common/src/utils.ts | 58 +++ packages/excalidraw/actions/actionCanvas.tsx | 3 +- .../excalidraw/actions/actionLinearEditor.tsx | 4 + .../excalidraw/actions/actionProperties.tsx | 141 ++++-- packages/excalidraw/actions/index.ts | 1 + packages/excalidraw/actions/types.ts | 1 + packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/Actions.scss | 117 +++++ packages/excalidraw/components/Actions.tsx | 460 +++++++++++++++++- packages/excalidraw/components/App.tsx | 54 +- .../components/ColorPicker/ColorPicker.scss | 16 + .../components/ColorPicker/ColorPicker.tsx | 153 +++++- .../components/FontPicker/FontPicker.scss | 5 + .../components/FontPicker/FontPicker.tsx | 36 +- .../components/FontPicker/FontPickerList.tsx | 62 ++- .../FontPicker/FontPickerTrigger.tsx | 26 +- packages/excalidraw/components/LayerUI.scss | 4 + packages/excalidraw/components/LayerUI.tsx | 130 +++-- .../components/PropertiesPopover.tsx | 8 + packages/excalidraw/components/Toolbar.scss | 10 + packages/excalidraw/components/icons.tsx | 69 +++ .../LiveCollaborationTrigger.tsx | 5 +- packages/excalidraw/css/styles.scss | 10 +- .../excalidraw/hooks/useTextEditorFocus.ts | 112 +++++ .../__snapshots__/contextmenu.test.tsx.snap | 17 + .../__snapshots__/excalidraw.test.tsx.snap | 1 + .../tests/__snapshots__/history.test.tsx.snap | 63 +++ .../regressionTests.test.tsx.snap | 52 ++ packages/excalidraw/types.ts | 7 + packages/excalidraw/wysiwyg/textWysiwyg.tsx | 33 +- .../tests/__snapshots__/export.test.ts.snap | 1 + 32 files changed, 1527 insertions(+), 147 deletions(-) create mode 100644 packages/excalidraw/hooks/useTextEditorFocus.ts diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 88a1027720..0366e0910a 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -129,6 +129,7 @@ export const CLASSES = { ZOOM_ACTIONS: "zoom-actions", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", + SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope", }; export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; @@ -347,9 +348,19 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = { // breakpoints // ----------------------------------------------------------------------------- // md screen -export const MQ_MAX_WIDTH_PORTRAIT = 730; export const MQ_MAX_WIDTH_LANDSCAPE = 1000; export const MQ_MAX_HEIGHT_LANDSCAPE = 500; + +// mobile: up to 699px +export const MQ_MAX_WIDTH_MOBILE = 699; + +// tablets +export const MQ_MIN_TABLET = 600; // lower bound (excludes phones) +export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops) + +// desktop/laptop +export const MQ_MIN_WIDTH_DESKTOP = 1440; + // sidebar export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229; // ----------------------------------------------------------------------------- diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 1054960650..8130482db5 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -21,6 +21,8 @@ import { FONT_FAMILY, getFontFamilyFallbacks, isDarwin, + isAndroid, + isIOS, WINDOWS_EMOJI_FALLBACK_FONT, } from "./constants"; @@ -1278,3 +1280,59 @@ export const reduceToCommonValue = ( return commonValue; }; + +export const isMobileOrTablet = (): boolean => { + const ua = navigator.userAgent || ""; + const platform = navigator.platform || ""; + const uaData = (navigator as any).userAgentData as + | { mobile?: boolean; platform?: string } + | undefined; + + // --- 1) chromium: prefer ua client hints ------------------------------- + if (uaData) { + const plat = (uaData.platform || "").toLowerCase(); + const isDesktopOS = + plat === "windows" || + plat === "macos" || + plat === "linux" || + plat === "chrome os"; + if (uaData.mobile === true) { + return true; + } + if (uaData.mobile === false && plat === "android") { + const looksTouchTablet = + matchMedia?.("(hover: none)").matches && + matchMedia?.("(pointer: coarse)").matches; + return looksTouchTablet; + } + if (isDesktopOS) { + return false; + } + } + + // --- 2) ios (includes ipad) -------------------------------------------- + if (isIOS) { + return true; + } + + // --- 3) android legacy ua fallback ------------------------------------- + if (isAndroid) { + const isAndroidPhone = /Mobile/i.test(ua); + const isAndroidTablet = !isAndroidPhone; + if (isAndroidPhone || isAndroidTablet) { + const looksTouchTablet = + matchMedia?.("(hover: none)").matches && + matchMedia?.("(pointer: coarse)").matches; + return looksTouchTablet; + } + } + + // --- 4) last resort desktop exclusion ---------------------------------- + const looksDesktopPlatform = + /Win|Linux|CrOS|Mac/.test(platform) || + /Windows NT|X11|CrOS|Macintosh/.test(ua); + if (looksDesktopPlatform) { + return false; + } + return false; +}; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 535d96c7d3..d0039d1c29 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({ : CaptureUpdateAction.EVENTUALLY, }; }, - PanelComponent: ({ elements, appState, updateData, appProps }) => { + PanelComponent: ({ elements, appState, updateData, appProps, data }) => { // FIXME move me to src/components/mainMenu/DefaultItems.tsx return ( ); }, diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 9b18c64de8..8437ece8b5 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -88,6 +88,10 @@ export const actionToggleLinearEditor = register({ selectedElementIds: appState.selectedElementIds, })[0] as ExcalidrawLinearElement; + if (!selectedElement) { + return null; + } + const label = t( selectedElement.type === "arrow" ? "labels.lineEditor.editArrow" diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 63cfe76727..c03309e9cc 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -137,6 +137,11 @@ import { isSomeElementSelected, } from "../scene"; +import { + withCaretPositionPreservation, + restoreCaretPosition, +} from "../hooks/useTextEditorFocus"; + import { register } from "./register"; import type { AppClassProperties, AppState, Primitive } from "../types"; @@ -321,9 +326,11 @@ export const actionChangeStrokeColor = register({ : CaptureUpdateAction.EVENTUALLY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => ( <> - + {appState.stylesPanelMode === "full" && ( + + )} ), @@ -398,9 +406,11 @@ export const actionChangeBackgroundColor = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => ( <> - + {appState.stylesPanelMode === "full" && ( + + )} ), @@ -518,9 +529,11 @@ export const actionChangeStrokeWidth = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
- {t("labels.strokeWidth")} + {appState.stylesPanelMode === "full" && ( + {t("labels.strokeWidth")} + )}
( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
- {t("labels.sloppiness")} + {appState.stylesPanelMode === "full" && ( + {t("labels.sloppiness")} + )}
( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
- {t("labels.strokeStyle")} + {appState.stylesPanelMode === "full" && ( + {t("labels.strokeStyle")} + )}
{ return changeFontSize(elements, appState, app, () => value, value); }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
{t("labels.fontSize")}
@@ -756,7 +773,14 @@ export const actionChangeFontSize = register({ ? null : appState.currentItemFontSize || DEFAULT_FONT_SIZE, )} - onChange={(value) => updateData(value)} + onChange={(value) => { + withCaretPositionPreservation( + () => updateData(value), + appState.stylesPanelMode === "compact", + !!appState.editingTextElement, + data?.onPreventClose, + ); + }} />
@@ -1016,7 +1040,7 @@ export const actionChangeFontFamily = register({ return result; }, - PanelComponent: ({ elements, appState, app, updateData }) => { + PanelComponent: ({ elements, appState, app, updateData, data }) => { 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 @@ -1094,20 +1118,28 @@ export const actionChangeFontFamily = register({ return (
- {t("labels.fontFamily")} + {appState.stylesPanelMode === "full" && ( + {t("labels.fontFamily")} + )} { - setBatchedData({ - openPopup: null, - currentHoveredFontFamily: null, - currentItemFontFamily: fontFamily, - }); - - // defensive clear so immediate close won't abuse the cached elements - cachedElementsRef.current.clear(); + withCaretPositionPreservation( + () => { + setBatchedData({ + openPopup: null, + currentHoveredFontFamily: null, + currentItemFontFamily: fontFamily, + }); + // defensive clear so immediate close won't abuse the cached elements + cachedElementsRef.current.clear(); + }, + appState.stylesPanelMode === "compact", + !!appState.editingTextElement, + ); }} onHover={(fontFamily) => { setBatchedData({ @@ -1164,25 +1196,28 @@ export const actionChangeFontFamily = register({ } setBatchedData({ + ...batchedData, openPopup: "fontFamily", }); } else { - // close, use the cache and clear it afterwards - const data = { - openPopup: null, + const fontFamilyData = { currentHoveredFontFamily: null, cachedElements: new Map(cachedElementsRef.current), resetAll: true, } as ChangeFontFamilyData; - if (isUnmounted.current) { - // in case the component was unmounted by the parent, trigger the update directly - updateData({ ...batchedData, ...data }); - } else { - setBatchedData(data); - } - + setBatchedData({ + ...fontFamilyData, + }); cachedElementsRef.current.clear(); + + // Refocus text editor when font picker closes if we were editing text + if ( + appState.stylesPanelMode === "compact" && + appState.editingTextElement + ) { + restoreCaretPosition(null); // Just refocus without saved position + } } }} /> @@ -1225,8 +1260,9 @@ export const actionChangeTextAlign = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => { + PanelComponent: ({ elements, appState, updateData, app, data }) => { const elementsMap = app.scene.getNonDeletedElementsMap(); + return (
{t("labels.textAlign")} @@ -1275,7 +1311,14 @@ export const actionChangeTextAlign = register({ (hasSelection) => hasSelection ? null : appState.currentItemTextAlign, )} - onChange={(value) => updateData(value)} + onChange={(value) => { + withCaretPositionPreservation( + () => updateData(value), + appState.stylesPanelMode === "compact", + !!appState.editingTextElement, + data?.onPreventClose, + ); + }} />
@@ -1317,7 +1360,7 @@ export const actionChangeVerticalAlign = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => { + PanelComponent: ({ elements, appState, updateData, app, data }) => { return (
@@ -1367,7 +1410,14 @@ export const actionChangeVerticalAlign = register({ ) !== null, (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), )} - onChange={(value) => updateData(value)} + onChange={(value) => { + withCaretPositionPreservation( + () => updateData(value), + appState.stylesPanelMode === "compact", + !!appState.editingTextElement, + data?.onPreventClose, + ); + }} />
@@ -1616,6 +1666,25 @@ export const actionChangeArrowhead = register({ }, }); +export const actionChangeArrowProperties = register({ + name: "changeArrowProperties", + label: "Change arrow properties", + trackEvent: false, + perform: (elements, appState, value, app) => { + // This action doesn't perform any changes directly + // It's just a container for the arrow type and arrowhead actions + return false; + }, + PanelComponent: ({ elements, appState, updateData, app, renderAction }) => { + return ( +
+ {renderAction("changeArrowType")} + {renderAction("changeArrowhead")} +
+ ); + }, +}); + export const actionChangeArrowType = register({ name: "changeArrowType", label: "Change arrow types", diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index f37747aebd..2719a5d0a2 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -18,6 +18,7 @@ export { actionChangeFontFamily, actionChangeTextAlign, actionChangeVerticalAlign, + actionChangeArrowProperties, } from "./actionProperties"; export { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index e6f3631263..302a76fb4e 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -69,6 +69,7 @@ export type ActionName = | "changeStrokeStyle" | "changeArrowhead" | "changeArrowType" + | "changeArrowProperties" | "changeOpacity" | "changeFontSize" | "toggleCanvasMenu" diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 6c4a971162..2a37b138d8 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -123,6 +123,7 @@ export const getDefaultAppState = (): Omit< searchMatches: null, lockedMultiSelections: {}, activeLockedId: null, + stylesPanelMode: "full", }; }; @@ -247,6 +248,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 }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index 5826628de1..93b5ef7c3e 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/packages/excalidraw/components/Actions.scss @@ -91,3 +91,120 @@ } } } + +.compact-shape-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: calc(100vh - 200px); + overflow-y: auto; + padding: 0.5rem; + + .compact-action-item { + position: relative; + display: flex; + justify-content: center; + align-items: center; + min-height: 2.5rem; + + --default-button-size: 2rem; + + .compact-action-button { + width: 2rem; + height: 2rem; + border: none; + border-radius: var(--border-radius-lg); + background: transparent; + color: var(--color-on-surface); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + svg { + width: 1rem; + height: 1rem; + flex: 0 0 auto; + } + + &:hover { + background: var(--button-hover-bg, var(--island-bg-color)); + border-color: var( + --button-hover-border, + var(--button-border, var(--default-border-color)) + ); + } + + &:active { + background: var(--button-active-bg, var(--island-bg-color)); + border-color: var(--button-active-border, var(--color-primary-darkest)); + } + } + + .compact-popover-content { + .popover-section { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + + .popover-section-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .buttonList { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + } + } + } + } +} + +.compact-shape-actions-island { + width: fit-content; + overflow-x: hidden; +} + +.compact-popover-content { + .popover-section { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + + .popover-section-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .buttonList { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + } + } +} + +.shape-actions-theme-scope { + --button-border: transparent; + --button-bg: var(--color-surface-mid); +} + +:root.theme--dark .shape-actions-theme-scope { + --button-hover-bg: #363541; + --button-bg: var(--color-surface-high); +} diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 91bef0e057..f43a4925de 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,5 +1,6 @@ import clsx from "clsx"; import { useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; import { CLASSES, @@ -19,6 +20,7 @@ import { isImageElement, isLinearElement, isTextElement, + isArrowElement, } from "@excalidraw/element"; import { hasStrokeColor, toolIsArrow } from "@excalidraw/element"; @@ -46,15 +48,20 @@ import { hasStrokeWidth, } from "../scene"; +import { getFormValue } from "../actions/actionProperties"; + +import { useTextEditorFocus } from "../hooks/useTextEditorFocus"; + import { getToolbarTools } from "./shapes"; import "./Actions.scss"; -import { useDevice } from "./App"; +import { useDevice, useExcalidrawContainer } from "./App"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; import { Tooltip } from "./Tooltip"; import DropdownMenu from "./dropdownMenu/DropdownMenu"; +import { PropertiesPopover } from "./PropertiesPopover"; import { EmbedIcon, extraToolsIcon, @@ -63,11 +70,29 @@ import { laserPointerToolIcon, MagicIcon, LassoIcon, + sharpArrowIcon, + roundArrowIcon, + elbowArrowIcon, + TextSizeIcon, + adjustmentsIcon, + DotsHorizontalIcon, } from "./icons"; -import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types"; +import type { + AppClassProperties, + AppProps, + UIAppState, + Zoom, + AppState, +} from "../types"; import type { ActionManager } from "../actions/manager"; +// Common CSS class combinations +const PROPERTIES_CLASSES = clsx([ + CLASSES.SHAPE_ACTIONS_THEME_SCOPE, + "properties-content", +]); + export const canChangeStrokeColor = ( appState: UIAppState, targetElements: ExcalidrawElement[], @@ -280,6 +305,437 @@ export const SelectedShapeActions = ({ ); }; +export const CompactShapeActions = ({ + 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 { 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 */} + {canChangeStrokeColor(appState, targetElements) && ( +
+ {renderAction("changeStrokeColor")} +
+ )} + + {/* Background Color */} + {canChangeBackgroundColor(appState, targetElements) && ( +
+ {renderAction("changeBackgroundColor")} +
+ )} + + {/* 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 && ( +
+ {renderAction("toggleLinearEditor")} +
+ )} + + {/* Text Properties */} + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && ( + <> +
+ {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")} +
+
+ )} +
+
+ + )} + + {/* Dedicated Copy Button */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ {renderAction("duplicateSelection")} +
+ )} + + {/* Dedicated Delete Button */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ {renderAction("deleteSelectedElements")} +
+ )} + + {/* 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 ShapesSwitcher = ({ activeTool, appState, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 788600749d..3bbfdca6e1 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -41,9 +41,6 @@ import { LINE_CONFIRM_THRESHOLD, MAX_ALLOWED_FILE_BYTES, MIME_TYPES, - MQ_MAX_HEIGHT_LANDSCAPE, - MQ_MAX_WIDTH_LANDSCAPE, - MQ_MAX_WIDTH_PORTRAIT, MQ_RIGHT_SIDEBAR_MIN_WIDTH, POINTER_BUTTON, ROUNDNESS, @@ -100,9 +97,14 @@ import { randomInteger, CLASSES, Emitter, - isMobile, MINIMUM_ARROW_SIZE, DOUBLE_TAP_POSITION_THRESHOLD, + isMobileOrTablet, + MQ_MAX_WIDTH_MOBILE, + MQ_MAX_HEIGHT_LANDSCAPE, + MQ_MAX_WIDTH_LANDSCAPE, + MQ_MIN_TABLET, + MQ_MAX_TABLET, } from "@excalidraw/common"; import { @@ -667,7 +669,7 @@ class App extends React.Component { constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); - this.defaultSelectionTool = this.isMobileOrTablet() + this.defaultSelectionTool = isMobileOrTablet() ? ("lasso" as const) : ("selection" as const); const { @@ -2420,23 +2422,20 @@ class App extends React.Component { } }; - private isMobileOrTablet = (): boolean => { - const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0; - const hasCoarsePointer = - "matchMedia" in window && - window?.matchMedia("(pointer: coarse)")?.matches; - const isTouchMobile = hasTouch && hasCoarsePointer; - - return isMobile || isTouchMobile; - }; - private isMobileBreakpoint = (width: number, height: number) => { return ( - width < MQ_MAX_WIDTH_PORTRAIT || + width <= MQ_MAX_WIDTH_MOBILE || (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE) ); }; + private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => { + const minSide = Math.min(editorWidth, editorHeight); + const maxSide = Math.max(editorWidth, editorHeight); + + return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET; + }; + private refreshViewportBreakpoints = () => { const container = this.excalidrawContainerRef.current; if (!container) { @@ -2481,6 +2480,17 @@ class App extends React.Component { canFitSidebar: editorWidth > sidebarBreakpoint, }); + // also check if we need to update the app state + this.setState({ + stylesPanelMode: + // NOTE: we could also remove the isMobileOrTablet check here and + // always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP) + // but not too narrow (> MQ_MAX_WIDTH_MOBILE) + this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet() + ? "compact" + : "full", + }); + if (prevEditorState !== nextEditorState) { this.device = { ...this.device, editor: nextEditorState }; return true; @@ -3147,7 +3157,7 @@ class App extends React.Component { this.addElementsFromPasteOrLibrary({ elements, files: data.files || null, - position: this.isMobileOrTablet() ? "center" : "cursor", + position: isMobileOrTablet() ? "center" : "cursor", retainSeed: isPlainPaste, }); return; @@ -3172,7 +3182,7 @@ class App extends React.Component { this.addElementsFromPasteOrLibrary({ elements, files, - position: this.isMobileOrTablet() ? "center" : "cursor", + position: isMobileOrTablet() ? "center" : "cursor", }); return; @@ -6668,8 +6678,6 @@ class App extends React.Component { pointerDownState.hit.element && this.isASelectedElement(pointerDownState.hit.element); - const isMobileOrTablet = this.isMobileOrTablet(); - if ( !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && !pointerDownState.resize.handleType && @@ -6683,12 +6691,12 @@ class App extends React.Component { // block dragging after lasso selection on PCs until the next pointer down // (on mobile or tablet, we want to allow user to drag immediately) - pointerDownState.drag.blockDragging = !isMobileOrTablet; + pointerDownState.drag.blockDragging = !isMobileOrTablet(); } // only for mobile or tablet, if we hit an element, select it immediately like normal selection if ( - isMobileOrTablet && + isMobileOrTablet() && pointerDownState.hit.element && !hitSelectedElement ) { @@ -8489,7 +8497,7 @@ class App extends React.Component { if ( this.state.activeTool.type === "lasso" && this.lassoTrail.hasCurrentTrail && - !(this.isMobileOrTablet() && pointerDownState.hit.element) && + !(isMobileOrTablet() && pointerDownState.hit.element) && !this.state.activeTool.fromSelection ) { return; diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.scss b/packages/excalidraw/components/ColorPicker/ColorPicker.scss index 7a78395d6f..0e3768dcc0 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.scss +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.scss @@ -22,6 +22,12 @@ @include isMobile { max-width: 11rem; } + + &.color-picker-container--no-top-picks { + display: flex; + justify-content: center; + grid-template-columns: unset; + } } .color-picker__top-picks { @@ -80,6 +86,16 @@ } } + .color-picker__button-background { + display: flex; + align-items: center; + justify-content: center; + svg { + width: 100%; + height: 100%; + } + } + &.active { .color-picker__button-outline { position: absolute; diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index 270d61f4cd..51c7bbd2c5 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -1,6 +1,6 @@ import * as Popover from "@radix-ui/react-popover"; import clsx from "clsx"; -import { useRef } from "react"; +import { useRef, useEffect } from "react"; import { COLOR_OUTLINE_CONTRAST_THRESHOLD, @@ -18,7 +18,12 @@ import { useExcalidrawContainer } from "../App"; import { ButtonSeparator } from "../ButtonSeparator"; import { activeEyeDropperAtom } from "../EyeDropper"; import { PropertiesPopover } from "../PropertiesPopover"; -import { slashIcon } from "../icons"; +import { backgroundIcon, slashIcon, strokeIcon } from "../icons"; +import { + saveCaretPosition, + restoreCaretPosition, + temporarilyDisableTextEditorBlur, +} from "../../hooks/useTextEditorFocus"; import { ColorInput } from "./ColorInput"; import { Picker } from "./Picker"; @@ -67,6 +72,7 @@ interface ColorPickerProps { palette?: ColorPaletteCustom | null; topPicks?: ColorTuple; updateData: (formData?: any) => void; + compactMode?: boolean; } const ColorPickerPopupContent = ({ @@ -77,6 +83,8 @@ const ColorPickerPopupContent = ({ elements, palette = COLOR_PALETTE, updateData, + getOpenPopup, + appState, }: Pick< ColorPickerProps, | "type" @@ -86,7 +94,10 @@ const ColorPickerPopupContent = ({ | "elements" | "palette" | "updateData" ->) => { + | "appState" +> & { + getOpenPopup: () => AppState["openPopup"]; +}) => { const { container } = useExcalidrawContainer(); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); @@ -117,6 +128,8 @@ const ColorPickerPopupContent = ({ { // refocus due to eye dropper focusPickerContent(); @@ -131,8 +144,23 @@ const ColorPickerPopupContent = ({ } }} onClose={() => { - updateData({ openPopup: null }); + // only clear if we're still the active popup (avoid racing with switch) + if (getOpenPopup() === type) { + updateData({ openPopup: null }); + } setActiveColorPickerSection(null); + + // Refocus text editor when popover closes if we were editing text + if (appState.editingTextElement) { + setTimeout(() => { + const textEditor = document.querySelector( + ".excalidraw-wysiwyg", + ) as HTMLTextAreaElement; + if (textEditor) { + textEditor.focus(); + } + }, 0); + } }} > {palette ? ( @@ -141,7 +169,17 @@ const ColorPickerPopupContent = ({ palette={palette} color={color} onChange={(changedColor) => { + // Save caret position before color change if editing text + const savedSelection = appState.editingTextElement + ? saveCaretPosition() + : null; + onChange(changedColor); + + // Restore caret position after color change if editing text + if (appState.editingTextElement && savedSelection) { + restoreCaretPosition(savedSelection); + } }} onEyeDropperToggle={(force) => { setEyeDropperState((state) => { @@ -168,6 +206,7 @@ const ColorPickerPopupContent = ({ if (eyeDropperState) { setEyeDropperState(null); } else { + // close explicitly on Escape updateData({ openPopup: null }); } }} @@ -188,11 +227,32 @@ const ColorPickerTrigger = ({ label, color, type, + compactMode = false, + mode = "background", + onToggle, + editingTextElement, }: { color: string | null; label: string; type: ColorPickerType; + compactMode?: boolean; + mode?: "background" | "stroke"; + onToggle: () => void; + editingTextElement?: boolean; }) => { + const handleClick = (e: React.MouseEvent) => { + // use pointerdown so we run before outside-close logic + e.preventDefault(); + e.stopPropagation(); + + // If editing text, temporarily disable the wysiwyg blur event + if (editingTextElement) { + temporarilyDisableTextEditorBlur(); + } + + onToggle(); + }; + return (
{!color && slashIcon}
+ {compactMode && color && ( +
+ {mode === "background" ? ( + + {backgroundIcon} + + ) : ( + + {strokeIcon} + + )} +
+ )}
); }; @@ -224,25 +313,59 @@ export const ColorPicker = ({ topPicks, updateData, appState, + compactMode = false, }: ColorPickerProps) => { + const openRef = useRef(appState.openPopup); + useEffect(() => { + openRef.current = appState.openPopup; + }, [appState.openPopup]); return (
-
- - +
+ {!compactMode && ( + + )} + {!compactMode && } { - updateData({ openPopup: open ? type : null }); + if (open) { + updateData({ openPopup: type }); + } }} > {/* serves as an active color indicator as well */} - + { + // atomic switch: if another popup is open, close it first, then open this one next tick + if (appState.openPopup === type) { + // toggle off on same trigger + updateData({ openPopup: null }); + } else if (appState.openPopup) { + updateData({ openPopup: type }); + } else { + // open this one + updateData({ openPopup: type }); + } + }} + /> {/* popup content */} {appState.openPopup === type && ( openRef.current} + appState={appState} /> )} diff --git a/packages/excalidraw/components/FontPicker/FontPicker.scss b/packages/excalidraw/components/FontPicker/FontPicker.scss index 5a572585e1..70859e8091 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.scss +++ b/packages/excalidraw/components/FontPicker/FontPicker.scss @@ -11,5 +11,10 @@ 2rem + 4 * var(--default-button-size) ); // 4 gaps + 4 buttons } + + &--compact { + display: block; + grid-template-columns: none; + } } } diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index 118c6fac3c..891ae49efd 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -1,4 +1,5 @@ import * as Popover from "@radix-ui/react-popover"; +import clsx from "clsx"; import React, { useCallback, useMemo } from "react"; import { FONT_FAMILY } from "@excalidraw/common"; @@ -58,6 +59,7 @@ interface FontPickerProps { onHover: (fontFamily: FontFamilyValues) => void; onLeave: () => void; onPopupChange: (open: boolean) => void; + compactMode?: boolean; } export const FontPicker = React.memo( @@ -69,6 +71,7 @@ export const FontPicker = React.memo( onHover, onLeave, onPopupChange, + compactMode = false, }: FontPickerProps) => { const defaultFonts = useMemo(() => DEFAULT_FONTS, []); const onSelectCallback = useCallback( @@ -81,18 +84,29 @@ export const FontPicker = React.memo( ); return ( -
-
- - type="button" - options={defaultFonts} - value={selectedFontFamily} - onClick={onSelectCallback} - /> -
- +
+ {!compactMode && ( +
+ + type="button" + options={defaultFonts} + value={selectedFontFamily} + onClick={onSelectCallback} + /> +
+ )} + {!compactMode && } - + {isOpened && ( { const { container } = useExcalidrawContainer(); - const { fonts } = useApp(); + const app = useApp(); + const { fonts } = app; const { showDeprecatedFonts } = useAppProps(); const [searchTerm, setSearchTerm] = useState(""); @@ -187,6 +188,42 @@ export const FontPickerList = React.memo( onLeave, ]); + // Create a wrapped onSelect function that preserves caret position + const wrappedOnSelect = useCallback( + (fontFamily: FontFamilyValues) => { + // Save caret position before font selection if editing text + let savedSelection: { start: number; end: number } | null = null; + if (app.state.editingTextElement) { + const textEditor = document.querySelector( + ".excalidraw-wysiwyg", + ) as HTMLTextAreaElement; + if (textEditor) { + savedSelection = { + start: textEditor.selectionStart, + end: textEditor.selectionEnd, + }; + } + } + + onSelect(fontFamily); + + // Restore caret position after font selection if editing text + if (app.state.editingTextElement && savedSelection) { + setTimeout(() => { + const textEditor = document.querySelector( + ".excalidraw-wysiwyg", + ) as HTMLTextAreaElement; + if (textEditor && savedSelection) { + textEditor.focus(); + textEditor.selectionStart = savedSelection.start; + textEditor.selectionEnd = savedSelection.end; + } + }, 0); + } + }, + [onSelect, app.state.editingTextElement], + ); + const onKeyDown = useCallback>( (event) => { const handled = fontPickerKeyHandler({ @@ -194,7 +231,7 @@ export const FontPickerList = React.memo( inputRef, hoveredFont, filteredFonts, - onSelect, + onSelect: wrappedOnSelect, onHover, onClose, }); @@ -204,7 +241,7 @@ export const FontPickerList = React.memo( event.stopPropagation(); } }, - [hoveredFont, filteredFonts, onSelect, onHover, onClose], + [hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose], ); useEffect(() => { @@ -240,7 +277,7 @@ export const FontPickerList = React.memo( // allow to tab between search and selected font tabIndex={font.value === selectedFontFamily ? 0 : -1} onClick={(e) => { - onSelect(Number(e.currentTarget.value)); + wrappedOnSelect(Number(e.currentTarget.value)); }} onMouseMove={() => { if (hoveredFont?.value !== font.value) { @@ -282,9 +319,24 @@ export const FontPickerList = React.memo( className="properties-content" container={container} style={{ width: "15rem" }} - onClose={onClose} + onClose={() => { + onClose(); + + // Refocus text editor when font picker closes if we were editing text + if (app.state.editingTextElement) { + setTimeout(() => { + const textEditor = document.querySelector( + ".excalidraw-wysiwyg", + ) as HTMLTextAreaElement; + if (textEditor) { + textEditor.focus(); + } + }, 0); + } + }} onPointerLeave={onLeave} onKeyDown={onKeyDown} + preventAutoFocusOnTouch={!!app.state.editingTextElement} > { - const isTriggerActive = useMemo( - () => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)), - [selectedFontFamily], - ); + const setAppState = useExcalidrawSetAppState(); return ( - {/* Empty div as trigger so it's stretched 100% due to different button sizes */} -
+
{}} + active={isOpened} + onClick={() => { + setAppState((appState) => ({ + openPopup: + appState.openPopup === "fontFamily" ? null : appState.openPopup, + })); + }} + style={{ + border: "none", + }} />
diff --git a/packages/excalidraw/components/LayerUI.scss b/packages/excalidraw/components/LayerUI.scss index 36153d72b5..5c202a0679 100644 --- a/packages/excalidraw/components/LayerUI.scss +++ b/packages/excalidraw/components/LayerUI.scss @@ -24,6 +24,10 @@ gap: 0.75rem; pointer-events: none !important; + &--compact { + gap: 0.5rem; + } + & > * { pointer-events: var(--ui-pointerEvents); } diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index d216f1d46d..6748095322 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -4,6 +4,7 @@ import React from "react"; import { CLASSES, DEFAULT_SIDEBAR, + MQ_MIN_WIDTH_DESKTOP, TOOL_TYPE, arrayToMap, capitalizeString, @@ -28,7 +29,11 @@ import { useAtom, useAtomValue } from "../editor-jotai"; import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; -import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; +import { + SelectedShapeActions, + ShapesSwitcher, + CompactShapeActions, +} from "./Actions"; import { LoadingMessage } from "./LoadingMessage"; import { LockButton } from "./LockButton"; import { MobileMenu } from "./MobileMenu"; @@ -157,6 +162,25 @@ const LayerUI = ({ const device = useDevice(); const tunnels = useInitializeTunnels(); + const spacing = + appState.stylesPanelMode === "compact" + ? { + menuTopGap: 4, + toolbarColGap: 4, + toolbarRowGap: 1, + toolbarInnerRowGap: 0.5, + islandPadding: 1, + collabMarginLeft: 8, + } + : { + menuTopGap: 6, + toolbarColGap: 4, + toolbarRowGap: 1, + toolbarInnerRowGap: 1, + islandPadding: 1, + collabMarginLeft: 8, + }; + const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider; const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); @@ -209,31 +233,55 @@ const LayerUI = ({
); - const renderSelectedShapeActions = () => ( -
- { + const isCompactMode = appState.stylesPanelMode === "compact"; + + return ( +
- - -
- ); + {isCompactMode ? ( + + + + ) : ( + + + + )} +
+ ); + }; const renderFixedSideContainer = () => { const shouldRenderSelectedShapeActions = showSelectedShapeActions( @@ -250,9 +298,19 @@ const LayerUI = ({ return (
- + {renderCanvasActions()} - {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} +
+ {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} +
{!appState.viewModeEnabled && appState.openDialog?.name !== "elementLinkSelector" && ( @@ -262,17 +320,19 @@ const LayerUI = ({ {renderWelcomeScreen && ( )} - + {heading} - + @@ -418,7 +480,9 @@ const LayerUI = ({ }} tab={DEFAULT_SIDEBAR.defaultTab} > - {t("toolBar.library")} + {appState.stylesPanelMode === "full" && + appState.width >= MQ_MIN_WIDTH_DESKTOP && + t("toolBar.library")} {appState.openDialog?.name === "ttd" && } diff --git a/packages/excalidraw/components/PropertiesPopover.tsx b/packages/excalidraw/components/PropertiesPopover.tsx index d8372ea27b..d4437b3858 100644 --- a/packages/excalidraw/components/PropertiesPopover.tsx +++ b/packages/excalidraw/components/PropertiesPopover.tsx @@ -17,6 +17,7 @@ interface PropertiesPopoverProps { onPointerLeave?: React.PointerEventHandler; onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"]; onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"]; + preventAutoFocusOnTouch?: boolean; } export const PropertiesPopover = React.forwardRef< @@ -34,6 +35,7 @@ export const PropertiesPopover = React.forwardRef< onFocusOutside, onPointerLeave, onPointerDownOutside, + preventAutoFocusOnTouch = false, }, ref, ) => { @@ -64,6 +66,12 @@ export const PropertiesPopover = React.forwardRef< onKeyDown={onKeyDown} onFocusOutside={onFocusOutside} onPointerDownOutside={onPointerDownOutside} + onOpenAutoFocus={(e) => { + // prevent auto-focus on touch devices to avoid keyboard popup + if (preventAutoFocusOnTouch && device.isTouchScreen) { + e.preventDefault(); + } + }} onCloseAutoFocus={(e) => { e.stopPropagation(); // prevents focusing the trigger diff --git a/packages/excalidraw/components/Toolbar.scss b/packages/excalidraw/components/Toolbar.scss index 1565120aca..14c4cc174b 100644 --- a/packages/excalidraw/components/Toolbar.scss +++ b/packages/excalidraw/components/Toolbar.scss @@ -10,6 +10,16 @@ } } + &--compact { + .ToolIcon__keybinding { + display: none; + } + + .App-toolbar__divider { + margin: 0; + } + } + &__divider { width: 1px; height: 1.5rem; diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 29bdc6d3c7..33e59380c7 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -118,6 +118,17 @@ export const DotsIcon = createIcon( tablerIconProps, ); +// tabler-icons: dots-horizontal (horizontal equivalent of dots-vertical) +export const DotsHorizontalIcon = createIcon( + + + + + + , + tablerIconProps, +); + // tabler-icons: pinned export const PinIcon = createIcon( @@ -396,6 +407,19 @@ export const TextIcon = createIcon( tablerIconProps, ); +export const TextSizeIcon = createIcon( + + + + + + + + + , + tablerIconProps, +); + // modified tabler-icons: photo export const ImageIcon = createIcon( @@ -2269,3 +2293,48 @@ export const elementLinkIcon = createIcon( , tablerIconProps, ); + +export const resizeIcon = createIcon( + + + + + , + tablerIconProps, +); + +export const adjustmentsIcon = createIcon( + + + + + + + + + + + + , + tablerIconProps, +); + +export const backgroundIcon = createIcon( + + + + + + + + , + tablerIconProps, +); + +export const strokeIcon = createIcon( + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx b/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx index 1aa187167e..0efadcfc62 100644 --- a/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx +++ b/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx @@ -1,5 +1,7 @@ import clsx from "clsx"; +import { isMobileOrTablet, MQ_MIN_WIDTH_DESKTOP } from "@excalidraw/common"; + import { t } from "../../i18n"; import { Button } from "../Button"; import { share } from "../icons"; @@ -17,7 +19,8 @@ const LiveCollaborationTrigger = ({ } & React.ButtonHTMLAttributes) => { const appState = useUIAppState(); - const showIconOnly = appState.width < 830; + const showIconOnly = + isMobileOrTablet() || appState.width < MQ_MIN_WIDTH_DESKTOP; return (
@@ -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 35/48] 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 36/48] 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 37/48] 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 38/48] 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 39/48] 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 40/48] 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 41/48] 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 42/48] 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 43/48] 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 44/48] 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