From 2e4ca2d11a8a5ccf10d1aaefa820d5f4ef3761ad Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Wed, 16 Apr 2025 13:10:14 +0200 Subject: [PATCH] Expose scene.mutateElement and use original mutateElement --- packages/element/src/Scene.ts | 13 +- packages/element/src/align.ts | 2 +- packages/element/src/binding.ts | 36 ++--- packages/element/src/dragElements.ts | 6 +- packages/element/src/elbowArrow.ts | 24 +--- packages/element/src/flowchart.ts | 4 +- packages/element/src/fractionalIndex.ts | 6 +- packages/element/src/frame.ts | 8 +- packages/element/src/linearElementEditor.ts | 23 ++-- packages/element/src/mutateElement.ts | 73 +++++------ packages/element/src/resizeElements.ts | 25 ++-- packages/element/src/textElement.ts | 12 +- packages/element/tests/duplicate.test.tsx | 2 +- packages/element/tests/elbowArrow.test.tsx | 4 +- packages/element/tests/sortElements.test.ts | 6 +- .../excalidraw/actions/actionBoundText.tsx | 12 +- .../actions/actionDeleteSelected.test.tsx | 8 +- .../actions/actionDeleteSelected.tsx | 2 +- .../excalidraw/actions/actionFinalize.tsx | 8 +- packages/excalidraw/actions/actionFlip.ts | 4 +- packages/excalidraw/actions/actionFrame.ts | 4 +- .../excalidraw/actions/actionProperties.tsx | 4 +- packages/excalidraw/change.ts | 4 +- packages/excalidraw/clipboard.ts | 4 +- packages/excalidraw/components/App.tsx | 123 +++++++++++------- .../components/ElementLinkDialog.tsx | 4 +- packages/excalidraw/components/LayerUI.tsx | 4 +- .../excalidraw/components/Stats/Angle.tsx | 8 +- .../excalidraw/components/Stats/Dimension.tsx | 4 +- .../excalidraw/components/Stats/FontSize.tsx | 2 +- .../components/Stats/MultiAngle.tsx | 8 +- .../components/Stats/MultiDimension.tsx | 4 +- .../components/Stats/MultiFontSize.tsx | 4 +- .../excalidraw/components/Stats/Position.tsx | 4 +- .../components/Stats/stats.test.tsx | 2 +- packages/excalidraw/components/Stats/utils.ts | 8 +- .../components/hyperlink/Hyperlink.tsx | 10 +- packages/excalidraw/index.tsx | 1 - .../excalidraw/tests/elementLocking.test.tsx | 6 +- packages/excalidraw/tests/helpers/api.ts | 8 +- packages/excalidraw/tests/helpers/ui.ts | 2 +- .../tests/linearElementEditor.test.tsx | 2 +- packages/excalidraw/types.ts | 1 + packages/excalidraw/wysiwyg/textWysiwyg.tsx | 10 +- 44 files changed, 249 insertions(+), 260 deletions(-) diff --git a/packages/element/src/Scene.ts b/packages/element/src/Scene.ts index 51a86ded9..73fba0b39 100644 --- a/packages/element/src/Scene.ts +++ b/packages/element/src/Scene.ts @@ -20,7 +20,7 @@ import { import { getSelectedElements } from "@excalidraw/element/selection"; import { - mutateElementWith, + mutateElement, type ElementUpdate, } from "@excalidraw/element/mutateElement"; @@ -424,23 +424,22 @@ class Scene { return getElementsInGroup(elementsMap, id); }; - // TODO_SCENE: should be accessed as app.scene through the API - // TODO_SCENE: inform mutation false is the new default, meaning all mutateElement with nothing should likely use scene instead // Mutate an element with passed updates and trigger the component to update. Make sure you // are calling it either from a React event handler or within unstable_batchedUpdates(). - mutate>( + mutateElement>( element: TElement, updates: ElementUpdate, options: { - informMutation?: boolean; - isDragging?: boolean; + informMutation: boolean; + isDragging: boolean; } = { informMutation: true, + isDragging: false, }, ) { const elementsMap = this.getNonDeletedElementsMap(); - mutateElementWith(element, elementsMap, updates, options); + mutateElement(element, elementsMap, updates, options); if (options.informMutation) { this.triggerUpdate(); diff --git a/packages/element/src/align.ts b/packages/element/src/align.ts index 9d7b254fc..b6dd4d454 100644 --- a/packages/element/src/align.ts +++ b/packages/element/src/align.ts @@ -32,7 +32,7 @@ export const alignElements = ( ); return group.map((element) => { // update element - const updatedEle = scene.mutate(element, { + const updatedEle = scene.mutateElement(element, { x: element.x + translation.x, y: element.y + translation.y, }); diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 0ab7746ff..a1c61e4ba 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -48,7 +48,7 @@ import { type Heading, } from "./heading"; import { LinearElementEditor } from "./linearElementEditor"; -import { mutateElementWith } from "./mutateElement"; +import { mutateElement } from "./mutateElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { isArrowElement, @@ -157,7 +157,7 @@ export const bindOrUnbindLinearElement = ( ); getNonDeletedElements(scene, onlyUnbound).forEach((element) => { - scene.mutate(element, { + scene.mutateElement(element, { boundElements: element.boundElements?.filter( (element) => element.type !== "arrow" || element.id !== linearElement.id, @@ -509,13 +509,13 @@ export const bindLinearElement = ( }; } - scene.mutate(linearElement, { + scene.mutateElement(linearElement, { [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding, }); const boundElementsMap = arrayToMap(hoveredElement.boundElements || []); if (!boundElementsMap.has(linearElement.id)) { - scene.mutate(hoveredElement, { + scene.mutateElement(hoveredElement, { boundElements: (hoveredElement.boundElements || []).concat({ id: linearElement.id, type: "arrow", @@ -564,7 +564,7 @@ const unbindLinearElement = ( if (binding == null) { return null; } - scene.mutate(linearElement, { [field]: null }); + scene.mutateElement(linearElement, { [field]: null }); return binding.elementId; }; @@ -790,7 +790,7 @@ export const updateBoundElements = ( // `linearElement` is being moved/scaled already, just update the binding if (simultaneouslyUpdatedElementIds.has(element.id)) { - mutateElementWith(element, elementsMap, bindings); + scene.mutateElement(element, bindings); return; } @@ -1499,7 +1499,7 @@ const fixReversedBindingsForBindables = ( (el) => el.id === newArrowId, )! as ExcalidrawArrowElement; - mutateElementWith(newArrow, originalElements, { + mutateElement(newArrow, originalElements, { startBinding: oldArrow.startBinding?.elementId === binding.id ? { @@ -1515,7 +1515,7 @@ const fixReversedBindingsForBindables = ( } : newArrow.endBinding, }); - mutateElementWith(duplicate, originalElements, { + mutateElement(duplicate, originalElements, { boundElements: [ ...(duplicate.boundElements ?? []).filter( (el) => el.id !== binding.id && el.id !== newArrowId, @@ -1529,7 +1529,7 @@ const fixReversedBindingsForBindables = ( } else { // Linked arrow is outside the selection, // so we move the binding to the duplicate - mutateElementWith(oldArrow, originalElements, { + mutateElement(oldArrow, originalElements, { startBinding: oldArrow.startBinding?.elementId === original.id ? { @@ -1545,7 +1545,7 @@ const fixReversedBindingsForBindables = ( } : oldArrow.endBinding, }); - mutateElementWith(duplicate, originalElements, { + mutateElement(duplicate, originalElements, { boundElements: [ ...(duplicate.boundElements ?? []), { @@ -1554,7 +1554,7 @@ const fixReversedBindingsForBindables = ( }, ], }); - mutateElementWith(original, originalElements, { + mutateElement(original, originalElements, { boundElements: original.boundElements?.filter((_, i) => i !== idx) ?? null, }); @@ -1580,13 +1580,13 @@ const fixReversedBindingsForArrows = ( const newBindable = elementsWithClones.find( (el) => el.id === newBindableId, ) as ExcalidrawBindableElement; - mutateElementWith(duplicate, originalElements, { + mutateElement(duplicate, originalElements, { [bindingProp]: { ...original[bindingProp], elementId: newBindableId, }, }); - mutateElementWith(newBindable, originalElements, { + mutateElement(newBindable, originalElements, { boundElements: [ ...(newBindable.boundElements ?? []).filter( (el) => el.id !== original.id && el.id !== duplicate.id, @@ -1603,13 +1603,13 @@ const fixReversedBindingsForArrows = ( (el) => el.id === oldBindableId, ); if (originalBindable) { - mutateElementWith(duplicate, originalElements, { + mutateElement(duplicate, originalElements, { [bindingProp]: original[bindingProp], }); - mutateElementWith(original, originalElements, { + mutateElement(original, originalElements, { [bindingProp]: null, }); - mutateElementWith(originalBindable, originalElements, { + mutateElement(originalBindable, originalElements, { boundElements: [ ...(originalBindable.boundElements?.filter( (el) => el.id !== original.id, @@ -1672,10 +1672,10 @@ export const fixBindingsAfterDeletion = ( for (const element of deletedElements) { BoundElement.unbindAffected(elements, element, (element, updates) => - mutateElementWith(element, elements, updates), + mutateElement(element, elements, updates), ); BindableElement.unbindAffected(elements, element, (element, updates) => - mutateElementWith(element, elements, updates), + mutateElement(element, elements, updates), ); } }; diff --git a/packages/element/src/dragElements.ts b/packages/element/src/dragElements.ts index 4bbb8dcd2..4f9eb9ca4 100644 --- a/packages/element/src/dragElements.ts +++ b/packages/element/src/dragElements.ts @@ -168,7 +168,7 @@ const updateElementCoords = ( const nextX = originalElement.x + dragOffset.x; const nextY = originalElement.y + dragOffset.y; - scene.mutate(element, { + scene.mutateElement(element, { x: nextX, y: nextY, }); @@ -292,7 +292,7 @@ export const dragNewElement = ({ }; } - scene.mutate( + scene.mutateElement( newElement, { x: newX + (originOffset?.x ?? 0), @@ -302,7 +302,7 @@ export const dragNewElement = ({ ...textAutoResize, ...imageInitialDimension, }, - { informMutation }, + { informMutation, isDragging: false }, ); } }; diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index bf85c0189..95a2aa8ef 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -46,7 +46,7 @@ import { headingForPoint, } from "./heading"; import { type ElementUpdate } from "./mutateElement"; -import { isBindableElement, isElbowArrow } from "./typeChecks"; +import { isBindableElement } from "./typeChecks"; import { type ExcalidrawElbowArrowElement, type NonDeletedSceneElementsMap, @@ -60,7 +60,6 @@ import type { Arrowhead, ElementsMap, ExcalidrawBindableElement, - ExcalidrawElement, FixedPointBinding, FixedSegment, NonDeletedExcalidrawElement, @@ -879,27 +878,6 @@ const handleEndpointDrag = ( const MAX_POS = 1e6; -export const elbowArrowNeedsToGetNormalized = ( - element: Readonly, - updates: { - points?: readonly LocalPoint[]; - fixedSegments?: readonly FixedSegment[] | null; - startBinding?: FixedPointBinding | null; - endBinding?: FixedPointBinding | null; - }, -) => { - const { points, fixedSegments, startBinding, endBinding } = updates; - - return ( - isElbowArrow(element) && - (Object.keys(updates).length === 0 || // normalization case - typeof points !== "undefined" || // repositioning - typeof fixedSegments !== "undefined" || // segment fixing - typeof startBinding !== "undefined" || - typeof endBinding !== "undefined") // manual binding to element - ); -}; - /** * */ diff --git a/packages/element/src/flowchart.ts b/packages/element/src/flowchart.ts index eb70cb770..62acd1c4e 100644 --- a/packages/element/src/flowchart.ts +++ b/packages/element/src/flowchart.ts @@ -19,7 +19,7 @@ import { type Heading, } from "./heading"; import { LinearElementEditor } from "./linearElementEditor"; -import { mutateElementWith } from "./mutateElement"; +import { mutateElement } from "./mutateElement"; import { newArrowElement, newElement } from "./newElement"; import { aabbForElement } from "./shapes"; import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame"; @@ -678,7 +678,7 @@ export class FlowChartCreator { ) ) { this.pendingNodes = this.pendingNodes.map((node) => - mutateElementWith(node, elementsMap, { + mutateElement(node, elementsMap, { frameId: startNode.frameId, }), ); diff --git a/packages/element/src/fractionalIndex.ts b/packages/element/src/fractionalIndex.ts index 6b4ba1ec5..ccf35d627 100644 --- a/packages/element/src/fractionalIndex.ts +++ b/packages/element/src/fractionalIndex.ts @@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing"; import { arrayToMap } from "@excalidraw/common"; -import { mutateElementWith } from "./mutateElement"; +import { mutateElement } from "./mutateElement"; import { getBoundTextElement } from "./textElement"; import { hasBoundTextElement } from "./typeChecks"; @@ -177,7 +177,7 @@ export const syncMovedIndices = ( // split mutation so we don't end up in an incosistent state for (const [element, update] of elementsUpdates) { - mutateElementWith(element, arrayToMap(elements), update); + mutateElement(element, arrayToMap(elements), update); } } catch (e) { // fallback to default sync @@ -198,7 +198,7 @@ export const syncInvalidIndices = ( const indicesGroups = getInvalidIndicesGroups(elements); const elementsUpdates = generateIndices(elements, indicesGroups); for (const [element, update] of elementsUpdates) { - mutateElementWith(element, arrayToMap(elements), update); + mutateElement(element, arrayToMap(elements), update); } return elements as OrderedExcalidrawElement[]; diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 7431cbff8..bd3cb14be 100644 --- a/packages/element/src/frame.ts +++ b/packages/element/src/frame.ts @@ -19,7 +19,7 @@ import { getCommonBounds, getElementAbsoluteCoords, } from "./bounds"; -import { mutateElementWith } from "./mutateElement"; +import { mutateElement } from "./mutateElement"; import { getBoundTextElement, getContainerElement } from "./textElement"; import { isFrameElement, @@ -57,7 +57,7 @@ export const bindElementsToFramesAfterDuplication = ( if (nextElementId) { const nextElement = nextElementMap.get(nextElementId); if (nextElement) { - mutateElementWith(nextElement, nextElementMap, { + mutateElement(nextElement, nextElementMap, { frameId: nextFrameId ?? element.frameId, }); } @@ -563,7 +563,7 @@ export const addElementsToFrame = ( } for (const element of finalElementsToAdd) { - mutateElementWith(element, elementsMap, { + mutateElement(element, elementsMap, { frameId: frame.id, }); } @@ -603,7 +603,7 @@ export const removeElementsFromFrame = ( } for (const [, element] of _elementsToRemove) { - mutateElementWith(element, elementsMap, { + mutateElement(element, elementsMap, { frameId: null, }); } diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index ed0180b04..55e3f5c4f 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -47,7 +47,7 @@ import { } from "./bounds"; import { headingIsHorizontal, vectorToHeading } from "./heading"; -import { mutateElementWith } from "./mutateElement"; +import { mutateElement } from "./mutateElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { isBindingElement, @@ -793,7 +793,7 @@ export class LinearElementEditor { ); } else if (event.altKey && appState.editingLinearElement) { if (linearElementEditor.lastUncommittedPoint == null) { - scene.mutate(element, { + scene.mutateElement(element, { points: [ ...element.points, LinearElementEditor.createPointAt( @@ -1165,7 +1165,7 @@ export class LinearElementEditor { element: NonDeleted, elementsMap: ElementsMap, ) { - mutateElementWith( + mutateElement( element, elementsMap, LinearElementEditor.getNormalizedPoints(element), @@ -1221,7 +1221,7 @@ export class LinearElementEditor { return acc; }, []); - scene.mutate(element, { points: nextPoints }); + scene.mutateElement(element, { points: nextPoints }); // temp hack to ensure the line doesn't move when adding point to the end, // potentially expanding the bounding box @@ -1442,7 +1442,7 @@ export class LinearElementEditor { ...element.points.slice(segmentMidpoint.index!), ]; - scene.mutate(element, { points }); + scene.mutateElement(element, { points }); ret.pointerDownState = { ...linearElementEditor.pointerDownState, @@ -1495,8 +1495,9 @@ export class LinearElementEditor { updates.points = Array.from(nextPoints); - scene.mutate(element, updates, { - isDragging: options?.isDragging, + scene.mutateElement(element, updates, { + informMutation: true, + isDragging: options?.isDragging ?? false, }); } else { const nextCoords = getElementPointsCoords(element, nextPoints); @@ -1512,7 +1513,7 @@ export class LinearElementEditor { pointFrom(dX, dY), element.angle, ); - scene.mutate(element, { + scene.mutateElement(element, { ...otherUpdates, points: nextPoints, x: element.x + rotated[0], @@ -1571,7 +1572,7 @@ export class LinearElementEditor { elementsMap, ); if (points.length < 2) { - mutateElementWith(boundTextElement, elementsMap, { isDeleted: true }); + mutateElement(boundTextElement, elementsMap, { isDeleted: true }); } let x = 0; let y = 0; @@ -1823,7 +1824,7 @@ export class LinearElementEditor { .map((segment) => segment.index) .reduce((count, idx) => (idx < index ? count + 1 : count), 0); - scene.mutate(element, { + scene.mutateElement(element, { fixedSegments: nextFixedSegments, }); @@ -1860,7 +1861,7 @@ export class LinearElementEditor { scene: Scene, index: number, ): void { - scene.mutate(element, { + scene.mutateElement(element, { fixedSegments: element.fixedSegments?.filter( (segment) => segment.index !== index, ), diff --git a/packages/element/src/mutateElement.ts b/packages/element/src/mutateElement.ts index d089fbb66..090076420 100644 --- a/packages/element/src/mutateElement.ts +++ b/packages/element/src/mutateElement.ts @@ -10,12 +10,12 @@ import type { Mutable } from "@excalidraw/common/utility-types"; import { ShapeCache } from "./ShapeCache"; -import { - elbowArrowNeedsToGetNormalized, - updateElbowArrowPoints, -} from "./elbowArrow"; +import { updateElbowArrowPoints } from "./elbowArrow"; + +import { isElbowArrow } from "./typeChecks"; import type { + ElementsMap, ExcalidrawElbowArrowElement, ExcalidrawElement, NonDeletedSceneElementsMap, @@ -26,24 +26,38 @@ export type ElementUpdate = Omit< "id" | "version" | "versionNonce" | "updated" >; -// This function tracks updates of text elements for the purposes for collaboration. -// The version is used to compare updates when more than one user is working in -// the same drawing. Note: this won't trigger the component to update, unlike `scene.mutate`. -export const mutateElementWith = >( +/** + * This function tracks updates of text elements for the purposes for collaboration. + * The version is used to compare updates when more than one user is working in + * the same drawing. + * + * WARNING: this won't trigger the component to update, so if you need to trigger component update, + * use `scene.mutateElement` or `ExcalidrawImperativeAPI.mutateElement` instead. + */ +export const mutateElement = >( element: TElement, - elementsMap: Map, + elementsMap: ElementsMap, updates: ElementUpdate, options?: { isDragging?: boolean; }, ) => { + let didChange = false; + + // casting to any because can't use `in` operator + // (see https://github.com/microsoft/TypeScript/issues/21732) + const { points, fixedSegments, startBinding, endBinding, fileId } = + updates as any; + if ( - elbowArrowNeedsToGetNormalized( - element, - updates as ElementUpdate, - ) + isElbowArrow(element) && + (Object.keys(updates).length === 0 || // normalization case + typeof points !== "undefined" || // repositioning + typeof fixedSegments !== "undefined" || // segment fixing + typeof startBinding !== "undefined" || + typeof endBinding !== "undefined") // manual binding to element ) { - const normalizedUpdates = { + updates = { ...updates, angle: 0 as Radians, ...updateElbowArrowPoints( @@ -52,35 +66,8 @@ export const mutateElementWith = >( updates as ElementUpdate, options, ), - } as ElementUpdate; - - return mutateElement( - element as ExcalidrawElbowArrowElement, - normalizedUpdates, - ); - } - - return mutateElement(element, updates); -}; - -/** - * This function tracks updates of text elements for the purposes for collaboration. - * The version is used to compare updates when more than one user is working in - * the same drawing. - * - * @deprecated Use `scene.mutate` as direct equivalent, or `mutateElementWith` in case you don't need to trigger component update. - */ -export const mutateElement = >( - element: TElement, - updates: ElementUpdate, -): TElement => { - let didChange = false; - - // casting to any because can't use `in` operator - // (see https://github.com/microsoft/TypeScript/issues/21732) - const { points, fileId } = updates as any; - - if (typeof points !== "undefined") { + }; + } else if (typeof points !== "undefined") { updates = { ...getSizeFromPoints(points), ...updates }; } diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index ddd845661..8a1702afa 100644 --- a/packages/element/src/resizeElements.ts +++ b/packages/element/src/resizeElements.ts @@ -30,7 +30,6 @@ import { getElementBounds, } from "./bounds"; import { LinearElementEditor } from "./linearElementEditor"; -import { mutateElementWith } from "./mutateElement"; import { getBoundTextElement, getBoundTextElementId, @@ -230,13 +229,13 @@ const rotateSingleElement = ( } const boundTextElementId = getBoundTextElementId(element); - scene.mutate(element, { angle }); + scene.mutateElement(element, { angle }); if (boundTextElementId) { const textElement = scene.getElement(boundTextElementId); if (textElement && !isArrowElement(element)) { - scene.mutate(textElement, { angle }); + scene.mutateElement(textElement, { angle }); } } }; @@ -391,7 +390,7 @@ const resizeSingleTextElement = ( ); const [nextX, nextY] = newTopLeft; - scene.mutate(element, { + scene.mutateElement(element, { fontSize: metrics.size, width: nextWidth, height: nextHeight, @@ -506,7 +505,7 @@ const resizeSingleTextElement = ( autoResize: false, }; - scene.mutate(element, resizedElement); + scene.mutateElement(element, resizedElement); } }; @@ -552,7 +551,7 @@ const rotateMultipleElements = ( angle: normalizeRadians((centerAngle + origAngle) as Radians), }; - scene.mutate(element, updates); + scene.mutateElement(element, updates); updateBoundElements(element, scene, { simultaneouslyUpdated: elements, @@ -560,7 +559,7 @@ const rotateMultipleElements = ( const boundText = getBoundTextElement(element, elementsMap); if (boundText && !isArrowElement(element)) { - mutateElementWith(boundText, elementsMap, { + scene.mutateElement(boundText, { x: boundText.x + (rotatedCX - cx), y: boundText.y + (rotatedCY - cy), angle: normalizeRadians((centerAngle + origAngle) as Radians), @@ -923,7 +922,7 @@ export const resizeSingleElement = ( } if ("scale" in latestElement && "scale" in origElement) { - scene.mutate(latestElement, { + scene.mutateElement(latestElement, { scale: [ // defaulting because scaleX/Y can be 0/-0 (Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0], @@ -958,8 +957,9 @@ export const resizeSingleElement = ( ...rescaledPoints, }; - scene.mutate(latestElement, updates, { + scene.mutateElement(latestElement, updates, { informMutation: shouldInformMutation, + isDragging: false, }); updateBoundElements(latestElement, scene, { @@ -968,7 +968,7 @@ export const resizeSingleElement = ( }); if (boundTextElement && boundTextFont != null) { - scene.mutate(boundTextElement, { + scene.mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize, }); } @@ -1518,7 +1518,8 @@ export const resizeMultipleElements = ( } of elementsAndUpdates) { const { width, height, angle } = update; - scene.mutate(element, update, { + scene.mutateElement(element, update, { + informMutation: true, // needed for the fixed binding point udpate to take effect isDragging: true, }); @@ -1530,7 +1531,7 @@ export const resizeMultipleElements = ( const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement && boundTextFontSize) { - scene.mutate(boundTextElement, { + scene.mutateElement(boundTextElement, { fontSize: boundTextFontSize, angle: isLinearElement(element) ? undefined : angle, }); diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index fef3eb825..4a905ce5d 100644 --- a/packages/element/src/textElement.ts +++ b/packages/element/src/textElement.ts @@ -93,7 +93,7 @@ export const redrawTextBoundingBox = ( metrics.height, container.type, ); - scene.mutate(container, { height: nextHeight }); + scene.mutateElement(container, { height: nextHeight }); updateOriginalContainerCache(container.id, nextHeight); } @@ -102,7 +102,7 @@ export const redrawTextBoundingBox = ( metrics.width, container.type, ); - scene.mutate(container, { width: nextWidth }); + scene.mutateElement(container, { width: nextWidth }); } const updatedTextElement = { @@ -120,7 +120,7 @@ export const redrawTextBoundingBox = ( boundTextUpdates.y = y; } - scene.mutate(textElement, boundTextUpdates); + scene.mutateElement(textElement, boundTextUpdates); }; export const handleBindTextResize = ( @@ -182,20 +182,20 @@ export const handleBindTextResize = ( transformHandleType === "n") ? container.y - diff : container.y; - scene.mutate(container, { + scene.mutateElement(container, { height: containerHeight, y: updatedY, }); } - scene.mutate(textElement, { + scene.mutateElement(textElement, { text, width: nextWidth, height: nextHeight, }); if (!isArrowElement(container)) { - scene.mutate( + scene.mutateElement( textElement, computeBoundTextPosition(container, textElement, elementsMap), ); diff --git a/packages/element/tests/duplicate.test.tsx b/packages/element/tests/duplicate.test.tsx index 577522ba1..2be501043 100644 --- a/packages/element/tests/duplicate.test.tsx +++ b/packages/element/tests/duplicate.test.tsx @@ -62,7 +62,7 @@ describe("duplicating single elements", () => { // @ts-ignore element.__proto__ = { hello: "world" }; - h.app.scene.mutate(element, { + h.app.scene.mutateElement(element, { points: [pointFrom(1, 2), pointFrom(3, 4)], }); diff --git a/packages/element/tests/elbowArrow.test.tsx b/packages/element/tests/elbowArrow.test.tsx index 752ff3d64..25f64072e 100644 --- a/packages/element/tests/elbowArrow.test.tsx +++ b/packages/element/tests/elbowArrow.test.tsx @@ -143,7 +143,7 @@ describe("elbow arrow routing", () => { elbowed: true, }) as ExcalidrawElbowArrowElement; scene.insertElement(arrow); - h.app.scene.mutate(arrow, { + h.app.scene.mutateElement(arrow, { points: [ pointFrom(-45 - arrow.x, -100.1 - arrow.y), pointFrom(45 - arrow.x, 99.9 - arrow.y), @@ -195,7 +195,7 @@ describe("elbow arrow routing", () => { expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null); - h.app.scene.mutate(arrow, { + h.app.scene.mutateElement(arrow, { points: [pointFrom(0, 0), pointFrom(90, 200)], }); diff --git a/packages/element/tests/sortElements.test.ts b/packages/element/tests/sortElements.test.ts index 29ce376aa..486d110db 100644 --- a/packages/element/tests/sortElements.test.ts +++ b/packages/element/tests/sortElements.test.ts @@ -35,7 +35,7 @@ describe("normalizeElementsOrder", () => { boundElements: [], }); - h.app.scene.mutate(container, { + h.app.scene.mutateElement(container, { boundElements: [{ type: "text", id: boundText.id }], }); @@ -352,7 +352,7 @@ describe("normalizeElementsOrder", () => { containerId: container.id, }); - h.app.scene.mutate(container, { + h.app.scene.mutateElement(container, { boundElements: [ { type: "text", id: boundText.id }, { type: "text", id: "xxx" }, @@ -387,7 +387,7 @@ describe("normalizeElementsOrder", () => { boundElements: [], groupIds: ["C", "A"], }); - h.app.scene.mutate(container, { + h.app.scene.mutateElement(container, { boundElements: [{ type: "text", id: boundText.id }], }); diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index facd7354a..d556745f9 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -76,7 +76,7 @@ export const actionUnbindText = register({ boundTextElement, elementsMap, ); - app.scene.mutate(boundTextElement as ExcalidrawTextElement, { + app.scene.mutateElement(boundTextElement as ExcalidrawTextElement, { containerId: null, width, height, @@ -84,7 +84,7 @@ export const actionUnbindText = register({ x, y, }); - app.scene.mutate(element, { + app.scene.mutateElement(element, { boundElements: element.boundElements?.filter( (ele) => ele.id !== boundTextElement.id, ), @@ -149,13 +149,13 @@ export const actionBindText = register({ textElement = selectedElements[1] as ExcalidrawTextElement; container = selectedElements[0] as ExcalidrawTextContainer; } - app.scene.mutate(textElement, { + app.scene.mutateElement(textElement, { containerId: container.id, verticalAlign: VERTICAL_ALIGN.MIDDLE, textAlign: TEXT_ALIGN.CENTER, autoResize: true, }); - app.scene.mutate(container, { + app.scene.mutateElement(container, { boundElements: (container.boundElements || []).concat({ type: "text", id: textElement.id, @@ -292,7 +292,7 @@ export const actionWrapTextInContainer = register({ } if (startBinding || endBinding) { - app.scene.mutate(ele, { + app.scene.mutateElement(ele, { startBinding, endBinding, }); @@ -300,7 +300,7 @@ export const actionWrapTextInContainer = register({ }); } - app.scene.mutate(textElement, { + app.scene.mutateElement(textElement, { containerId: container.id, verticalAlign: VERTICAL_ALIGN.MIDDLE, boundElements: null, diff --git a/packages/excalidraw/actions/actionDeleteSelected.test.tsx b/packages/excalidraw/actions/actionDeleteSelected.test.tsx index 245df9528..708d2cc55 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.test.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.test.tsx @@ -56,7 +56,7 @@ describe("deleting selected elements when frame selected should keep children + frameId: f1.id, }); - h.app.scene.mutate(r1, { + h.app.scene.mutateElement(r1, { boundElements: [{ type: "text", id: t1.id }], }); @@ -94,7 +94,7 @@ describe("deleting selected elements when frame selected should keep children + frameId: null, }); - h.app.scene.mutate(r1, { + h.app.scene.mutateElement(r1, { boundElements: [{ type: "text", id: t1.id }], }); @@ -132,7 +132,7 @@ describe("deleting selected elements when frame selected should keep children + frameId: null, }); - h.app.scene.mutate(r1, { + h.app.scene.mutateElement(r1, { boundElements: [{ type: "text", id: t1.id }], }); @@ -170,7 +170,7 @@ describe("deleting selected elements when frame selected should keep children + frameId: null, }); - h.app.scene.mutate(a1, { + h.app.scene.mutateElement(a1, { boundElements: [{ type: "text", id: t1.id }], }); diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 444b95125..e183d05f4 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -91,7 +91,7 @@ const deleteSelectedElements = ( el.boundElements.forEach((candidate) => { const bound = app.scene.getNonDeletedElementsMap().get(candidate.id); if (bound && isElbowArrow(bound)) { - app.scene.mutate(bound, { + app.scene.mutateElement(bound, { startBinding: el.id === bound.startBinding?.elementId ? null diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 45f1c3a53..22638ee91 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -71,10 +71,10 @@ export const actionFinalize = register({ scene.getElement(appState.pendingImageElementId); if (pendingImageElement) { - scene.mutate( + scene.mutateElement( pendingImageElement, { isDeleted: true }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ); } @@ -99,7 +99,7 @@ export const actionFinalize = register({ !lastCommittedPoint || points[points.length - 1] !== lastCommittedPoint ) { - scene.mutate(multiPointElement, { + scene.mutateElement(multiPointElement, { points: multiPointElement.points.slice(0, -1), }); } @@ -123,7 +123,7 @@ export const actionFinalize = register({ if (isLoop) { const linePoints = multiPointElement.points; const firstPoint = linePoints[0]; - scene.mutate(multiPointElement, { + scene.mutateElement(multiPointElement, { points: linePoints.map((p, index) => index === linePoints.length - 1 ? pointFrom(firstPoint[0], firstPoint[1]) diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 1b8b2661d..becc8a976 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -189,13 +189,13 @@ const flipElements = ( getCommonBoundingBox(selectedElements); const [diffX, diffY] = [midX - newMidX, midY - newMidY]; otherElements.forEach((element) => - app.scene.mutate(element, { + app.scene.mutateElement(element, { x: element.x + diffX, y: element.y + diffY, }), ); elbowArrows.forEach((element) => - app.scene.mutate(element, { + app.scene.mutateElement(element, { x: element.x + diffX, y: element.y + diffY, }), diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index 7db4dad71..7882d26f6 100644 --- a/packages/excalidraw/actions/actionFrame.ts +++ b/packages/excalidraw/actions/actionFrame.ts @@ -1,5 +1,5 @@ import { getNonDeletedElements } from "@excalidraw/element"; -import { mutateElementWith } from "@excalidraw/element/mutateElement"; +import { mutateElement } from "@excalidraw/element/mutateElement"; import { newFrameElement } from "@excalidraw/element/newElement"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; import { @@ -194,7 +194,7 @@ export const actionWrapSelectionInFrame = register({ for (const elementInGroup of elementsInGroup) { const index = elementInGroup.groupIds.indexOf(appState.editingGroupId); - mutateElementWith(elementInGroup, elementsMap, { + mutateElement(elementInGroup, elementsMap, { groupIds: elementInGroup.groupIds.slice(0, index), }); } diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 02bf9f721..df07960af 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -211,7 +211,7 @@ const offsetElementAfterFontResize = ( if (isBoundToContainer(nextElement) || !nextElement.autoResize) { return nextElement; } - return scene.mutate(nextElement, { + return scene.mutateElement(nextElement, { x: prevElement.textAlign === "left" ? prevElement.x @@ -915,7 +915,7 @@ export const actionChangeFontFamily = register({ if (resetContainers && container && cachedContainer) { // reset the container back to it's cached version - app.scene.mutate(container, { ...cachedContainer }); + app.scene.mutateElement(container, { ...cachedContainer }); } if (!skipFontFaceCheck) { diff --git a/packages/excalidraw/change.ts b/packages/excalidraw/change.ts index 9b2e0a56d..20610661c 100644 --- a/packages/excalidraw/change.ts +++ b/packages/excalidraw/change.ts @@ -15,7 +15,7 @@ import { } from "@excalidraw/element/binding"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { - mutateElementWith, + mutateElement, newElementWith, } from "@excalidraw/element/mutateElement"; import { @@ -1344,7 +1344,7 @@ export class ElementsChange implements Change { updates as ElementUpdate, ); } else { - affectedElement = mutateElementWith( + affectedElement = mutateElement( nextElement, nextElements, updates as ElementUpdate, diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 086c407db..c9ee92cac 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -7,7 +7,7 @@ import { isPromiseLike, } from "@excalidraw/common"; -import { mutateElementWith } from "@excalidraw/element/mutateElement"; +import { mutateElement } from "@excalidraw/element/mutateElement"; import { deepCopyElement } from "@excalidraw/element/duplicate"; import { isFrameLikeElement, @@ -172,7 +172,7 @@ export const serializeAsClipboardJSON = ({ !framesToCopy.has(getContainingFrame(element, elementsMap)!) ) { const copiedElement = deepCopyElement(element); - mutateElementWith(copiedElement, elementsMap, { + mutateElement(copiedElement, elementsMap, { frameId: null, }); return copiedElement; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 53d81d3ed..c660234cb 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -301,6 +301,8 @@ import { isNonDeletedElement } from "@excalidraw/element"; import Scene from "@excalidraw/element/Scene"; +import type { ElementUpdate } from "@excalidraw/element/mutateElement"; + import type { LocalPoint, Radians } from "@excalidraw/math"; import type { @@ -329,7 +331,7 @@ import type { ExcalidrawElbowArrowElement, } from "@excalidraw/element/types"; -import type { ValueOf } from "@excalidraw/common/utility-types"; +import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; import { actionAddToLibrary, @@ -776,6 +778,7 @@ class App extends React.Component { if (excalidrawAPI) { const api: ExcalidrawImperativeAPI = { updateScene: this.updateScene, + mutateElement: this.mutateElement, updateLibrary: this.library.updateLibrary, addFiles: this.addFiles, resetScene: this.resetScene, @@ -1403,7 +1406,7 @@ class App extends React.Component { private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => { if (frame) { - this.scene.mutate(frame, { name: frame.name?.trim() || null }); + this.scene.mutateElement(frame, { name: frame.name?.trim() || null }); } this.setState({ editingFrame: null }); }; @@ -1460,7 +1463,7 @@ class App extends React.Component { autoFocus value={frameNameInEdit} onChange={(e) => { - this.scene.mutate(f, { + this.scene.mutateElement(f, { name: e.target.value, }); }} @@ -1951,20 +1954,20 @@ class App extends React.Component { // state only. // Thus reset so that we prefer local cache (if there was some // generationData set previously) - this.scene.mutate( + this.scene.mutateElement( frameElement, { customData: { generationData: undefined }, }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ); } else { - this.scene.mutate( + this.scene.mutateElement( frameElement, { customData: { generationData: data }, }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ); } this.magicGenerations.set(frameElement.id, data); @@ -2136,7 +2139,7 @@ class App extends React.Component { this.scene.insertElement(frame); for (const child of selectedElements) { - this.scene.mutate(child, { frameId: frame.id }); + this.scene.mutateElement(child, { frameId: frame.id }); } this.setState({ @@ -3456,10 +3459,10 @@ class App extends React.Component { } // hack to reset the `y` coord because we vertically center during // insertImageElement - this.scene.mutate( + this.scene.mutateElement( initializedImageElement, { y }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ); y = imageElement.y + imageElement.height + 25; @@ -4014,6 +4017,17 @@ class App extends React.Component { }, ); + public mutateElement = >( + element: TElement, + updates: ElementUpdate, + informMutation = true, + ) => { + return this.scene.mutateElement(element, updates, { + informMutation, + isDragging: false, + }); + }; + private triggerRender = ( /** force always re-renders canvas even if no change */ force?: boolean, @@ -4426,13 +4440,13 @@ class App extends React.Component { } selectedElements.forEach((element) => { - this.scene.mutate( + this.scene.mutateElement( element, { x: element.x + offsetX, y: element.y + offsetY, }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ); updateBoundElements(element, this.scene, { @@ -5335,7 +5349,7 @@ class App extends React.Component { const minHeight = getApproxMinLineHeight(fontSize, lineHeight); const newHeight = Math.max(container.height, minHeight); const newWidth = Math.max(container.width, minWidth); - this.scene.mutate(container, { + this.scene.mutateElement(container, { height: newHeight, width: newWidth, }); @@ -5389,7 +5403,7 @@ class App extends React.Component { }); if (!existingTextElement && shouldBindToContainer && container) { - this.scene.mutate(container, { + this.scene.mutateElement(container, { boundElements: (container.boundElements || []).concat({ type: "text", id: element.id, @@ -5940,7 +5954,7 @@ class App extends React.Component { lastPoint, ) >= LINE_CONFIRM_THRESHOLD ) { - this.scene.mutate( + this.scene.mutateElement( multiElement, { points: [ @@ -5948,7 +5962,7 @@ class App extends React.Component { pointFrom(scenePointerX - rx, scenePointerY - ry), ], }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ); } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); @@ -5964,12 +5978,12 @@ class App extends React.Component { ) < LINE_CONFIRM_THRESHOLD ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); - this.scene.mutate( + this.scene.mutateElement( multiElement, { points: points.slice(0, -1), }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ); } else { const [gridX, gridY] = getGridPoint( @@ -6003,7 +6017,7 @@ class App extends React.Component { } // update last uncommitted point - this.scene.mutate( + this.scene.mutateElement( multiElement, { points: [ @@ -6688,7 +6702,7 @@ class App extends React.Component { const frame = this.getTopLayerFrameAtSceneCoords({ x, y }); - this.scene.mutate(pendingImageElement, { + this.scene.mutateElement(pendingImageElement, { x, y, frameId: frame ? frame.id : null, @@ -7742,7 +7756,7 @@ class App extends React.Component { multiElement.type === "line" && isPathALoop(multiElement.points, this.state.zoom.value) ) { - this.scene.mutate(multiElement, { + this.scene.mutateElement(multiElement, { lastCommittedPoint: multiElement.points[multiElement.points.length - 1], }); @@ -7753,7 +7767,7 @@ class App extends React.Component { // Elbow arrows cannot be created by putting down points // only the start and end points can be defined if (isElbowArrow(multiElement) && multiElement.points.length > 1) { - this.scene.mutate(multiElement, { + this.scene.mutateElement(multiElement, { lastCommittedPoint: multiElement.points[multiElement.points.length - 1], }); @@ -7790,7 +7804,7 @@ class App extends React.Component { })); // clicking outside commit zone → update reference for last committed // point - this.scene.mutate(multiElement, { + this.scene.mutateElement(multiElement, { lastCommittedPoint: multiElement.points[multiElement.points.length - 1], }); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); @@ -7876,7 +7890,7 @@ class App extends React.Component { ), }; }); - this.scene.mutate(element, { + this.scene.mutateElement(element, { points: [...element.points, pointFrom(0, 0)], }); const boundElement = getHoveredElementForBinding( @@ -8458,7 +8472,7 @@ class App extends React.Component { ), }; - this.scene.mutate(croppingElement, { + this.scene.mutateElement(croppingElement, { crop: nextCrop, }); @@ -8655,7 +8669,7 @@ class App extends React.Component { ? newElement.pressures : [...newElement.pressures, event.pressure]; - this.scene.mutate( + this.scene.mutateElement( newElement, { points: [...points, pointFrom(dx, dy)], @@ -8663,6 +8677,7 @@ class App extends React.Component { }, { informMutation: false, + isDragging: false, }, ); @@ -8686,23 +8701,23 @@ class App extends React.Component { } if (points.length === 1) { - this.scene.mutate( + this.scene.mutateElement( newElement, { points: [...points, pointFrom(dx, dy)], }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ); } else if ( points.length === 2 || (points.length > 1 && isElbowArrow(newElement)) ) { - this.scene.mutate( + this.scene.mutateElement( newElement, { points: [...points.slice(0, -1), pointFrom(dx, dy)], }, - { isDragging: true }, + { isDragging: true, informMutation: false }, ); } @@ -8916,7 +8931,7 @@ class App extends React.Component { .map((e) => elementsMap.get(e.id)) .filter((e) => isElbowArrow(e)) .forEach((e) => { - !!e && this.scene.mutate(e, {}); + !!e && this.scene.mutateElement(e, {}); }); } } @@ -8952,7 +8967,10 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), ); if (element) { - this.scene.mutate(element as ExcalidrawElbowArrowElement, {}); + this.scene.mutateElement( + element as ExcalidrawElbowArrowElement, + {}, + ); } } @@ -9047,7 +9065,7 @@ class App extends React.Component { ? [] : [...newElement.pressures, childEvent.pressure]; - this.scene.mutate(newElement, { + this.scene.mutateElement(newElement, { points: [...points, pointFrom(dx, dy)], pressures, lastCommittedPoint: pointFrom(dx, dy), @@ -9094,7 +9112,7 @@ class App extends React.Component { ); if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) { - this.scene.mutate( + this.scene.mutateElement( newElement, { points: [ @@ -9105,7 +9123,7 @@ class App extends React.Component { ), ], }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ); this.setState({ @@ -9165,7 +9183,7 @@ class App extends React.Component { ); if (newElement.width < minWidth) { - this.scene.mutate(newElement, { + this.scene.mutateElement(newElement, { autoResize: true, }); } @@ -9215,9 +9233,14 @@ class App extends React.Component { } if (newElement) { - this.scene.mutate(newElement, getNormalizedDimensions(newElement), { - informMutation: false, - }); + this.scene.mutateElement( + newElement, + getNormalizedDimensions(newElement), + { + informMutation: false, + isDragging: false, + }, + ); // the above does not guarantee the scene to be rendered again, hence the trigger below this.scene.triggerUpdate(); } @@ -9249,7 +9272,7 @@ class App extends React.Component { ) { // remove the linear element from all groups // before removing it from the frame as well - this.scene.mutate(linearElement, { + this.scene.mutateElement(linearElement, { groupIds: [], }); @@ -9278,12 +9301,12 @@ class App extends React.Component { this.state.editingGroupId!, ); - this.scene.mutate( + this.scene.mutateElement( element, { groupIds: element.groupIds.slice(0, index), }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ); } @@ -9295,12 +9318,12 @@ class App extends React.Component { element.groupIds[element.groupIds.length - 1], ).length < 2 ) { - this.scene.mutate( + this.scene.mutateElement( element, { groupIds: [], }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ); } }); @@ -9870,12 +9893,12 @@ class App extends React.Component { const dataURL = this.files[fileId]?.dataURL || (await getDataURL(imageFile)); - const imageElement = this.scene.mutate( + const imageElement = this.scene.mutateElement( _imageElement, { fileId, }, - { informMutation: false }, + { informMutation: false, isDragging: false }, ) as NonDeleted; return new Promise>( @@ -9941,7 +9964,7 @@ class App extends React.Component { showCursorImagePreview, }); } catch (error: any) { - this.scene.mutate(imageElement, { + this.scene.mutateElement(imageElement, { isDeleted: true, }); this.actionManager.executeAction(actionFinalize); @@ -10087,7 +10110,7 @@ class App extends React.Component { imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value ) { const placeholderSize = 100 / this.state.zoom.value; - this.scene.mutate(imageElement, { + this.scene.mutateElement(imageElement, { x: imageElement.x - placeholderSize / 2, y: imageElement.y - placeholderSize / 2, width: placeholderSize, @@ -10121,7 +10144,7 @@ class App extends React.Component { const x = imageElement.x + imageElement.width / 2 - width / 2; const y = imageElement.y + imageElement.height / 2 - height / 2; - this.scene.mutate(imageElement, { + this.scene.mutateElement(imageElement, { x, y, width, @@ -10742,7 +10765,7 @@ class App extends React.Component { transformHandleType, ); - this.scene.mutate( + this.scene.mutateElement( croppingElement, cropElement( croppingElement, diff --git a/packages/excalidraw/components/ElementLinkDialog.tsx b/packages/excalidraw/components/ElementLinkDialog.tsx index 0c5464e05..e9766f3d7 100644 --- a/packages/excalidraw/components/ElementLinkDialog.tsx +++ b/packages/excalidraw/components/ElementLinkDialog.tsx @@ -71,7 +71,7 @@ const ElementLinkDialog = ({ if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) { const elementToLink = elementsMap.get(sourceElementId); elementToLink && - scene.mutate(elementToLink, { + scene.mutateElement(elementToLink, { link: nextLink, }); } @@ -79,7 +79,7 @@ const ElementLinkDialog = ({ if (!nextLink && linkEdited && sourceElementId) { const elementToLink = elementsMap.get(sourceElementId); elementToLink && - scene.mutate(elementToLink, { + scene.mutateElement(elementToLink, { link: null, }); } diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index c3720eeb5..b2e0d446f 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -10,7 +10,7 @@ import { isShallowEqual, } from "@excalidraw/common"; -import { mutateElementWith } from "@excalidraw/element/mutateElement"; +import { mutateElement } from "@excalidraw/element/mutateElement"; import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions"; @@ -446,7 +446,7 @@ const LayerUI = ({ if (selectedElements.length) { for (const element of selectedElements) { - mutateElementWith(element, arrayToMap(elements), { + mutateElement(element, arrayToMap(elements), { [altKey && eyeDropperState.swapPreviewOnAlt ? colorPickerType === "elementBackground" ? "strokeColor" diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index 3873516e0..d0cb187da 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -43,14 +43,14 @@ const handleDegreeChange: DragInputCallbackType = ({ if (nextValue !== undefined) { const nextAngle = degreesToRadians(nextValue as Degrees); - scene.mutate(latestElement, { + scene.mutateElement(latestElement, { angle: nextAngle, }); updateBindings(latestElement, scene); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { - scene.mutate(boundTextElement, { angle: nextAngle }); + scene.mutateElement(boundTextElement, { angle: nextAngle }); } return; @@ -69,14 +69,14 @@ const handleDegreeChange: DragInputCallbackType = ({ const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees); - scene.mutate(latestElement, { + scene.mutateElement(latestElement, { angle: nextAngle, }); updateBindings(latestElement, scene); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { - scene.mutate(boundTextElement, { angle: nextAngle }); + scene.mutateElement(boundTextElement, { angle: nextAngle }); } } }; diff --git a/packages/excalidraw/components/Stats/Dimension.tsx b/packages/excalidraw/components/Stats/Dimension.tsx index d974bd8de..c838b581f 100644 --- a/packages/excalidraw/components/Stats/Dimension.tsx +++ b/packages/excalidraw/components/Stats/Dimension.tsx @@ -113,7 +113,7 @@ const handleDimensionChange: DragInputCallbackType< }; } - scene.mutate(element, { + scene.mutateElement(element, { crop: nextCrop, width: nextCrop.width / (crop.naturalWidth / uncroppedWidth), height: nextCrop.height / (crop.naturalHeight / uncroppedHeight), @@ -144,7 +144,7 @@ const handleDimensionChange: DragInputCallbackType< height: nextCropHeight, }; - scene.mutate(element, { + scene.mutateElement(element, { crop: nextCrop, width: nextCrop.width / (crop.naturalWidth / uncroppedWidth), height: nextCrop.height / (crop.naturalHeight / uncroppedHeight), diff --git a/packages/excalidraw/components/Stats/FontSize.tsx b/packages/excalidraw/components/Stats/FontSize.tsx index 4670fe74f..635f2cd5a 100644 --- a/packages/excalidraw/components/Stats/FontSize.tsx +++ b/packages/excalidraw/components/Stats/FontSize.tsx @@ -68,7 +68,7 @@ const handleFontSizeChange: DragInputCallbackType< } if (nextFontSize) { - scene.mutate(latestElement, { + scene.mutateElement(latestElement, { fontSize: nextFontSize, }); redrawTextBoundingBox( diff --git a/packages/excalidraw/components/Stats/MultiAngle.tsx b/packages/excalidraw/components/Stats/MultiAngle.tsx index 52fee522a..a22a01147 100644 --- a/packages/excalidraw/components/Stats/MultiAngle.tsx +++ b/packages/excalidraw/components/Stats/MultiAngle.tsx @@ -53,13 +53,13 @@ const handleDegreeChange: DragInputCallbackType< if (!element) { continue; } - scene.mutate(element, { + scene.mutateElement(element, { angle: nextAngle, }); const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement && !isArrowElement(element)) { - scene.mutate(boundTextElement, { angle: nextAngle }); + scene.mutateElement(boundTextElement, { angle: nextAngle }); } } @@ -87,13 +87,13 @@ const handleDegreeChange: DragInputCallbackType< const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees); - scene.mutate(latestElement, { + scene.mutateElement(latestElement, { angle: nextAngle, }); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { - scene.mutate(boundTextElement, { angle: nextAngle }); + scene.mutateElement(boundTextElement, { angle: nextAngle }); } } scene.triggerUpdate(); diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 1b9b5b750..ddac0ee3f 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -81,7 +81,7 @@ const resizeElementInGroup = ( const elementsMap = scene.getNonDeletedElementsMap(); const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); - scene.mutate(latestElement, updates); + scene.mutateElement(latestElement, updates); const boundTextElement = getBoundTextElement( origElement, @@ -94,7 +94,7 @@ const resizeElementInGroup = ( }); const latestBoundTextElement = elementsMap.get(boundTextElement.id); if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { - scene.mutate(latestBoundTextElement, { + scene.mutateElement(latestBoundTextElement, { fontSize: newFontSize, }); handleBindTextResize( diff --git a/packages/excalidraw/components/Stats/MultiFontSize.tsx b/packages/excalidraw/components/Stats/MultiFontSize.tsx index 1f3daa3ea..075016ad1 100644 --- a/packages/excalidraw/components/Stats/MultiFontSize.tsx +++ b/packages/excalidraw/components/Stats/MultiFontSize.tsx @@ -84,7 +84,7 @@ const handleFontSizeChange: DragInputCallbackType< nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); for (const textElement of latestTextElements) { - scene.mutate(textElement, { + scene.mutateElement(textElement, { fontSize: nextFontSize, }); @@ -112,7 +112,7 @@ const handleFontSizeChange: DragInputCallbackType< if (shouldChangeByStepSize) { nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); } - scene.mutate(latestElement, { + scene.mutateElement(latestElement, { fontSize: nextFontSize, }); diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index d4e0cb9d0..523538581 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -100,7 +100,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ }; } - scene.mutate(element, { + scene.mutateElement(element, { crop: nextCrop, }); @@ -118,7 +118,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height), }; - scene.mutate(element, { + scene.mutateElement(element, { crop: nextCrop, }); diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index d842678b0..cfb2b4ee4 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -478,7 +478,7 @@ describe("stats for a non-generic element", () => { containerId: container.id, fontSize: 20, }); - h.app.scene.mutate(container, { + h.app.scene.mutateElement(container, { boundElements: [{ type: "text", id: text.id }], }); API.setElements([container, text]); diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index 3905a012e..79e7ed18b 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -146,13 +146,13 @@ export const moveElement = ( -originalElement.angle as Radians, ); - scene.mutate( + scene.mutateElement( latestElement, { x, y, }, - { informMutation: shouldInformMutation }, + { informMutation: shouldInformMutation, isDragging: false }, ); updateBindings(latestElement, scene); @@ -163,13 +163,13 @@ export const moveElement = ( if (boundTextElement) { const latestBoundTextElement = elementsMap.get(boundTextElement.id); latestBoundTextElement && - scene.mutate( + scene.mutateElement( latestBoundTextElement, { x: boundTextElement.x + changeInX, y: boundTextElement.y + changeInY, }, - { informMutation: shouldInformMutation }, + { informMutation: shouldInformMutation, isDragging: false }, ); } }; diff --git a/packages/excalidraw/components/hyperlink/Hyperlink.tsx b/packages/excalidraw/components/hyperlink/Hyperlink.tsx index d07bff005..65883017e 100644 --- a/packages/excalidraw/components/hyperlink/Hyperlink.tsx +++ b/packages/excalidraw/components/hyperlink/Hyperlink.tsx @@ -115,7 +115,7 @@ export const Hyperlink = ({ setAppState({ activeEmbeddable: null }); } if (!link) { - scene.mutate(element, { + scene.mutateElement(element, { link: null, }); updateEmbedValidationStatus(element, false); @@ -127,7 +127,7 @@ export const Hyperlink = ({ setToast({ message: t("toast.unableToEmbed"), closable: true }); } element.link && embeddableLinkCache.set(element.id, element.link); - scene.mutate(element, { + scene.mutateElement(element, { link, }); updateEmbedValidationStatus(element, false); @@ -145,7 +145,7 @@ export const Hyperlink = ({ : 1; const hasLinkChanged = embeddableLinkCache.get(element.id) !== element.link; - scene.mutate(element, { + scene.mutateElement(element, { ...(hasLinkChanged ? { width: @@ -170,7 +170,7 @@ export const Hyperlink = ({ } } } else { - scene.mutate(element, { link }); + scene.mutateElement(element, { link }); } }, [ element, @@ -231,7 +231,7 @@ export const Hyperlink = ({ const handleRemove = useCallback(() => { trackEvent("hyperlink", "delete"); - scene.mutate(element, { link: null }); + scene.mutateElement(element, { link: null }); setAppState({ showHyperlinkPopup: false }); }, [setAppState, element, scene]); diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 221977fa8..5ea746754 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -259,7 +259,6 @@ export { } from "@excalidraw/common"; export { - mutateElementWith, mutateElement, newElementWith, bumpVersion, diff --git a/packages/excalidraw/tests/elementLocking.test.tsx b/packages/excalidraw/tests/elementLocking.test.tsx index a687816d0..8c4f34874 100644 --- a/packages/excalidraw/tests/elementLocking.test.tsx +++ b/packages/excalidraw/tests/elementLocking.test.tsx @@ -296,7 +296,7 @@ describe("element locking", () => { height: textSize, containerId: container.id, }); - h.app.scene.mutate(container, { + h.app.scene.mutateElement(container, { boundElements: [{ id: text.id, type: "text" }], }); @@ -337,7 +337,7 @@ describe("element locking", () => { containerId: container.id, locked: true, }); - h.app.scene.mutate(container, { + h.app.scene.mutateElement(container, { boundElements: [{ id: text.id, type: "text" }], }); API.setElements([container, text]); @@ -371,7 +371,7 @@ describe("element locking", () => { containerId: container.id, locked: true, }); - h.app.scene.mutate(container, { + h.app.scene.mutateElement(container, { boundElements: [{ id: text.id, type: "text" }], }); API.setElements([container, text]); diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index 52df18f08..2c7ccd0e4 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -99,10 +99,10 @@ export class API { // eslint-disable-next-line prettier/prettier static updateElement = ( - ...args: Parameters> + ...args: Parameters> ) => { act(() => { - h.app.scene.mutate(...args); + h.app.scene.mutateElement(...args); }); }; @@ -418,7 +418,7 @@ export class API { }); - h.app.scene.mutate( + h.app.scene.mutateElement( rectangle, { boundElements: [{ type: "text", id: text.id }], @@ -452,7 +452,7 @@ export class API { : opts?.label?.frameId ?? null, }); - h.app.scene.mutate( + h.app.scene.mutateElement( arrow, { boundElements: [{ type: "text", id: text.id }], diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 79d343126..377c5fa8c 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -518,7 +518,7 @@ export class UI { if (angle !== 0) { act(() => { - h.app.scene.mutate(origElement, { angle }); + h.app.scene.mutateElement(origElement, { angle }); }); } diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index dcd022b89..dadfeb9b4 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -118,7 +118,7 @@ describe("Test Linear Elements", () => { ], roundness, }); - h.app.scene.mutate(line, { points: line.points }); + h.app.scene.mutateElement(line, { points: line.points }); API.setElements([line]); mouse.clickAt(p1[0], p1[1]); return line; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 717993b43..f81b02d43 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -779,6 +779,7 @@ export type UnsubscribeCallback = () => void; export interface ExcalidrawImperativeAPI { updateScene: InstanceType["updateScene"]; + mutateElement: InstanceType["mutateElement"]; updateLibrary: InstanceType["updateLibrary"]; resetScene: InstanceType["resetScene"]; getSceneElementsIncludingDeleted: InstanceType< diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index a1511a32e..a7ddf659e 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -199,7 +199,7 @@ export const textWysiwyg = ({ container.type, ); - app.scene.mutate(container, { height: targetContainerHeight }); + app.scene.mutateElement(container, { height: targetContainerHeight }); return; } else if ( // autoshrink container height until original container height @@ -212,7 +212,7 @@ export const textWysiwyg = ({ height, container.type, ); - app.scene.mutate(container, { height: targetContainerHeight }); + app.scene.mutateElement(container, { height: targetContainerHeight }); } else { const { y } = computeBoundTextPosition( container, @@ -285,7 +285,7 @@ export const textWysiwyg = ({ editable.style.fontFamily = getFontFamilyString(updatedTextElement); } - app.scene.mutate(updatedTextElement, { x: coordX, y: coordY }); + app.scene.mutateElement(updatedTextElement, { x: coordX, y: coordY }); } }; @@ -557,7 +557,7 @@ export const textWysiwyg = ({ if (editable.value.trim()) { const boundTextElementId = getBoundTextElementId(container); if (!boundTextElementId || boundTextElementId !== element.id) { - app.scene.mutate(container, { + app.scene.mutateElement(container, { boundElements: (container.boundElements || []).concat({ type: "text", id: element.id, @@ -568,7 +568,7 @@ export const textWysiwyg = ({ bumpVersion(container); } } else { - app.scene.mutate(container, { + app.scene.mutateElement(container, { boundElements: container.boundElements?.filter( (ele) => !isTextElement(