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

@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
| API | Signature | Usage |
| --- | --- | --- |
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData |
| [updateLibrary](#updatelibrary) | `function` | updates the library |
| [addFiles](#addfiles) | `function` | add files data to the appState |
| [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
| [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene |
@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
```jsx live
function App() {

View File

@ -438,7 +438,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
commitToHistory: true,
commitToStore: true,
});
}
});

View File

@ -356,7 +356,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.excalidrawAPI.updateScene({
elements,
commitToHistory: false,
});
}
};
@ -501,14 +500,12 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
return element;
});
// remove deleted elements from elements array & history to ensure we don't
// remove deleted elements from elements array to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
});
this.saveCollabRoomToFirebase(getSyncableElements(elements));
@ -544,9 +541,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
const remoteElements = decryptedData.payload.elements;
const reconciledElements =
this._reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
this.handleRemoteSceneUpdate(reconciledElements);
// noop if already resolved via init from firebase
scenePromise.resolve({
elements: reconciledElements,
@ -745,19 +740,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
private handleRemoteSceneUpdate = (
elements: ReconciledExcalidrawElement[],
{ init = false }: { init?: boolean } = {},
) => {
this.excalidrawAPI.updateScene({
elements,
commitToHistory: !!init,
});
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
// when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
this.excalidrawAPI.history.clear();
this.loadImageFiles();
};

View File

@ -269,7 +269,7 @@ export const loadScene = async (
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
commitToHistory: false,
commitToStore: false,
};
};

View File

@ -1,13 +1,18 @@
import { vi } from "vitest";
import {
act,
render,
updateSceneData,
waitFor,
} from "../../packages/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
import { API } from "../../packages/excalidraw/tests/helpers/api";
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex";
import {
createRedoAction,
createUndoAction,
} from "../../packages/excalidraw/actions/actionHistory";
import { newElementWith } from "../../packages/excalidraw";
const { h } = window;
@ -58,39 +63,188 @@ vi.mock("socket.io-client", () => {
};
});
/**
* These test would deserve to be extended by testing collab with (at least) two clients simultanouesly,
* while having access to both scenes, appstates stores, histories and etc.
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/
describe("collaboration", () => {
it("creating room should reset deleted elements", async () => {
it("should allow to undo / redo even on force-deleted elements", async () => {
await render(<ExcalidrawApp />);
// To update the scene with deleted elements before starting collab
const rect1Props = {
type: "rectangle",
id: "A",
height: 200,
width: 100,
} as const;
const rect2Props = {
type: "rectangle",
id: "B",
width: 100,
height: 200,
} as const;
const rect1 = API.createElement({ ...rect1Props });
const rect2 = API.createElement({ ...rect2Props });
updateSceneData({
elements: syncInvalidIndices([
API.createElement({ type: "rectangle", id: "A" }),
API.createElement({
type: "rectangle",
id: "B",
isDeleted: true,
}),
]),
});
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
expect(API.getStateHistory().length).toBe(1);
});
window.collab.startCollaboration(null);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);
elements: syncInvalidIndices([rect1, rect2]),
commitToStore: true,
});
updateSceneData({
elements: syncInvalidIndices([
rect1,
newElementWith(h.elements[1], { isDeleted: true }),
]),
commitToStore: true,
});
const undoAction = createUndoAction(h.history);
// noop
h.app.actionManager.executeAction(undoAction);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);
expect(API.getUndoStack().length).toBe(2);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
// one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server
window.collab.startCollaboration(null);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
// we never delete from the local snapshot as it is used for correct diff calculation
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const undoAction = createUndoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
]);
});
// simulate force deleting the element remotely
updateSceneData({
elements: syncInvalidIndices([rect1]),
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const redoAction = createRedoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
updateSceneData({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
commitToStore: true,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
updateSceneData({
elements: syncInvalidIndices([rect1]),
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
});
});
});

View File

@ -15,9 +15,14 @@ Please add the latest change on the top under the correct section.
### Features
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
@ -30,6 +35,10 @@ Please add the latest change on the top under the correct section.
### Breaking Changes
- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
### Breaking Changes
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties.
@ -92,8 +101,6 @@ define: {
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
---
## 0.17.0 (2023-11-14)
### Features

View File

@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random";
import { t } from "../i18n";
import { LIBRARY_DISABLED_TYPES } from "../constants";
import { StoreAction } from "../store";
export const actionAddToLibrary = register({
name: "addToLibrary",
@ -17,7 +18,7 @@ export const actionAddToLibrary = register({
for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t(`errors.libraryElementTypeError.${type}`),
@ -41,7 +42,7 @@ export const actionAddToLibrary = register({
})
.then(() => {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
toast: { message: t("toast.addedToLibrary") },
@ -50,7 +51,7 @@ export const actionAddToLibrary = register({
})
.catch((error) => {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: error.message,

View File

@ -15,6 +15,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { StoreAction } from "../store";
import { AppClassProperties, AppState, UIAppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
@ -70,7 +71,7 @@ export const actionAlignTop = register({
position: "start",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -103,7 +104,7 @@ export const actionAlignBottom = register({
position: "end",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -136,7 +137,7 @@ export const actionAlignLeft = register({
position: "start",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -169,7 +170,7 @@ export const actionAlignRight = register({
position: "end",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -202,7 +203,7 @@ export const actionAlignVerticallyCentered = register({
position: "center",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
@ -231,7 +232,7 @@ export const actionAlignHorizontallyCentered = register({
position: "center",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (

View File

@ -34,6 +34,7 @@ import { Mutable } from "../utility-types";
import { arrayToMap, getFontString } from "../utils";
import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
export const actionUnbindText = register({
name: "unbindText",
@ -85,7 +86,7 @@ export const actionUnbindText = register({
return {
elements,
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
@ -161,7 +162,7 @@ export const actionBindText = register({
return {
elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
@ -320,7 +321,7 @@ export const actionWrapTextInContainer = register({
...appState,
selectedElementIds: containerIds,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});

View File

@ -10,7 +10,13 @@ import {
ZoomResetIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
import {
CURSOR_TYPE,
MAX_ZOOM,
MIN_ZOOM,
THEME,
ZOOM_STEP,
} from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
@ -31,6 +37,7 @@ import {
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "../store";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@ -46,7 +53,9 @@ export const actionChangeViewBackgroundColor = register({
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
commitToHistory: !!value.viewBackgroundColor,
storeAction: !!value.viewBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => {
@ -102,7 +111,7 @@ export const actionClearCanvas = register({
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
@ -127,16 +136,17 @@ export const actionZoomIn = register({
),
userToFollow: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, appState }) => (
<ToolButton
type="button"
className="zoom-in-button zoom-button"
icon={ZoomInIcon}
title={`${t("buttons.zoomIn")}${getShortcutKey("CtrlOrCmd++")}`}
aria-label={t("buttons.zoomIn")}
disabled={appState.zoom.value >= MAX_ZOOM}
onClick={() => {
updateData(null);
}}
@ -167,16 +177,17 @@ export const actionZoomOut = register({
),
userToFollow: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, appState }) => (
<ToolButton
type="button"
className="zoom-out-button zoom-button"
icon={ZoomOutIcon}
title={`${t("buttons.zoomOut")}${getShortcutKey("CtrlOrCmd+-")}`}
aria-label={t("buttons.zoomOut")}
disabled={appState.zoom.value <= MIN_ZOOM}
onClick={() => {
updateData(null);
}}
@ -207,7 +218,7 @@ export const actionResetZoom = register({
),
userToFollow: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData, appState }) => (
@ -282,8 +293,8 @@ export const zoomToFitBounds = ({
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, 0.1),
30.0,
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
) as NormalizedZoomValue;
let appStateWidth = appState.width;
@ -328,7 +339,7 @@ export const zoomToFitBounds = ({
scrollY,
zoom: { value: newZoomValue },
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
};
@ -447,7 +458,7 @@ export const actionToggleTheme = register({
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
@ -485,7 +496,7 @@ export const actionToggleEraserTool = register({
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) => event.key === KEYS.E,
@ -524,7 +535,7 @@ export const actionToggleHandTool = register({
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

View File

@ -14,6 +14,7 @@ import { isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionCopy = register({
name: "copy",
@ -31,7 +32,7 @@ export const actionCopy = register({
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: error.message,
@ -40,7 +41,7 @@ export const actionCopy = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
// don't supply a shortcut since we handle this conditionally via onCopy event
@ -66,7 +67,7 @@ export const actionPaste = register({
if (isFirefox) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
@ -75,7 +76,7 @@ export const actionPaste = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
@ -88,7 +89,7 @@ export const actionPaste = register({
} catch (error: any) {
console.error(error);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
@ -97,7 +98,7 @@ export const actionPaste = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
// don't supply a shortcut since we handle this conditionally via onCopy event
@ -124,7 +125,7 @@ export const actionCopyAsSvg = register({
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
@ -147,7 +148,7 @@ export const actionCopyAsSvg = register({
},
);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
@ -156,7 +157,7 @@ export const actionCopyAsSvg = register({
...appState,
errorMessage: error.message,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
@ -174,7 +175,7 @@ export const actionCopyAsPng = register({
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
const selectedElements = app.scene.getSelectedElements({
@ -208,7 +209,7 @@ export const actionCopyAsPng = register({
}),
},
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
@ -217,7 +218,7 @@ export const actionCopyAsPng = register({
...appState,
errorMessage: error.message,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
@ -252,7 +253,7 @@ export const copyText = register({
throw new Error(t("errors.copyToSystemClipboardFailed"));
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
predicate: (elements, appState, _, app) => {

View File

@ -13,6 +13,7 @@ import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
@ -112,7 +113,7 @@ export const actionDeleteSelected = register({
...nextAppState,
editingLinearElement: null,
},
commitToHistory: false,
storeAction: StoreAction.CAPTURE,
};
}
@ -144,7 +145,7 @@ export const actionDeleteSelected = register({
: [0],
},
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
let { elements: nextElements, appState: nextAppState } =
@ -164,10 +165,12 @@ export const actionDeleteSelected = register({
multiElement: null,
activeEmbeddable: null,
},
commitToHistory: isSomeElementSelected(
storeAction: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
)
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
keyTest: (event, appState, elements) =>

View File

@ -11,6 +11,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { StoreAction } from "../store";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
@ -58,7 +59,7 @@ export const distributeHorizontally = register({
space: "between",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -89,7 +90,7 @@ export const distributeVertically = register({
space: "between",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

View File

@ -32,6 +32,7 @@ import {
getSelectedElements,
} from "../scene/selection";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
@ -54,13 +55,13 @@ export const actionDuplicateSelection = register({
return {
elements,
appState: ret.appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
return {
...duplicateElements(elements, appState),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
@ -241,9 +242,10 @@ const duplicateElements = (
}
// step (3)
const finalElements = finalElementsReversed.reverse();
syncMovedIndices(finalElements, arrayToMap([...oldElements, ...newElements]));
const finalElements = syncMovedIndices(
finalElementsReversed.reverse(),
arrayToMap(newElements),
);
// ---------------------------------------------------------------------------

View File

@ -4,6 +4,7 @@ import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import { arrayToMap } from "../utils";
import { register } from "./register";
@ -66,7 +67,7 @@ export const actionToggleElementLock = register({
? null
: appState.selectedLinearElement,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event, appState, elements, app) => {
@ -111,7 +112,7 @@ export const actionUnlockAllElements = register({
lockedElements.map((el) => [el.id, true]),
),
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
label: "labels.elementLock.unlockAll",

View File

@ -19,13 +19,17 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
import "../components/ToolIcon.scss";
import { StoreAction } from "../store";
export const actionChangeProjectName = register({
name: "changeProjectName",
label: "labels.fileTitle",
trackEvent: false,
perform: (_elements, appState, value) => {
return { appState: { ...appState, name: value }, commitToHistory: false };
return {
appState: { ...appState, name: value },
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
<ProjectName
@ -44,7 +48,7 @@ export const actionChangeExportScale = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ elements: allElements, appState, updateData }) => {
@ -94,7 +98,7 @@ export const actionChangeExportBackground = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportBackground: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (
@ -114,7 +118,7 @@ export const actionChangeExportEmbedScene = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (
@ -156,7 +160,7 @@ export const actionSaveToActiveFile = register({
: await saveAsJSON(elements, appState, app.files, app.getName());
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
fileHandle,
@ -178,7 +182,7 @@ export const actionSaveToActiveFile = register({
} else {
console.warn(error);
}
return { commitToHistory: false };
return { storeAction: StoreAction.NONE };
}
},
keyTest: (event) =>
@ -203,7 +207,7 @@ export const actionSaveFileToDisk = register({
app.getName(),
);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
openDialog: null,
@ -217,7 +221,7 @@ export const actionSaveFileToDisk = register({
} else {
console.warn(error);
}
return { commitToHistory: false };
return { storeAction: StoreAction.NONE };
}
},
keyTest: (event) =>
@ -256,7 +260,7 @@ export const actionLoadScene = register({
elements: loadedElements,
appState: loadedAppState,
files,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
} catch (error: any) {
if (error?.name === "AbortError") {
@ -267,7 +271,7 @@ export const actionLoadScene = register({
elements,
appState: { ...appState, errorMessage: error.message },
files: app.files,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
@ -281,7 +285,7 @@ export const actionExportWithDarkMode = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportWithDarkMode: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (

View File

@ -15,6 +15,7 @@ import {
import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types";
import { resetCursor } from "../cursor";
import { StoreAction } from "../store";
export const actionFinalize = register({
name: "finalize",
@ -48,8 +49,9 @@ export const actionFinalize = register({
...appState,
cursorButton: "up",
editingLinearElement: null,
selectedLinearElement: null,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
}
@ -90,7 +92,9 @@ export const actionFinalize = register({
});
}
}
if (isInvisiblySmallElement(multiPointElement)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter(
(el) => el.id !== multiPointElement.id,
);
@ -186,7 +190,8 @@ export const actionFinalize = register({
: appState.selectedLinearElement,
pendingImageElementId: null,
},
commitToHistory: appState.activeTool.type === "freedraw",
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event, appState) =>

View File

@ -18,6 +18,7 @@ import {
} from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@ -38,7 +39,7 @@ export const actionFlipHorizontal = register({
app,
),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) => event.shiftKey && event.code === CODES.H,
@ -63,7 +64,7 @@ export const actionFlipVertical = register({
app,
),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

View File

@ -9,6 +9,7 @@ import { setCursorForShape } from "../cursor";
import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks";
import { frameToolIcon } from "../components/icons";
import { StoreAction } from "../store";
const isSingleFrameSelected = (
appState: UIAppState,
@ -44,14 +45,14 @@ export const actionSelectAllElementsInFrame = register({
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
commitToHistory: false,
storeAction: StoreAction.CAPTURE,
};
}
return {
elements,
appState,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
predicate: (elements, appState, _, app) =>
@ -75,14 +76,14 @@ export const actionRemoveAllElementsFromFrame = register({
[selectedElement.id]: true,
},
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
return {
elements,
appState,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
predicate: (elements, appState, _, app) =>
@ -104,7 +105,7 @@ export const actionupdateFrameRendering = register({
enabled: !appState.frameRendering.enabled,
},
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.frameRendering.enabled,
@ -134,7 +135,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame",
}),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) =>

View File

@ -17,7 +17,11 @@ import {
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import {
ExcalidrawElement,
ExcalidrawTextElement,
OrderedExcalidrawElement,
} from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
@ -28,6 +32,7 @@ import {
replaceAllElementsInFrame,
} from "../frame";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
@ -72,7 +77,7 @@ export const actionGroup = register({
});
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, commitToHistory: false };
return { appState, elements, storeAction: StoreAction.NONE };
}
// if everything is already grouped into 1 group, there is nothing to do
const selectedGroupIds = getSelectedGroupIds(appState);
@ -92,7 +97,7 @@ export const actionGroup = register({
]);
if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids
return { appState, elements, commitToHistory: false };
return { appState, elements, storeAction: StoreAction.NONE };
}
}
@ -134,19 +139,19 @@ export const actionGroup = register({
// to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
const lastGroupElementIndex = nextElements.lastIndexOf(
lastElementInGroup as OrderedExcalidrawElement,
);
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = nextElements
.slice(0, lastGroupElementIndex)
.filter(
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
);
const reorderedElements = [
...elementsBeforeGroup,
...elementsInGroup,
...elementsAfterGroup,
];
syncMovedIndices(reorderedElements, arrayToMap(elementsInGroup));
const reorderedElements = syncMovedIndices(
[...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup],
arrayToMap(elementsInGroup),
);
return {
appState: {
@ -158,7 +163,7 @@ export const actionGroup = register({
),
},
elements: reorderedElements,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
predicate: (elements, appState, _, app) =>
@ -188,7 +193,7 @@ export const actionUngroup = register({
const elementsMap = arrayToMap(elements);
if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false };
return { appState, elements, storeAction: StoreAction.NONE };
}
let nextElements = [...elements];
@ -261,7 +266,7 @@ export const actionUngroup = register({
return {
appState: { ...appState, ...updateAppState },
elements: nextElements,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

View File

@ -2,113 +2,117 @@ import { Action, ActionResult } from "./types";
import { UndoIcon, RedoIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import History, { HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { History, HistoryChangedEvent } from "../history";
import { AppState } from "../types";
import { KEYS } from "../keys";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import { syncInvalidIndices } from "../fractionalIndex";
import { SceneElementsMap } from "../element/types";
import { IStore, StoreAction } from "../store";
import { useEmitter } from "../hooks/useEmitter";
const writeData = (
prevElements: readonly ExcalidrawElement[],
appState: AppState,
updater: () => HistoryEntry | null,
appState: Readonly<AppState>,
updater: () => [SceneElementsMap, AppState] | void,
): ActionResult => {
const commitToHistory = false;
if (
!appState.multiElement &&
!appState.resizingElement &&
!appState.editingElement &&
!appState.draggingElement
) {
const data = updater();
if (data === null) {
return { commitToHistory };
const result = updater();
if (!result) {
return { storeAction: StoreAction.NONE };
}
const prevElementMap = arrayToMap(prevElements);
const nextElements = data.elements;
const nextElementMap = arrayToMap(nextElements);
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id),
);
const elements = nextElements
.map((nextElement) =>
newElementWith(
prevElementMap.get(nextElement.id) || nextElement,
nextElement,
),
)
.concat(
deletedElements.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
);
fixBindingsAfterDeletion(elements, deletedElements);
// TODO: will be replaced in #7348
syncInvalidIndices(elements);
const [nextElementsMap, nextAppState] = result;
const nextElements = Array.from(nextElementsMap.values());
return {
elements,
appState: { ...appState, ...data.appState },
commitToHistory,
syncHistory: true,
appState: nextAppState,
elements: nextElements,
storeAction: StoreAction.UPDATE,
};
}
return { commitToHistory };
return { storeAction: StoreAction.NONE };
};
type ActionCreator = (history: History) => Action;
type ActionCreator = (history: History, store: IStore) => Action;
export const createUndoAction: ActionCreator = (history) => ({
export const createUndoAction: ActionCreator = (history, store) => ({
name: "undo",
label: "buttons.undo",
icon: UndoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()),
writeData(appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData, data }) => (
<ToolButton
type="button"
icon={UndoIcon}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter,
new HistoryChangedEvent(),
);
return (
<ToolButton
type="button"
icon={UndoIcon}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={isUndoStackEmpty}
/>
);
},
});
export const createRedoAction: ActionCreator = (history) => ({
export const createRedoAction: ActionCreator = (history, store) => ({
name: "redo",
label: "buttons.redo",
icon: RedoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()),
writeData(appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => (
<ToolButton
type="button"
icon={RedoIcon}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,
new HistoryChangedEvent(),
);
return (
<ToolButton
type="button"
icon={RedoIcon}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={isRedoStackEmpty}
/>
);
},
});

View File

@ -2,6 +2,7 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleLinearEditor = register({
@ -41,7 +42,7 @@ export const actionToggleLinearEditor = register({
...appState,
editingLinearElement,
},
commitToHistory: false,
storeAction: StoreAction.CAPTURE,
};
},
});

View File

@ -5,6 +5,7 @@ import { isEmbeddableElement } from "../element/typeChecks";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import { getShortcutKey } from "../utils";
import { register } from "./register";
@ -24,7 +25,7 @@ export const actionLink = register({
showHyperlinkPopup: "editor",
openMenu: null,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
trackEvent: { category: "hyperlink", action: "click" },

View File

@ -4,6 +4,7 @@ import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register";
import { KEYS } from "../keys";
import { StoreAction } from "../store";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
@ -14,7 +15,7 @@ export const actionToggleCanvasMenu = register({
...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas",
},
commitToHistory: false,
storeAction: StoreAction.NONE,
}),
PanelComponent: ({ appState, updateData }) => (
<ToolButton
@ -36,7 +37,7 @@ export const actionToggleEditMenu = register({
...appState,
openMenu: appState.openMenu === "shape" ? null : "shape",
},
commitToHistory: false,
storeAction: StoreAction.NONE,
}),
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
@ -73,7 +74,7 @@ export const actionShortcuts = register({
name: "help",
},
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) => event.key === KEYS.QUESTION_MARK,

View File

@ -7,6 +7,7 @@ import {
microphoneMutedIcon,
} from "../components/icons";
import { t } from "../i18n";
import { StoreAction } from "../store";
import { Collaborator } from "../types";
import { register } from "./register";
import clsx from "clsx";
@ -27,7 +28,7 @@ export const actionGoToCollaborator = register({
...appState,
userToFollow: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
@ -41,7 +42,7 @@ export const actionGoToCollaborator = register({
// Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData, data, appState }) => {

View File

@ -96,6 +96,7 @@ import {
import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { StoreAction } from "../store";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@ -231,7 +232,7 @@ const changeFontSize = (
? [...newFontSizes][0]
: fallbackValue ?? appState.currentItemFontSize,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
};
@ -261,7 +262,9 @@ export const actionChangeStrokeColor = register({
...appState,
...value,
},
commitToHistory: !!value.currentItemStrokeColor,
storeAction: !!value.currentItemStrokeColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@ -305,7 +308,9 @@ export const actionChangeBackgroundColor = register({
...appState,
...value,
},
commitToHistory: !!value.currentItemBackgroundColor,
storeAction: !!value.currentItemBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@ -349,7 +354,7 @@ export const actionChangeFillStyle = register({
}),
),
appState: { ...appState, currentItemFillStyle: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@ -422,7 +427,7 @@ export const actionChangeStrokeWidth = register({
}),
),
appState: { ...appState, currentItemStrokeWidth: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -477,7 +482,7 @@ export const actionChangeSloppiness = register({
}),
),
appState: { ...appState, currentItemRoughness: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -528,7 +533,7 @@ export const actionChangeStrokeStyle = register({
}),
),
appState: { ...appState, currentItemStrokeStyle: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -583,7 +588,7 @@ export const actionChangeOpacity = register({
true,
),
appState: { ...appState, currentItemOpacity: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -758,7 +763,7 @@ export const actionChangeFontFamily = register({
...appState,
currentItemFontFamily: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
@ -859,7 +864,7 @@ export const actionChangeTextAlign = register({
...appState,
currentItemTextAlign: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
@ -949,7 +954,7 @@ export const actionChangeVerticalAlign = register({
appState: {
...appState,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
@ -1030,7 +1035,7 @@ export const actionChangeRoundness = register({
...appState,
currentItemRoundness: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@ -1182,7 +1187,7 @@ export const actionChangeArrowhead = register({
? "currentItemStartArrowhead"
: "currentItemEndArrowhead"]: value.type,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {

View File

@ -7,6 +7,7 @@ import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { selectAllIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionSelectAll = register({
name: "selectAll",
@ -50,7 +51,7 @@ export const actionSelectAll = register({
? new LinearElementEditor(elements[0])
: null,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,

View File

@ -26,6 +26,7 @@ import {
import { getSelectedElements } from "../scene";
import { ExcalidrawTextElement } from "../element/types";
import { paintIcon } from "../components/icons";
import { StoreAction } from "../store";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@ -54,7 +55,7 @@ export const actionCopyStyles = register({
...appState,
toast: { message: t("toast.copyStyles") },
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) =>
@ -71,7 +72,7 @@ export const actionPasteStyles = register({
const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) {
return { elements, commitToHistory: false };
return { elements, storeAction: StoreAction.NONE };
}
const selectedElements = getSelectedElements(elements, appState, {
@ -160,7 +161,7 @@ export const actionPasteStyles = register({
}
return element;
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

View File

@ -3,6 +3,7 @@ import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { gridIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionToggleGridMode = register({
name: "gridMode",
@ -21,7 +22,7 @@ export const actionToggleGridMode = register({
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridSize !== null,

View File

@ -1,5 +1,6 @@
import { magnetIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleObjectsSnapMode = register({
@ -18,7 +19,7 @@ export const actionToggleObjectsSnapMode = register({
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.objectsSnapModeEnabled,

View File

@ -1,6 +1,7 @@
import { register } from "./register";
import { CODES, KEYS } from "../keys";
import { abacusIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionToggleStats = register({
name: "stats",
@ -15,7 +16,7 @@ export const actionToggleStats = register({
...appState,
showStats: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.showStats,

View File

@ -1,5 +1,6 @@
import { eyeIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleViewMode = register({
@ -18,7 +19,7 @@ export const actionToggleViewMode = register({
...appState,
viewModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.viewModeEnabled,

View File

@ -1,5 +1,6 @@
import { coffeeIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleZenMode = register({
@ -18,7 +19,7 @@ export const actionToggleZenMode = register({
...appState,
zenModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.zenModeEnabled,

View File

@ -15,6 +15,7 @@ import {
SendToBackIcon,
} from "../components/icons";
import { isDarwin } from "../constants";
import { StoreAction } from "../store";
export const actionSendBackward = register({
name: "sendBackward",
@ -25,7 +26,7 @@ export const actionSendBackward = register({
return {
elements: moveOneLeft(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyPriority: 40,
@ -54,7 +55,7 @@ export const actionBringForward = register({
return {
elements: moveOneRight(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyPriority: 40,
@ -83,7 +84,7 @@ export const actionSendToBack = register({
return {
elements: moveAllLeft(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -120,7 +121,7 @@ export const actionBringToFront = register({
return {
elements: moveAllRight(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

View File

@ -7,7 +7,7 @@ import {
PanelComponentProps,
ActionSource,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { trackEvent } from "../analytics";
import { isPromiseLike } from "../utils";
@ -46,13 +46,13 @@ export class ActionManager {
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[];
app: AppClassProperties;
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[],
app: AppClassProperties,
) {
this.updater = (actionResult) => {

View File

@ -1,5 +1,5 @@
import React from "react";
import { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
import {
AppClassProperties,
AppState,
@ -8,6 +8,7 @@ import {
UIAppState,
} from "../types";
import { MarkOptional } from "../utility-types";
import { StoreAction } from "../store";
export type ActionSource =
| "ui"
@ -25,14 +26,13 @@ export type ActionResult =
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
commitToHistory: boolean;
syncHistory?: boolean;
storeAction: keyof typeof StoreAction;
replaceFiles?: boolean;
}
| false;
type ActionFn = (
elements: readonly ExcalidrawElement[],
elements: readonly OrderedExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: AppClassProperties,

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size) !important;
height: var(--lg-icon-size) !important;

View File

@ -183,6 +183,7 @@ import {
ExcalidrawIframeElement,
ExcalidrawEmbeddableElement,
Ordered,
OrderedExcalidrawElement,
} from "../element/types";
import { getCenter, getDistance } from "../gesture";
import {
@ -194,7 +195,7 @@ import {
isSelectedViaGroup,
selectGroupsForSelectedElements,
} from "../groups";
import History from "../history";
import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
import {
CODES,
@ -278,11 +279,12 @@ import {
muteFSAbortError,
isTestEnv,
easeOut,
arrayToMap,
updateStable,
addEventListener,
normalizeEOL,
getDateTime,
isShallowEqual,
arrayToMap,
} from "../utils";
import {
createSrcDoc,
@ -410,6 +412,7 @@ import { ElementCanvasButton } from "./MagicButton";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import FollowMode from "./FollowMode/FollowMode";
import { IStore, Store, StoreAction } from "../store";
import { AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animated-trail";
import { LaserTrails } from "../laser-trails";
@ -540,6 +543,7 @@ class App extends React.Component<AppProps, AppState> {
public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined;
public id: string;
private store: IStore;
private history: History;
private excalidrawContainerValue: {
container: HTMLDivElement | null;
@ -665,6 +669,10 @@ class App extends React.Component<AppProps, AppState> {
this.canvas = document.createElement("canvas");
this.rc = rough.canvas(this.canvas);
this.renderer = new Renderer(this.scene);
this.store = new Store();
this.history = new History();
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene,
@ -714,10 +722,14 @@ class App extends React.Component<AppProps, AppState> {
onSceneUpdated: this.onSceneUpdated,
});
this.history = new History();
this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history));
this.actionManager.registerAll(actions);
this.actionManager.registerAction(
createUndoAction(this.history, this.store),
);
this.actionManager.registerAction(
createRedoAction(this.history, this.store),
);
}
private onWindowMessage(event: MessageEvent) {
@ -2092,12 +2104,12 @@ class App extends React.Component<AppProps, AppState> {
if (shouldUpdateStrokeColor) {
this.syncActionResult({
appState: { ...this.state, currentItemStrokeColor: color },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
} else {
this.syncActionResult({
appState: { ...this.state, currentItemBackgroundColor: color },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
}
} else {
@ -2111,6 +2123,7 @@ class App extends React.Component<AppProps, AppState> {
}
return el;
}),
commitToStore: true,
});
}
},
@ -2135,10 +2148,14 @@ class App extends React.Component<AppProps, AppState> {
editingElement = element;
}
});
this.scene.replaceAllElements(actionResult.elements);
if (actionResult.commitToHistory) {
this.history.resumeRecording();
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.shouldUpdateSnapshot();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.shouldCaptureIncrement();
}
this.scene.replaceAllElements(actionResult.elements);
}
if (actionResult.files) {
@ -2149,8 +2166,10 @@ class App extends React.Component<AppProps, AppState> {
}
if (actionResult.appState || editingElement || this.state.contextMenu) {
if (actionResult.commitToHistory) {
this.history.resumeRecording();
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.shouldUpdateSnapshot();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.shouldCaptureIncrement();
}
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
@ -2180,34 +2199,24 @@ class App extends React.Component<AppProps, AppState> {
editingElement = null;
}
this.setState(
(state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
viewModeEnabled,
zenModeEnabled,
gridSize,
theme,
name,
errorMessage,
});
},
() => {
if (actionResult.syncHistory) {
this.history.setCurrentState(
this.state,
this.scene.getElementsIncludingDeleted(),
);
}
},
);
this.setState((state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
viewModeEnabled,
zenModeEnabled,
gridSize,
theme,
name,
errorMessage,
});
});
}
},
);
@ -2231,6 +2240,10 @@ class App extends React.Component<AppProps, AppState> {
this.history.clear();
};
private resetStore = () => {
this.store.clear();
};
/**
* Resets scene & history.
* ! Do not use to clear scene user action !
@ -2243,6 +2256,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: opts?.resetLoadingState ? false : state.isLoading,
theme: this.state.theme,
}));
this.resetStore();
this.resetHistory();
},
);
@ -2327,10 +2341,11 @@ class App extends React.Component<AppProps, AppState> {
// seems faster even in browsers that do fire the loadingdone event.
this.fonts.loadFontsForElements(scene.elements);
this.resetStore();
this.resetHistory();
this.syncActionResult({
...scene,
commitToHistory: true,
storeAction: StoreAction.UPDATE,
});
};
@ -2420,9 +2435,17 @@ class App extends React.Component<AppProps, AppState> {
configurable: true,
value: this.history,
},
store: {
configurable: true,
value: this.store,
},
});
}
this.store.onStoreIncrementEmitter.on((increment) => {
this.history.record(increment.elementsChange, increment.appStateChange);
});
this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners();
@ -2479,6 +2502,7 @@ class App extends React.Component<AppProps, AppState> {
this.laserTrails.stop();
this.eraserTrail.stop();
this.onChangeEmitter.clear();
this.store.onStoreIncrementEmitter.clear();
ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout);
@ -2623,7 +2647,8 @@ class App extends React.Component<AppProps, AppState> {
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
this.updateEmbeddables();
const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getNonDeletedElementsMap();
const elementsMap = this.scene.getElementsMapIncludingDeleted();
const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap();
if (!this.state.showWelcomeScreen && !elements.length) {
this.setState({ showWelcomeScreen: true });
@ -2739,7 +2764,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
) {
// defer so that the commitToHistory flag isn't reset via current update
// defer so that the storeAction flag isn't reset via current update
setTimeout(() => {
// execute only if the condition still holds when the deferred callback
// executes (it can be scheduled multiple times depending on how
@ -2778,13 +2803,14 @@ class App extends React.Component<AppProps, AppState> {
LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiElement,
-1,
elementsMap,
nonDeletedElementsMap,
),
),
this,
);
}
this.history.record(this.state, elements);
this.store.capture(elementsMap, this.state);
// Do not notify consumers if we're still loading the scene. Among other
// potential issues, this fixes a case where the tab isn't focused during
@ -3154,7 +3180,7 @@ class App extends React.Component<AppProps, AppState> {
this.files = { ...this.files, ...opts.files };
}
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
@ -3389,7 +3415,7 @@ class App extends React.Component<AppProps, AppState> {
PLAIN_PASTE_TOAST_SHOWN = true;
}
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
}
setAppState: React.Component<any, AppState>["setState"] = (
@ -3657,10 +3683,51 @@ class App extends React.Component<AppProps, AppState> {
elements?: SceneData["elements"];
appState?: Pick<AppState, K> | null;
collaborators?: SceneData["collaborators"];
commitToHistory?: SceneData["commitToHistory"];
commitToStore?: SceneData["commitToStore"];
}) => {
if (sceneData.commitToHistory) {
this.history.resumeRecording();
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
if (sceneData.commitToStore) {
this.store.shouldCaptureIncrement();
}
if (sceneData.elements || sceneData.appState) {
let nextCommittedAppState = this.state;
let nextCommittedElements: Map<string, OrderedExcalidrawElement>;
if (sceneData.appState) {
nextCommittedAppState = {
...this.state,
...sceneData.appState, // Here we expect just partial appState
};
}
const prevElements = this.scene.getElementsIncludingDeleted();
if (sceneData.elements) {
/**
* We need to schedule a snapshot update, as in case `commitToStore` is false (i.e. remote update),
* as it's essential for computing local changes after the async action is completed (i.e. not to include remote changes in the diff).
*
* This is also a breaking change for all local `updateScene` calls without set `commitToStore` to true,
* as it makes such updates impossible to undo (previously they were undone coincidentally with the switch to the whole snapshot captured by the history).
*
* WARN: be careful here as moving it elsewhere could break the history for remote client without noticing
* - we need to find a way to test two concurrent client updates simultaneously, while having access to both stores & histories.
*/
this.store.shouldUpdateSnapshot();
// TODO#7348: deprecate once exchanging just store increments between clients
nextCommittedElements = this.store.ignoreUncomittedElements(
arrayToMap(prevElements),
arrayToMap(nextElements),
);
} else {
nextCommittedElements = arrayToMap(prevElements);
}
// WARN: Performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
this.store.capture(nextCommittedElements, nextCommittedAppState);
}
if (sceneData.appState) {
@ -3668,7 +3735,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (sceneData.elements) {
this.scene.replaceAllElements(sceneData.elements);
this.scene.replaceAllElements(nextElements);
}
if (sceneData.collaborators) {
@ -3896,7 +3963,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement.elementId !==
selectedElements[0].id
) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElement,
@ -4308,7 +4375,7 @@ class App extends React.Component<AppProps, AppState> {
]);
}
if (!isDeleted || isExistingElement) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
}
this.setState({
@ -4793,7 +4860,7 @@ class App extends React.Component<AppProps, AppState> {
(!this.state.editingLinearElement ||
this.state.editingLinearElement.elementId !== selectedElements[0].id)
) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
this.setState({
editingLinearElement: new LinearElementEditor(selectedElements[0]),
});
@ -4818,6 +4885,7 @@ class App extends React.Component<AppProps, AppState> {
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
if (selectedGroupId) {
this.store.shouldCaptureIncrement();
this.setState((prevState) => ({
...prevState,
...selectGroupsForSelectedElements(
@ -6300,7 +6368,7 @@ class App extends React.Component<AppProps, AppState> {
const ret = LinearElementEditor.handlePointerDown(
event,
this.state,
this.history,
this.store,
pointerDownState.origin,
linearElementEditor,
this,
@ -7848,7 +7916,7 @@ class App extends React.Component<AppProps, AppState> {
if (isLinearElement(draggingElement)) {
if (draggingElement!.points.length > 1) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
}
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,
@ -7917,14 +7985,16 @@ class App extends React.Component<AppProps, AppState> {
isInvisiblySmallElement(draggingElement)
) {
// remove invisible element which was added in onPointerDown
this.scene.replaceAllElements(
this.scene
// update the store snapshot, so that invisible elements are not captured by the store
this.updateScene({
elements: this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== draggingElement.id),
);
this.setState({
draggingElement: null,
appState: {
draggingElement: null,
},
});
return;
}
@ -8086,15 +8156,16 @@ class App extends React.Component<AppProps, AppState> {
}
if (resizingElement) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
}
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
this.scene.replaceAllElements(
this.scene
// update the store snapshot, so that invisible elements are not captured by the store
this.updateScene({
elements: this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== resizingElement.id),
);
});
}
// handle frame membership for resizing frames and/or selected elements
@ -8395,9 +8466,13 @@ class App extends React.Component<AppProps, AppState> {
if (
activeTool.type !== "selection" ||
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state)
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) ||
!isShallowEqual(
this.state.previousSelectedElementIds,
this.state.selectedElementIds,
)
) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
}
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
@ -8475,7 +8550,7 @@ class App extends React.Component<AppProps, AppState> {
this.elementsPendingErasure = new Set();
if (didChange) {
this.history.resumeRecording();
this.store.shouldCaptureIncrement();
this.scene.replaceAllElements(elements);
}
};
@ -9038,7 +9113,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false,
},
replaceFiles: true,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
return;
} catch (error: any) {
@ -9118,12 +9193,13 @@ class App extends React.Component<AppProps, AppState> {
) => {
file = await normalizeFile(file);
try {
const elements = this.scene.getElementsIncludingDeleted();
let ret;
try {
ret = await loadSceneOrLibraryFromBlob(
file,
this.state,
this.scene.getElementsIncludingDeleted(),
elements,
fileHandle,
);
} catch (error: any) {
@ -9152,6 +9228,13 @@ class App extends React.Component<AppProps, AppState> {
}
if (ret.type === MIME_TYPES.excalidraw) {
// Restore the fractional indices by mutating elements and update the
// store snapshot, otherwise we would end up with duplicate indices
syncInvalidIndices(elements.concat(ret.data.elements));
this.store.snapshot = this.store.snapshot.clone(
arrayToMap(elements),
this.state,
);
this.setState({ isLoading: true });
this.syncActionResult({
...ret.data,
@ -9160,7 +9243,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false,
},
replaceFiles: true,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
} else if (ret.type === MIME_TYPES.excalidrawlib) {
await this.library
@ -9770,6 +9853,7 @@ declare global {
setState: React.Component<any, AppState>["setState"];
app: InstanceType<typeof App>;
history: History;
store: Store;
};
}
}

View File

@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
hidden?: boolean;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
className?: string;
style?: CSSProperties;
isLoading?: boolean;
@ -124,10 +125,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div className="ToolIcon__icon" aria-hidden="true">
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">

View File

@ -77,8 +77,7 @@
}
.ToolIcon_type_button,
.Modal .ToolIcon_type_button,
.ToolIcon_type_button {
.Modal .ToolIcon_type_button {
padding: 0;
border: none;
margin: 0;
@ -101,6 +100,22 @@
background-color: var(--button-gray-3);
}
&:disabled {
cursor: default;
&:active,
&:focus-visible,
&:hover {
background-color: initial;
border: none;
box-shadow: none;
}
svg {
color: var(--color-disabled);
}
}
&--show {
visibility: visible;
}

View File

@ -210,6 +210,7 @@ export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1;
export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 30.0;
export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds

View File

@ -98,6 +98,8 @@
--color-gray-90: #1e1e1e;
--color-gray-100: #121212;
--color-disabled: var(--color-gray-40);
--color-warning: #fceeca;
--color-warning-dark: #f5c354;
--color-warning-darker: #f3ab2c;
@ -208,6 +210,8 @@
--color-primary-light-darker: #43415e;
--color-primary-hover: #bbb8ff;
--color-disabled: var(--color-gray-70);
--color-text-warning: var(--color-gray-80);
--color-danger: #ffa8a5;

View File

@ -50,6 +50,15 @@
color: var(--color-on-primary-container);
}
}
&[aria-disabled="true"] {
background: initial;
border: none;
svg {
color: var(--color-disabled);
}
}
}
}

View File

@ -20,7 +20,7 @@ const shouldDiscardRemoteElement = (
// local element is being edited
(local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id ||
local.id === localAppState.draggingElement?.id || // TODO: Is this still valid? As draggingElement is selection element, which is never part of the elements array
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with

View File

@ -294,7 +294,7 @@ const restoreElement = (
};
/**
* Repairs contaienr element's boundElements array by removing duplicates and
* Repairs container element's boundElements array by removing duplicates and
* fixing containerId of bound elements if not present. Also removes any
* bound elements that do not exist in the elements array.
*

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;

View File

@ -355,6 +355,24 @@ export const getMaximumGroups = (
return Array.from(groups.values());
};
export const getNonDeletedGroupIds = (elements: ElementsMap) => {
const nonDeletedGroupIds = new Set<string>();
for (const [, element] of elements) {
// defensive check
if (element.isDeleted) {
continue;
}
// defensive fallback
for (const groupId of element.groupIds ?? []) {
nonDeletedGroupIds.add(groupId);
}
}
return nonDeletedGroupIds;
};
export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
const allGroups = elements.flatMap((element) => element.groupIds);
const groupCount = new Map<string, number>();

View File

@ -1,265 +1,210 @@
import { AppStateChange, ElementsChange } from "./change";
import { SceneElementsMap } from "./element/types";
import { Emitter } from "./emitter";
import { Snapshot } from "./store";
import { AppState } from "./types";
import { ExcalidrawElement } from "./element/types";
import { isLinearElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { Mutable } from "./utility-types";
export interface HistoryEntry {
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
elements: ExcalidrawElement[];
type HistoryStack = HistoryEntry[];
export class HistoryChangedEvent {
constructor(
public readonly isUndoStackEmpty: boolean = true,
public readonly isRedoStackEmpty: boolean = true,
) {}
}
interface DehydratedExcalidrawElement {
id: string;
versionNonce: number;
}
export class History {
public readonly onHistoryChangedEmitter = new Emitter<
[HistoryChangedEvent]
>();
interface DehydratedHistoryEntry {
appState: string;
elements: DehydratedExcalidrawElement[];
}
private readonly undoStack: HistoryStack = [];
private readonly redoStack: HistoryStack = [];
const clearAppStatePropertiesForHistory = (appState: AppState) => {
return {
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
viewBackgroundColor: appState.viewBackgroundColor,
editingLinearElement: appState.editingLinearElement,
editingGroupId: appState.editingGroupId,
name: appState.name,
};
};
class History {
private elementCache = new Map<string, Map<number, ExcalidrawElement>>();
private recording: boolean = true;
private stateHistory: DehydratedHistoryEntry[] = [];
private redoStack: DehydratedHistoryEntry[] = [];
private lastEntry: HistoryEntry | null = null;
private hydrateHistoryEntry({
appState,
elements,
}: DehydratedHistoryEntry): HistoryEntry {
return {
appState: JSON.parse(appState),
elements: elements.map((dehydratedExcalidrawElement) => {
const element = this.elementCache
.get(dehydratedExcalidrawElement.id)
?.get(dehydratedExcalidrawElement.versionNonce);
if (!element) {
throw new Error(
`Element not found: ${dehydratedExcalidrawElement.id}:${dehydratedExcalidrawElement.versionNonce}`,
);
}
return element;
}),
};
public get isUndoStackEmpty() {
return this.undoStack.length === 0;
}
private dehydrateHistoryEntry({
appState,
elements,
}: HistoryEntry): DehydratedHistoryEntry {
return {
appState: JSON.stringify(appState),
elements: elements.map((element: ExcalidrawElement) => {
if (!this.elementCache.has(element.id)) {
this.elementCache.set(element.id, new Map());
}
const versions = this.elementCache.get(element.id)!;
if (!versions.has(element.versionNonce)) {
versions.set(element.versionNonce, deepCopyElement(element));
}
return {
id: element.id,
versionNonce: element.versionNonce,
};
}),
};
public get isRedoStackEmpty() {
return this.redoStack.length === 0;
}
getSnapshotForTest() {
return {
recording: this.recording,
stateHistory: this.stateHistory.map((dehydratedHistoryEntry) =>
this.hydrateHistoryEntry(dehydratedHistoryEntry),
),
redoStack: this.redoStack.map((dehydratedHistoryEntry) =>
this.hydrateHistoryEntry(dehydratedHistoryEntry),
),
};
}
clear() {
this.stateHistory.length = 0;
public clear() {
this.undoStack.length = 0;
this.redoStack.length = 0;
this.lastEntry = null;
this.elementCache.clear();
}
private generateEntry = (
appState: AppState,
elements: readonly ExcalidrawElement[],
): DehydratedHistoryEntry =>
this.dehydrateHistoryEntry({
appState: clearAppStatePropertiesForHistory(appState),
elements: elements.reduce((elements, element) => {
if (
isLinearElement(element) &&
appState.multiElement &&
appState.multiElement.id === element.id
) {
// don't store multi-point arrow if still has only one point
if (
appState.multiElement &&
appState.multiElement.id === element.id &&
element.points.length < 2
) {
return elements;
}
elements.push({
...element,
// don't store last point if not committed
points:
element.lastCommittedPoint !==
element.points[element.points.length - 1]
? element.points.slice(0, -1)
: element.points,
});
} else {
elements.push(element);
}
return elements;
}, [] as Mutable<typeof elements>),
});
shouldCreateEntry(nextEntry: HistoryEntry): boolean {
const { lastEntry } = this;
if (!lastEntry) {
return true;
}
if (nextEntry.elements.length !== lastEntry.elements.length) {
return true;
}
// loop from right to left as changes are likelier to happen on new elements
for (let i = nextEntry.elements.length - 1; i > -1; i--) {
const prev = nextEntry.elements[i];
const next = lastEntry.elements[i];
if (
!prev ||
!next ||
prev.id !== next.id ||
prev.versionNonce !== next.versionNonce
) {
return true;
}
}
// note: this is safe because entry's appState is guaranteed no excess props
let key: keyof typeof nextEntry.appState;
for (key in nextEntry.appState) {
if (key === "editingLinearElement") {
if (
nextEntry.appState[key]?.elementId ===
lastEntry.appState[key]?.elementId
) {
continue;
}
}
if (key === "selectedElementIds" || key === "selectedGroupIds") {
continue;
}
if (nextEntry.appState[key] !== lastEntry.appState[key]) {
return true;
}
}
return false;
}
pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
const newEntryDehydrated = this.generateEntry(appState, elements);
const newEntry: HistoryEntry = this.hydrateHistoryEntry(newEntryDehydrated);
if (newEntry) {
if (!this.shouldCreateEntry(newEntry)) {
return;
}
this.stateHistory.push(newEntryDehydrated);
this.lastEntry = newEntry;
// As a new entry was pushed, we invalidate the redo stack
this.clearRedoStack();
}
}
clearRedoStack() {
this.redoStack.splice(0, this.redoStack.length);
}
redoOnce(): HistoryEntry | null {
if (this.redoStack.length === 0) {
return null;
}
const entryToRestore = this.redoStack.pop();
if (entryToRestore !== undefined) {
this.stateHistory.push(entryToRestore);
return this.hydrateHistoryEntry(entryToRestore);
}
return null;
}
undoOnce(): HistoryEntry | null {
if (this.stateHistory.length === 1) {
return null;
}
const currentEntry = this.stateHistory.pop();
const entryToRestore = this.stateHistory[this.stateHistory.length - 1];
if (currentEntry !== undefined) {
this.redoStack.push(currentEntry);
return this.hydrateHistoryEntry(entryToRestore);
}
return null;
}
/**
* Updates history's `lastEntry` to latest app state. This is necessary
* when doing undo/redo which itself doesn't commit to history, but updates
* app state in a way that would break `shouldCreateEntry` which relies on
* `lastEntry` to reflect last comittable history state.
* We can't update `lastEntry` from within history when calling undo/redo
* because the action potentially mutates appState/elements before storing
* it.
* Record a local change which will go into the history
*/
setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) {
this.lastEntry = this.hydrateHistoryEntry(
this.generateEntry(appState, elements),
public record(
elementsChange: ElementsChange,
appStateChange: AppStateChange,
) {
const entry = HistoryEntry.create(appStateChange, elementsChange);
if (!entry.isEmpty()) {
// we have the latest changes, no need to `applyLatest`, which is done within `History.push`
this.undoStack.push(entry.inverse());
if (!entry.elementsChange.isEmpty()) {
// don't reset redo stack on local appState changes,
// as a simple click (unselect) could lead to losing all the redo entries
// only reset on non empty elements changes!
this.redoStack.length = 0;
}
this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
}
}
public undo(
elements: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
) {
return this.perform(
elements,
appState,
snapshot,
() => History.pop(this.undoStack),
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
);
}
// Suspicious that this is called so many places. Seems error-prone.
resumeRecording() {
this.recording = true;
public redo(
elements: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
) {
return this.perform(
elements,
appState,
snapshot,
() => History.pop(this.redoStack),
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
);
}
record(state: AppState, elements: readonly ExcalidrawElement[]) {
if (this.recording) {
this.pushEntry(state, elements);
this.recording = false;
private perform(
elements: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
pop: () => HistoryEntry | null,
push: (entry: HistoryEntry) => void,
): [SceneElementsMap, AppState] | void {
try {
let historyEntry = pop();
if (historyEntry === null) {
return;
}
let nextElements = elements;
let nextAppState = appState;
let containsVisibleChange = false;
// iterate through the history entries in case they result in no visible changes
while (historyEntry) {
try {
[nextElements, nextAppState, containsVisibleChange] =
historyEntry.applyTo(nextElements, nextAppState, snapshot);
} finally {
// make sure to always push / pop, even if the increment is corrupted
push(historyEntry);
}
if (containsVisibleChange) {
break;
}
historyEntry = pop();
}
return [nextElements, nextAppState];
} finally {
// trigger the history change event before returning completely
// also trigger it just once, no need doing so on each entry
this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
}
}
private static pop(stack: HistoryStack): HistoryEntry | null {
if (!stack.length) {
return null;
}
const entry = stack.pop();
if (entry !== undefined) {
return entry;
}
return null;
}
private static push(
stack: HistoryStack,
entry: HistoryEntry,
prevElements: SceneElementsMap,
) {
const updatedEntry = entry.inverse().applyLatestChanges(prevElements);
return stack.push(updatedEntry);
}
}
export default History;
export class HistoryEntry {
private constructor(
public readonly appStateChange: AppStateChange,
public readonly elementsChange: ElementsChange,
) {}
public static create(
appStateChange: AppStateChange,
elementsChange: ElementsChange,
) {
return new HistoryEntry(appStateChange, elementsChange);
}
public inverse(): HistoryEntry {
return new HistoryEntry(
this.appStateChange.inverse(),
this.elementsChange.inverse(),
);
}
public applyTo(
elements: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] =
this.elementsChange.applyTo(elements, snapshot.elements);
const [nextAppState, appStateContainsVisibleChange] =
this.appStateChange.applyTo(appState, nextElements);
const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange;
return [nextElements, nextAppState, appliedVisibleChanges];
}
/**
* Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`.
*/
public applyLatestChanges(elements: SceneElementsMap): HistoryEntry {
const updatedElementsChange =
this.elementsChange.applyLatestChanges(elements);
return HistoryEntry.create(this.appStateChange, updatedElementsChange);
}
public isEmpty(): boolean {
return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
}
}

View File

@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
import { Emitter } from "../emitter";
export const useEmitter = <TEvent extends unknown>(
emitter: Emitter<[TEvent]>,
initialState: TEvent,
) => {
const [event, setEvent] = useState<TEvent>(initialState);
useEffect(() => {
const unsubscribe = emitter.on((event) => {
setEvent(event);
});
return () => {
unsubscribe();
};
}, [emitter]);
return event;
};

View File

@ -140,10 +140,6 @@ class Scene {
};
private versionNonce: number | undefined;
getElementsMapIncludingDeleted() {
return this.elementsMap;
}
getNonDeletedElementsMap() {
return this.nonDeletedElementsMap;
}
@ -152,6 +148,10 @@ class Scene {
return this.elements;
}
getElementsMapIncludingDeleted() {
return this.elementsMap;
}
getNonDeletedElements() {
return this.nonDeletedElements;
}

View File

@ -0,0 +1,332 @@
import { getDefaultAppState } from "./appState";
import { AppStateChange, ElementsChange } from "./change";
import { newElementWith } from "./element/mutateElement";
import { deepCopyElement } from "./element/newElement";
import { OrderedExcalidrawElement } from "./element/types";
import { Emitter } from "./emitter";
import { AppState, ObservedAppState } from "./types";
import { isShallowEqual } from "./utils";
export const getObservedAppState = (appState: AppState): ObservedAppState => {
return {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
};
};
export const StoreAction = {
NONE: "NONE",
UPDATE: "UPDATE",
CAPTURE: "CAPTURE",
} as const;
/**
* Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
*
* @experimental this interface is experimental and subject to change.
*/
export interface IStore {
onStoreIncrementEmitter: Emitter<[StoreIncrementEvent]>;
get snapshot(): Snapshot;
set snapshot(snapshot: Snapshot);
/**
* Use to schedule update of the snapshot, useful on updates for which we don't need to calculate increments (i.e. remote updates).
*/
shouldUpdateSnapshot(): void;
/**
* Use to schedule calculation of a store increment on a next component update.
*/
shouldCaptureIncrement(): void;
/**
* Capture changes to the `elements` and `appState` by calculating changes (based on a snapshot) and emitting resulting changes as a store increment.
*
* @emits StoreIncrementEvent
*/
capture(
elements: Map<string, OrderedExcalidrawElement>,
appState: AppState,
): void;
/**
* Clears the store instance.
*/
clear(): void;
/**
* Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot.
*
* This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
*
* Once we will be exchanging just store increments for all ephemerals, this could be deprecated.
*/
ignoreUncomittedElements(
prevElements: Map<string, OrderedExcalidrawElement>,
nextElements: Map<string, OrderedExcalidrawElement>,
): Map<string, OrderedExcalidrawElement>;
}
/**
* Represent an increment to the Store.
*/
class StoreIncrementEvent {
constructor(
public readonly elementsChange: ElementsChange,
public readonly appStateChange: AppStateChange,
) {}
}
export class Store implements IStore {
public readonly onStoreIncrementEmitter = new Emitter<
[StoreIncrementEvent]
>();
private calculatingIncrement: boolean = false;
private updatingSnapshot: boolean = false;
private _snapshot = Snapshot.empty();
public get snapshot() {
return this._snapshot;
}
public set snapshot(snapshot: Snapshot) {
this._snapshot = snapshot;
}
public shouldUpdateSnapshot = () => {
this.updatingSnapshot = true;
};
// Suspicious that this is called so many places. Seems error-prone.
public shouldCaptureIncrement = () => {
this.calculatingIncrement = true;
};
public capture = (
elements: Map<string, OrderedExcalidrawElement>,
appState: AppState,
): void => {
// Quick exit for irrelevant changes
if (!this.calculatingIncrement && !this.updatingSnapshot) {
return;
}
try {
const nextSnapshot = this._snapshot.clone(elements, appState);
// Optimisation, don't continue if nothing has changed
if (this._snapshot !== nextSnapshot) {
// Calculate and record the changes based on the previous and next snapshot
if (this.calculatingIncrement) {
const elementsChange = nextSnapshot.meta.didElementsChange
? ElementsChange.calculate(
this._snapshot.elements,
nextSnapshot.elements,
)
: ElementsChange.empty();
const appStateChange = nextSnapshot.meta.didAppStateChange
? AppStateChange.calculate(
this._snapshot.appState,
nextSnapshot.appState,
)
: AppStateChange.empty();
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
// Notify listeners with the increment
this.onStoreIncrementEmitter.trigger(
new StoreIncrementEvent(elementsChange, appStateChange),
);
}
}
// Update the snapshot
this._snapshot = nextSnapshot;
}
} finally {
// Reset props
this.updatingSnapshot = false;
this.calculatingIncrement = false;
}
};
public ignoreUncomittedElements = (
prevElements: Map<string, OrderedExcalidrawElement>,
nextElements: Map<string, OrderedExcalidrawElement>,
) => {
for (const [id, prevElement] of prevElements.entries()) {
const nextElement = nextElements.get(id);
if (!nextElement) {
// Nothing to care about here, elements were forcefully deleted
continue;
}
const elementSnapshot = this._snapshot.elements.get(id);
// Checks for in progress async user action
if (!elementSnapshot) {
// Detected yet uncomitted local element
nextElements.delete(id);
} else if (elementSnapshot.version < prevElement.version) {
// Element was already commited, but the snapshot version is lower than current current local version
nextElements.set(id, elementSnapshot);
}
}
return nextElements;
};
public clear = (): void => {
this._snapshot = Snapshot.empty();
};
}
export class Snapshot {
private constructor(
public readonly elements: Map<string, OrderedExcalidrawElement>,
public readonly appState: ObservedAppState,
public readonly meta: {
didElementsChange: boolean;
didAppStateChange: boolean;
isEmpty?: boolean;
} = {
didElementsChange: false,
didAppStateChange: false,
isEmpty: false,
},
) {}
public static empty() {
return new Snapshot(
new Map(),
getObservedAppState(getDefaultAppState() as AppState),
{ didElementsChange: false, didAppStateChange: false, isEmpty: true },
);
}
public isEmpty() {
return this.meta.isEmpty;
}
/**
* Efficiently clone the existing snapshot.
*
* @returns same instance if there are no changes detected, new instance otherwise.
*/
public clone(
elements: Map<string, OrderedExcalidrawElement>,
appState: AppState,
) {
const didElementsChange = this.detectChangedElements(elements);
// Not watching over everything from app state, just the relevant props
const nextAppStateSnapshot = getObservedAppState(appState);
const didAppStateChange = this.detectChangedAppState(nextAppStateSnapshot);
// Nothing has changed, so there is no point of continuing further
if (!didElementsChange && !didAppStateChange) {
return this;
}
// Clone only if there was really a change
let nextElementsSnapshot = this.elements;
if (didElementsChange) {
nextElementsSnapshot = this.createElementsSnapshot(elements);
}
const snapshot = new Snapshot(nextElementsSnapshot, nextAppStateSnapshot, {
didElementsChange,
didAppStateChange,
});
return snapshot;
}
/**
* Detect if there any changed elements.
*
* NOTE: we shouldn't use `sceneVersionNonce` instead, as we need to call this before the scene updates.
*/
private detectChangedElements(
nextElements: Map<string, OrderedExcalidrawElement>,
) {
if (this.elements === nextElements) {
return false;
}
if (this.elements.size !== nextElements.size) {
return true;
}
// loop from right to left as changes are likelier to happen on new elements
const keys = Array.from(nextElements.keys());
for (let i = keys.length - 1; i >= 0; i--) {
const prev = this.elements.get(keys[i]);
const next = nextElements.get(keys[i]);
if (
!prev ||
!next ||
prev.id !== next.id ||
prev.versionNonce !== next.versionNonce
) {
return true;
}
}
return false;
}
private detectChangedAppState(observedAppState: ObservedAppState) {
return !isShallowEqual(this.appState, observedAppState, {
selectedElementIds: isShallowEqual,
selectedGroupIds: isShallowEqual,
});
}
/**
* Perform structural clone, cloning only elements that changed.
*/
private createElementsSnapshot(
nextElements: Map<string, OrderedExcalidrawElement>,
) {
const clonedElements = new Map();
for (const [id, prevElement] of this.elements.entries()) {
// Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
// i.e. during collab, persist or whenenever isDeleted elements get cleared
if (!nextElements.get(id)) {
// When we cannot find the prev element in the next elements, we mark it as deleted
clonedElements.set(
id,
newElementWith(prevElement, { isDeleted: true }),
);
} else {
clonedElements.set(id, prevElement);
}
}
for (const [id, nextElement] of nextElements.entries()) {
const prevElement = clonedElements.get(id);
// At this point our elements are reconcilled already, meaning the next element is always newer
if (
!prevElement || // element was added
(prevElement && prevElement.versionNonce !== nextElement.versionNonce) // element was updated
) {
clonedElements.set(id, deepCopyElement(nextElement));
}
}
return clonedElements;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"type": "rectangle",
"updated": 1,
"version": 7,
"versionNonce": 745419401,
"versionNonce": 1984422985,
"width": 300,
"x": 201,
"y": 2,
@ -186,7 +186,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"endArrowhead": null,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id1",
"focus": -0.46666666666666673,
@ -227,10 +227,10 @@ exports[`move element > rectangles with binding arrow 7`] = `
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "line",
"type": "arrow",
"updated": 1,
"version": 12,
"versionNonce": 1984422985,
"version": 14,
"versionNonce": 2066753033,
"width": 81,
"x": 110,
"y": 49.981789081137734,

View File

@ -27,7 +27,7 @@ const checkpoint = (name: string) => {
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),

View File

@ -69,9 +69,18 @@ export class API {
return selectedElements[0];
};
static getStateHistory = () => {
static getUndoStack = () => {
// @ts-ignore
return h.history.stateHistory;
return h.history.undoStack;
};
static getRedoStack = () => {
// @ts-ignore
return h.history.redoStack;
};
static getSnapshot = () => {
return Array.from(h.store.snapshot.elements.values());
};
static clearSelection = () => {

View File

@ -113,6 +113,18 @@ export class Keyboard {
Keyboard.codeDown(code);
Keyboard.codeUp(code);
};
static undo = () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress("z");
});
};
static redo = () => {
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress("z");
});
};
}
const getElementPointForSelection = (element: ExcalidrawElement): Point => {

File diff suppressed because it is too large Load Diff

View File

@ -77,30 +77,30 @@ describe("move element", () => {
// create elements
const rectA = UI.createElement("rectangle", { size: 100 });
const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 });
const line = UI.createElement("line", { x: 110, y: 50, size: 80 });
const arrow = UI.createElement("arrow", { x: 110, y: 50, size: 80 });
const elementsMap = h.app.scene.getNonDeletedElementsMap();
// bind line to two rectangles
bindOrUnbindLinearElement(
line.get() as NonDeleted<ExcalidrawLinearElement>,
arrow.get() as NonDeleted<ExcalidrawLinearElement>,
rectA.get() as ExcalidrawRectangleElement,
rectB.get() as ExcalidrawRectangleElement,
elementsMap,
);
// select the second rectangles
// select the second rectangle
new Pointer("mouse").clickOn(rectB);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`21`,
`20`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`19`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`17`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([200, 0]);
expect([line.x, line.y]).toEqual([110, 50]);
expect([line.width, line.height]).toEqual([80, 80]);
expect([arrow.x, arrow.y]).toEqual([110, 50]);
expect([arrow.width, arrow.height]).toEqual([80, 80]);
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
@ -118,8 +118,10 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([Math.round(line.x), Math.round(line.y)]).toEqual([110, 50]);
expect([Math.round(line.width), Math.round(line.height)]).toEqual([81, 81]);
expect([Math.round(arrow.x), Math.round(arrow.y)]).toEqual([110, 50]);
expect([Math.round(arrow.width), Math.round(arrow.height)]).toEqual([
81, 81,
]);
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});

View File

@ -35,7 +35,7 @@ const checkpoint = (name: string) => {
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
@ -359,6 +359,7 @@ describe("regression tests", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
Keyboard.keyPress(KEYS.Z);
Keyboard.keyPress(KEYS.Z);
});
expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
Keyboard.withModifierKeys({ ctrl: true }, () => {
@ -372,7 +373,7 @@ describe("regression tests", () => {
});
it("noop interaction after undo shouldn't create history entry", () => {
expect(API.getStateHistory().length).toBe(1);
expect(API.getUndoStack().length).toBe(0);
UI.clickTool("rectangle");
mouse.down(10, 10);
@ -386,35 +387,35 @@ describe("regression tests", () => {
const secondElementEndPoint = mouse.getPosition();
expect(API.getStateHistory().length).toBe(3);
expect(API.getUndoStack().length).toBe(2);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
expect(API.getStateHistory().length).toBe(2);
expect(API.getUndoStack().length).toBe(1);
// clicking an element shouldn't add to history
mouse.restorePosition(...firstElementEndPoint);
mouse.click();
expect(API.getStateHistory().length).toBe(2);
expect(API.getUndoStack().length).toBe(1);
Keyboard.withModifierKeys({ shift: true, ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
expect(API.getStateHistory().length).toBe(3);
expect(API.getUndoStack().length).toBe(2);
// clicking an element shouldn't add to history
// clicking an element should add to history
mouse.click();
expect(API.getStateHistory().length).toBe(3);
expect(API.getUndoStack().length).toBe(3);
const firstSelectedElementId = API.getSelectedElement().id;
// same for clicking the element just redo-ed
mouse.restorePosition(...secondElementEndPoint);
mouse.click();
expect(API.getStateHistory().length).toBe(3);
expect(API.getUndoStack().length).toBe(4);
expect(API.getSelectedElement().id).not.toEqual(firstSelectedElementId);
});

View File

@ -199,6 +199,24 @@ export type InteractiveCanvasAppState = Readonly<
}
>;
export type ObservedAppState = ObservedStandaloneAppState &
ObservedElementsAppState;
export type ObservedStandaloneAppState = {
name: AppState["name"];
viewBackgroundColor: AppState["viewBackgroundColor"];
};
export type ObservedElementsAppState = {
editingGroupId: AppState["editingGroupId"];
selectedElementIds: AppState["selectedElementIds"];
selectedGroupIds: AppState["selectedGroupIds"];
// Avoiding storing whole instance, as it could lead into state incosistencies, empty undos/redos and etc.
editingLinearElementId: LinearElementEditor["elementId"] | null;
// Right now it's coupled to `editingLinearElement`, ideally it should not be really needed as we already have selectedElementIds & editingLinearElementId
selectedLinearElementId: LinearElementEditor["elementId"] | null;
};
export interface AppState {
contextMenu: {
items: ContextMenuItems;
@ -489,7 +507,7 @@ export type SceneData = {
elements?: ImportedDataState["elements"];
appState?: ImportedDataState["appState"];
collaborators?: Map<SocketId, Collaborator>;
commitToHistory?: boolean;
commitToStore?: boolean;
};
export enum UserIdleState {

View File

@ -671,6 +671,18 @@ export const arrayToMapWithIndex = <T extends { id: string }>(
return acc;
}, new Map<string, [element: T, index: number]>());
/**
* Transform array into an object, use only when array order is irrelevant.
*/
export const arrayToObject = <T>(
array: readonly T[],
groupBy?: (value: T) => string,
) =>
array.reduce((acc, value) => {
acc[groupBy ? groupBy(value) : String(value)] = value;
return acc;
}, {} as { [key: string]: T });
export const isTestEnv = () => import.meta.env.MODE === "test";
export const isDevEnv = () => import.meta.env.MODE === "development";