diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index 385b9b140e..71e3885b12 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -9,7 +9,7 @@ import { } from "@excalidraw/excalidraw/renderer/helpers"; import { type AppState } from "@excalidraw/excalidraw/types"; import { throttleRAF } from "@excalidraw/common"; -import { useCallback, useImperativeHandle, useRef } from "react"; +import { useCallback } from "react"; import { isLineSegment, @@ -18,6 +18,8 @@ import { } from "@excalidraw/math"; import { isCurve } from "@excalidraw/math/curve"; +import React from "react"; + import type { Curve } from "@excalidraw/math"; import type { DebugElement } from "@excalidraw/utils/visualdebug"; @@ -113,10 +115,6 @@ const _debugRenderer = ( scale, ); - if (appState.height !== canvas.height || appState.width !== canvas.width) { - refresh(); - } - const context = bootstrapCanvas({ canvas, scale, @@ -314,35 +312,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => { interface DebugCanvasProps { appState: AppState; scale: number; - ref?: React.Ref; } -const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => { - const { width, height } = appState; +const DebugCanvas = React.forwardRef( + ({ appState, scale }, ref) => { + const { width, height } = appState; - const canvasRef = useRef(null); - useImperativeHandle( - ref, - () => canvasRef.current, - [canvasRef], - ); - - return ( - - Debug Canvas - - ); -}; + return ( + + Debug Canvas + + ); + }, +); export default DebugCanvas; diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index c3c348cebc..c797c6e8c2 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -36,6 +36,7 @@ export const APP_NAME = "Excalidraw"; // (happens a lot with fast clicks with the text tool) export const TEXT_AUTOWRAP_THRESHOLD = 36; // px export const DRAGGING_THRESHOLD = 10; // px +export const MINIMUM_ARROW_SIZE = 20; // px export const LINE_CONFIRM_THRESHOLD = 8; // px export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_TRANSLATE_AMOUNT = 1; diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index 9504237b51..bd428d8560 100644 --- a/packages/element/src/delta.ts +++ b/packages/element/src/delta.ts @@ -2,6 +2,7 @@ import { arrayToMap, arrayToObject, assertNever, + invariant, isDevEnv, isShallowEqual, isTestEnv, @@ -548,7 +549,7 @@ export class AppStateDelta implements DeltaContainer { selectedElementIds: addedSelectedElementIds = {}, selectedGroupIds: addedSelectedGroupIds = {}, selectedLinearElementId, - editingLinearElementId, + selectedLinearElementIsEditing, ...directlyApplicablePartial } = this.delta.inserted; @@ -564,39 +565,46 @@ export class AppStateDelta implements DeltaContainer { removedSelectedGroupIds, ); - const selectedLinearElement = - selectedLinearElementId && nextElements.has(selectedLinearElementId) - ? new LinearElementEditor( - nextElements.get( - selectedLinearElementId, - ) as NonDeleted, - nextElements, - ) - : null; + let selectedLinearElement = appState.selectedLinearElement; - const editingLinearElement = - editingLinearElementId && nextElements.has(editingLinearElementId) - ? new LinearElementEditor( - nextElements.get( - editingLinearElementId, - ) as NonDeleted, - nextElements, - ) - : null; + 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 nextAppState = { ...appState, ...directlyApplicablePartial, selectedElementIds: mergedSelectedElementIds, selectedGroupIds: mergedSelectedGroupIds, - selectedLinearElement: - typeof selectedLinearElementId !== "undefined" - ? selectedLinearElement // element was either inserted or deleted - : appState.selectedLinearElement, // otherwise assign what we had before - editingLinearElement: - typeof editingLinearElementId !== "undefined" - ? editingLinearElement // element was either inserted or deleted - : appState.editingLinearElement, // otherwise assign what we had before + selectedLinearElement, }; const constainsVisibleChanges = this.filterInvisibleChanges( @@ -725,8 +733,7 @@ export class AppStateDelta implements DeltaContainer { } break; - case "selectedLinearElementId": - case "editingLinearElementId": + case "selectedLinearElementId": { const appStateKey = AppStateDelta.convertToAppStateKey(key); const linearElement = nextAppState[appStateKey]; @@ -746,6 +753,19 @@ export class AppStateDelta implements DeltaContainer { } 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": { const prevLockedUnits = prevAppState[key] || {}; const nextLockedUnits = nextAppState[key] || {}; @@ -779,16 +799,11 @@ export class AppStateDelta implements DeltaContainer { } private static convertToAppStateKey( - key: keyof Pick< - ObservedElementsAppState, - "selectedLinearElementId" | "editingLinearElementId" - >, - ): keyof Pick { + key: keyof Pick, + ): keyof Pick { switch (key) { case "selectedLinearElementId": return "selectedLinearElement"; - case "editingLinearElementId": - return "editingLinearElement"; } } @@ -856,8 +871,8 @@ export class AppStateDelta implements DeltaContainer { editingGroupId, selectedGroupIds, selectedElementIds, - editingLinearElementId, selectedLinearElementId, + selectedLinearElementIsEditing, croppingElementId, lockedMultiSelections, activeLockedId, diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 3f666c412c..995d866b54 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -149,10 +149,12 @@ export class LinearElementEditor { public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly elbowed: boolean; public readonly customLineAngle: number | null; + public readonly isEditing: boolean; constructor( element: NonDeleted, elementsMap: ElementsMap, + isEditing: boolean = false, ) { this.elementId = element.id as string & { _brand: "excalidrawLinearElementId"; @@ -187,6 +189,7 @@ export class LinearElementEditor { this.segmentMidPointHoveredCoords = null; this.elbowed = isElbowArrow(element) && element.elbowed; this.customLineAngle = null; + this.isEditing = isEditing; } // --------------------------------------------------------------------------- @@ -194,6 +197,7 @@ export class LinearElementEditor { // --------------------------------------------------------------------------- static POINT_HANDLE_SIZE = 10; + /** * @param id the `elementId` from the instance of this class (so that we can * statically guarantee this method returns an ExcalidrawLinearElement) @@ -215,11 +219,14 @@ export class LinearElementEditor { setState: React.Component["setState"], elementsMap: NonDeletedSceneElementsMap, ) { - if (!appState.editingLinearElement || !appState.selectionElement) { + if ( + !appState.selectedLinearElement?.isEditing || + !appState.selectionElement + ) { return false; } - const { editingLinearElement } = appState; - const { selectedPointsIndices, elementId } = editingLinearElement; + const { selectedLinearElement } = appState; + const { selectedPointsIndices, elementId } = selectedLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { @@ -260,8 +267,8 @@ export class LinearElementEditor { }); setState({ - editingLinearElement: { - ...editingLinearElement, + selectedLinearElement: { + ...selectedLinearElement, selectedPointsIndices: nextSelectedPoints.length ? nextSelectedPoints : null, @@ -479,9 +486,6 @@ export class LinearElementEditor { return { ...app.state, - editingLinearElement: app.state.editingLinearElement - ? newLinearElementEditor - : null, selectedLinearElement: newLinearElementEditor, suggestedBindings, }; @@ -618,7 +622,7 @@ export class LinearElementEditor { // Since its not needed outside editor unless 2 pointer lines or bound text if ( !isElbowArrow(element) && - !appState.editingLinearElement && + !appState.selectedLinearElement?.isEditing && element.points.length > 2 && !boundText ) { @@ -684,7 +688,7 @@ export class LinearElementEditor { ); if ( points.length >= 3 && - !appState.editingLinearElement && + !appState.selectedLinearElement?.isEditing && !isElbowArrow(element) ) { return null; @@ -881,7 +885,7 @@ export class LinearElementEditor { segmentMidpoint, elementsMap, ); - } else if (event.altKey && appState.editingLinearElement) { + } else if (event.altKey && appState.selectedLinearElement?.isEditing) { if (linearElementEditor.lastUncommittedPoint == null) { scene.mutateElement(element, { points: [ @@ -1023,14 +1027,14 @@ export class LinearElementEditor { app: AppClassProperties, ): LinearElementEditor | null { const appState = app.state; - if (!appState.editingLinearElement) { + if (!appState.selectedLinearElement?.isEditing) { return null; } - const { elementId, lastUncommittedPoint } = appState.editingLinearElement; + const { elementId, lastUncommittedPoint } = appState.selectedLinearElement; const elementsMap = app.scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { - return appState.editingLinearElement; + return appState.selectedLinearElement; } const { points } = element; @@ -1040,10 +1044,12 @@ export class LinearElementEditor { if (lastPoint === lastUncommittedPoint) { LinearElementEditor.deletePoints(element, app, [points.length - 1]); } - return { - ...appState.editingLinearElement, - lastUncommittedPoint: null, - }; + return appState.selectedLinearElement?.lastUncommittedPoint + ? { + ...appState.selectedLinearElement, + lastUncommittedPoint: null, + } + : appState.selectedLinearElement; } let newPoint: LocalPoint; @@ -1067,8 +1073,8 @@ export class LinearElementEditor { newPoint = LinearElementEditor.createPointAt( element, elementsMap, - scenePointerX - appState.editingLinearElement.pointerOffset.x, - scenePointerY - appState.editingLinearElement.pointerOffset.y, + scenePointerX - appState.selectedLinearElement.pointerOffset.x, + scenePointerY - appState.selectedLinearElement.pointerOffset.y, event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) ? null : app.getEffectiveGridSize(), @@ -1092,7 +1098,7 @@ export class LinearElementEditor { LinearElementEditor.addPoints(element, app.scene, [newPoint]); } return { - ...appState.editingLinearElement, + ...appState.selectedLinearElement, lastUncommittedPoint: element.points[element.points.length - 1], }; } @@ -1251,12 +1257,12 @@ export class LinearElementEditor { // --------------------------------------------------------------------------- static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState { invariant( - appState.editingLinearElement, + appState.selectedLinearElement?.isEditing, "Not currently editing a linear element", ); const elementsMap = scene.getNonDeletedElementsMap(); - const { selectedPointsIndices, elementId } = appState.editingLinearElement; + const { selectedPointsIndices, elementId } = appState.selectedLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); invariant( @@ -1318,8 +1324,8 @@ export class LinearElementEditor { return { ...appState, - editingLinearElement: { - ...appState.editingLinearElement, + selectedLinearElement: { + ...appState.selectedLinearElement, selectedPointsIndices: nextSelectedIndices, }, }; @@ -1331,8 +1337,9 @@ export class LinearElementEditor { pointIndices: readonly number[], ) { const isUncommittedPoint = - app.state.editingLinearElement?.lastUncommittedPoint === - element.points[element.points.length - 1]; + app.state.selectedLinearElement?.isEditing && + app.state.selectedLinearElement?.lastUncommittedPoint === + element.points[element.points.length - 1]; const nextPoints = element.points.filter((_, idx) => { return !pointIndices.includes(idx); @@ -1505,7 +1512,7 @@ export class LinearElementEditor { pointFrom(pointerCoords.x, pointerCoords.y), ); if ( - !appState.editingLinearElement && + !appState.selectedLinearElement?.isEditing && dist < DRAGGING_THRESHOLD / appState.zoom.value ) { return false; diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index e870d977fb..008d6afc4a 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => { return element.strokeWidth * 12; case "text": return element.fontSize / 2; + case "arrow": + if (element.endArrowhead || element.endArrowhead) { + return 40; + } + return 20; default: return 20; } diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index ae0c969e5a..2bf70f5814 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -978,8 +978,8 @@ const getDefaultObservedAppState = (): ObservedAppState => { viewBackgroundColor: COLOR_PALETTE.white, selectedElementIds: {}, selectedGroupIds: {}, - editingLinearElementId: null, selectedLinearElementId: null, + selectedLinearElementIsEditing: null, croppingElementId: null, activeLockedId: null, lockedMultiSelections: {}, @@ -998,14 +998,14 @@ export const getObservedAppState = ( croppingElementId: appState.croppingElementId, activeLockedId: appState.activeLockedId, lockedMultiSelections: appState.lockedMultiSelections, - editingLinearElementId: - (appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer - (appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot - null, selectedLinearElementId: (appState as AppState).selectedLinearElement?.elementId ?? (appState as ObservedAppState).selectedLinearElementId ?? null, + selectedLinearElementIsEditing: + (appState as AppState).selectedLinearElement?.isEditing ?? + (appState as ObservedAppState).selectedLinearElementIsEditing ?? + null, }; Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { diff --git a/packages/element/src/transformHandles.ts b/packages/element/src/transformHandles.ts index f2b0cd2782..679937d4ae 100644 --- a/packages/element/src/transformHandles.ts +++ b/packages/element/src/transformHandles.ts @@ -330,7 +330,7 @@ export const shouldShowBoundingBox = ( elements: readonly NonDeletedExcalidrawElement[], appState: InteractiveCanvasAppState, ) => { - if (appState.editingLinearElement) { + if (appState.selectedLinearElement?.isEditing) { return false; } if (elements.length > 1) { diff --git a/packages/element/tests/binding.test.tsx b/packages/element/tests/binding.test.tsx index 69f4e6dded..a3da1c66d9 100644 --- a/packages/element/tests/binding.test.tsx +++ b/packages/element/tests/binding.test.tsx @@ -155,10 +155,10 @@ describe("element binding", () => { // NOTE this mouse down/up + await needs to be done in order to repro // the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740 mouse.reset(); - expect(h.state.editingLinearElement).not.toBe(null); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); mouse.down(0, 0); await new Promise((r) => setTimeout(r, 100)); - expect(h.state.editingLinearElement).toBe(null); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(API.getSelectedElement().type).toBe("rectangle"); mouse.up(); expect(API.getSelectedElement().type).toBe("rectangle"); diff --git a/packages/element/tests/delta.test.tsx b/packages/element/tests/delta.test.tsx index 9c416f6ef4..4d56aac834 100644 --- a/packages/element/tests/delta.test.tsx +++ b/packages/element/tests/delta.test.tsx @@ -16,6 +16,7 @@ describe("AppStateDelta", () => { editingGroupId: null, croppingElementId: null, editingLinearElementId: null, + selectedLinearElementIsEditing: null, lockedMultiSelections: {}, activeLockedId: null, }; @@ -58,6 +59,7 @@ describe("AppStateDelta", () => { editingGroupId: null, croppingElementId: null, selectedLinearElementId: null, + selectedLinearElementIsEditing: null, editingLinearElementId: null, activeLockedId: null, lockedMultiSelections: {}, @@ -105,6 +107,7 @@ describe("AppStateDelta", () => { editingGroupId: null, croppingElementId: null, selectedLinearElementId: null, + selectedLinearElementIsEditing: null, editingLinearElementId: null, activeLockedId: null, lockedMultiSelections: {}, diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 4b957022c6..f1306b8728 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -136,7 +136,8 @@ describe("Test Linear Elements", () => { Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.ENTER); }); - expect(h.state.editingLinearElement?.elementId).toEqual(line.id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(line.id); }; const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => { @@ -253,75 +254,82 @@ describe("Test Linear Elements", () => { }); fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should enter line editor via enter (line)", () => { createTwoPointerLinearElement("line"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.clickAt(midpoint[0], midpoint[1]); Keyboard.keyPress(KEYS.ENTER); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); // ctrl+enter alias (to align with arrows) it("should enter line editor via ctrl+enter (line)", () => { createTwoPointerLinearElement("line"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.clickAt(midpoint[0], midpoint[1]); Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.ENTER); }); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should enter line editor via ctrl+enter (arrow)", () => { createTwoPointerLinearElement("arrow"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.clickAt(midpoint[0], midpoint[1]); Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.ENTER); }); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should enter line editor on ctrl+dblclick (simple arrow)", () => { createTwoPointerLinearElement("arrow"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.doubleClick(); }); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should enter line editor on ctrl+dblclick (line)", () => { createTwoPointerLinearElement("line"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.doubleClick(); }); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should enter line editor on dblclick (line)", () => { createTwoPointerLinearElement("line"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.doubleClick(); - expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id); }); it("should not enter line editor on dblclick (arrow)", async () => { createTwoPointerLinearElement("arrow"); - expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.doubleClick(); - expect(h.state.editingLinearElement).toEqual(null); + expect(h.state.selectedLinearElement).toBe(null); await getTextEditor(); }); @@ -330,10 +338,12 @@ describe("Test Linear Elements", () => { const arrow = h.elements[0] as ExcalidrawLinearElement; enterLineEditingMode(arrow); - expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id); mouse.doubleClick(); - expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id); expect(h.elements.length).toEqual(1); expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); @@ -367,7 +377,7 @@ describe("Test Linear Elements", () => { // drag line from midpoint drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `12`, + `11`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); @@ -469,7 +479,7 @@ describe("Test Linear Elements", () => { drag(startPoint, endPoint); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `12`, + `11`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -537,7 +547,7 @@ describe("Test Linear Elements", () => { ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `16`, + `14`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -588,7 +598,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `12`, + `11`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); @@ -629,7 +639,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `12`, + `11`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); @@ -677,7 +687,7 @@ describe("Test Linear Elements", () => { deletePoint(points[2]); expect(line.points.length).toEqual(3); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `18`, + `17`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -735,7 +745,7 @@ describe("Test Linear Elements", () => { ), ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `16`, + `14`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(5); @@ -833,7 +843,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `12`, + `11`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 20d7d129fd..a9281ce84e 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -205,16 +205,19 @@ export const actionDeleteSelected = register({ icon: TrashIcon, trackEvent: { category: "element", action: "delete" }, perform: (elements, appState, formData, app) => { - if (appState.editingLinearElement) { + if (appState.selectedLinearElement?.isEditing) { const { elementId, selectedPointsIndices, startBindingElement, endBindingElement, - } = appState.editingLinearElement; + } = appState.selectedLinearElement; const elementsMap = app.scene.getNonDeletedElementsMap(); - const element = LinearElementEditor.getElement(elementId, elementsMap); - if (!element) { + const linearElement = LinearElementEditor.getElement( + elementId, + elementsMap, + ); + if (!linearElement) { return false; } // case: no point selected → do nothing, as deleting the whole element @@ -225,10 +228,10 @@ export const actionDeleteSelected = register({ return false; } - // case: deleting last remaining point - if (element.points.length < 2) { + // case: deleting all points + if (selectedPointsIndices.length >= linearElement.points.length) { const nextElements = elements.map((el) => { - if (el.id === element.id) { + if (el.id === linearElement.id) { return newElementWith(el, { isDeleted: true }); } return el; @@ -239,7 +242,7 @@ export const actionDeleteSelected = register({ elements: nextElements, appState: { ...nextAppState, - editingLinearElement: null, + selectedLinearElement: null, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; @@ -252,20 +255,24 @@ export const actionDeleteSelected = register({ ? null : startBindingElement, endBindingElement: selectedPointsIndices?.includes( - element.points.length - 1, + linearElement.points.length - 1, ) ? null : endBindingElement, }; - LinearElementEditor.deletePoints(element, app, selectedPointsIndices); + LinearElementEditor.deletePoints( + linearElement, + app, + selectedPointsIndices, + ); return { elements, appState: { ...appState, - editingLinearElement: { - ...appState.editingLinearElement, + selectedLinearElement: { + ...appState.selectedLinearElement, ...binding, selectedPointsIndices: selectedPointsIndices?.[0] > 0 diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index b6363a7306..c1b2a9da42 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({ } // duplicate selected point(s) if editing a line - if (appState.editingLinearElement) { + if (appState.selectedLinearElement?.isEditing) { // TODO: Invariants should be checked here instead of duplicateSelectedPoints() try { const newAppState = LinearElementEditor.duplicateSelectedPoints( diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 7a4511e051..9baeb0b6f0 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -94,9 +94,9 @@ export const actionFinalize = register({ } } - if (appState.editingLinearElement) { + if (appState.selectedLinearElement?.isEditing) { const { elementId, startBindingElement, endBindingElement } = - appState.editingLinearElement; + appState.selectedLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); if (element) { @@ -122,7 +122,11 @@ export const actionFinalize = register({ appState: { ...appState, cursorButton: "up", - editingLinearElement: null, + selectedLinearElement: new LinearElementEditor( + element, + arrayToMap(elementsMap), + false, // exit editing mode + ), }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; @@ -154,11 +158,7 @@ export const actionFinalize = register({ if (element) { // pen and mouse have hover - if ( - appState.multiElement && - element.type !== "freedraw" && - appState.lastPointerDownWith !== "touch" - ) { + if (appState.multiElement && element.type !== "freedraw") { const { points, lastCommittedPoint } = element; if ( !lastCommittedPoint || @@ -289,7 +289,7 @@ export const actionFinalize = register({ }, keyTest: (event, appState) => (event.key === KEYS.ESCAPE && - (appState.editingLinearElement !== null || + (appState.selectedLinearElement?.isEditing || (!appState.newElement && appState.multiElement === null))) || ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && appState.multiElement !== null), diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 28295d9395..9b18c64de8 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -1,10 +1,9 @@ -import { LinearElementEditor } from "@excalidraw/element"; import { isElbowArrow, isLinearElement, isLineElement, } from "@excalidraw/element"; -import { arrayToMap } from "@excalidraw/common"; +import { arrayToMap, invariant } from "@excalidraw/common"; import { toggleLinePolygonState, @@ -46,7 +45,7 @@ export const actionToggleLinearEditor = register({ predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); if ( - !appState.editingLinearElement && + !appState.selectedLinearElement?.isEditing && selectedElements.length === 1 && isLinearElement(selectedElements[0]) && !isElbowArrow(selectedElements[0]) @@ -61,14 +60,25 @@ export const actionToggleLinearEditor = register({ includeBoundTextElement: true, })[0] as ExcalidrawLinearElement; - const editingLinearElement = - appState.editingLinearElement?.elementId === selectedElement.id - ? null - : new LinearElementEditor(selectedElement, arrayToMap(elements)); + invariant(selectedElement, "No selected element found"); + invariant( + appState.selectedLinearElement, + "No selected linear element found", + ); + invariant( + selectedElement.id === appState.selectedLinearElement.elementId, + "Selected element ID and linear editor elementId does not match", + ); + + const selectedLinearElement = { + ...appState.selectedLinearElement, + isEditing: !appState.selectedLinearElement.isEditing, + }; + return { appState: { ...appState, - editingLinearElement, + selectedLinearElement, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts index a580528585..7157d76176 100644 --- a/packages/excalidraw/actions/actionSelectAll.ts +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -21,7 +21,7 @@ export const actionSelectAll = register({ trackEvent: { category: "canvas" }, viewMode: false, perform: (elements, appState, value, app) => { - if (appState.editingLinearElement) { + if (appState.selectedLinearElement?.isEditing) { return false; } diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index dcc3fba11b..6c4a971162 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -48,7 +48,6 @@ export const getDefaultAppState = (): Omit< newElement: null, editingTextElement: null, editingGroupId: null, - editingLinearElement: null, activeTool: { type: "selection", customType: null, @@ -175,7 +174,6 @@ const APP_STATE_STORAGE_CONF = (< newElement: { browser: false, export: false, server: false }, editingTextElement: { browser: false, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false }, - editingLinearElement: { browser: false, export: false, server: false }, activeTool: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false }, penDetected: { browser: true, export: false, server: false }, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 919e9c688d..5c9d59ada3 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -140,7 +140,7 @@ export const SelectedShapeActions = ({ targetElements.length === 1 || isSingleElementBoundContainer; const showLineEditorAction = - !appState.editingLinearElement && + !appState.selectedLinearElement?.isEditing && targetElements.length === 1 && isLinearElement(targetElements[0]) && !isElbowArrow(targetElements[0]); @@ -505,15 +505,3 @@ export const ExitZenModeAction = ({ {t("buttons.exitZenMode")} ); - -export const FinalizeAction = ({ - renderAction, - className, -}: { - renderAction: ActionManager["renderAction"]; - className?: string; -}) => ( -
- {renderAction("finalize", { size: "small" })} -
-); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index c32a1cd889..a7d8fbcf32 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -100,6 +100,7 @@ import { randomInteger, CLASSES, Emitter, + MINIMUM_ARROW_SIZE, } from "@excalidraw/common"; import { @@ -2158,9 +2159,14 @@ class App extends React.Component { public dismissLinearEditor = () => { setTimeout(() => { - this.setState({ - editingLinearElement: null, - }); + if (this.state.selectedLinearElement?.isEditing) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + isEditing: false, + }, + }); + } }); }; @@ -2856,15 +2862,15 @@ class App extends React.Component { ); if ( - this.state.editingLinearElement && - !this.state.selectedElementIds[this.state.editingLinearElement.elementId] + this.state.selectedLinearElement?.isEditing && + !this.state.selectedElementIds[this.state.selectedLinearElement.elementId] ) { // defer so that the scheduleCapture flag isn't reset via current update setTimeout(() => { // execute only if the condition still holds when the deferred callback // executes (it can be scheduled multiple times depending on how // many times the component renders) - this.state.editingLinearElement && + this.state.selectedLinearElement?.isEditing && this.actionManager.executeAction(actionFinalize); }); } @@ -4419,17 +4425,13 @@ class App extends React.Component { if (event[KEYS.CTRL_OR_CMD] || isLineElement(selectedElement)) { if (isLinearElement(selectedElement)) { if ( - !this.state.editingLinearElement || - this.state.editingLinearElement.elementId !== selectedElement.id + !this.state.selectedLinearElement?.isEditing || + this.state.selectedLinearElement.elementId !== + selectedElement.id ) { this.store.scheduleCapture(); if (!isElbowArrow(selectedElement)) { - this.setState({ - editingLinearElement: new LinearElementEditor( - selectedElement, - this.scene.getNonDeletedElementsMap(), - ), - }); + this.actionManager.executeAction(actionToggleLinearEditor); } } } @@ -5432,15 +5434,12 @@ class App extends React.Component { if ( ((event[KEYS.CTRL_OR_CMD] && isSimpleArrow(selectedLinearElement)) || isLineElement(selectedLinearElement)) && - this.state.editingLinearElement?.elementId !== selectedLinearElement.id + (!this.state.selectedLinearElement?.isEditing || + this.state.selectedLinearElement.elementId !== + selectedLinearElement.id) ) { - this.store.scheduleCapture(); - this.setState({ - editingLinearElement: new LinearElementEditor( - selectedLinearElement, - this.scene.getNonDeletedElementsMap(), - ), - }); + // Use the proper action to ensure immediate history capture + this.actionManager.executeAction(actionToggleLinearEditor); return; } else if ( this.state.selectedLinearElement && @@ -5505,8 +5504,8 @@ class App extends React.Component { return; } } else if ( - this.state.editingLinearElement && - this.state.editingLinearElement.elementId === + this.state.selectedLinearElement?.isEditing && + this.state.selectedLinearElement.elementId === selectedLinearElement.id && isLineElement(selectedLinearElement) ) { @@ -5561,7 +5560,7 @@ class App extends React.Component { // shouldn't edit/create text when inside line editor (often false positive) - if (!this.state.editingLinearElement) { + if (!this.state.selectedLinearElement?.isEditing) { const container = this.getTextBindableContainerAtPosition( sceneX, sceneY, @@ -5859,8 +5858,8 @@ class App extends React.Component { } if ( - this.state.editingLinearElement && - !this.state.editingLinearElement.isDragging + this.state.selectedLinearElement?.isEditing && + !this.state.selectedLinearElement.isDragging ) { const editingLinearElement = LinearElementEditor.handlePointerMove( event, @@ -5868,30 +5867,34 @@ class App extends React.Component { scenePointerY, this, ); + const linearElement = editingLinearElement + ? this.scene.getElement(editingLinearElement.elementId) + : null; if ( editingLinearElement && - editingLinearElement !== this.state.editingLinearElement + editingLinearElement !== this.state.selectedLinearElement ) { // Since we are reading from previous state which is not possible with // automatic batching in React 18 hence using flush sync to synchronously // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details. flushSync(() => { this.setState({ - editingLinearElement, + selectedLinearElement: editingLinearElement, }); }); } - if (editingLinearElement?.lastUncommittedPoint != null) { + if ( + editingLinearElement?.lastUncommittedPoint != null && + linearElement && + isBindingElementType(linearElement.type) + ) { this.maybeSuggestBindingAtCursor( scenePointer, editingLinearElement.elbowed, ); - } else { - // causes stack overflow if not sync - flushSync(() => { - this.setState({ suggestedBindings: [] }); - }); + } else if (this.state.suggestedBindings.length) { + this.setState({ suggestedBindings: [] }); } } @@ -6037,7 +6040,7 @@ class App extends React.Component { if ( selectedElements.length === 1 && !isOverScrollBar && - !this.state.editingLinearElement + !this.state.selectedLinearElement?.isEditing ) { // for linear elements, we'd like to prioritize point dragging over edge resizing // therefore, we update and check hovered point index first @@ -6155,15 +6158,6 @@ class App extends React.Component { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } else if (isOverScrollBar) { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); - } else if ( - this.state.selectedLinearElement && - hitElement?.id === this.state.selectedLinearElement.elementId - ) { - this.handleHoverSelectedLinearElement( - this.state.selectedLinearElement, - scenePointerX, - scenePointerY, - ); } else if ( // if using cmd/ctrl, we're not dragging !event[KEYS.CTRL_OR_CMD] @@ -6205,6 +6199,14 @@ class App extends React.Component { } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } + + if (this.state.selectedLinearElement) { + this.handleHoverSelectedLinearElement( + this.state.selectedLinearElement, + scenePointerX, + scenePointerY, + ); + } } if (this.state.openDialog?.name === "elementLinkSelector" && hitElement) { @@ -7042,7 +7044,7 @@ class App extends React.Component { if ( selectedElements.length === 1 && - !this.state.editingLinearElement && + !this.state.selectedLinearElement?.isEditing && !isElbowArrow(selectedElements[0]) && !( this.state.selectedLinearElement && @@ -7113,8 +7115,7 @@ class App extends React.Component { } } else { if (this.state.selectedLinearElement) { - const linearElementEditor = - this.state.editingLinearElement || this.state.selectedLinearElement; + const linearElementEditor = this.state.selectedLinearElement; const ret = LinearElementEditor.handlePointerDown( event, this, @@ -7128,10 +7129,6 @@ class App extends React.Component { } if (ret.linearElementEditor) { this.setState({ selectedLinearElement: ret.linearElementEditor }); - - if (this.state.editingLinearElement) { - this.setState({ editingLinearElement: ret.linearElementEditor }); - } } if (ret.didAddPoint) { return true; @@ -7232,11 +7229,11 @@ class App extends React.Component { this.clearSelection(hitElement); } - if (this.state.editingLinearElement) { + if (this.state.selectedLinearElement?.isEditing) { this.setState({ selectedElementIds: makeNextSelectedElementIds( { - [this.state.editingLinearElement.elementId]: true, + [this.state.selectedLinearElement.elementId]: true, }, this.state, ), @@ -8099,16 +8096,12 @@ class App extends React.Component { this.scene, ); - flushSync(() => { - if (this.state.selectedLinearElement) { - this.setState({ - selectedLinearElement: { - ...this.state.selectedLinearElement, - segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords, - pointerDownState: ret.pointerDownState, - }, - }); - } + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords, + pointerDownState: ret.pointerDownState, + }, }); return; } @@ -8167,7 +8160,9 @@ class App extends React.Component { pointDistance( pointFrom(pointerCoords.x, pointerCoords.y), pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), - ) < DRAGGING_THRESHOLD + ) * + this.state.zoom.value < + MINIMUM_ARROW_SIZE ) { return; } @@ -8185,8 +8180,7 @@ class App extends React.Component { const elementsMap = this.scene.getNonDeletedElementsMap(); if (this.state.selectedLinearElement) { - const linearElementEditor = - this.state.editingLinearElement || this.state.selectedLinearElement; + const linearElementEditor = this.state.selectedLinearElement; if ( LinearElementEditor.shouldAddMidpoint( @@ -8222,16 +8216,6 @@ class App extends React.Component { }, }); } - if (this.state.editingLinearElement) { - this.setState({ - editingLinearElement: { - ...this.state.editingLinearElement, - pointerDownState: ret.pointerDownState, - selectedPointsIndices: ret.selectedPointsIndices, - segmentMidPointHoveredCoords: null, - }, - }); - } }); return; @@ -8265,9 +8249,9 @@ class App extends React.Component { ); const isSelectingPointsInLineEditor = - this.state.editingLinearElement && + this.state.selectedLinearElement?.isEditing && event.shiftKey && - this.state.editingLinearElement.elementId === + this.state.selectedLinearElement.elementId === pointerDownState.hit.element?.id; if ( (hasHitASelectedElement || @@ -8620,23 +8604,21 @@ class App extends React.Component { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; if (event.altKey) { - flushSync(() => { - this.setActiveTool( - { type: "lasso", fromSelection: true }, - event.shiftKey, - ); - this.lassoTrail.startPath( - pointerDownState.origin.x, - pointerDownState.origin.y, - event.shiftKey, - ); - this.setAppState({ - selectionElement: null, - }); + this.setActiveTool( + { type: "lasso", fromSelection: true }, + event.shiftKey, + ); + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + event.shiftKey, + ); + this.setAppState({ + selectionElement: null, }); - } else { - this.maybeDragNewGenericElement(pointerDownState, event); + return; } + this.maybeDragNewGenericElement(pointerDownState, event); } else if (this.state.activeTool.type === "lasso") { if (!event.altKey && this.state.activeTool.fromSelection) { this.setActiveTool({ type: "selection" }); @@ -8756,7 +8738,7 @@ class App extends React.Component { const elements = this.scene.getNonDeletedElements(); // box-select line editor points - if (this.state.editingLinearElement) { + if (this.state.selectedLinearElement?.isEditing) { LinearElementEditor.handleBoxSelection( event, this.state, @@ -8999,23 +8981,23 @@ class App extends React.Component { // Handle end of dragging a point of a linear element, might close a loop // and sets binding element - if (this.state.editingLinearElement) { + if (this.state.selectedLinearElement?.isEditing) { if ( !pointerDownState.boxSelection.hasOccurred && pointerDownState.hit?.element?.id !== - this.state.editingLinearElement.elementId + this.state.selectedLinearElement.elementId ) { this.actionManager.executeAction(actionFinalize); } else { const editingLinearElement = LinearElementEditor.handlePointerUp( childEvent, - this.state.editingLinearElement, + this.state.selectedLinearElement, this.state, this.scene, ); - if (editingLinearElement !== this.state.editingLinearElement) { + if (editingLinearElement !== this.state.selectedLinearElement) { this.setState({ - editingLinearElement, + selectedLinearElement: editingLinearElement, suggestedBindings: [], }); } @@ -9118,25 +9100,54 @@ class App extends React.Component { this.state, ); - if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) { - this.scene.mutateElement( - newElement, - { - points: [ - ...newElement.points, - pointFrom( - pointerCoords.x - newElement.x, - pointerCoords.y - newElement.y, - ), - ], - }, - { informMutation: false, isDragging: false }, - ); + const dragDistance = + pointDistance( + pointFrom(pointerCoords.x, pointerCoords.y), + pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), + ) * this.state.zoom.value; - this.setState({ - multiElement: newElement, - newElement, - }); + if ( + (!pointerDownState.drag.hasOccurred || + dragDistance < MINIMUM_ARROW_SIZE) && + newElement && + !multiElement + ) { + if (this.device.isTouchScreen) { + const FIXED_DELTA_X = Math.min( + (this.state.width * 0.7) / this.state.zoom.value, + 100, + ); + + this.scene.mutateElement( + newElement, + { + x: newElement.x - FIXED_DELTA_X / 2, + points: [ + pointFrom(0, 0), + pointFrom(FIXED_DELTA_X, 0), + ], + }, + { informMutation: false, isDragging: false }, + ); + + this.actionManager.executeAction(actionFinalize); + } else { + const dx = pointerCoords.x - newElement.x; + const dy = pointerCoords.y - newElement.y; + + this.scene.mutateElement( + newElement, + { + points: [...newElement.points, pointFrom(dx, dy)], + }, + { informMutation: false, isDragging: false }, + ); + + this.setState({ + multiElement: newElement, + newElement, + }); + } } else if (pointerDownState.drag.hasOccurred && !multiElement) { if ( isBindingEnabled(this.state) && @@ -9499,14 +9510,17 @@ class App extends React.Component { !pointerDownState.hit.wasAddedToSelection && // if we're editing a line, pointerup shouldn't switch selection if // box selected - (!this.state.editingLinearElement || + (!this.state.selectedLinearElement?.isEditing || !pointerDownState.boxSelection.hasOccurred) && // hitElement can be set when alt + ctrl to toggle lasso and we will // just respect the selected elements from lasso instead this.state.activeTool.type !== "lasso" ) { // when inside line editor, shift selects points instead - if (childEvent.shiftKey && !this.state.editingLinearElement) { + if ( + childEvent.shiftKey && + !this.state.selectedLinearElement?.isEditing + ) { if (this.state.selectedElementIds[hitElement.id]) { if (isSelectedViaGroup(this.state, hitElement)) { this.setState((_prevState) => { @@ -9684,8 +9698,9 @@ class App extends React.Component { (!hitElement && pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements)) ) { - if (this.state.editingLinearElement) { - this.setState({ editingLinearElement: null }); + if (this.state.selectedLinearElement?.isEditing) { + // Exit editing mode but keep the element selected + this.actionManager.executeAction(actionToggleLinearEditor); } else { // Deselect selected elements this.setState({ diff --git a/packages/excalidraw/components/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index 5ab9d7bcea..39870a34df 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -115,7 +115,7 @@ const getHints = ({ appState.selectionElement && !selectedElements.length && !appState.editingTextElement && - !appState.editingLinearElement + !appState.selectedLinearElement?.isEditing ) { return [t("hints.deepBoxSelect")]; } @@ -130,8 +130,8 @@ const getHints = ({ if (selectedElements.length === 1) { if (isLinearElement(selectedElements[0])) { - if (appState.editingLinearElement) { - return appState.editingLinearElement.selectedPointsIndices + if (appState.selectedLinearElement?.isEditing) { + return appState.selectedLinearElement.selectedPointsIndices ? t("hints.lineEditor_pointSelected") : t("hints.lineEditor_nothingSelected"); } diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 4b1cd70605..c375a2b168 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -192,7 +192,6 @@ const getRelevantAppStateProps = ( viewModeEnabled: appState.viewModeEnabled, openDialog: appState.openDialog, editingGroupId: appState.editingGroupId, - editingLinearElement: appState.editingLinearElement, selectedElementIds: appState.selectedElementIds, frameToHighlight: appState.frameToHighlight, offsetLeft: appState.offsetLeft, diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index 01ce94c431..9e23fa500b 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -34,6 +34,13 @@ const StaticCanvas = (props: StaticCanvasProps) => { const wrapperRef = useRef(null); const isComponentMounted = useRef(false); + useEffect(() => { + props.canvas.style.width = `${props.appState.width}px`; + props.canvas.style.height = `${props.appState.height}px`; + props.canvas.width = props.appState.width * props.scale; + props.canvas.height = props.appState.height * props.scale; + }, [props.appState.height, props.appState.width, props.canvas, props.scale]); + useEffect(() => { const wrapper = wrapperRef.current; if (!wrapper) { @@ -49,26 +56,6 @@ const StaticCanvas = (props: StaticCanvasProps) => { canvas.classList.add("excalidraw__canvas", "static"); } - const widthString = `${props.appState.width}px`; - const heightString = `${props.appState.height}px`; - if (canvas.style.width !== widthString) { - canvas.style.width = widthString; - } - if (canvas.style.height !== heightString) { - canvas.style.height = heightString; - } - - const scaledWidth = props.appState.width * props.scale; - const scaledHeight = props.appState.height * props.scale; - // setting width/height resets the canvas even if dimensions not changed, - // which would cause flicker when we skip frame (due to throttling) - if (canvas.width !== scaledWidth) { - canvas.width = scaledWidth; - } - if (canvas.height !== scaledHeight) { - canvas.height = scaledHeight; - } - renderStaticScene( { canvas, diff --git a/packages/excalidraw/components/footer/Footer.tsx b/packages/excalidraw/components/footer/Footer.tsx index 427628e7c9..3b213d796a 100644 --- a/packages/excalidraw/components/footer/Footer.tsx +++ b/packages/excalidraw/components/footer/Footer.tsx @@ -2,13 +2,7 @@ import clsx from "clsx"; import { actionShortcuts } from "../../actions"; import { useTunnels } from "../../context/tunnels"; -import { - ExitZenModeAction, - FinalizeAction, - UndoRedoActions, - ZoomActions, -} from "../Actions"; -import { useDevice } from "../App"; +import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions"; import { HelpButton } from "../HelpButton"; import { Section } from "../Section"; import Stack from "../Stack"; @@ -29,10 +23,6 @@ const Footer = ({ }) => { const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels(); - const device = useDevice(); - const showFinalize = - !appState.viewModeEnabled && appState.multiElement && device.isTouchScreen; - return (