move logic from mutate to shape switch

This commit is contained in:
Ryan Di 2025-04-23 22:39:56 +10:00
commit c83cc025df
68 changed files with 1024 additions and 1157 deletions

View file

@ -50,14 +50,8 @@ const alignSelectedElements = (
alignment: Alignment,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const elementsMap = arrayToMap(elements);
const updatedElements = alignElements(
selectedElements,
elementsMap,
alignment,
app.scene,
);
const updatedElements = alignElements(selectedElements, alignment, app.scene);
const updatedElementsMap = arrayToMap(updatedElements);

View file

@ -27,7 +27,6 @@ import {
isUsingAdaptiveRadius,
} from "@excalidraw/element/typeChecks";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { measureText } from "@excalidraw/element/textMeasurements";
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
@ -43,12 +42,12 @@ import type {
import type { Mutable } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
import type { Radians } from "../../math/src";
import type { AppState } from "../types";
export const actionUnbindText = register({
@ -80,7 +79,7 @@ export const actionUnbindText = register({
boundTextElement,
elementsMap,
);
mutateElement(boundTextElement as ExcalidrawTextElement, {
app.scene.mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
@ -88,7 +87,7 @@ export const actionUnbindText = register({
x,
y,
});
mutateElement(element, {
app.scene.mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
@ -153,25 +152,21 @@ export const actionBindText = register({
textElement = selectedElements[1] as ExcalidrawTextElement;
container = selectedElements[0] as ExcalidrawTextContainer;
}
mutateElement(textElement, {
app.scene.mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
});
mutateElement(container, {
app.scene.mutateElement(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: textElement.id,
}),
});
const originalContainerHeight = container.height;
redrawTextBoundingBox(
textElement,
container,
app.scene.getNonDeletedElementsMap(),
);
redrawTextBoundingBox(textElement, container, app.scene);
// overwritting the cache with original container height so
// it can be restored when unbind
updateOriginalContainerCache(container.id, originalContainerHeight);
@ -301,27 +296,23 @@ export const actionWrapTextInContainer = register({
}
if (startBinding || endBinding) {
mutateElement(ele, { startBinding, endBinding }, false);
app.scene.mutateElement(ele, {
startBinding,
endBinding,
});
}
});
}
mutateElement(
textElement,
{
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
},
false,
);
redrawTextBoundingBox(
textElement,
container,
app.scene.getNonDeletedElementsMap(),
);
app.scene.mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
});
redrawTextBoundingBox(textElement, container, app.scene);
updatedElements = pushContainerBelowText(
[...updatedElements, container],

View file

@ -1,6 +1,6 @@
import React from "react";
import { Excalidraw, mutateElement } from "../index";
import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api";
import { act, assertElements, render } from "../tests/test-utils";
@ -56,7 +56,7 @@ describe("deleting selected elements when frame selected should keep children +
frameId: f1.id,
});
mutateElement(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,
});
mutateElement(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,
});
mutateElement(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,
});
mutateElement(a1, {
h.app.scene.mutateElement(a1, {
boundElements: [{ type: "text", id: t1.id }],
});

View file

@ -3,10 +3,7 @@ import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { getContainerElement } from "@excalidraw/element/textElement";
import {
isBoundToContainer,
@ -94,7 +91,7 @@ const deleteSelectedElements = (
el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
app.scene.mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
@ -102,7 +99,6 @@ const deleteSelectedElements = (
endBinding:
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
});
mutateElement(bound, { points: bound.points });
}
});
}
@ -261,7 +257,11 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(element, selectedPointsIndices);
LinearElementEditor.deletePoints(
element,
app.scene,
selectedPointsIndices,
);
return {
elements,

View file

@ -43,7 +43,7 @@ export const actionDuplicateSelection = register({
try {
const newAppState = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene.getNonDeletedElementsMap(),
app.scene,
);
return {

View file

@ -5,7 +5,7 @@ import {
bindOrUnbindLinearElement,
} from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { mutateElement } from "@excalidraw/element/mutateElement";
import {
isBindingElement,
isLinearElement,
@ -46,7 +46,6 @@ export const actionFinalize = register({
element,
startBindingElement,
endBindingElement,
elementsMap,
scene,
);
}
@ -72,7 +71,11 @@ export const actionFinalize = register({
scene.getElement(appState.pendingImageElementId);
if (pendingImageElement) {
mutateElement(pendingImageElement, { isDeleted: true }, false);
scene.mutateElement(
pendingImageElement,
{ isDeleted: true },
{ informMutation: false, isDragging: false },
);
}
if (window.document.activeElement instanceof HTMLElement) {
@ -96,7 +99,7 @@ export const actionFinalize = register({
!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint
) {
mutateElement(multiPointElement, {
scene.mutateElement(multiPointElement, {
points: multiPointElement.points.slice(0, -1),
});
}
@ -120,7 +123,7 @@ export const actionFinalize = register({
if (isLoop) {
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0];
mutateElement(multiPointElement, {
scene.mutateElement(multiPointElement, {
points: linePoints.map((p, index) =>
index === linePoints.length - 1
? pointFrom(firstPoint[0], firstPoint[1])
@ -140,13 +143,7 @@ export const actionFinalize = register({
-1,
arrayToMap(elements),
);
maybeBindLinearElement(
multiPointElement,
appState,
{ x, y },
elementsMap,
elements,
);
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene);
}
}
@ -202,7 +199,10 @@ export const actionFinalize = register({
// To select the linear element when user has finished mutipoint editing
selectedLinearElement:
multiPointElement && isLinearElement(multiPointElement)
? new LinearElementEditor(multiPointElement)
? new LinearElementEditor(
multiPointElement,
arrayToMap(newElements),
)
: appState.selectedLinearElement,
pendingImageElementId: null,
},

View file

@ -4,10 +4,7 @@ import {
isBindingEnabled,
} from "@excalidraw/element/binding";
import { getCommonBoundingBox } from "@excalidraw/element/bounds";
import {
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { resizeMultipleElements } from "@excalidraw/element/resizeElements";
import {
@ -162,11 +159,9 @@ const flipElements = (
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
isBindingEnabled(appState),
[],
app.scene,
appState.zoom,
);
@ -194,13 +189,13 @@ const flipElements = (
getCommonBoundingBox(selectedElements);
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
otherElements.forEach((element) =>
mutateElement(element, {
app.scene.mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
elbowArrows.forEach((element) =>
mutateElement(element, {
app.scene.mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),

View file

@ -173,11 +173,9 @@ export const actionWrapSelectionInFrame = register({
},
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
const elementsMap = app.scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2] = getCommonBounds(
selectedElements,
app.scene.getNonDeletedElementsMap(),
);
const [x1, y1, x2, y2] = getCommonBounds(selectedElements, elementsMap);
const PADDING = 16;
const frame = newFrameElement({
x: x1 - PADDING,
@ -196,13 +194,9 @@ export const actionWrapSelectionInFrame = register({
for (const elementInGroup of elementsInGroup) {
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
mutateElement(
elementInGroup,
{
groupIds: elementInGroup.groupIds.slice(0, index),
},
false,
);
mutateElement(elementInGroup, elementsMap, {
groupIds: elementInGroup.groupIds.slice(0, index),
});
}
}

View file

@ -2,6 +2,8 @@ import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
@ -50,7 +52,7 @@ export const actionToggleLinearEditor = register({
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
? null
: new LinearElementEditor(selectedElement);
: new LinearElementEditor(selectedElement, arrayToMap(elements));
return {
appState: {
...appState,

View file

@ -34,10 +34,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { newElementWith } from "@excalidraw/element/mutateElement";
import {
getBoundTextElement,
@ -61,6 +58,7 @@ import type { LocalPoint } from "@excalidraw/math";
import type {
Arrowhead,
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawLinearElement,
@ -68,9 +66,10 @@ import type {
FontFamilyValues,
TextAlign,
VerticalAlign,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
@ -207,25 +206,22 @@ export const getFormValue = function <T extends Primitive>(
const offsetElementAfterFontResize = (
prevElement: ExcalidrawTextElement,
nextElement: ExcalidrawTextElement,
scene: Scene,
) => {
if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
return nextElement;
}
return mutateElement(
nextElement,
{
x:
prevElement.textAlign === "left"
? prevElement.x
: prevElement.x +
(prevElement.width - nextElement.width) /
(prevElement.textAlign === "center" ? 2 : 1),
// centering vertically is non-standard, but for Excalidraw I think
// it makes sense
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
},
false,
);
return scene.mutateElement(nextElement, {
x:
prevElement.textAlign === "left"
? prevElement.x
: prevElement.x +
(prevElement.width - nextElement.width) /
(prevElement.textAlign === "center" ? 2 : 1),
// centering vertically is non-standard, but for Excalidraw I think
// it makes sense
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
});
};
const changeFontSize = (
@ -251,10 +247,14 @@ const changeFontSize = (
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
app.scene,
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
newElement = offsetElementAfterFontResize(
oldElement,
newElement,
app.scene,
);
return newElement;
}
@ -264,15 +264,11 @@ const changeFontSize = (
);
// Update arrow elements after text elements have been updated
const updatedElementsMap = arrayToMap(updatedElements);
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).forEach((element) => {
if (isTextElement(element)) {
updateBoundElements(
element,
updatedElementsMap as NonDeletedSceneElementsMap,
);
updateBoundElements(element, app.scene);
}
});
@ -778,7 +774,7 @@ type ChangeFontFamilyData = Partial<
>
> & {
/** cache of selected & editing elements populated on opened popup */
cachedElements?: Map<string, ExcalidrawElement>;
cachedElements?: ElementsMap;
/** flag to reset all elements to their cached versions */
resetAll?: true;
/** flag to reset all containers to their cached versions */
@ -919,7 +915,7 @@ export const actionChangeFontFamily = register({
if (resetContainers && container && cachedContainer) {
// reset the container back to it's cached version
mutateElement(container, { ...cachedContainer }, false);
app.scene.mutateElement(container, { ...cachedContainer });
}
if (!skipFontFaceCheck) {
@ -950,12 +946,7 @@ export const actionChangeFontFamily = register({
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw
redrawTextBoundingBox(
element,
container,
app.scene.getNonDeletedElementsMap(),
false,
);
redrawTextBoundingBox(element, container, app.scene);
}
} else {
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
@ -972,8 +963,7 @@ export const actionChangeFontFamily = register({
redrawTextBoundingBox(
latestElement as ExcalidrawTextElement,
latestContainer,
app.scene.getNonDeletedElementsMap(),
false,
app.scene,
);
}
}
@ -987,7 +977,7 @@ export const actionChangeFontFamily = register({
return result;
},
PanelComponent: ({ elements, appState, app, updateData }) => {
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
const cachedElementsRef = useRef<ElementsMap>(new Map());
const prevSelectedFontFamilyRef = useRef<number | null>(null);
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
@ -996,7 +986,7 @@ export const actionChangeFontFamily = register({
const selectedFontFamily = useMemo(() => {
const getFontFamily = (
elementsArray: readonly ExcalidrawElement[],
elementsMap: Map<string, ExcalidrawElement>,
elementsMap: ElementsMap,
) =>
getFormValue(
elementsArray,
@ -1179,7 +1169,7 @@ export const actionChangeTextAlign = register({
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
app.scene,
);
return newElement;
}
@ -1270,7 +1260,7 @@ export const actionChangeVerticalAlign = register({
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
app.scene,
);
return newElement;
}
@ -1670,10 +1660,10 @@ export const actionChangeArrowType = register({
newElement,
startHoveredElement,
"start",
elementsMap,
app.scene,
);
endHoveredElement &&
bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
bindLinearElement(newElement, endHoveredElement, "end", app.scene);
const startBinding =
startElement && newElement.startBinding
@ -1684,7 +1674,6 @@ export const actionChangeArrowType = register({
newElement,
startElement,
"start",
elementsMap,
),
}
: null;
@ -1697,7 +1686,6 @@ export const actionChangeArrowType = register({
newElement,
endElement,
"end",
elementsMap,
),
}
: null;
@ -1729,7 +1717,7 @@ export const actionChangeArrowType = register({
newElement.startBinding.elementId,
) as ExcalidrawBindableElement;
if (startElement) {
bindLinearElement(newElement, startElement, "start", elementsMap);
bindLinearElement(newElement, startElement, "start", app.scene);
}
}
if (newElement.endBinding) {
@ -1737,7 +1725,7 @@ export const actionChangeArrowType = register({
newElement.endBinding.elementId,
) as ExcalidrawBindableElement;
if (endElement) {
bindLinearElement(newElement, endElement, "end", elementsMap);
bindLinearElement(newElement, endElement, "end", app.scene);
}
}
}
@ -1758,6 +1746,7 @@ export const actionChangeArrowType = register({
if (selected) {
newState.selectedLinearElement = new LinearElementEditor(
selected as ExcalidrawLinearElement,
arrayToMap(elements),
);
}
}

View file

@ -2,7 +2,7 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isLinearElement, isTextElement } from "@excalidraw/element/typeChecks";
import { KEYS } from "@excalidraw/common";
import { arrayToMap, KEYS } from "@excalidraw/common";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
@ -53,7 +53,7 @@ export const actionSelectAll = register({
// single linear element selected
Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0])
? new LinearElementEditor(elements[0])
? new LinearElementEditor(elements[0], arrayToMap(elements))
: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,

View file

@ -139,11 +139,8 @@ export const actionPasteStyles = register({
element.id === newElement.containerId,
) || null;
}
redrawTextBoundingBox(
newElement,
container,
app.scene.getNonDeletedElementsMap(),
);
redrawTextBoundingBox(newElement, container, app.scene);
}
if (

View file

@ -37,6 +37,8 @@ import {
syncMovedIndices,
} from "@excalidraw/element/fractionalIndex";
import Scene from "@excalidraw/element/Scene";
import type { BindableProp, BindingProp } from "@excalidraw/element/binding";
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
@ -490,6 +492,7 @@ export class AppStateChange implements Change<AppState> {
nextElements.get(
selectedLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
)
: null;
@ -499,6 +502,7 @@ export class AppStateChange implements Change<AppState> {
nextElements.get(
editingLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
)
: null;
@ -1132,9 +1136,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
}
try {
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
// the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
nextElements = ElementsChange.reorderElements(
@ -1143,8 +1144,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
flags,
);
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements);
ElementsChange.redrawTextBoundingBoxes(tempScene, changedElements);
// Need ordered nextElements to avoid z-index binding issues
ElementsChange.redrawBoundArrows(nextElements, changedElements);
ElementsChange.redrawBoundArrows(tempScene, changedElements);
} catch (e) {
console.error(
`Couldn't mutate elements after applying elements change`,
@ -1337,8 +1344,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
} else {
affectedElement = mutateElement(
nextElement,
nextElements,
updates as ElementUpdate<OrderedExcalidrawElement>,
);
) as OrderedExcalidrawElement;
}
nextAffectedElements.set(affectedElement.id, affectedElement);
@ -1456,9 +1464,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
}
private static redrawTextBoundingBoxes(
elements: SceneElementsMap,
scene: Scene,
changed: Map<string, OrderedExcalidrawElement>,
) {
const elements = scene.getNonDeletedElementsMap();
const boxesToRedraw = new Map<
string,
{ container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement }
@ -1498,17 +1507,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
continue;
}
redrawTextBoundingBox(boundText, container, elements, false);
redrawTextBoundingBox(boundText, container, scene);
}
}
private static redrawBoundArrows(
elements: SceneElementsMap,
scene: Scene,
changed: Map<string, OrderedExcalidrawElement>,
) {
for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements, {
updateBoundElements(element, scene, {
changedElements: changed,
});
}

View file

@ -172,7 +172,7 @@ export const serializeAsClipboardJSON = ({
!framesToCopy.has(getContainingFrame(element, elementsMap)!)
) {
const copiedElement = deepCopyElement(element);
mutateElement(copiedElement, {
mutateElement(copiedElement, elementsMap, {
frameId: null,
});
return copiedElement;

View file

@ -122,10 +122,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { newElementWith } from "@excalidraw/element/mutateElement";
import {
newFrameElement,
@ -303,6 +300,10 @@ import {
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 {
@ -328,9 +329,10 @@ import type {
MagicGenerationData,
ExcalidrawNonSelectionElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
} from "@excalidraw/element/types";
import type { ValueOf } from "@excalidraw/common/utility-types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
import {
actionAddToLibrary,
@ -403,7 +405,6 @@ import {
hasBackground,
isSomeElementSelected,
} from "../scene";
import Scene from "../scene/Scene";
import { getStateForZoom } from "../scene/zoom";
import {
dataURLToFile,
@ -765,6 +766,7 @@ class App extends React.Component<AppProps, AppState> {
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene,
mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles,
resetScene: this.resetScene,
@ -1392,7 +1394,7 @@ class App extends React.Component<AppProps, AppState> {
private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => {
if (frame) {
mutateElement(frame, { name: frame.name?.trim() || null });
this.scene.mutateElement(frame, { name: frame.name?.trim() || null });
}
this.setState({ editingFrame: null });
};
@ -1449,7 +1451,7 @@ class App extends React.Component<AppProps, AppState> {
autoFocus
value={frameNameInEdit}
onChange={(e) => {
mutateElement(f, {
this.scene.mutateElement(f, {
name: e.target.value,
});
}}
@ -1675,7 +1677,7 @@ class App extends React.Component<AppProps, AppState> {
<Hyperlink
key={firstSelectedElement.id}
element={firstSelectedElement}
elementsMap={allElementsMap}
scene={this.scene}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
setToast={this.setToast}
@ -1944,16 +1946,20 @@ class App extends React.Component<AppProps, AppState> {
// state only.
// Thus reset so that we prefer local cache (if there was some
// generationData set previously)
mutateElement(
this.scene.mutateElement(
frameElement,
{ customData: { generationData: undefined } },
false,
{
customData: { generationData: undefined },
},
{ informMutation: false, isDragging: false },
);
} else {
mutateElement(
this.scene.mutateElement(
frameElement,
{ customData: { generationData: data } },
false,
{
customData: { generationData: data },
},
{ informMutation: false, isDragging: false },
);
}
this.magicGenerations.set(frameElement.id, data);
@ -2125,7 +2131,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.insertElement(frame);
for (const child of selectedElements) {
mutateElement(child, { frameId: frame.id });
this.scene.mutateElement(child, { frameId: frame.id });
}
this.setState({
@ -2924,8 +2930,7 @@ class App extends React.Component<AppProps, AppState> {
nonDeletedElementsMap,
),
),
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.scene,
);
}
@ -3323,11 +3328,7 @@ class App extends React.Component<AppProps, AppState> {
newElement,
this.scene.getElementsMapIncludingDeleted(),
);
redrawTextBoundingBox(
newElement,
container,
this.scene.getElementsMapIncludingDeleted(),
);
redrawTextBoundingBox(newElement, container, this.scene);
}
});
@ -3450,7 +3451,11 @@ class App extends React.Component<AppProps, AppState> {
}
// hack to reset the `y` coord because we vertically center during
// insertImageElement
mutateElement(initializedImageElement, { y }, false);
this.scene.mutateElement(
initializedImageElement,
{ y },
{ informMutation: false, isDragging: false },
);
y = imageElement.y + imageElement.height + 25;
@ -4004,6 +4009,17 @@ class App extends React.Component<AppProps, AppState> {
},
);
public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
informMutation = true,
) => {
return this.scene.mutateElement(element, updates, {
informMutation,
isDragging: false,
});
};
private triggerRender = (
/** force always re-renders canvas even if no change */
force?: boolean,
@ -4202,9 +4218,9 @@ class App extends React.Component<AppProps, AppState> {
) {
this.flowChartCreator.createNodes(
selectedElements[0],
this.scene.getNonDeletedElementsMap(),
this.state,
getLinkDirectionFromKey(event.key),
this.scene,
);
}
@ -4446,16 +4462,16 @@ class App extends React.Component<AppProps, AppState> {
}
selectedElements.forEach((element) => {
mutateElement(
this.scene.mutateElement(
element,
{
x: element.x + offsetX,
y: element.y + offsetY,
},
false,
{ informMutation: false, isDragging: false },
);
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
updateBoundElements(element, this.scene, {
simultaneouslyUpdated: selectedElements,
});
});
@ -4489,6 +4505,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElement,
this.scene.getNonDeletedElementsMap(),
),
});
}
@ -4682,11 +4699,9 @@ class App extends React.Component<AppProps, AppState> {
if (isArrowKey(event.key)) {
bindOrUnbindLinearElements(
this.scene.getSelectedElements(this.state).filter(isLinearElement),
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.scene,
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.scene,
this.state.zoom,
);
this.setState({ suggestedBindings: [] });
@ -4993,7 +5008,7 @@ class App extends React.Component<AppProps, AppState> {
onChange: withBatchedUpdates((nextOriginalText) => {
updateElement(nextOriginalText, false);
if (isNonDeletedElement(element)) {
updateBoundElements(element, this.scene.getNonDeletedElementsMap());
updateBoundElements(element, this.scene);
}
}),
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
@ -5356,7 +5371,10 @@ class App extends React.Component<AppProps, AppState> {
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
const newHeight = Math.max(container.height, minHeight);
const newWidth = Math.max(container.width, minWidth);
mutateElement(container, { height: newHeight, width: newWidth });
this.scene.mutateElement(container, {
height: newHeight,
width: newWidth,
});
sceneX = container.x + newWidth / 2;
sceneY = container.y + newHeight / 2;
if (parentCenterPosition) {
@ -5407,7 +5425,7 @@ class App extends React.Component<AppProps, AppState> {
});
if (!existingTextElement && shouldBindToContainer && container) {
mutateElement(container, {
this.scene.mutateElement(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: element.id,
@ -5483,7 +5501,10 @@ class App extends React.Component<AppProps, AppState> {
) {
this.store.shouldCaptureIncrement();
this.setState({
editingLinearElement: new LinearElementEditor(selectedElements[0]),
editingLinearElement: new LinearElementEditor(
selectedElements[0],
this.scene.getNonDeletedElementsMap(),
),
});
return;
} else if (
@ -5507,7 +5528,11 @@ class App extends React.Component<AppProps, AppState> {
if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement();
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
LinearElementEditor.deleteFixedSegment(
selectedElements[0],
this.scene,
midPoint,
);
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
@ -5890,7 +5915,6 @@ class App extends React.Component<AppProps, AppState> {
scenePointerX,
scenePointerY,
this,
this.scene.getNonDeletedElementsMap(),
);
if (
@ -5952,7 +5976,7 @@ class App extends React.Component<AppProps, AppState> {
lastPoint,
) >= LINE_CONFIRM_THRESHOLD
) {
mutateElement(
this.scene.mutateElement(
multiElement,
{
points: [
@ -5960,7 +5984,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
],
},
false,
{ informMutation: false, isDragging: false },
);
} else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
@ -5976,12 +6000,12 @@ class App extends React.Component<AppProps, AppState> {
) < LINE_CONFIRM_THRESHOLD
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
mutateElement(
this.scene.mutateElement(
multiElement,
{
points: points.slice(0, -1),
},
false,
{ informMutation: false, isDragging: false },
);
} else {
const [gridX, gridY] = getGridPoint(
@ -6013,8 +6037,9 @@ class App extends React.Component<AppProps, AppState> {
if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
// update last uncommitted point
mutateElement(
this.scene.mutateElement(
multiElement,
{
points: [
@ -6025,9 +6050,9 @@ class App extends React.Component<AppProps, AppState> {
),
],
},
false,
{
isDragging: true,
informMutation: false,
},
);
@ -6618,7 +6643,7 @@ class App extends React.Component<AppProps, AppState> {
const frame = this.getTopLayerFrameAtSceneCoords({ x, y });
mutateElement(pendingImageElement, {
this.scene.mutateElement(pendingImageElement, {
x,
y,
frameId: frame ? frame.id : null,
@ -7673,7 +7698,7 @@ class App extends React.Component<AppProps, AppState> {
multiElement.type === "line" &&
isPathALoop(multiElement.points, this.state.zoom.value)
) {
mutateElement(multiElement, {
this.scene.mutateElement(multiElement, {
lastCommittedPoint:
multiElement.points[multiElement.points.length - 1],
});
@ -7684,7 +7709,7 @@ class App extends React.Component<AppProps, AppState> {
// 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) {
mutateElement(multiElement, {
this.scene.mutateElement(multiElement, {
lastCommittedPoint:
multiElement.points[multiElement.points.length - 1],
});
@ -7721,7 +7746,7 @@ class App extends React.Component<AppProps, AppState> {
}));
// clicking outside commit zone → update reference for last committed
// point
mutateElement(multiElement, {
this.scene.mutateElement(multiElement, {
lastCommittedPoint: multiElement.points[multiElement.points.length - 1],
});
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
@ -7807,7 +7832,7 @@ class App extends React.Component<AppProps, AppState> {
),
};
});
mutateElement(element, {
this.scene.mutateElement(element, {
points: [...element.points, pointFrom<LocalPoint>(0, 0)],
});
const boundElement = getHoveredElementForBinding(
@ -8057,7 +8082,7 @@ class App extends React.Component<AppProps, AppState> {
index,
gridX,
gridY,
this.scene.getNonDeletedElementsMap(),
this.scene,
);
flushSync(() => {
@ -8162,7 +8187,7 @@ class App extends React.Component<AppProps, AppState> {
pointerCoords,
this,
!event[KEYS.CTRL_OR_CMD],
elementsMap,
this.scene,
);
if (!ret) {
return;
@ -8389,7 +8414,7 @@ class App extends React.Component<AppProps, AppState> {
),
};
mutateElement(croppingElement, {
this.scene.mutateElement(croppingElement, {
crop: nextCrop,
});
@ -8646,13 +8671,16 @@ class App extends React.Component<AppProps, AppState> {
? newElement.pressures
: [...newElement.pressures, event.pressure];
mutateElement(
this.scene.mutateElement(
newElement,
{
points: [...points, pointFrom<LocalPoint>(dx, dy)],
pressures,
},
false,
{
informMutation: false,
isDragging: false,
},
);
this.setState({
@ -8675,24 +8703,23 @@ class App extends React.Component<AppProps, AppState> {
}
if (points.length === 1) {
mutateElement(
this.scene.mutateElement(
newElement,
{
points: [...points, pointFrom<LocalPoint>(dx, dy)],
},
false,
{ informMutation: false, isDragging: false },
);
} else if (
points.length === 2 ||
(points.length > 1 && isElbowArrow(newElement))
) {
mutateElement(
this.scene.mutateElement(
newElement,
{
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
},
false,
{ isDragging: true },
{ isDragging: true, informMutation: false },
);
}
@ -8803,7 +8830,10 @@ class App extends React.Component<AppProps, AppState> {
selectedLinearElement:
elementsWithinSelection.length === 1 &&
isLinearElement(elementsWithinSelection[0])
? new LinearElementEditor(elementsWithinSelection[0])
? new LinearElementEditor(
elementsWithinSelection[0],
this.scene.getNonDeletedElementsMap(),
)
: null,
showHyperlinkPopup:
elementsWithinSelection.length === 1 &&
@ -8909,7 +8939,7 @@ class App extends React.Component<AppProps, AppState> {
.map((e) => elementsMap.get(e.id))
.filter((e) => isElbowArrow(e))
.forEach((e) => {
!!e && mutateElement(e, {}, true);
!!e && this.scene.mutateElement(e, {});
});
}
}
@ -8945,7 +8975,10 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
);
if (element) {
mutateElement(element, {}, true);
this.scene.mutateElement(
element as ExcalidrawElbowArrowElement,
{},
);
}
}
@ -8974,7 +9007,6 @@ class App extends React.Component<AppProps, AppState> {
element,
startBindingElement,
endBindingElement,
elementsMap,
this.scene,
);
}
@ -9041,7 +9073,7 @@ class App extends React.Component<AppProps, AppState> {
? []
: [...newElement.pressures, childEvent.pressure];
mutateElement(newElement, {
this.scene.mutateElement(newElement, {
points: [...points, pointFrom<LocalPoint>(dx, dy)],
pressures,
lastCommittedPoint: pointFrom<LocalPoint>(dx, dy),
@ -9088,15 +9120,20 @@ class App extends React.Component<AppProps, AppState> {
);
if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) {
mutateElement(newElement, {
points: [
...newElement.points,
pointFrom<LocalPoint>(
pointerCoords.x - newElement.x,
pointerCoords.y - newElement.y,
),
],
});
this.scene.mutateElement(
newElement,
{
points: [
...newElement.points,
pointFrom<LocalPoint>(
pointerCoords.x - newElement.x,
pointerCoords.y - newElement.y,
),
],
},
{ informMutation: false, isDragging: false },
);
this.setState({
multiElement: newElement,
newElement,
@ -9110,8 +9147,7 @@ class App extends React.Component<AppProps, AppState> {
newElement,
this.state,
pointerCoords,
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.scene,
);
}
this.setState({ suggestedBindings: [], startBoundElement: null });
@ -9129,7 +9165,10 @@ class App extends React.Component<AppProps, AppState> {
},
prevState,
),
selectedLinearElement: new LinearElementEditor(newElement),
selectedLinearElement: new LinearElementEditor(
newElement,
this.scene.getNonDeletedElementsMap(),
),
}));
} else {
this.setState((prevState) => ({
@ -9152,7 +9191,7 @@ class App extends React.Component<AppProps, AppState> {
);
if (newElement.width < minWidth) {
mutateElement(newElement, {
this.scene.mutateElement(newElement, {
autoResize: true,
});
}
@ -9202,7 +9241,14 @@ class App extends React.Component<AppProps, AppState> {
}
if (newElement) {
mutateElement(newElement, getNormalizedDimensions(newElement));
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();
}
@ -9234,7 +9280,7 @@ class App extends React.Component<AppProps, AppState> {
) {
// remove the linear element from all groups
// before removing it from the frame as well
mutateElement(linearElement, {
this.scene.mutateElement(linearElement, {
groupIds: [],
});
@ -9263,12 +9309,12 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingGroupId!,
);
mutateElement(
this.scene.mutateElement(
element,
{
groupIds: element.groupIds.slice(0, index),
},
false,
{ informMutation: false, isDragging: false },
);
}
@ -9280,12 +9326,12 @@ class App extends React.Component<AppProps, AppState> {
element.groupIds[element.groupIds.length - 1],
).length < 2
) {
mutateElement(
this.scene.mutateElement(
element,
{
groupIds: [],
},
false,
{ informMutation: false, isDragging: false },
);
}
});
@ -9395,7 +9441,10 @@ class App extends React.Component<AppProps, AppState> {
// the one we've hit
if (selectedElements.length === 1) {
this.setState({
selectedLinearElement: new LinearElementEditor(hitElement),
selectedLinearElement: new LinearElementEditor(
hitElement,
this.scene.getNonDeletedElementsMap(),
),
});
}
}
@ -9520,7 +9569,10 @@ class App extends React.Component<AppProps, AppState> {
selectedLinearElement:
newSelectedElements.length === 1 &&
isLinearElement(newSelectedElements[0])
? new LinearElementEditor(newSelectedElements[0])
? new LinearElementEditor(
newSelectedElements[0],
this.scene.getNonDeletedElementsMap(),
)
: prevState.selectedLinearElement,
};
});
@ -9594,7 +9646,10 @@ class App extends React.Component<AppProps, AppState> {
// Don't set `selectedLinearElement` if its same as the hitElement, this is mainly to prevent resetting the `hoverPointIndex` to -1.
// Future we should update the API to take care of setting the correct `hoverPointIndex` when initialized
prevState.selectedLinearElement?.elementId !== hitElement.id
? new LinearElementEditor(hitElement)
? new LinearElementEditor(
hitElement,
this.scene.getNonDeletedElementsMap(),
)
: prevState.selectedLinearElement,
}));
}
@ -9687,11 +9742,9 @@ class App extends React.Component<AppProps, AppState> {
bindOrUnbindLinearElements(
linearElements,
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.scene,
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.scene,
this.state.zoom,
);
}
@ -9848,12 +9901,12 @@ class App extends React.Component<AppProps, AppState> {
const dataURL =
this.files[fileId]?.dataURL || (await getDataURL(imageFile));
const imageElement = mutateElement(
const imageElement = this.scene.mutateElement(
_imageElement,
{
fileId,
},
false,
{ informMutation: false, isDragging: false },
) as NonDeleted<InitializedExcalidrawImageElement>;
return new Promise<NonDeleted<InitializedExcalidrawImageElement>>(
@ -9919,7 +9972,7 @@ class App extends React.Component<AppProps, AppState> {
showCursorImagePreview,
});
} catch (error: any) {
mutateElement(imageElement, {
this.scene.mutateElement(imageElement, {
isDeleted: true,
});
this.actionManager.executeAction(actionFinalize);
@ -10065,7 +10118,7 @@ class App extends React.Component<AppProps, AppState> {
imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value
) {
const placeholderSize = 100 / this.state.zoom.value;
mutateElement(imageElement, {
this.scene.mutateElement(imageElement, {
x: imageElement.x - placeholderSize / 2,
y: imageElement.y - placeholderSize / 2,
width: placeholderSize,
@ -10099,7 +10152,7 @@ class App extends React.Component<AppProps, AppState> {
const x = imageElement.x + imageElement.width / 2 - width / 2;
const y = imageElement.y + imageElement.height / 2 - height / 2;
mutateElement(imageElement, {
this.scene.mutateElement(imageElement, {
x,
y,
width,
@ -10530,7 +10583,10 @@ class App extends React.Component<AppProps, AppState> {
this,
),
selectedLinearElement: isLinearElement(element)
? new LinearElementEditor(element)
? new LinearElementEditor(
element,
this.scene.getNonDeletedElementsMap(),
)
: null,
}
: this.state),
@ -10563,8 +10619,9 @@ class App extends React.Component<AppProps, AppState> {
height: distance(pointerDownState.origin.y, pointerCoords.y),
shouldMaintainAspectRatio: shouldMaintainAspectRatio(event),
shouldResizeFromCenter: false,
scene: this.scene,
zoom: this.state.zoom.value,
informMutation,
informMutation: false,
});
return;
}
@ -10628,6 +10685,7 @@ class App extends React.Component<AppProps, AppState> {
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter: shouldResizeFromCenter(event),
zoom: this.state.zoom.value,
scene: this.scene,
widthAspectRatio: aspectRatio,
originOffset: this.state.originSnapOffset,
informMutation,
@ -10715,7 +10773,7 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType,
);
mutateElement(
this.scene.mutateElement(
croppingElement,
cropElement(
croppingElement,
@ -10730,16 +10788,12 @@ class App extends React.Component<AppProps, AppState> {
),
);
updateBoundElements(
croppingElement,
this.scene.getNonDeletedElementsMap(),
{
newSize: {
width: croppingElement.width,
height: croppingElement.height,
},
updateBoundElements(croppingElement, this.scene, {
newSize: {
width: croppingElement.width,
height: croppingElement.height,
},
);
});
this.setState({
isCropping: transformHandleType && transformHandleType !== "rotation",
@ -10853,7 +10907,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.originalElements,
transformHandleType,
selectedElements,
this.scene.getElementsMapIncludingDeleted(),
this.scene,
shouldRotateWithDiscreteAngle(event),
shouldResizeFromCenter(event),

View file

@ -6,9 +6,10 @@ import {
defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection,
} from "@excalidraw/element/elementLink";
import { mutateElement } from "@excalidraw/element/mutateElement";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
@ -21,20 +22,20 @@ import { TrashIcon } from "./icons";
import "./ElementLinkDialog.scss";
import type { AppProps, AppState, UIAppState } from "../types";
const ElementLinkDialog = ({
sourceElementId,
onClose,
elementsMap,
appState,
scene,
generateLinkForSelection = defaultGetElementLinkFromSelection,
}: {
sourceElementId: ExcalidrawElement["id"];
elementsMap: ElementsMap;
appState: UIAppState;
scene: Scene;
onClose?: () => void;
generateLinkForSelection: AppProps["generateLinkForSelection"];
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const originalLink = elementsMap.get(sourceElementId)?.link ?? null;
const [nextLink, setNextLink] = useState<string | null>(originalLink);
@ -70,7 +71,7 @@ const ElementLinkDialog = ({
if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) {
const elementToLink = elementsMap.get(sourceElementId);
elementToLink &&
mutateElement(elementToLink, {
scene.mutateElement(elementToLink, {
link: nextLink,
});
}
@ -78,13 +79,13 @@ const ElementLinkDialog = ({
if (!nextLink && linkEdited && sourceElementId) {
const elementToLink = elementsMap.get(sourceElementId);
elementToLink &&
mutateElement(elementToLink, {
scene.mutateElement(elementToLink, {
link: null,
});
}
onClose?.();
}, [sourceElementId, nextLink, elementsMap, linkEdited, onClose]);
}, [sourceElementId, nextLink, elementsMap, linkEdited, scene, onClose]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {

View file

@ -5,6 +5,7 @@ import {
CLASSES,
DEFAULT_SIDEBAR,
TOOL_TYPE,
arrayToMap,
capitalizeString,
isShallowEqual,
} from "@excalidraw/common";
@ -17,7 +18,6 @@ import { ShapeCache } from "@excalidraw/element/ShapeCache";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import Scene from "../scene/Scene";
import { actionToggleStats } from "../actions";
import { trackEvent } from "../analytics";
import { isHandToolActive } from "../appState";
@ -446,22 +446,18 @@ const LayerUI = ({
if (selectedElements.length) {
for (const element of selectedElements) {
mutateElement(
element,
{
[altKey && eyeDropperState.swapPreviewOnAlt
? colorPickerType === "elementBackground"
? "strokeColor"
: "backgroundColor"
: colorPickerType === "elementBackground"
? "backgroundColor"
: "strokeColor"]: color,
},
false,
);
mutateElement(element, arrayToMap(elements), {
[altKey && eyeDropperState.swapPreviewOnAlt
? colorPickerType === "elementBackground"
? "strokeColor"
: "backgroundColor"
: colorPickerType === "elementBackground"
? "backgroundColor"
: "strokeColor"]: color,
});
ShapeCache.delete(element);
}
Scene.getScene(selectedElements[0])?.triggerUpdate();
app.scene.triggerUpdate();
} else if (colorPickerType === "elementBackground") {
setAppState({
currentItemBackgroundColor: color,
@ -494,7 +490,7 @@ const LayerUI = ({
openDialog: null,
});
}}
elementsMap={app.scene.getNonDeletedElementsMap()}
scene={app.scene}
appState={appState}
generateLinkForSelection={generateLinkForSelection}
/>

View file

@ -10,6 +10,7 @@ import {
isElbowArrow,
isLinearElement,
isSharpArrow,
isUsingAdaptiveRadius,
} from "@excalidraw/element/typeChecks";
import {
@ -26,24 +27,23 @@ import {
import { wrapText } from "@excalidraw/element/textWrapping";
import { getFontString, updateActiveTool } from "@excalidraw/common";
import { getFontString, isDevEnv, updateActiveTool } from "@excalidraw/common";
import { measureText } from "@excalidraw/element/textMeasurements";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
convertElementType,
CONVERTIBLE_GENERIC_TYPES,
CONVERTIBLE_LINEAR_TYPES,
isConvertibleGenericType,
isConvertibleLinearType,
} from "@excalidraw/element/mutateElement";
newArrowElement,
newElement,
newLinearElement,
} from "@excalidraw/element/newElement";
import { ShapeCache } from "@excalidraw/element/ShapeCache";
import type {
ConvertibleGenericTypes,
ConvertibleLinearTypes,
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawDiamondElement,
ExcalidrawElbowArrowElement,
@ -51,12 +51,22 @@ import type {
ExcalidrawEllipseElement,
ExcalidrawLinearElement,
ExcalidrawRectangleElement,
ExcalidrawSelectionElement,
ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer,
FixedSegment,
} from "@excalidraw/element/types";
import { mutateElement, sceneCoordsToViewportCoords } from "..";
import type { Mutable } from "@excalidraw/common/utility-types";
import type Scene from "@excalidraw/element/Scene";
import {
bumpVersion,
mutateElement,
ROUNDNESS,
sceneCoordsToViewportCoords,
} from "..";
import { trackEvent } from "../analytics";
import { atom, editorJotaiStore, useAtom } from "../editor-jotai";
@ -74,6 +84,8 @@ import {
import type App from "./App";
import type { AppClassProperties } from "../types";
const GAP_HORIZONTAL = 8;
const GAP_VERTICAL = 10;
@ -328,7 +340,7 @@ const Panel = ({
export const adjustBoundTextSize = (
container: ExcalidrawTextContainer,
boundText: ExcalidrawTextElementWithContainer,
elementsMap: ElementsMap,
scene: Scene,
) => {
const maxWidth = getBoundTextMaxWidth(container, boundText);
const maxHeight = getBoundTextMaxHeight(container, boundText);
@ -362,17 +374,13 @@ export const adjustBoundTextSize = (
);
}
mutateElement(
boundText,
{
fontSize: nextFontSize,
width: metrics.width,
height: metrics.height,
},
false,
);
mutateElement(boundText, scene.getNonDeletedElementsMap(), {
fontSize: nextFontSize,
width: metrics.width,
height: metrics.height,
});
redrawTextBoundingBox(boundText, container, elementsMap, false);
redrawTextBoundingBox(boundText, container, scene);
};
export const switchShapes = (
@ -427,12 +435,7 @@ export const switchShapes = (
const convertedElements: Record<string, ExcalidrawElement> = {};
for (const element of selectedGenericSwitchableElements) {
const convertedElement = convertElementType(
element,
nextType,
app,
false,
);
const convertedElement = convertElementType(element, nextType, app);
convertedElements[convertedElement.id] = convertedElement;
}
@ -458,21 +461,17 @@ export const switchShapes = (
editorJotaiStore.get(shapeSwitchFontSizeAtom)?.[element.id]
?.elementType === nextType
) {
mutateElement(
boundText,
{
fontSize:
editorJotaiStore.get(shapeSwitchFontSizeAtom)?.[element.id]
?.fontSize ?? boundText.fontSize,
},
false,
);
mutateElement(boundText, app.scene.getNonDeletedElementsMap(), {
fontSize:
editorJotaiStore.get(shapeSwitchFontSizeAtom)?.[element.id]
?.fontSize ?? boundText.fontSize,
});
}
adjustBoundTextSize(
element as ExcalidrawTextContainer,
boundText,
app.scene.getNonDeletedElementsMap(),
app.scene,
);
}
}
@ -509,7 +508,7 @@ export const switchShapes = (
if (nextType && isConvertibleLinearType(nextType)) {
const convertedElements: Record<string, ExcalidrawElement> = {};
for (const element of selectedLinearSwitchableElements) {
const converted = convertElementType(element, nextType, app, false);
const converted = convertElementType(element, nextType, app);
convertedElements[converted.id] = converted;
}
@ -534,7 +533,11 @@ export const switchShapes = (
const { properties, initialType } = cachedLinear;
if (initialType === nextType) {
mutateElement(element, properties, false);
mutateElement(
element,
app.scene.getNonDeletedElementsMap(),
properties,
);
continue;
}
}
@ -560,7 +563,7 @@ export const switchShapes = (
fixedSegments,
},
);
mutateElement(element, updates, false);
mutateElement(element, app.scene.getNonDeletedElementsMap(), updates);
}
}
}
@ -570,7 +573,10 @@ export const switchShapes = (
selectedElementIds,
selectedLinearElement:
selectedLinearSwitchableElements.length === 1
? new LinearElementEditor(firstElement as ExcalidrawLinearElement)
? new LinearElementEditor(
firstElement as ExcalidrawLinearElement,
app.scene.getNonDeletedElementsMap(),
)
: null,
activeTool: updateActiveTool(prevState, {
type: "selection",
@ -812,4 +818,176 @@ const sanitizePoints = (points: readonly LocalPoint[]): LocalPoint[] => {
return sanitized;
};
// Declare the constant array with a read-only type so that its values can only be one of the valid union.
const CONVERTIBLE_GENERIC_TYPES: readonly ConvertibleGenericTypes[] = [
"rectangle",
"diamond",
"ellipse",
];
const CONVERTIBLE_LINEAR_TYPES: readonly ConvertibleLinearTypes[] = [
"line",
"sharpArrow",
"curvedArrow",
"elbowArrow",
];
type NewElementType = ConvertibleGenericTypes | ConvertibleLinearTypes;
const isConvertibleGenericType = (
elementType: string,
): elementType is ConvertibleGenericTypes =>
CONVERTIBLE_GENERIC_TYPES.includes(elementType as ConvertibleGenericTypes);
const isConvertibleLinearType = (
elementType: string,
): elementType is ConvertibleLinearTypes =>
elementType === "arrow" ||
CONVERTIBLE_LINEAR_TYPES.includes(elementType as ConvertibleLinearTypes);
/**
* Converts an element to a new type, adding or removing properties as needed
* so that the element object is always valid.
*
* Valid conversions at this point:
* - switching between generic elements
* e.g. rectangle -> diamond
* - switching between linear elements
* e.g. elbow arrow -> line
*/
export const convertElementType = <
TElement extends Mutable<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>
>,
>(
element: TElement,
newType: NewElementType,
app: AppClassProperties,
): ExcalidrawElement => {
if (!isValidConversion(element.type, newType)) {
if (isDevEnv()) {
throw Error(`Invalid conversion from ${element.type} to ${newType}.`);
}
return element;
}
const startType = isSharpArrow(element)
? "sharpArrow"
: isCurvedArrow(element)
? "curvedArrow"
: isElbowArrow(element)
? "elbowArrow"
: element.type;
if (element.type === newType) {
return element;
}
ShapeCache.delete(element);
if (
isConvertibleGenericType(startType) &&
isConvertibleGenericType(newType)
) {
const nextElement = bumpVersion(
newElement({
...element,
type: newType,
roundness:
newType === "diamond" && element.roundness
? {
type: isUsingAdaptiveRadius(newType)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: element.roundness,
}),
);
switch (nextElement.type) {
case "rectangle":
return nextElement as ExcalidrawRectangleElement;
case "diamond":
return nextElement as ExcalidrawDiamondElement;
case "ellipse":
return nextElement as ExcalidrawEllipseElement;
}
}
if (isConvertibleLinearType(element.type)) {
if (newType === "line") {
const nextElement = newLinearElement({
...element,
type: "line",
});
return bumpVersion(nextElement);
}
if (newType === "sharpArrow") {
const nextElement = newArrowElement({
...element,
type: "arrow",
elbowed: false,
roundness: null,
startArrowhead: app.state.currentItemStartArrowhead,
endArrowhead: app.state.currentItemEndArrowhead,
});
return bumpVersion(nextElement);
}
if (newType === "curvedArrow") {
const nextElement = newArrowElement({
...element,
type: "arrow",
elbowed: false,
roundness: {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
},
startArrowhead: app.state.currentItemStartArrowhead,
endArrowhead: app.state.currentItemEndArrowhead,
});
return bumpVersion(nextElement);
}
if (newType === "elbowArrow") {
const nextElement = newArrowElement({
...element,
type: "arrow",
elbowed: true,
fixedSegments: null,
});
return bumpVersion(nextElement);
}
}
return element;
};
const isValidConversion = (
startType: string,
targetType: NewElementType,
): startType is NewElementType => {
if (
isConvertibleGenericType(startType) &&
isConvertibleGenericType(targetType)
) {
return true;
}
if (
isConvertibleLinearType(startType) &&
isConvertibleLinearType(targetType)
) {
return true;
}
// NOTE: add more conversions when needed
return false;
};
export default ShapeSwitch;

View file

@ -1,7 +1,5 @@
import { degreesToRadians, radiansToDegrees } from "@excalidraw/math";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { getBoundTextElement } from "@excalidraw/element/textElement";
import { isArrowElement, isElbowArrow } from "@excalidraw/element/typeChecks";
@ -9,13 +7,14 @@ import type { Degrees } from "@excalidraw/math";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface AngleProps {
@ -35,7 +34,6 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
if (origElement && !isElbowArrow(origElement)) {
const latestElement = elementsMap.get(origElement.id);
@ -45,14 +43,14 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
if (nextValue !== undefined) {
const nextAngle = degreesToRadians(nextValue as Degrees);
mutateElement(latestElement, {
scene.mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap, elements, scene);
updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
scene.mutateElement(boundTextElement, { angle: nextAngle });
}
return;
@ -71,14 +69,14 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement(latestElement, {
scene.mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap, elements, scene);
updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
scene.mutateElement(boundTextElement, { angle: nextAngle });
}
}
};

View file

@ -1,9 +1,10 @@
import type Scene from "@excalidraw/element/Scene";
import { getNormalizedGridStep } from "../../scene";
import StatsDragInput from "./DragInput";
import { getStepSizedValue } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface PositionProps {

View file

@ -5,17 +5,17 @@ import {
MINIMAL_CROP_SIZE,
getUncroppedWidthAndHeight,
} from "@excalidraw/element/cropElement";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { resizeSingleElement } from "@excalidraw/element/resizeElements";
import { isImageElement } from "@excalidraw/element/typeChecks";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface DimensionDragInputProps {
@ -113,7 +113,7 @@ const handleDimensionChange: DragInputCallbackType<
};
}
mutateElement(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,
};
mutateElement(element, {
scene.mutateElement(element, {
crop: nextCrop,
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
@ -176,8 +176,8 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
latestElement,
origElement,
elementsMap,
originalElementsMap,
scene,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
@ -223,8 +223,8 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
latestElement,
origElement,
elementsMap,
originalElementsMap,
scene,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,

View file

@ -7,6 +7,8 @@ import { deepCopyElement } from "@excalidraw/element/duplicate";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { CaptureUpdateAction } from "../../store";
import { useApp } from "../App";
import { InlineIcon } from "../InlineIcon";
@ -16,7 +18,6 @@ import { SMALLEST_DELTA } from "./utils";
import "./DragInput.scss";
import type { StatsInputProperty } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
export type DragInputCallbackType<
@ -216,13 +217,12 @@ const StatsDragInput = <
y: number;
} | null = null;
let originalElementsMap: Map<string, ExcalidrawElement> | null =
app.scene
.getNonDeletedElements()
.reduce((acc: ElementsMap, element) => {
acc.set(element.id, deepCopyElement(element));
return acc;
}, new Map());
let originalElementsMap: ElementsMap | null = app.scene
.getNonDeletedElements()
.reduce((acc: ElementsMap, element) => {
acc.set(element.id, deepCopyElement(element));
return acc;
}, new Map());
let originalElements: readonly E[] | null = elements.map(
(element) => originalElementsMap!.get(element.id) as E,

View file

@ -1,4 +1,3 @@
import { mutateElement } from "@excalidraw/element/mutateElement";
import {
getBoundTextElement,
redrawTextBoundingBox,
@ -13,13 +12,14 @@ import type {
ExcalidrawTextElement,
} from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { fontSizeIcon } from "../icons";
import StatsDragInput from "./DragInput";
import { getStepSizedValue } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface FontSizeProps {
@ -68,13 +68,13 @@ const handleFontSizeChange: DragInputCallbackType<
}
if (nextFontSize) {
mutateElement(latestElement, {
scene.mutateElement(latestElement, {
fontSize: nextFontSize,
});
redrawTextBoundingBox(
latestElement,
scene.getContainerElement(latestElement),
scene.getNonDeletedElementsMap(),
scene,
);
}
}

View file

@ -1,7 +1,5 @@
import { degreesToRadians, radiansToDegrees } from "@excalidraw/math";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { getBoundTextElement } from "@excalidraw/element/textElement";
import { isArrowElement } from "@excalidraw/element/typeChecks";
@ -11,13 +9,14 @@ import type { Degrees } from "@excalidraw/math";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface MultiAngleProps {
@ -54,17 +53,13 @@ const handleDegreeChange: DragInputCallbackType<
if (!element) {
continue;
}
mutateElement(
element,
{
angle: nextAngle,
},
false,
);
scene.mutateElement(element, {
angle: nextAngle,
});
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle }, false);
scene.mutateElement(boundTextElement, { angle: nextAngle });
}
}
@ -92,17 +87,13 @@ const handleDegreeChange: DragInputCallbackType<
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement(
latestElement,
{
angle: nextAngle,
},
false,
);
scene.mutateElement(latestElement, {
angle: nextAngle,
});
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle }, false);
scene.mutateElement(boundTextElement, { angle: nextAngle });
}
}
scene.triggerUpdate();

View file

@ -3,7 +3,6 @@ import { useMemo } from "react";
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
import { updateBoundElements } from "@excalidraw/element/binding";
import { mutateElement } from "@excalidraw/element/mutateElement";
import {
rescalePointsInElement,
resizeSingleElement,
@ -23,13 +22,14 @@ import type {
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import DragInput from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type { AtomicUnit } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface MultiDimensionProps {
@ -75,33 +75,31 @@ const resizeElementInGroup = (
scale: number,
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
mutateElement(latestElement, updates, false);
scene.mutateElement(latestElement, updates);
const boundTextElement = getBoundTextElement(
origElement,
originalElementsMap,
);
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, {
updateBoundElements(latestElement, scene, {
newSize: { width: updates.width, height: updates.height },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
mutateElement(
latestBoundTextElement,
{
fontSize: newFontSize,
},
false,
);
scene.mutateElement(latestBoundTextElement, {
fontSize: newFontSize,
});
handleBindTextResize(
latestElement,
elementsMap,
scene,
property === "width" ? "e" : "s",
true,
);
@ -118,8 +116,8 @@ const resizeGroup = (
property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
// keep aspect ratio for groups
if (property === "width") {
@ -141,8 +139,8 @@ const resizeGroup = (
scale,
latestElement,
origElement,
elementsMap,
originalElementsMap,
scene,
);
}
};
@ -194,8 +192,8 @@ const handleDimensionChange: DragInputCallbackType<
property,
latestElements,
originalElements,
elementsMap,
originalElementsMap,
scene,
);
} else {
const [el] = elementsInUnit;
@ -237,8 +235,8 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
latestElement,
origElement,
elementsMap,
originalElementsMap,
scene,
property === "width" ? "e" : "s",
{
shouldInformMutation: false,
@ -301,8 +299,8 @@ const handleDimensionChange: DragInputCallbackType<
property,
latestElements,
originalElements,
elementsMap,
originalElementsMap,
scene,
);
} else {
const [el] = elementsInUnit;
@ -340,8 +338,8 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
latestElement,
origElement,
elementsMap,
originalElementsMap,
scene,
property === "width" ? "e" : "s",
{
shouldInformMutation: false,

View file

@ -1,4 +1,3 @@
import { mutateElement } from "@excalidraw/element/mutateElement";
import {
getBoundTextElement,
redrawTextBoundingBox,
@ -16,13 +15,14 @@ import type {
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { fontSizeIcon } from "../icons";
import StatsDragInput from "./DragInput";
import { getStepSizedValue } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface MultiFontSizeProps {
@ -84,19 +84,14 @@ const handleFontSizeChange: DragInputCallbackType<
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
for (const textElement of latestTextElements) {
mutateElement(
textElement,
{
fontSize: nextFontSize,
},
false,
);
scene.mutateElement(textElement, {
fontSize: nextFontSize,
});
redrawTextBoundingBox(
textElement,
scene.getContainerElement(textElement),
elementsMap,
false,
scene,
);
}
@ -117,19 +112,14 @@ const handleFontSizeChange: DragInputCallbackType<
if (shouldChangeByStepSize) {
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
}
mutateElement(
latestElement,
{
fontSize: nextFontSize,
},
false,
);
scene.mutateElement(latestElement, {
fontSize: nextFontSize,
});
redrawTextBoundingBox(
latestElement,
scene.getContainerElement(latestElement),
elementsMap,
false,
scene,
);
}

View file

@ -5,12 +5,9 @@ import { isTextElement } from "@excalidraw/element/typeChecks";
import { getCommonBounds } from "@excalidraw/element/bounds";
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import StatsDragInput from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
@ -18,7 +15,6 @@ import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type { AtomicUnit } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface MultiPositionProps {
@ -36,13 +32,11 @@ const moveElements = (
property: MultiPositionProps["property"],
changeInTopX: number,
changeInTopY: number,
elements: readonly ExcalidrawElement[],
originalElements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
for (let i = 0; i < elements.length; i++) {
for (let i = 0; i < originalElements.length; i++) {
const origElement = originalElements[i];
const [cx, cy] = [
@ -65,8 +59,6 @@ const moveElements = (
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
@ -78,11 +70,10 @@ const moveGroupTo = (
nextX: number,
nextY: number,
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
originalElementsMap: ElementsMap,
scene: Scene,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const [x1, y1, ,] = getCommonBounds(originalElements);
const offsetX = nextX - x1;
const offsetY = nextY - y1;
@ -112,8 +103,6 @@ const moveGroupTo = (
topLeftX + offsetX,
topLeftY + offsetY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
@ -135,7 +124,6 @@ const handlePositionChange: DragInputCallbackType<
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
if (nextValue !== undefined) {
for (const atomicUnit of getAtomicUnits(
@ -159,8 +147,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftX,
newTopLeftY,
elementsInUnit.map((el) => el.original),
elementsMap,
elements,
originalElementsMap,
scene,
);
@ -188,8 +174,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
@ -214,8 +198,6 @@ const handlePositionChange: DragInputCallbackType<
changeInTopX,
changeInTopY,
originalElements,
originalElements,
elementsMap,
originalElementsMap,
scene,
);

View file

@ -4,16 +4,16 @@ import {
getFlipAdjustedCropPosition,
getUncroppedWidthAndHeight,
} from "@excalidraw/element/cropElement";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { isImageElement } from "@excalidraw/element/typeChecks";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import StatsDragInput from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface PositionProps {
@ -38,7 +38,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
const [cx, cy] = [
origElement.x + origElement.width / 2,
@ -101,7 +100,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
};
}
mutateElement(element, {
scene.mutateElement(element, {
crop: nextCrop,
});
@ -119,7 +118,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height),
};
mutateElement(element, {
scene.mutateElement(element, {
crop: nextCrop,
});
@ -133,8 +132,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
@ -166,8 +163,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);

View file

@ -17,7 +17,7 @@ import type {
ExcalidrawTextElement,
} from "@excalidraw/element/types";
import { Excalidraw, getCommonBounds, mutateElement } from "../..";
import { Excalidraw, getCommonBounds } from "../..";
import { actionGroup } from "../../actions";
import { t } from "../../i18n";
import * as StaticScene from "../../renderer/staticScene";
@ -478,7 +478,7 @@ describe("stats for a non-generic element", () => {
containerId: container.id,
fontSize: 20,
});
mutateElement(container, {
h.app.scene.mutateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
API.setElements([container, text]);

View file

@ -4,7 +4,6 @@ import {
bindOrUnbindLinearElements,
updateBoundElements,
} from "@excalidraw/element/binding";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { getBoundTextElement } from "@excalidraw/element/textElement";
import {
isFrameLikeElement,
@ -24,10 +23,10 @@ import type {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import type Scene from "../../scene/Scene";
import type Scene from "@excalidraw/element/Scene";
import type { AppState } from "../../types";
export type StatsInputProperty =
@ -119,12 +118,11 @@ export const moveElement = (
newTopLeftX: number,
newTopLeftY: number,
originalElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
originalElementsMap: ElementsMap,
shouldInformMutation = true,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const latestElement = elementsMap.get(originalElement.id);
if (!latestElement) {
return;
@ -148,15 +146,15 @@ export const moveElement = (
-originalElement.angle as Radians,
);
mutateElement(
scene.mutateElement(
latestElement,
{
x,
y,
},
shouldInformMutation,
{ informMutation: shouldInformMutation, isDragging: false },
);
updateBindings(latestElement, elementsMap, elements, scene);
updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement(
originalElement,
@ -165,13 +163,13 @@ export const moveElement = (
if (boundTextElement) {
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
latestBoundTextElement &&
mutateElement(
scene.mutateElement(
latestBoundTextElement,
{
x: boundTextElement.x + changeInX,
y: boundTextElement.y + changeInY,
},
shouldInformMutation,
{ informMutation: shouldInformMutation, isDragging: false },
);
}
};
@ -199,8 +197,6 @@ export const getAtomicUnits = (
export const updateBindings = (
latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
@ -209,16 +205,8 @@ export const updateBindings = (
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements(
[latestElement],
elementsMap,
elements,
scene,
true,
[],
options?.zoom,
);
bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom);
} else {
updateBoundElements(latestElement, elementsMap, options);
updateBoundElements(latestElement, scene, options);
}
};

View file

@ -21,8 +21,6 @@ import {
embeddableURLValidator,
} from "@excalidraw/element/embeddable";
import { mutateElement } from "@excalidraw/element/mutateElement";
import {
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
@ -33,6 +31,8 @@ import {
import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
import type Scene from "@excalidraw/element/Scene";
import type {
ElementsMap,
ExcalidrawEmbeddableElement,
@ -70,14 +70,14 @@ const embeddableLinkCache = new Map<
export const Hyperlink = ({
element,
elementsMap,
scene,
setAppState,
onLinkOpen,
setToast,
updateEmbedValidationStatus,
}: {
element: NonDeletedExcalidrawElement;
elementsMap: ElementsMap;
scene: Scene;
setAppState: React.Component<any, AppState>["setState"];
onLinkOpen: ExcalidrawProps["onLinkOpen"];
setToast: (
@ -88,6 +88,7 @@ export const Hyperlink = ({
status: boolean,
) => void;
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const appState = useExcalidrawAppState();
const appProps = useAppProps();
const device = useDevice();
@ -114,7 +115,7 @@ export const Hyperlink = ({
setAppState({ activeEmbeddable: null });
}
if (!link) {
mutateElement(element, {
scene.mutateElement(element, {
link: null,
});
updateEmbedValidationStatus(element, false);
@ -126,7 +127,7 @@ export const Hyperlink = ({
setToast({ message: t("toast.unableToEmbed"), closable: true });
}
element.link && embeddableLinkCache.set(element.id, element.link);
mutateElement(element, {
scene.mutateElement(element, {
link,
});
updateEmbedValidationStatus(element, false);
@ -144,7 +145,7 @@ export const Hyperlink = ({
: 1;
const hasLinkChanged =
embeddableLinkCache.get(element.id) !== element.link;
mutateElement(element, {
scene.mutateElement(element, {
...(hasLinkChanged
? {
width:
@ -169,10 +170,11 @@ export const Hyperlink = ({
}
}
} else {
mutateElement(element, { link });
scene.mutateElement(element, { link });
}
}, [
element,
scene,
setToast,
appProps.validateEmbeddable,
appState.activeEmbeddable,
@ -229,9 +231,9 @@ export const Hyperlink = ({
const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete");
mutateElement(element, { link: null });
scene.mutateElement(element, { link: null });
setAppState({ showHyperlinkPopup: false });
}, [setAppState, element]);
}, [setAppState, element, scene]);
const onEdit = () => {
trackEvent("hyperlink", "edit", "popup-ui");

View file

@ -38,10 +38,13 @@ import { redrawTextBoundingBox } from "@excalidraw/element/textElement";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { getCommonBounds } from "@excalidraw/element/bounds";
import Scene from "@excalidraw/element/Scene";
import type { ElementConstructorOpts } from "@excalidraw/element/newElement";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElement,
@ -63,8 +66,6 @@ import type {
import type { MarkOptional } from "@excalidraw/common/utility-types";
import { getCommonBounds } from "..";
export type ValidLinearElement = {
type: "arrow" | "line";
x: number;
@ -221,7 +222,7 @@ const DEFAULT_DIMENSION = 100;
const bindTextToContainer = (
container: ExcalidrawElement,
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
elementsMap: ElementsMap,
scene: Scene,
) => {
const textElement: ExcalidrawTextElement = newTextElement({
x: 0,
@ -240,7 +241,8 @@ const bindTextToContainer = (
}),
});
redrawTextBoundingBox(textElement, container, elementsMap);
redrawTextBoundingBox(textElement, container, scene);
return [container, textElement] as const;
};
@ -249,7 +251,7 @@ const bindLinearElementToElement = (
start: ValidLinearElement["start"],
end: ValidLinearElement["end"],
elementStore: ElementStore,
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): {
linearElement: ExcalidrawLinearElement;
startBoundElement?: ExcalidrawElement;
@ -335,7 +337,7 @@ const bindLinearElementToElement = (
linearElement,
startBoundElement as ExcalidrawBindableElement,
"start",
elementsMap,
scene,
);
}
}
@ -410,7 +412,7 @@ const bindLinearElementToElement = (
linearElement,
endBoundElement as ExcalidrawBindableElement,
"end",
elementsMap,
scene,
);
}
}
@ -651,6 +653,9 @@ export const convertToExcalidrawElements = (
}
const elementsMap = elementStore.getElementsMap();
// we don't have a real scene, so we just use a temp scene to query and mutate elements
const scene = new Scene(elementsMap);
// Add labels and arrow bindings
for (const [id, element] of elementsWithIds) {
const excalidrawElement = elementStore.getElement(id)!;
@ -664,7 +669,7 @@ export const convertToExcalidrawElements = (
let [container, text] = bindTextToContainer(
excalidrawElement,
element?.label,
elementsMap,
scene,
);
elementStore.add(container);
elementStore.add(text);
@ -692,7 +697,7 @@ export const convertToExcalidrawElements = (
originalStart,
originalEnd,
elementStore,
elementsMap,
scene,
);
container = linearElement;
elementStore.add(linearElement);
@ -717,7 +722,7 @@ export const convertToExcalidrawElements = (
start,
end,
elementStore,
elementsMap,
scene,
);
elementStore.add(linearElement);

View file

@ -28,6 +28,8 @@ import type {
import type { ValueOf } from "@excalidraw/common/utility-types";
import type Scene from "@excalidraw/element/Scene";
import { CascadiaFontFaces } from "./Cascadia";
import { ComicShannsFontFaces } from "./ComicShanns";
import { EmojiFontFaces } from "./Emoji";
@ -40,8 +42,6 @@ import { NunitoFontFaces } from "./Nunito";
import { VirgilFontFaces } from "./Virgil";
import { XiaolaiFontFaces } from "./Xiaolai";
import type Scene from "../scene/Scene";
export class Fonts {
// it's ok to track fonts across multiple instances only once, so let's use
// a static member to reduce memory footprint

View file

@ -149,6 +149,7 @@ export class LassoTrail extends AnimatedTrail {
this.app.scene.getNonDeletedElement(
selectedIds[0],
) as NonDeleted<ExcalidrawLinearElement>,
this.app.scene.getNonDeletedElementsMap(),
)
: null,
};

View file

@ -9,10 +9,11 @@ import type {
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene";
import { renderStaticSceneThrottled } from "../renderer/staticScene";
import type Scene from "./Scene";
import type { RenderableElementsMap } from "./types";
import type { AppState } from "../types";

View file

@ -1,459 +0,0 @@
import throttle from "lodash.throttle";
import {
randomInteger,
arrayToMap,
toBrandedType,
isDevEnv,
isTestEnv,
isReadonlyArray,
} from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element/groups";
import {
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex";
import { getSelectedElements } from "@excalidraw/element/selection";
import type { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawFrameLikeElement,
ElementsMapOrArray,
SceneElementsMap,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
Ordered,
} from "@excalidraw/element/types";
import type { Assert, SameType } from "@excalidraw/common/utility-types";
import type { AppState } from "../types";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void;
type SelectionHash = string & { __brand: "selectionHash" };
const getNonDeletedElements = <T extends ExcalidrawElement>(
allElements: readonly T[],
) => {
const elementsMap = new Map() as NonDeletedSceneElementsMap;
const elements: T[] = [];
for (const element of allElements) {
if (!element.isDeleted) {
elements.push(element as NonDeleted<T>);
elementsMap.set(
element.id,
element as Ordered<NonDeletedExcalidrawElement>,
);
}
}
return { elementsMap, elements };
};
const validateIndicesThrottled = throttle(
(elements: readonly ExcalidrawElement[]) => {
if (isDevEnv() || isTestEnv() || window?.DEBUG_FRACTIONAL_INDICES) {
validateFractionalIndices(elements, {
// throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES`
shouldThrow: isDevEnv() || isTestEnv(),
includeBoundTextValidation: true,
});
}
},
1000 * 60,
{ leading: true, trailing: false },
);
const hashSelectionOpts = (
opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0],
) => {
const keys = ["includeBoundTextElement", "includeElementsInFrames"] as const;
type HashableKeys = Omit<typeof opts, "selectedElementIds" | "elements">;
// just to ensure we're hashing all expected keys
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _ = Assert<
SameType<
Required<HashableKeys>,
Pick<Required<HashableKeys>, typeof keys[number]>
>
>;
let hash = "";
for (const key of keys) {
hash += `${key}:${opts[key] ? "1" : "0"}`;
}
return hash as SelectionHash;
};
// ideally this would be a branded type but it'd be insanely hard to work with
// in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
if (typeof elementKey === "string") {
return true;
}
return false;
};
class Scene {
// ---------------------------------------------------------------------------
// static methods/props
// ---------------------------------------------------------------------------
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
private static sceneMapById = new Map<string, Scene>();
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
if (isIdKey(elementKey)) {
// for cases where we don't have access to the element object
// (e.g. restore serialized appState with id references)
this.sceneMapById.set(elementKey, scene);
} else {
this.sceneMapByElement.set(elementKey, scene);
// if mapping element objects, also cache the id string when later
// looking up by id alone
this.sceneMapById.set(elementKey.id, scene);
}
}
/**
* @deprecated pass down `app.scene` and use it directly
*/
static getScene(elementKey: ElementKey): Scene | null {
if (isIdKey(elementKey)) {
return this.sceneMapById.get(elementKey) || null;
}
return this.sceneMapByElement.get(elementKey) || null;
}
// ---------------------------------------------------------------------------
// instance methods/props
// ---------------------------------------------------------------------------
private callbacks: Set<SceneStateCallback> = new Set();
private nonDeletedElements: readonly Ordered<NonDeletedExcalidrawElement>[] =
[];
private nonDeletedElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
new Map(),
);
// ideally all elements within the scene should be wrapped around with `Ordered` type, but right now there is no real benefit doing so
private elements: readonly OrderedExcalidrawElement[] = [];
private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
[];
private frames: readonly ExcalidrawFrameLikeElement[] = [];
private elementsMap = toBrandedType<SceneElementsMap>(new Map());
private selectedElementsCache: {
selectedElementIds: AppState["selectedElementIds"] | null;
elements: readonly NonDeletedExcalidrawElement[] | null;
cache: Map<SelectionHash, NonDeletedExcalidrawElement[]>;
} = {
selectedElementIds: null,
elements: null,
cache: new Map(),
};
/**
* Random integer regenerated each scene update.
*
* Does not relate to elements versions, it's only a renderer
* cache-invalidation nonce at the moment.
*/
private sceneNonce: number | undefined;
getSceneNonce() {
return this.sceneNonce;
}
getNonDeletedElementsMap() {
return this.nonDeletedElementsMap;
}
getElementsIncludingDeleted() {
return this.elements;
}
getElementsMapIncludingDeleted() {
return this.elementsMap;
}
getNonDeletedElements() {
return this.nonDeletedElements;
}
getFramesIncludingDeleted() {
return this.frames;
}
getSelectedElements(opts: {
// NOTE can be ommitted by making Scene constructor require App instance
selectedElementIds: AppState["selectedElementIds"];
/**
* for specific cases where you need to use elements not from current
* scene state. This in effect will likely result in cache-miss, and
* the cache won't be updated in this case.
*/
elements?: ElementsMapOrArray;
// selection-related options
includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean;
}): NonDeleted<ExcalidrawElement>[] {
const hash = hashSelectionOpts(opts);
const elements = opts?.elements || this.nonDeletedElements;
if (
this.selectedElementsCache.elements === elements &&
this.selectedElementsCache.selectedElementIds === opts.selectedElementIds
) {
const cached = this.selectedElementsCache.cache.get(hash);
if (cached) {
return cached;
}
} else if (opts?.elements == null) {
// if we're operating on latest scene elements and the cache is not
// storing the latest elements, clear the cache
this.selectedElementsCache.cache.clear();
}
const selectedElements = getSelectedElements(
elements,
{ selectedElementIds: opts.selectedElementIds },
opts,
);
// cache only if we're not using custom elements
if (opts?.elements == null) {
this.selectedElementsCache.selectedElementIds = opts.selectedElementIds;
this.selectedElementsCache.elements = this.nonDeletedElements;
this.selectedElementsCache.cache.set(hash, selectedElements);
}
return selectedElements;
}
getNonDeletedFramesLikes(): readonly NonDeleted<ExcalidrawFrameLikeElement>[] {
return this.nonDeletedFramesLikes;
}
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
return (this.elementsMap.get(id) as T | undefined) || null;
}
getNonDeletedElement(
id: ExcalidrawElement["id"],
): NonDeleted<ExcalidrawElement> | null {
const element = this.getElement(id);
if (element && isNonDeletedElement(element)) {
return element;
}
return null;
}
/**
* A utility method to help with updating all scene elements, with the added
* performance optimization of not renewing the array if no change is made.
*
* Maps all current excalidraw elements, invoking the callback for each
* element. The callback should either return a new mapped element, or the
* original element if no changes are made. If no changes are made to any
* element, this results in a no-op. Otherwise, the newly mapped elements
* are set as the next scene's elements.
*
* @returns whether a change was made
*/
mapElements(
iteratee: (element: ExcalidrawElement) => ExcalidrawElement,
): boolean {
let didChange = false;
const newElements = this.elements.map((element) => {
const nextElement = iteratee(element);
if (nextElement !== element) {
didChange = true;
}
return nextElement;
});
if (didChange) {
this.replaceAllElements(newElements);
}
return didChange;
}
replaceAllElements(nextElements: ElementsMapOrArray) {
const _nextElements = isReadonlyArray(nextElements)
? nextElements
: Array.from(nextElements.values());
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements);
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();
this.elements.forEach((element) => {
if (isFrameLikeElement(element)) {
nextFrameLikes.push(element);
}
this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this);
});
const nonDeletedElements = getNonDeletedElements(this.elements);
this.nonDeletedElements = nonDeletedElements.elements;
this.nonDeletedElementsMap = nonDeletedElements.elementsMap;
this.frames = nextFrameLikes;
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements;
this.triggerUpdate();
}
triggerUpdate() {
this.sceneNonce = randomInteger();
for (const callback of Array.from(this.callbacks)) {
callback();
}
}
onUpdate(cb: SceneStateCallback): SceneStateCallbackRemover {
if (this.callbacks.has(cb)) {
throw new Error();
}
this.callbacks.add(cb);
return () => {
if (!this.callbacks.has(cb)) {
throw new Error();
}
this.callbacks.delete(cb);
};
}
destroy() {
this.elements = [];
this.nonDeletedElements = [];
this.nonDeletedFramesLikes = [];
this.frames = [];
this.elementsMap.clear();
this.selectedElementsCache.selectedElementIds = null;
this.selectedElementsCache.elements = null;
this.selectedElementsCache.cache.clear();
Scene.sceneMapById.forEach((scene, elementKey) => {
if (scene === this) {
Scene.sceneMapById.delete(elementKey);
}
});
// done not for memory leaks, but to guard against possible late fires
// (I guess?)
this.callbacks.clear();
}
insertElementAtIndex(element: ExcalidrawElement, index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
if (!elements.length) {
return;
}
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
...elements,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap(elements));
this.replaceAllElements(nextElements);
}
insertElement = (element: ExcalidrawElement) => {
const index = element.frameId
? this.getElementIndex(element.frameId)
: this.elements.length;
this.insertElementAtIndex(element, index);
};
insertElements = (elements: ExcalidrawElement[]) => {
if (!elements.length) {
return;
}
const index = elements[0]?.frameId
? this.getElementIndex(elements[0].frameId)
: this.elements.length;
this.insertElementsAtIndex(elements, index);
};
getElementIndex(elementId: string) {
return this.elements.findIndex((element) => element.id === elementId);
}
getContainerElement = (
element:
| (ExcalidrawElement & {
containerId: ExcalidrawElement["id"] | null;
})
| null,
) => {
if (!element) {
return null;
}
if (element.containerId) {
return this.getElement(element.containerId) || null;
}
return null;
};
getElementsFromId = (id: string): ExcalidrawElement[] => {
const elementsMap = this.getNonDeletedElementsMap();
// first check if the id is an element
const el = elementsMap.get(id);
if (el) {
return [el];
}
// then, check if the id is a group
return getElementsInGroup(elementsMap, id);
};
}
export default Scene;

View file

@ -7490,7 +7490,7 @@ History {
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of elements 1`] = `1`;
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `10`;
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `9`;
exports[`history > multiplayer undo/redo > should iterate through the history when when element change relates to remotely deleted element > [end of test] appState 1`] = `
{
@ -10561,7 +10561,7 @@ History {
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`;
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `15`;
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `14`;
exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] appState 1`] = `
{
@ -20188,4 +20188,4 @@ History {
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of elements 1`] = `1`;
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `21`;
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `20`;

View file

@ -50,7 +50,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
"type": "arrow",
"updated": 1,
"version": 8,
"versionNonce": 1604849351,
"versionNonce": 400692809,
"width": 70,
"x": 30,
"y": 30,
@ -106,7 +106,7 @@ exports[`multi point mode in linear elements > line 3`] = `
"type": "line",
"updated": 1,
"version": 8,
"versionNonce": 1604849351,
"versionNonce": 400692809,
"width": 70,
"x": 30,
"y": 30,

View file

@ -6832,7 +6832,7 @@ History {
exports[`regression tests > draw every type of shape > [end of test] number of elements 1`] = `0`;
exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `33`;
exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `31`;
exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] appState 1`] = `
{
@ -14550,7 +14550,7 @@ History {
exports[`regression tests > undo/redo drawing an element > [end of test] number of elements 1`] = `0`;
exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `20`;
exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `19`;
exports[`regression tests > updates fontSize & fontFamily appState > [end of test] appState 1`] = `
{

View file

@ -313,7 +313,7 @@ describe("Test dragCreate", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@ -342,7 +342,7 @@ describe("Test dragCreate", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});

View file

@ -1,7 +1,5 @@
import React from "react";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { KEYS } from "@excalidraw/common";
import { actionSelectAll } from "../actions";
@ -298,7 +296,7 @@ describe("element locking", () => {
height: textSize,
containerId: container.id,
});
mutateElement(container, {
h.app.scene.mutateElement(container, {
boundElements: [{ id: text.id, type: "text" }],
});
@ -339,7 +337,7 @@ describe("element locking", () => {
containerId: container.id,
locked: true,
});
mutateElement(container, {
h.app.scene.mutateElement(container, {
boundElements: [{ id: text.id, type: "text" }],
});
API.setElements([container, text]);
@ -373,7 +371,7 @@ describe("element locking", () => {
containerId: container.id,
locked: true,
});
mutateElement(container, {
h.app.scene.mutateElement(container, {
boundElements: [{ id: text.id, type: "text" }],
});
API.setElements([container, text]);

View file

@ -6,7 +6,6 @@ import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS, assertNever } from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element/mutateElement";
import {
newArrowElement,
newElement,
@ -100,10 +99,10 @@ export class API {
// eslint-disable-next-line prettier/prettier
static updateElement = <T extends ExcalidrawElement>(
...args: Parameters<typeof mutateElement<T>>
...args: Parameters<typeof h.app.scene.mutateElement<T>>
) => {
act(() => {
mutateElement<T>(...args);
h.app.scene.mutateElement(...args);
});
};
@ -419,12 +418,11 @@ export class API {
});
mutateElement(
h.app.scene.mutateElement(
rectangle,
{
boundElements: [{ type: "text", id: text.id }],
},
false,
);
return [rectangle, text];
@ -453,12 +451,11 @@ export class API {
: opts?.label?.frameId ?? null,
});
mutateElement(
h.app.scene.mutateElement(
arrow,
{
boundElements: [{ type: "text", id: text.id }],
},
false,
);
return [arrow, text];

View file

@ -5,7 +5,6 @@ import {
getElementPointsCoords,
} from "@excalidraw/element/bounds";
import { cropElement } from "@excalidraw/element/cropElement";
import { mutateElement } from "@excalidraw/element/mutateElement";
import {
getTransformHandles,
getTransformHandlesFromCoords,
@ -526,7 +525,7 @@ export class UI {
if (angle !== 0) {
act(() => {
mutateElement(origElement, { angle });
h.app.scene.mutateElement(origElement, { angle });
});
}

View file

@ -30,7 +30,7 @@ import type {
FontString,
} from "@excalidraw/element/types";
import { Excalidraw, mutateElement } from "../index";
import { Excalidraw } from "../index";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import * as StaticScene from "../renderer/staticScene";
import { API } from "../tests/helpers/api";
@ -118,7 +118,7 @@ describe("Test Linear Elements", () => {
],
roundness,
});
mutateElement(line, { points: line.points });
h.app.scene.mutateElement(line, { points: line.points });
API.setElements([line]);
mouse.clickAt(p1[0], p1[1]);
return line;
@ -177,7 +177,7 @@ describe("Test Linear Elements", () => {
pointFrom<LocalPoint>(0.5, 0),
pointFrom<LocalPoint>(100, 100),
]);
new LinearElementEditor(element);
new LinearElementEditor(element, arrayToMap(h.elements));
expect(element.points).toEqual([
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(99.5, 100),
@ -1271,7 +1271,7 @@ describe("Test Linear Elements", () => {
expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
h.elements[0],
arrayToMap(h.elements),
h.app.scene,
"nw",
false,
);
@ -1384,7 +1384,7 @@ describe("Test Linear Elements", () => {
const [origStartX, origStartY] = [line.x, line.y];
act(() => {
LinearElementEditor.movePoints(line, [
LinearElementEditor.movePoints(line, h.app.scene, [
{
index: 0,
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),

View file

@ -13,8 +13,6 @@ import type {
ExcalidrawRectangleElement,
} from "@excalidraw/element/types";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import { Excalidraw } from "../index";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import * as StaticScene from "../renderer/staticScene";
@ -85,15 +83,13 @@ describe("move element", () => {
const rectA = UI.createElement("rectangle", { size: 100 });
const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 });
const arrow = UI.createElement("arrow", { x: 110, y: 50, size: 80 });
const elementsMap = h.app.scene.getNonDeletedElementsMap();
act(() => {
// bind line to two rectangles
bindOrUnbindLinearElement(
arrow.get() as NonDeleted<ExcalidrawLinearElement>,
rectA.get() as ExcalidrawRectangleElement,
rectB.get() as ExcalidrawRectangleElement,
elementsMap,
{} as Scene,
h.app.scene,
);
});
@ -170,8 +166,6 @@ describe("duplicate element on move when ALT is clicked", () => {
fireEvent.pointerMove(canvas, { clientX: 10, clientY: 60 });
fireEvent.pointerUp(canvas);
// TODO: This used to be 4, but binding made it go up to 5. Do we need
// that additional render?
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`4`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`3`);
expect(h.state.selectionElement).toBeNull();

View file

@ -119,7 +119,7 @@ describe("multi point mode in linear elements", () => {
});
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;
@ -162,7 +162,7 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER,
});
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;

View file

@ -784,6 +784,7 @@ export type UnsubscribeCallback = () => void;
export interface ExcalidrawImperativeAPI {
updateScene: InstanceType<typeof App>["updateScene"];
mutateElement: InstanceType<typeof App>["mutateElement"];
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
resetScene: InstanceType<typeof App>["resetScene"];
getSceneElementsIncludingDeleted: InstanceType<

View file

@ -15,7 +15,7 @@ import {
} from "@excalidraw/element/containerCache";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { bumpVersion, mutateElement } from "@excalidraw/element/mutateElement";
import { bumpVersion } from "@excalidraw/element/mutateElement";
import {
getBoundTextElementId,
getContainerElement,
@ -200,7 +200,7 @@ export const textWysiwyg = ({
container.type,
);
mutateElement(container, { height: targetContainerHeight });
app.scene.mutateElement(container, { height: targetContainerHeight });
return;
} else if (
// autoshrink container height until original container height
@ -213,7 +213,7 @@ export const textWysiwyg = ({
height,
container.type,
);
mutateElement(container, { height: targetContainerHeight });
app.scene.mutateElement(container, { height: targetContainerHeight });
} else {
const { y } = computeBoundTextPosition(
container,
@ -286,7 +286,7 @@ export const textWysiwyg = ({
editable.style.fontFamily = getFontFamilyString(updatedTextElement);
}
mutateElement(updatedTextElement, { x: coordX, y: coordY });
app.scene.mutateElement(updatedTextElement, { x: coordX, y: coordY });
}
};
@ -558,7 +558,7 @@ export const textWysiwyg = ({
if (editable.value.trim()) {
const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId || boundTextElementId !== element.id) {
mutateElement(container, {
app.scene.mutateElement(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: element.id,
@ -569,7 +569,7 @@ export const textWysiwyg = ({
bumpVersion(container);
}
} else {
mutateElement(container, {
app.scene.mutateElement(container, {
boundElements: container.boundElements?.filter(
(ele) =>
!isTextElement(
@ -578,11 +578,8 @@ export const textWysiwyg = ({
),
});
}
redrawTextBoundingBox(
updateElement,
container,
app.scene.getNonDeletedElementsMap(),
);
redrawTextBoundingBox(updateElement, container, app.scene);
}
onSubmit({