feat: multiplayer undo / redo (#7348)

This commit is contained in:
Marcel Mraz 2024-04-17 13:01:24 +01:00 committed by GitHub
parent 5211b003b8
commit 530617be90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 34885 additions and 14877 deletions

View file

@ -9,7 +9,6 @@ import {
ExcalidrawElement,
ExcalidrawRectangleElement,
ExcalidrawDiamondElement,
ExcalidrawTextElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
@ -21,6 +20,8 @@ import {
NonDeletedExcalidrawElement,
ElementsMap,
NonDeletedSceneElementsMap,
ExcalidrawTextElement,
ExcalidrawArrowElement,
} from "./types";
import { getElementAbsoluteCoords } from "./bounds";
@ -28,11 +29,14 @@ import { AppClassProperties, AppState, Point } from "../types";
import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene";
import {
isArrowElement,
isBindableElement,
isBindingElement,
isBoundToContainer,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { ElementUpdate, mutateElement } from "./mutateElement";
import Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
@ -365,71 +369,71 @@ const calculateFocusAndGap = (
export const updateBoundElements = (
changedElement: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
},
) => {
const boundLinearElements = (changedElement.boundElements ?? []).filter(
(el) => el.type === "arrow",
);
if (boundLinearElements.length === 0) {
return;
}
const { newSize, simultaneouslyUpdated } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
const scene = Scene.getScene(changedElement)!;
getNonDeletedElements(
scene,
boundLinearElements.map((el) => el.id),
).forEach((element) => {
if (!isLinearElement(element)) {
if (!isBindableElement(changedElement)) {
return;
}
boundElementsVisitor(elementsMap, changedElement, (element) => {
if (!isLinearElement(element) || element.isDeleted) {
return;
}
const bindableElement = changedElement as ExcalidrawBindableElement;
// In case the boundElements are stale
if (!doesNeedUpdate(element, bindableElement)) {
if (!doesNeedUpdate(element, changedElement)) {
return;
}
const startBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.startBinding,
newSize,
);
const endBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.endBinding,
newSize,
);
const bindings = {
startBinding: maybeCalculateNewGapWhenScaling(
changedElement,
element.startBinding,
newSize,
),
endBinding: maybeCalculateNewGapWhenScaling(
changedElement,
element.endBinding,
newSize,
),
};
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, { startBinding, endBinding });
mutateElement(element, bindings);
return;
}
updateBoundPoint(
element,
"start",
startBinding,
changedElement as ExcalidrawBindableElement,
bindableElementsVisitor(
elementsMap,
);
updateBoundPoint(
element,
"end",
endBinding,
changedElement as ExcalidrawBindableElement,
elementsMap,
(bindableElement, bindingProp) => {
if (
bindableElement &&
isBindableElement(bindableElement) &&
(bindingProp === "startBinding" || bindingProp === "endBinding")
) {
updateBoundPoint(
element,
bindingProp,
bindings[bindingProp],
bindableElement,
elementsMap,
);
}
},
);
const boundText = getBoundTextElement(
element,
scene.getNonDeletedElementsMap(),
);
if (boundText) {
handleBindTextResize(element, scene.getNonDeletedElementsMap(), false);
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !boundText.isDeleted) {
handleBindTextResize(element, elementsMap, false);
}
});
};
@ -452,26 +456,21 @@ const getSimultaneouslyUpdatedElementIds = (
const updateBoundPoint = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
startOrEnd: "startBinding" | "endBinding",
binding: PointBinding | null | undefined,
changedElement: ExcalidrawBindableElement,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): void => {
if (
binding == null ||
// We only need to update the other end if this is a 2 point line element
(binding.elementId !== changedElement.id && linearElement.points.length > 2)
(binding.elementId !== bindableElement.id &&
linearElement.points.length > 2)
) {
return;
}
const bindingElement = Scene.getScene(linearElement)!.getElement(
binding.elementId,
) as ExcalidrawBindableElement | null;
if (bindingElement == null) {
// We're not cleaning up after deleted elements atm., so handle this case
return;
}
const direction = startOrEnd === "start" ? -1 : 1;
const direction = startOrEnd === "startBinding" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
const adjacentPointIndex = edgePointIndex - direction;
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
@ -480,7 +479,7 @@ const updateBoundPoint = (
elementsMap,
);
const focusPointAbsolute = determineFocusPoint(
bindingElement,
bindableElement,
binding.focus,
adjacentPoint,
elementsMap,
@ -492,7 +491,7 @@ const updateBoundPoint = (
newEdgePoint = focusPointAbsolute;
} else {
const intersections = intersectElementWithLine(
bindingElement,
bindableElement,
adjacentPoint,
focusPointAbsolute,
binding.gap,
@ -519,7 +518,7 @@ const updateBoundPoint = (
),
},
],
{ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding },
{ [startOrEnd]: binding },
);
};
@ -773,70 +772,37 @@ export const fixBindingsAfterDeletion = (
sceneElements: readonly ExcalidrawElement[],
deletedElements: readonly ExcalidrawElement[],
): void => {
const deletedElementIds = new Set(
deletedElements.map((element) => element.id),
);
// non-deleted which bindings need to be updated
const affectedElements: Set<ExcalidrawElement["id"]> = new Set();
deletedElements.forEach((deletedElement) => {
if (isBindableElement(deletedElement)) {
deletedElement.boundElements?.forEach((element) => {
if (!deletedElementIds.has(element.id)) {
affectedElements.add(element.id);
}
});
} else if (isBindingElement(deletedElement)) {
if (deletedElement.startBinding) {
affectedElements.add(deletedElement.startBinding.elementId);
}
if (deletedElement.endBinding) {
affectedElements.add(deletedElement.endBinding.elementId);
}
}
});
sceneElements
.filter(({ id }) => affectedElements.has(id))
.forEach((element) => {
if (isBindableElement(element)) {
mutateElement(element, {
boundElements: newBoundElementsAfterDeletion(
element.boundElements,
deletedElementIds,
),
});
} else if (isBindingElement(element)) {
mutateElement(element, {
startBinding: newBindingAfterDeletion(
element.startBinding,
deletedElementIds,
),
endBinding: newBindingAfterDeletion(
element.endBinding,
deletedElementIds,
),
});
}
});
};
const elements = arrayToMap(sceneElements);
const newBindingAfterDeletion = (
binding: PointBinding | null,
deletedElementIds: Set<ExcalidrawElement["id"]>,
): PointBinding | null => {
if (binding == null || deletedElementIds.has(binding.elementId)) {
return null;
for (const element of deletedElements) {
BoundElement.unbindAffected(elements, element, mutateElement);
BindableElement.unbindAffected(elements, element, mutateElement);
}
return binding;
};
const newBoundElementsAfterDeletion = (
const newBoundElements = (
boundElements: ExcalidrawElement["boundElements"],
deletedElementIds: Set<ExcalidrawElement["id"]>,
idsToRemove: Set<ExcalidrawElement["id"]>,
elementsToAdd: Array<ExcalidrawElement> = [],
) => {
if (!boundElements) {
return null;
}
return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
const nextBoundElements = boundElements.filter(
(boundElement) => !idsToRemove.has(boundElement.id),
);
nextBoundElements.push(
...elementsToAdd.map(
(x) =>
({ id: x.id, type: x.type } as
| ExcalidrawArrowElement
| ExcalidrawTextElement),
),
);
return nextBoundElements;
};
export const bindingBorderTest = (
@ -1382,3 +1348,306 @@ export const findFocusPointForRectangulars = (
});
return tangentPoint!;
};
export const bindingProperties: Set<BindableProp | BindingProp> = new Set([
"boundElements",
"frameId",
"containerId",
"startBinding",
"endBinding",
]);
export type BindableProp = "boundElements";
export type BindingProp =
| "frameId"
| "containerId"
| "startBinding"
| "endBinding";
type BoundElementsVisitingFunc = (
boundElement: ExcalidrawElement | undefined,
bindingProp: BindableProp,
bindingId: string,
) => void;
type BindableElementVisitingFunc = (
bindableElement: ExcalidrawElement | undefined,
bindingProp: BindingProp,
bindingId: string,
) => void;
/**
* Tries to visit each bound element (does not have to be found).
*/
const boundElementsVisitor = (
elements: ElementsMap,
element: ExcalidrawElement,
visit: BoundElementsVisitingFunc,
) => {
if (isBindableElement(element)) {
// create new instance so that possible mutations won't play a role in visiting order
const boundElements = element.boundElements?.slice() ?? [];
// last added text should be the one we keep (~previous are duplicates)
boundElements.forEach(({ id }) => {
visit(elements.get(id), "boundElements", id);
});
}
};
/**
* Tries to visit each bindable element (does not have to be found).
*/
const bindableElementsVisitor = (
elements: ElementsMap,
element: ExcalidrawElement,
visit: BindableElementVisitingFunc,
) => {
if (element.frameId) {
const id = element.frameId;
visit(elements.get(id), "frameId", id);
}
if (isBoundToContainer(element)) {
const id = element.containerId;
visit(elements.get(id), "containerId", id);
}
if (isArrowElement(element)) {
if (element.startBinding) {
const id = element.startBinding.elementId;
visit(elements.get(id), "startBinding", id);
}
if (element.endBinding) {
const id = element.endBinding.elementId;
visit(elements.get(id), "endBinding", id);
}
}
};
/**
* Bound element containing bindings to `frameId`, `containerId`, `startBinding` or `endBinding`.
*/
export class BoundElement {
/**
* Unbind the affected non deleted bindable elements (removing element from `boundElements`).
* - iterates non deleted bindable elements (`containerId` | `startBinding.elementId` | `endBinding.elementId`) of the current element
* - prepares updates to unbind each bindable element's `boundElements` from the current element
*/
public static unbindAffected(
elements: ElementsMap,
boundElement: ExcalidrawElement | undefined,
updateElementWith: (
affected: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>,
) => void,
) {
if (!boundElement) {
return;
}
bindableElementsVisitor(elements, boundElement, (bindableElement) => {
// bindable element is deleted, this is fine
if (!bindableElement || bindableElement.isDeleted) {
return;
}
boundElementsVisitor(
elements,
bindableElement,
(_, __, boundElementId) => {
if (boundElementId === boundElement.id) {
updateElementWith(bindableElement, {
boundElements: newBoundElements(
bindableElement.boundElements,
new Set([boundElementId]),
),
});
}
},
);
});
}
/**
* Rebind the next affected non deleted bindable elements (adding element to `boundElements`).
* - iterates non deleted bindable elements (`containerId` | `startBinding.elementId` | `endBinding.elementId`) of the current element
* - prepares updates to rebind each bindable element's `boundElements` to the current element
*
* NOTE: rebind expects that affected elements were previously unbound with `BoundElement.unbindAffected`
*/
public static rebindAffected = (
elements: ElementsMap,
boundElement: ExcalidrawElement | undefined,
updateElementWith: (
affected: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>,
) => void,
) => {
// don't try to rebind element that is deleted
if (!boundElement || boundElement.isDeleted) {
return;
}
bindableElementsVisitor(
elements,
boundElement,
(bindableElement, bindingProp) => {
// unbind from bindable elements, as bindings from non deleted elements into deleted elements are incorrect
if (!bindableElement || bindableElement.isDeleted) {
updateElementWith(boundElement, { [bindingProp]: null });
return;
}
// frame bindings are unidirectional, there is nothing to rebind
if (bindingProp === "frameId") {
return;
}
if (
bindableElement.boundElements?.find((x) => x.id === boundElement.id)
) {
return;
}
if (isArrowElement(boundElement)) {
// rebind if not found!
updateElementWith(bindableElement, {
boundElements: newBoundElements(
bindableElement.boundElements,
new Set(),
new Array(boundElement),
),
});
}
if (isTextElement(boundElement)) {
if (!bindableElement.boundElements?.find((x) => x.type === "text")) {
// rebind only if there is no other text bound already
updateElementWith(bindableElement, {
boundElements: newBoundElements(
bindableElement.boundElements,
new Set(),
new Array(boundElement),
),
});
} else {
// unbind otherwise
updateElementWith(boundElement, { [bindingProp]: null });
}
}
},
);
};
}
/**
* Bindable element containing bindings to `boundElements`.
*/
export class BindableElement {
/**
* Unbind the affected non deleted bound elements (resetting `containerId`, `startBinding`, `endBinding` to `null`).
* - iterates through non deleted `boundElements` of the current element
* - prepares updates to unbind each bound element from the current element
*/
public static unbindAffected(
elements: ElementsMap,
bindableElement: ExcalidrawElement | undefined,
updateElementWith: (
affected: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>,
) => void,
) {
if (!bindableElement) {
return;
}
boundElementsVisitor(elements, bindableElement, (boundElement) => {
// bound element is deleted, this is fine
if (!boundElement || boundElement.isDeleted) {
return;
}
bindableElementsVisitor(
elements,
boundElement,
(_, bindingProp, bindableElementId) => {
// making sure there is an element to be unbound
if (bindableElementId === bindableElement.id) {
updateElementWith(boundElement, { [bindingProp]: null });
}
},
);
});
}
/**
* Rebind the affected non deleted bound elements (for now setting only `containerId`, as we cannot rebind arrows atm).
* - iterates through non deleted `boundElements` of the current element
* - prepares updates to rebind each bound element to the current element or unbind it from `boundElements` in case of conflicts
*
* NOTE: rebind expects that affected elements were previously unbound with `BindaleElement.unbindAffected`
*/
public static rebindAffected = (
elements: ElementsMap,
bindableElement: ExcalidrawElement | undefined,
updateElementWith: (
affected: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>,
) => void,
) => {
// don't try to rebind element that is deleted (i.e. updated as deleted)
if (!bindableElement || bindableElement.isDeleted) {
return;
}
boundElementsVisitor(
elements,
bindableElement,
(boundElement, _, boundElementId) => {
// unbind from bindable elements, as bindings from non deleted elements into deleted elements are incorrect
if (!boundElement || boundElement.isDeleted) {
updateElementWith(bindableElement, {
boundElements: newBoundElements(
bindableElement.boundElements,
new Set([boundElementId]),
),
});
return;
}
if (isTextElement(boundElement)) {
const boundElements = bindableElement.boundElements?.slice() ?? [];
// check if this is the last element in the array, if not, there is an previously bound text which should be unbound
if (
boundElements.reverse().find((x) => x.type === "text")?.id ===
boundElement.id
) {
if (boundElement.containerId !== bindableElement.id) {
// rebind if not bound already!
updateElementWith(boundElement, {
containerId: bindableElement.id,
} as ElementUpdate<ExcalidrawTextElement>);
}
} else {
if (boundElement.containerId !== null) {
// unbind if not unbound already
updateElementWith(boundElement, {
containerId: null,
} as ElementUpdate<ExcalidrawTextElement>);
}
// unbind from boundElements as the element got bound to some other element in the meantime
updateElementWith(bindableElement, {
boundElements: newBoundElements(
bindableElement.boundElements,
new Set([boundElement.id]),
),
});
}
}
},
);
};
}

View file

@ -13,6 +13,7 @@ import {
} from "./types";
import { sanitizeHTMLAttribute } from "../data/url";
import { MarkRequired } from "../utility-types";
import { StoreAction } from "../store";
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
@ -314,7 +315,7 @@ export const actionSetEmbeddableAsActiveTool = register({
type: "embeddable",
}),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
});

View file

@ -36,7 +36,6 @@ import {
AppClassProperties,
} from "../types";
import { mutateElement } from "./mutateElement";
import History from "../history";
import {
bindOrUnbindLinearElement,
@ -50,6 +49,7 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { DRAGGING_THRESHOLD } from "../constants";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { IStore } from "../store";
const editorMidPointsCache: {
version: number | null;
@ -642,7 +642,7 @@ export class LinearElementEditor {
static handlePointerDown(
event: React.PointerEvent<HTMLElement>,
appState: AppState,
history: History,
store: IStore,
scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor,
app: AppClassProperties,
@ -700,7 +700,7 @@ export class LinearElementEditor {
});
ret.didAddPoint = true;
}
history.resumeRecording();
store.shouldCaptureIncrement();
ret.linearElementEditor = {
...linearElementEditor,
pointerDownState: {

View file

@ -7,9 +7,9 @@ import { getUpdatedTimestamp } from "../utils";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"id" | "version" | "versionNonce"
"id" | "version" | "versionNonce" | "updated"
>;
// This function tracks updates of text elements for the purposes for collaboration.
@ -79,6 +79,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
didChange = true;
}
}
if (!didChange) {
return element;
}

View file

@ -6,6 +6,9 @@ import { AppState, Zoom } from "../types";
import { getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils";
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
// - could also be part of `_clearElements`
export const isInvisiblySmallElement = (
element: ExcalidrawElement,
): boolean => {

View file

@ -48,6 +48,7 @@ export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null,
elementsMap: ElementsMap,
informMutation: boolean = true,
) => {
let maxWidth = undefined;
const boundTextUpdates = {
@ -56,6 +57,7 @@ export const redrawTextBoundingBox = (
text: textElement.text,
width: textElement.width,
height: textElement.height,
angle: container?.angle ?? textElement.angle,
};
boundTextUpdates.text = textElement.text;
@ -89,7 +91,7 @@ export const redrawTextBoundingBox = (
metrics.height,
container.type,
);
mutateElement(container, { height: nextHeight });
mutateElement(container, { height: nextHeight }, informMutation);
updateOriginalContainerCache(container.id, nextHeight);
}
if (metrics.width > maxContainerWidth) {
@ -97,7 +99,7 @@ export const redrawTextBoundingBox = (
metrics.width,
container.type,
);
mutateElement(container, { width: nextWidth });
mutateElement(container, { width: nextWidth }, informMutation);
}
const updatedTextElement = {
...textElement,
@ -112,7 +114,7 @@ export const redrawTextBoundingBox = (
boundTextUpdates.y = y;
}
mutateElement(textElement, boundTextUpdates);
mutateElement(textElement, boundTextUpdates, informMutation);
};
export const bindTextToShapeAfterDuplication = (

View file

@ -20,6 +20,7 @@ import {
ExcalidrawIframeElement,
ExcalidrawIframeLikeElement,
ExcalidrawMagicFrameElement,
ExcalidrawArrowElement,
} from "./types";
export const isInitializedImageElement = (
@ -101,7 +102,7 @@ export const isLinearElement = (
export const isArrowElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLinearElement => {
): element is ExcalidrawArrowElement => {
return element != null && element.type === "arrow";
};

View file

@ -26,6 +26,11 @@ type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
export type FractionalIndex = string & { _brand: "franctionalIndex" };
export type BoundElement = Readonly<{
id: ExcalidrawLinearElement["id"];
type: "arrow" | "text";
}>;
type _ExcalidrawElementBase = Readonly<{
id: string;
x: number;
@ -62,12 +67,7 @@ type _ExcalidrawElementBase = Readonly<{
groupIds: readonly GroupId[];
frameId: string | null;
/** other elements that are bound to this element */
boundElements:
| readonly Readonly<{
id: ExcalidrawLinearElement["id"];
type: "arrow" | "text";
}>[]
| null;
boundElements: readonly BoundElement[] | null;
/** epoch (ms) timestamp of last element update */
updated: number;
link: string | null;