mirror of
https://github.com/excalidraw/excalidraw.git
synced 2024-11-02 03:25:53 +01:00
feat: multiplayer undo / redo (#7348)
This commit is contained in:
parent
5211b003b8
commit
530617be90
@ -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() {
|
||||
|
@ -438,7 +438,7 @@ const ExcalidrawWrapper = () => {
|
||||
excalidrawAPI.updateScene({
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
commitToHistory: true,
|
||||
commitToStore: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -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();
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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 }) => (
|
||||
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -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) =>
|
||||
|
@ -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) => {
|
||||
|
@ -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) =>
|
||||
|
@ -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) =>
|
||||
|
@ -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),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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 }) => (
|
||||
|
@ -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) =>
|
||||
|
@ -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) =>
|
||||
|
@ -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) =>
|
||||
|
@ -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) =>
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -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" },
|
||||
|
@ -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,
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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,
|
||||
|
@ -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) =>
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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) =>
|
||||
|
@ -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) => {
|
||||
|
@ -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,
|
||||
|
1529
packages/excalidraw/change.ts
Normal file
1529
packages/excalidraw/change.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -50,6 +50,15 @@
|
||||
color: var(--color-on-primary-container);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-disabled="true"] {
|
||||
background: initial;
|
||||
border: none;
|
||||
|
||||
svg {
|
||||
color: var(--color-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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]),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 => {
|
||||
|
@ -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 = (
|
||||
|
@ -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";
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>();
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
21
packages/excalidraw/hooks/useEmitter.ts
Normal file
21
packages/excalidraw/hooks/useEmitter.ts
Normal 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;
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
332
packages/excalidraw/store.ts
Normal file
332
packages/excalidraw/store.ts
Normal 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
18005
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
Normal file
18005
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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}`),
|
||||
|
@ -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 = () => {
|
||||
|
@ -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
@ -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());
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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 {
|
||||
|
@ -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";
|
||||
|
Loading…
Reference in New Issue
Block a user