chore: Remove editingLinearElement (#9771)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács
2025-07-24 17:02:21 +02:00
committed by GitHub
parent 416da62138
commit d6a934ed19
25 changed files with 348 additions and 398 deletions

View File

@@ -2,6 +2,7 @@ import {
arrayToMap,
arrayToObject,
assertNever,
invariant,
isDevEnv,
isShallowEqual,
isTestEnv,
@@ -548,7 +549,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
selectedElementIds: addedSelectedElementIds = {},
selectedGroupIds: addedSelectedGroupIds = {},
selectedLinearElementId,
editingLinearElementId,
selectedLinearElementIsEditing,
...directlyApplicablePartial
} = this.delta.inserted;
@@ -564,39 +565,46 @@ export class AppStateDelta implements DeltaContainer<AppState> {
removedSelectedGroupIds,
);
const selectedLinearElement =
selectedLinearElementId && nextElements.has(selectedLinearElementId)
? new LinearElementEditor(
nextElements.get(
selectedLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
)
: null;
let selectedLinearElement = appState.selectedLinearElement;
const editingLinearElement =
editingLinearElementId && nextElements.has(editingLinearElementId)
? new LinearElementEditor(
nextElements.get(
editingLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
)
: null;
if (selectedLinearElementId === null) {
// Unselect linear element (visible change)
selectedLinearElement = null;
} else if (
selectedLinearElementId &&
nextElements.has(selectedLinearElementId)
) {
selectedLinearElement = new LinearElementEditor(
nextElements.get(
selectedLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false
);
}
if (
// Value being 'null' is equivaluent to unknown in this case because it only gets set
// to null when 'selectedLinearElementId' is set to null
selectedLinearElementIsEditing != null
) {
invariant(
selectedLinearElement,
`selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`,
);
selectedLinearElement = {
...selectedLinearElement,
isEditing: selectedLinearElementIsEditing,
};
}
const nextAppState = {
...appState,
...directlyApplicablePartial,
selectedElementIds: mergedSelectedElementIds,
selectedGroupIds: mergedSelectedGroupIds,
selectedLinearElement:
typeof selectedLinearElementId !== "undefined"
? selectedLinearElement // element was either inserted or deleted
: appState.selectedLinearElement, // otherwise assign what we had before
editingLinearElement:
typeof editingLinearElementId !== "undefined"
? editingLinearElement // element was either inserted or deleted
: appState.editingLinearElement, // otherwise assign what we had before
selectedLinearElement,
};
const constainsVisibleChanges = this.filterInvisibleChanges(
@@ -725,8 +733,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
}
break;
case "selectedLinearElementId":
case "editingLinearElementId":
case "selectedLinearElementId": {
const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
@@ -746,6 +753,19 @@ export class AppStateDelta implements DeltaContainer<AppState> {
}
break;
}
case "selectedLinearElementIsEditing": {
// Changes in editing state are always visible
const prevIsEditing =
prevAppState.selectedLinearElement?.isEditing ?? false;
const nextIsEditing =
nextAppState.selectedLinearElement?.isEditing ?? false;
if (prevIsEditing !== nextIsEditing) {
visibleDifferenceFlag.value = true;
}
break;
}
case "lockedMultiSelections": {
const prevLockedUnits = prevAppState[key] || {};
const nextLockedUnits = nextAppState[key] || {};
@@ -779,16 +799,11 @@ export class AppStateDelta implements DeltaContainer<AppState> {
}
private static convertToAppStateKey(
key: keyof Pick<
ObservedElementsAppState,
"selectedLinearElementId" | "editingLinearElementId"
>,
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
key: keyof Pick<ObservedElementsAppState, "selectedLinearElementId">,
): keyof Pick<AppState, "selectedLinearElement"> {
switch (key) {
case "selectedLinearElementId":
return "selectedLinearElement";
case "editingLinearElementId":
return "editingLinearElement";
}
}
@@ -856,8 +871,8 @@ export class AppStateDelta implements DeltaContainer<AppState> {
editingGroupId,
selectedGroupIds,
selectedElementIds,
editingLinearElementId,
selectedLinearElementId,
selectedLinearElementIsEditing,
croppingElementId,
lockedMultiSelections,
activeLockedId,

View File

@@ -149,10 +149,12 @@ export class LinearElementEditor {
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean;
public readonly customLineAngle: number | null;
public readonly isEditing: boolean;
constructor(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
isEditing: boolean = false,
) {
this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId";
@@ -187,6 +189,7 @@ export class LinearElementEditor {
this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null;
this.isEditing = isEditing;
}
// ---------------------------------------------------------------------------
@@ -194,6 +197,7 @@ export class LinearElementEditor {
// ---------------------------------------------------------------------------
static POINT_HANDLE_SIZE = 10;
/**
* @param id the `elementId` from the instance of this class (so that we can
* statically guarantee this method returns an ExcalidrawLinearElement)
@@ -215,11 +219,14 @@ export class LinearElementEditor {
setState: React.Component<any, AppState>["setState"],
elementsMap: NonDeletedSceneElementsMap,
) {
if (!appState.editingLinearElement || !appState.selectionElement) {
if (
!appState.selectedLinearElement?.isEditing ||
!appState.selectionElement
) {
return false;
}
const { editingLinearElement } = appState;
const { selectedPointsIndices, elementId } = editingLinearElement;
const { selectedLinearElement } = appState;
const { selectedPointsIndices, elementId } = selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
@@ -260,8 +267,8 @@ export class LinearElementEditor {
});
setState({
editingLinearElement: {
...editingLinearElement,
selectedLinearElement: {
...selectedLinearElement,
selectedPointsIndices: nextSelectedPoints.length
? nextSelectedPoints
: null,
@@ -479,9 +486,6 @@ export class LinearElementEditor {
return {
...app.state,
editingLinearElement: app.state.editingLinearElement
? newLinearElementEditor
: null,
selectedLinearElement: newLinearElementEditor,
suggestedBindings,
};
@@ -618,7 +622,7 @@ export class LinearElementEditor {
// Since its not needed outside editor unless 2 pointer lines or bound text
if (
!isElbowArrow(element) &&
!appState.editingLinearElement &&
!appState.selectedLinearElement?.isEditing &&
element.points.length > 2 &&
!boundText
) {
@@ -684,7 +688,7 @@ export class LinearElementEditor {
);
if (
points.length >= 3 &&
!appState.editingLinearElement &&
!appState.selectedLinearElement?.isEditing &&
!isElbowArrow(element)
) {
return null;
@@ -881,7 +885,7 @@ export class LinearElementEditor {
segmentMidpoint,
elementsMap,
);
} else if (event.altKey && appState.editingLinearElement) {
} else if (event.altKey && appState.selectedLinearElement?.isEditing) {
if (linearElementEditor.lastUncommittedPoint == null) {
scene.mutateElement(element, {
points: [
@@ -1023,14 +1027,14 @@ export class LinearElementEditor {
app: AppClassProperties,
): LinearElementEditor | null {
const appState = app.state;
if (!appState.editingLinearElement) {
if (!appState.selectedLinearElement?.isEditing) {
return null;
}
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return appState.editingLinearElement;
return appState.selectedLinearElement;
}
const { points } = element;
@@ -1040,12 +1044,12 @@ export class LinearElementEditor {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
}
return appState.editingLinearElement.lastUncommittedPoint
return appState.selectedLinearElement?.lastUncommittedPoint
? {
...appState.editingLinearElement,
...appState.selectedLinearElement,
lastUncommittedPoint: null,
}
: appState.editingLinearElement;
: appState.selectedLinearElement;
}
let newPoint: LocalPoint;
@@ -1069,8 +1073,8 @@ export class LinearElementEditor {
newPoint = LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - appState.editingLinearElement.pointerOffset.x,
scenePointerY - appState.editingLinearElement.pointerOffset.y,
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null
: app.getEffectiveGridSize(),
@@ -1094,7 +1098,7 @@ export class LinearElementEditor {
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
}
return {
...appState.editingLinearElement,
...appState.selectedLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1],
};
}
@@ -1253,12 +1257,12 @@ export class LinearElementEditor {
// ---------------------------------------------------------------------------
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
invariant(
appState.editingLinearElement,
appState.selectedLinearElement?.isEditing,
"Not currently editing a linear element",
);
const elementsMap = scene.getNonDeletedElementsMap();
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
invariant(
@@ -1320,8 +1324,8 @@ export class LinearElementEditor {
return {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
selectedLinearElement: {
...appState.selectedLinearElement,
selectedPointsIndices: nextSelectedIndices,
},
};
@@ -1333,8 +1337,9 @@ export class LinearElementEditor {
pointIndices: readonly number[],
) {
const isUncommittedPoint =
app.state.editingLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
app.state.selectedLinearElement?.isEditing &&
app.state.selectedLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
const nextPoints = element.points.filter((_, idx) => {
return !pointIndices.includes(idx);
@@ -1507,7 +1512,7 @@ export class LinearElementEditor {
pointFrom(pointerCoords.x, pointerCoords.y),
);
if (
!appState.editingLinearElement &&
!appState.selectedLinearElement?.isEditing &&
dist < DRAGGING_THRESHOLD / appState.zoom.value
) {
return false;

View File

@@ -978,8 +978,8 @@ const getDefaultObservedAppState = (): ObservedAppState => {
viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {},
selectedGroupIds: {},
editingLinearElementId: null,
selectedLinearElementId: null,
selectedLinearElementIsEditing: null,
croppingElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
@@ -998,14 +998,14 @@ export const getObservedAppState = (
croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections,
editingLinearElementId:
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
null,
selectedLinearElementId:
(appState as AppState).selectedLinearElement?.elementId ??
(appState as ObservedAppState).selectedLinearElementId ??
null,
selectedLinearElementIsEditing:
(appState as AppState).selectedLinearElement?.isEditing ??
(appState as ObservedAppState).selectedLinearElementIsEditing ??
null,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {

View File

@@ -330,7 +330,7 @@ export const shouldShowBoundingBox = (
elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState,
) => {
if (appState.editingLinearElement) {
if (appState.selectedLinearElement?.isEditing) {
return false;
}
if (elements.length > 1) {

View File

@@ -155,10 +155,10 @@ describe("element binding", () => {
// NOTE this mouse down/up + await needs to be done in order to repro
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
mouse.reset();
expect(h.state.editingLinearElement).not.toBe(null);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
mouse.down(0, 0);
await new Promise((r) => setTimeout(r, 100));
expect(h.state.editingLinearElement).toBe(null);
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(API.getSelectedElement().type).toBe("rectangle");
mouse.up();
expect(API.getSelectedElement().type).toBe("rectangle");

View File

@@ -16,6 +16,7 @@ describe("AppStateDelta", () => {
editingGroupId: null,
croppingElementId: null,
editingLinearElementId: null,
selectedLinearElementIsEditing: null,
lockedMultiSelections: {},
activeLockedId: null,
};
@@ -58,6 +59,7 @@ describe("AppStateDelta", () => {
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
@@ -105,6 +107,7 @@ describe("AppStateDelta", () => {
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
activeLockedId: null,
lockedMultiSelections: {},

View File

@@ -136,7 +136,8 @@ describe("Test Linear Elements", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
};
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
@@ -253,75 +254,82 @@ describe("Test Linear Elements", () => {
});
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
// ctrl+enter alias (to align with arrows)
it("should enter line editor via ctrl+enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor via ctrl+enter (arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on dblclick (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should not enter line editor on dblclick (arrow)", async () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.doubleClick();
expect(h.state.editingLinearElement).toEqual(null);
expect(h.state.selectedLinearElement).toBe(null);
await getTextEditor();
});
@@ -330,10 +338,12 @@ describe("Test Linear Elements", () => {
const arrow = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(arrow);
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
expect(h.elements.length).toEqual(1);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);