fix: arrow restoration during undo/redo

This commit is contained in:
psmyrdek 2025-03-15 12:47:29 +01:00
parent 30983d801a
commit 56e044fcc9
3 changed files with 180 additions and 56 deletions

View file

@ -17,6 +17,7 @@ import {
isBoundToContainer, isBoundToContainer,
isImageElement, isImageElement,
isTextElement, isTextElement,
isArrowElement,
} from "./element/typeChecks"; } from "./element/typeChecks";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { getNonDeletedGroupIds } from "./groups"; import { getNonDeletedGroupIds } from "./groups";
@ -393,7 +394,7 @@ class Delta<T> {
} }
/** /**
* Encapsulates the modifications captured as `Delta`/s. * Encapsulates the modifications captured as `Delta`s.
*/ */
interface Change<T> { interface Change<T> {
/** /**
@ -1212,12 +1213,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
flags: { flags: {
containsVisibleDifference: boolean; containsVisibleDifference: boolean;
containsZindexDifference: boolean; containsZindexDifference: boolean;
} = {
// by default we don't care about about the flags
containsVisibleDifference: true,
containsZindexDifference: true,
}, },
) { ): OrderedExcalidrawElement {
const { boundElements, ...directlyApplicablePartial } = delta.inserted; const { boundElements, ...directlyApplicablePartial } = delta.inserted;
if ( if (
@ -1262,6 +1259,38 @@ export class ElementsChange implements Change<SceneElementsMap> {
delta.deleted.index !== delta.inserted.index; delta.deleted.index !== delta.inserted.index;
} }
// Fix for arrow points preservation during undo/redo
if (
element.type === "arrow" &&
isArrowElement(element) &&
(directlyApplicablePartial as any).points &&
!directlyApplicablePartial.isDeleted
) {
// Only update points if there's a binding change
if (
((directlyApplicablePartial as any).startBinding !== undefined &&
(directlyApplicablePartial as any).startBinding !==
element.startBinding) ||
((directlyApplicablePartial as any).endBinding !== undefined &&
(directlyApplicablePartial as any).endBinding !== element.endBinding)
) {
// Let the points be updated by the delta
return newElementWith(
element,
directlyApplicablePartial as ElementUpdate<typeof element>,
);
}
// Otherwise preserve the original points
const partialWithoutPoints = { ...directlyApplicablePartial };
delete (partialWithoutPoints as any).points;
return newElementWith(
element,
partialWithoutPoints as ElementUpdate<typeof element>,
);
}
return newElementWith(element, directlyApplicablePartial); return newElementWith(element, directlyApplicablePartial);
} }
@ -1496,6 +1525,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
if (!element.isDeleted && isBindableElement(element)) { if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements, { updateBoundElements(element, elements, {
changedElements: changed, changedElements: changed,
preservePoints: true, // Preserve arrow points during undo/redo
}); });
} }
} }

View file

@ -487,8 +487,20 @@ export const bindLinearElement = (
return; return;
} }
let binding: PointBinding | FixedPointBinding = { const binding: PointBinding | FixedPointBinding = {
elementId: hoveredElement.id, elementId: hoveredElement.id,
...(isElbowArrow(linearElement)
? {
...calculateFixedPointForElbowArrowBinding(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
focus: 0,
gap: 0,
}
: {
...normalizePointBinding( ...normalizePointBinding(
calculateFocusAndGap( calculateFocusAndGap(
linearElement, linearElement,
@ -498,20 +510,9 @@ export const bindLinearElement = (
), ),
hoveredElement, hoveredElement,
), ),
}),
}; };
if (isElbowArrow(linearElement)) {
binding = {
...binding,
...calculateFixedPointForElbowArrowBinding(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
};
}
mutateElement(linearElement, { mutateElement(linearElement, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding, [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
}); });
@ -739,9 +740,14 @@ export const updateBoundElements = (
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, OrderedExcalidrawElement>;
preservePoints?: boolean; // Add this option to preserve arrow points during undo/redo
}, },
) => { ) => {
const { newSize, simultaneouslyUpdated } = options ?? {}; const {
newSize,
simultaneouslyUpdated,
preservePoints = false,
} = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated, simultaneouslyUpdated,
); );
@ -794,6 +800,19 @@ export const updateBoundElements = (
return; return;
} }
// If preservePoints is true, only update the bindings without changing the points
if (preservePoints && isArrowElement(element)) {
mutateElement(element, {
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
});
return;
}
const updates = bindableElementsVisitor( const updates = bindableElementsVisitor(
elementsMap, elementsMap,
element, element,
@ -1271,16 +1290,13 @@ const updateBoundPoint = (
pointDistance(adjacentPoint, edgePointAbsolute) + pointDistance(adjacentPoint, edgePointAbsolute) +
pointDistance(adjacentPoint, center) + pointDistance(adjacentPoint, center) +
Math.max(bindableElement.width, bindableElement.height) * 2; Math.max(bindableElement.width, bindableElement.height) * 2;
const intersections = [ const intersections = intersectElementWithLineSegment(
...intersectElementWithLineSegment(
bindableElement, bindableElement,
lineSegment<GlobalPoint>( lineSegment<GlobalPoint>(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
vectorScale( vectorScale(
vectorNormalize( vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
vectorFromPoint(focusPointAbsolute, adjacentPoint),
),
interceptorLength, interceptorLength,
), ),
adjacentPoint, adjacentPoint,
@ -1290,16 +1306,23 @@ const updateBoundPoint = (
).sort( ).sort(
(g, h) => (g, h) =>
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint), pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
), );
// Fallback when arrow doesn't point to the shape
pointFromVector( // debugClear();
vectorScale( // debugDrawPoint(intersections[0], { color: "red", permanent: true });
vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)), // debugDrawLine(
pointDistance(adjacentPoint, edgePointAbsolute), // lineSegment<GlobalPoint>(
), // adjacentPoint,
adjacentPoint, // pointFromVector(
), // vectorScale(
]; // vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
// interceptorLength,
// ),
// adjacentPoint,
// ),
// ),
// { permanent: true, color: "green" },
// );
if (intersections.length > 1) { if (intersections.length > 1) {
// The adjacent point is outside the shape (+ gap) // The adjacent point is outside the shape (+ gap)
@ -1722,6 +1745,21 @@ const determineFocusDistance = (
) )
.sort((g, h) => Math.abs(g) - Math.abs(h)); .sort((g, h) => Math.abs(g) - Math.abs(h));
// debugClear();
// [
// lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[0]),
// lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[1]),
// ]
// .filter((p): p is GlobalPoint => p !== null)
// .forEach((p) => debugDrawPoint(p, { color: "black", permanent: true }));
// debugDrawPoint(determineFocusPoint(element, ordered[0] ?? 0, rotatedA), {
// color: "red",
// permanent: true,
// });
// debugDrawLine(rotatedInterceptor, { color: "green", permanent: true });
// debugDrawLine(interceptees[0], { color: "red", permanent: true });
// debugDrawLine(interceptees[1], { color: "red", permanent: true });
const signedDistanceRatio = ordered[0] ?? 0; const signedDistanceRatio = ordered[0] ?? 0;
return signedDistanceRatio; return signedDistanceRatio;

View file

@ -0,0 +1,56 @@
import React from "react";
import { vi } from "vitest";
import { pointFrom } from "@excalidraw/math";
import { Excalidraw } from "../index";
import { KEYS } from "../keys";
import { API } from "./helpers/api";
import { UI } from "./helpers/ui";
import { Keyboard, Pointer } from "./helpers/ui";
import { render } from "./test-utils";
const mouse = new Pointer("mouse");
describe("arrow undo/redo", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should maintain arrow shape after undo/redo", () => {
// Create a rectangle
const rectangle = UI.createElement("rectangle", {
x: 100,
y: 100,
width: 100,
height: 100,
});
// Create an arrow starting from rectangle border
const arrow = UI.createElement("arrow", {
x: 150,
y: 100,
width: 100,
height: 50,
points: [pointFrom(0, 0), pointFrom(100, 50)],
});
// Store original arrow points
const originalPoints = [...arrow.points];
// Perform undo
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
// Perform redo
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
// Verify arrow points are exactly the same after redo
expect(arrow.points).toEqual(originalPoints);
});
});