Compare commits
21 Commits
master
...
mrazator/t
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d872adf593 | ||
![]() |
260706c42f | ||
![]() |
5e98047267 | ||
![]() |
741380bd43 | ||
![]() |
5ed82cb646 | ||
![]() |
dddb07cf57 | ||
![]() |
d6a6c40051 | ||
![]() |
bf53d90c68 | ||
![]() |
b734f7cba8 | ||
![]() |
4f218856c3 | ||
![]() |
7dfba985f9 | ||
![]() |
5bc23d6dee | ||
![]() |
093e684d9e | ||
![]() |
84c1de7a03 | ||
![]() |
d1a9c593cc | ||
![]() |
a7154227cf | ||
![]() |
1e132e33ae | ||
![]() |
00ffa08e28 | ||
![]() |
5c1787bdf4 | ||
![]() |
de32256466 | ||
![]() |
02dc00a47e |
@ -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,8 @@ 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/src/data/types.ts#L38) | The `elements` to be updated in the scene |
|
||||
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/src/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/src/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 `store` should update it's snapshot, capture the update and calculates the diff. Captured changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
|
||||
| `skipSnapshotUpdate` | `boolean` | Implies whether the `store` should skip update of its snapshot, which is necessary for correct diff calculation. Relevant only when `elements` or `appState` are passed in. When `true`, `commitToStore` value will be ignored. Defaults to `false`. |
|
||||
|
||||
```jsx live
|
||||
function App() {
|
||||
|
@ -302,7 +302,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -449,14 +448,12 @@ class Collab extends PureComponent<Props, 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));
|
||||
@ -491,9 +488,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
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,
|
||||
@ -649,21 +644,11 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
}, LOAD_IMAGES_TIMEOUT);
|
||||
|
||||
private handleRemoteSceneUpdate = (
|
||||
elements: ReconciledElements,
|
||||
{ init = false }: { init?: boolean } = {},
|
||||
) => {
|
||||
private handleRemoteSceneUpdate = (elements: ReconciledElements) => {
|
||||
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();
|
||||
};
|
||||
|
||||
|
@ -18,7 +18,6 @@ import throttle from "lodash.throttle";
|
||||
import { newElementWith } from "../../src/element/mutateElement";
|
||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
||||
import { encryptData } from "../../src/data/encryption";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
|
||||
class Portal {
|
||||
collab: TCollabClass;
|
||||
@ -150,11 +149,7 @@ class Portal {
|
||||
this.broadcastedElementVersions.get(element.id)!) &&
|
||||
isSyncableElement(element)
|
||||
) {
|
||||
acc.push({
|
||||
...element,
|
||||
// z-index info for the reconciler
|
||||
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
|
||||
});
|
||||
acc.push(element);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
|
@ -1,15 +1,13 @@
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import { AppState } from "../../src/types";
|
||||
import { arrayToMapWithIndex } from "../../src/utils";
|
||||
import { arrayToMap } from "../../src/utils";
|
||||
import { orderByFractionalIndex } from "../../src/fractionalIndex";
|
||||
|
||||
export type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||
_brand: "reconciledElements";
|
||||
};
|
||||
|
||||
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
};
|
||||
export type BroadcastedExcalidrawElement = ExcalidrawElement;
|
||||
|
||||
const shouldDiscardRemoteElement = (
|
||||
localAppState: AppState,
|
||||
@ -21,7 +19,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 || // 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
|
||||
@ -39,116 +37,43 @@ export const reconcileElements = (
|
||||
remoteElements: readonly BroadcastedExcalidrawElement[],
|
||||
localAppState: AppState,
|
||||
): ReconciledElements => {
|
||||
const localElementsData =
|
||||
arrayToMapWithIndex<ExcalidrawElement>(localElements);
|
||||
const localElementsData = arrayToMap(localElements);
|
||||
const reconciledElements: ExcalidrawElement[] = [];
|
||||
const added = new Set<string>();
|
||||
|
||||
const reconciledElements: ExcalidrawElement[] = localElements.slice();
|
||||
|
||||
const duplicates = new WeakMap<ExcalidrawElement, true>();
|
||||
|
||||
let cursor = 0;
|
||||
let offset = 0;
|
||||
|
||||
let remoteElementIdx = -1;
|
||||
// process remote elements
|
||||
for (const remoteElement of remoteElements) {
|
||||
remoteElementIdx++;
|
||||
if (localElementsData.has(remoteElement.id)) {
|
||||
const localElement = localElementsData.get(remoteElement.id);
|
||||
|
||||
const local = localElementsData.get(remoteElement.id);
|
||||
|
||||
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
|
||||
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
||||
if (local) {
|
||||
// Unless the remote and local elements are the same element in which case
|
||||
// we need to keep it as we'd otherwise discard it from the resulting
|
||||
// array.
|
||||
if (local[0] === remoteElement) {
|
||||
if (
|
||||
localElement &&
|
||||
shouldDiscardRemoteElement(localAppState, localElement, remoteElement)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
duplicates.set(local[0], true);
|
||||
}
|
||||
|
||||
// parent may not be defined in case the remote client is running an older
|
||||
// excalidraw version
|
||||
const parent =
|
||||
remoteElement[PRECEDING_ELEMENT_KEY] ||
|
||||
remoteElements[remoteElementIdx - 1]?.id ||
|
||||
null;
|
||||
|
||||
if (parent != null) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
|
||||
// ^ indicates the element is the first in elements array
|
||||
if (parent === "^") {
|
||||
offset++;
|
||||
if (cursor === 0) {
|
||||
reconciledElements.unshift(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor - offset,
|
||||
]);
|
||||
} else {
|
||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor + 1 - offset,
|
||||
]);
|
||||
cursor++;
|
||||
}
|
||||
} else {
|
||||
let idx = localElementsData.has(parent)
|
||||
? localElementsData.get(parent)![1]
|
||||
: null;
|
||||
if (idx != null) {
|
||||
idx += offset;
|
||||
}
|
||||
if (idx != null && idx >= cursor) {
|
||||
reconciledElements.splice(idx + 1, 0, remoteElement);
|
||||
offset++;
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
idx + 1 - offset,
|
||||
]);
|
||||
cursor = idx + 1;
|
||||
} else if (idx != null) {
|
||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
||||
offset++;
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
cursor + 1 - offset,
|
||||
]);
|
||||
cursor++;
|
||||
} else {
|
||||
if (!added.has(remoteElement.id)) {
|
||||
reconciledElements.push(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
reconciledElements.length - 1 - offset,
|
||||
]);
|
||||
added.add(remoteElement.id);
|
||||
}
|
||||
}
|
||||
// no parent z-index information, local element exists → replace in place
|
||||
} else if (local) {
|
||||
reconciledElements[local[1]] = remoteElement;
|
||||
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
|
||||
// otherwise push to the end
|
||||
} else {
|
||||
reconciledElements.push(remoteElement);
|
||||
localElementsData.set(remoteElement.id, [
|
||||
remoteElement,
|
||||
reconciledElements.length - 1 - offset,
|
||||
]);
|
||||
if (!added.has(remoteElement.id)) {
|
||||
reconciledElements.push(remoteElement);
|
||||
added.add(remoteElement.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
|
||||
(element) => !duplicates.has(element),
|
||||
);
|
||||
// process local elements
|
||||
for (const localElement of localElements) {
|
||||
if (!added.has(localElement.id)) {
|
||||
reconciledElements.push(localElement);
|
||||
added.add(localElement.id);
|
||||
}
|
||||
}
|
||||
|
||||
return ret as ReconciledElements;
|
||||
return orderByFractionalIndex(
|
||||
reconciledElements,
|
||||
) as readonly ExcalidrawElement[] as ReconciledElements;
|
||||
};
|
||||
|
@ -278,7 +278,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,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -409,7 +409,7 @@ const ExcalidrawWrapper = () => {
|
||||
excalidrawAPI.updateScene({
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
commitToHistory: true,
|
||||
commitToStore: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -590,6 +590,7 @@ const ExcalidrawWrapper = () => {
|
||||
if (didChange) {
|
||||
excalidrawAPI.updateScene({
|
||||
elements,
|
||||
skipSnapshotUpdate: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -64,7 +64,7 @@ vi.mock("socket.io-client", () => {
|
||||
});
|
||||
|
||||
describe("collaboration", () => {
|
||||
it("creating room should reset deleted elements", async () => {
|
||||
it("creating room should reset deleted elements while keeping store snapshot in sync", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
// To update the scene with deleted elements before starting collab
|
||||
updateSceneData({
|
||||
@ -76,26 +76,43 @@ describe("collaboration", () => {
|
||||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
commitToStore: true,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A" }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true }),
|
||||
]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
expect(Array.from(h.store.snapshot.elements.values())).toEqual([
|
||||
expect.objectContaining({ id: "A" }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true }),
|
||||
]);
|
||||
});
|
||||
window.collab.startCollaboration(null);
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
// We never delete from the local store as it is used for correct diff calculation
|
||||
expect(Array.from(h.store.snapshot.elements.values())).toEqual([
|
||||
expect.objectContaining({ id: "A" }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
const undoAction = createUndoAction(h.history);
|
||||
// noop
|
||||
h.app.actionManager.executeAction(undoAction);
|
||||
|
||||
// As it was introduced #2270, undo is a noop here, but we might want to re-enable it,
|
||||
// since inability to undo your own deletions could be a bigger upsetting factor here
|
||||
await waitFor(() => {
|
||||
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
expect(Array.from(h.store.snapshot.elements.values())).toEqual([
|
||||
expect.objectContaining({ id: "A" }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { expect } from "chai";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import {
|
||||
BroadcastedExcalidrawElement,
|
||||
@ -15,7 +14,6 @@ type ElementLike = {
|
||||
id: string;
|
||||
version: number;
|
||||
versionNonce: number;
|
||||
[PRECEDING_ELEMENT_KEY]?: string | null;
|
||||
};
|
||||
|
||||
type Cache = Record<string, ExcalidrawElement | undefined>;
|
||||
@ -44,7 +42,6 @@ const createElement = (opts: { uid: string } | ElementLike) => {
|
||||
id,
|
||||
version,
|
||||
versionNonce: versionNonce || randomInteger(),
|
||||
[PRECEDING_ELEMENT_KEY]: parent || null,
|
||||
};
|
||||
};
|
||||
|
||||
@ -53,20 +50,15 @@ const idsToElements = (
|
||||
cache: Cache = {},
|
||||
): readonly ExcalidrawElement[] => {
|
||||
return ids.reduce((acc, _uid, idx) => {
|
||||
const {
|
||||
uid,
|
||||
id,
|
||||
version,
|
||||
[PRECEDING_ELEMENT_KEY]: parent,
|
||||
versionNonce,
|
||||
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
|
||||
const { uid, id, version, versionNonce } = createElement(
|
||||
typeof _uid === "string" ? { uid: _uid } : _uid,
|
||||
);
|
||||
const cached = cache[uid];
|
||||
const elem = {
|
||||
id,
|
||||
version: version ?? 0,
|
||||
versionNonce,
|
||||
...cached,
|
||||
[PRECEDING_ELEMENT_KEY]: parent,
|
||||
} as BroadcastedExcalidrawElement;
|
||||
// @ts-ignore
|
||||
cache[uid] = elem;
|
||||
@ -77,7 +69,6 @@ const idsToElements = (
|
||||
|
||||
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
|
||||
return elements.map((el, idx, els) => {
|
||||
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
|
||||
return el;
|
||||
});
|
||||
};
|
||||
@ -389,13 +380,11 @@ describe("elements reconciliation", () => {
|
||||
id: "A",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
},
|
||||
{
|
||||
id: "B",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
},
|
||||
];
|
||||
|
||||
@ -408,13 +397,11 @@ describe("elements reconciliation", () => {
|
||||
id: "A",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
};
|
||||
const el2 = {
|
||||
id: "B",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
[PRECEDING_ELEMENT_KEY]: null,
|
||||
};
|
||||
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
|
||||
});
|
||||
|
@ -37,6 +37,7 @@
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"fake-indexeddb": "3.1.7",
|
||||
"firebase": "8.3.3",
|
||||
"fractional-indexing-jittered": "0.9.0",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
"idb-keyval": "6.0.3",
|
||||
"image-blob-reduce": "3.0.1",
|
||||
|
@ -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 "./types";
|
||||
|
||||
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,
|
||||
|
@ -18,6 +18,7 @@ import { isSomeElementSelected } from "../scene";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -63,7 +64,7 @@ export const actionAlignTop = register({
|
||||
position: "start",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -94,7 +95,7 @@ export const actionAlignBottom = register({
|
||||
position: "end",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -125,7 +126,7 @@ export const actionAlignLeft = register({
|
||||
position: "start",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -156,7 +157,7 @@ export const actionAlignRight = register({
|
||||
position: "end",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -187,7 +188,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
position: "center",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
@ -214,7 +215,7 @@ export const actionAlignHorizontallyCentered = register({
|
||||
position: "center",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
|
@ -33,6 +33,7 @@ import { AppState } from "../types";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
@ -80,7 +81,7 @@ export const actionUnbindText = register({
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -149,7 +150,7 @@ export const actionBindText = register({
|
||||
return {
|
||||
elements: pushTextAboveContainer(elements, container, textElement),
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -299,7 +300,7 @@ export const actionWrapTextInContainer = register({
|
||||
...appState,
|
||||
selectedElementIds: containerIds,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -1,7 +1,13 @@
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { ZoomInIcon, ZoomOutIcon } 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";
|
||||
@ -22,6 +28,7 @@ import {
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { Bounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
@ -35,7 +42,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 }) => {
|
||||
@ -88,7 +97,7 @@ export const actionClearCanvas = register({
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -110,16 +119,17 @@ export const actionZoomIn = register({
|
||||
appState,
|
||||
),
|
||||
},
|
||||
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);
|
||||
}}
|
||||
@ -147,16 +157,17 @@ export const actionZoomOut = register({
|
||||
appState,
|
||||
),
|
||||
},
|
||||
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);
|
||||
}}
|
||||
@ -184,7 +195,7 @@ export const actionResetZoom = register({
|
||||
appState,
|
||||
),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
@ -261,8 +272,8 @@ export const zoomToFit = ({
|
||||
|
||||
// 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;
|
||||
@ -307,7 +318,7 @@ export const zoomToFit = ({
|
||||
scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
};
|
||||
|
||||
@ -377,7 +388,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,
|
||||
@ -414,7 +425,7 @@ export const actionToggleEraserTool = register({
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
@ -449,7 +460,7 @@ export const actionToggleHandTool = register({
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -13,6 +13,7 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
@ -28,7 +29,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,
|
||||
@ -37,7 +38,7 @@ export const actionCopy = register({
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copy",
|
||||
@ -63,7 +64,7 @@ export const actionPaste = register({
|
||||
|
||||
if (isFirefox) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("hints.firefox_clipboard_write"),
|
||||
@ -72,7 +73,7 @@ export const actionPaste = register({
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
||||
@ -85,7 +86,7 @@ export const actionPaste = register({
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
||||
@ -94,7 +95,7 @@ export const actionPaste = register({
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
@ -119,7 +120,7 @@ export const actionCopyAsSvg = register({
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
@ -141,7 +142,7 @@ export const actionCopyAsSvg = register({
|
||||
},
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@ -150,7 +151,7 @@ export const actionCopyAsSvg = register({
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
@ -166,7 +167,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({
|
||||
@ -199,7 +200,7 @@ export const actionCopyAsPng = register({
|
||||
}),
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@ -208,7 +209,7 @@ export const actionCopyAsPng = register({
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
@ -238,7 +239,7 @@ export const copyText = register({
|
||||
.join("\n\n");
|
||||
copyTextToSystemClipboard(text);
|
||||
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 "./types";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -109,7 +110,7 @@ export const actionDeleteSelected = register({
|
||||
...nextAppState,
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
};
|
||||
}
|
||||
|
||||
@ -141,7 +142,7 @@ export const actionDeleteSelected = register({
|
||||
: [0],
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
@ -161,10 +162,12 @@ export const actionDeleteSelected = register({
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
storeAction: isSomeElementSelected(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
),
|
||||
)
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.delete",
|
||||
|
@ -14,6 +14,7 @@ import { isSomeElementSelected } from "../scene";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@ -53,7 +54,7 @@ export const distributeHorizontally = register({
|
||||
space: "between",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -83,7 +84,7 @@ export const distributeVertically = register({
|
||||
space: "between",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -14,7 +14,7 @@ import {
|
||||
} from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||
import { ActionResult } from "./types";
|
||||
import { ActionResult, StoreAction } from "./types";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
@ -31,6 +31,7 @@ import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
getSelectedElements,
|
||||
} from "../scene/selection";
|
||||
import { fixFractionalIndices } from "../fractionalIndex";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
@ -47,13 +48,13 @@ export const actionDuplicateSelection = register({
|
||||
return {
|
||||
elements,
|
||||
appState: ret.appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...duplicateElements(elements, appState),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.duplicateSelection",
|
||||
@ -85,6 +86,7 @@ const duplicateElements = (
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||
|
||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||
const newElement = duplicateElement(
|
||||
@ -96,6 +98,7 @@ const duplicateElements = (
|
||||
y: element.y + GRID_SIZE / 2,
|
||||
},
|
||||
);
|
||||
duplicatedElementsMap.set(newElement.id, newElement);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
oldElements.push(element);
|
||||
newElements.push(newElement);
|
||||
@ -234,7 +237,10 @@ const duplicateElements = (
|
||||
|
||||
// step (3)
|
||||
|
||||
const finalElements = finalElementsReversed.reverse();
|
||||
const finalElements = fixFractionalIndices(
|
||||
finalElementsReversed.reverse(),
|
||||
duplicatedElementsMap,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
@ -4,6 +4,7 @@ import { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.every((el) => !el.locked);
|
||||
@ -44,7 +45,7 @@ export const actionToggleElementLock = register({
|
||||
? null
|
||||
: appState.selectedLinearElement,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState, app) => {
|
||||
@ -98,7 +99,7 @@ export const actionUnlockAllElements = register({
|
||||
lockedElements.map((el) => [el.id, true]),
|
||||
),
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.elementLock.unlockAll",
|
||||
|
@ -19,12 +19,16 @@ import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
import "../components/ToolIcon.scss";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, value) => {
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
return {
|
||||
appState: { ...appState, name: value },
|
||||
storeAction: StoreAction.UPDATE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, appProps, data }) => (
|
||||
<ProjectName
|
||||
@ -45,7 +49,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 }) => (
|
||||
@ -113,7 +117,7 @@ export const actionChangeExportEmbedScene = register({
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportEmbedScene: value },
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
@ -148,7 +152,7 @@ export const actionSaveToActiveFile = register({
|
||||
: await saveAsJSON(elements, appState, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
@ -170,7 +174,7 @@ export const actionSaveToActiveFile = register({
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
return { storeAction: StoreAction.NONE };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -192,7 +196,7 @@ export const actionSaveFileToDisk = register({
|
||||
app.files,
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog: null,
|
||||
@ -206,7 +210,7 @@ export const actionSaveFileToDisk = register({
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
return { storeAction: StoreAction.NONE };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@ -244,7 +248,7 @@ export const actionLoadScene = register({
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
@ -255,7 +259,7 @@ export const actionLoadScene = register({
|
||||
elements,
|
||||
appState: { ...appState, errorMessage: error.message },
|
||||
files: app.files,
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
@ -268,7 +272,7 @@ export const actionExportWithDarkMode = register({
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
@ -49,7 +50,7 @@ export const actionFinalize = register({
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -190,7 +191,10 @@ export const actionFinalize = register({
|
||||
: appState.selectedLinearElement,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
storeAction:
|
||||
appState.activeTool.type === "freedraw"
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.UPDATE,
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
unbindLinearElements,
|
||||
} from "../element/binding";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
@ -25,7 +26,7 @@ export const actionFlipHorizontal = register({
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||
@ -43,7 +44,7 @@ export const actionFlipVertical = register({
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -8,6 +8,7 @@ import { updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { register } from "./register";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@ -39,14 +40,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,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAllElementsInFrame",
|
||||
@ -74,14 +75,14 @@ export const actionRemoveAllElementsFromFrame = register({
|
||||
[selectedElement.id]: true,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.removeAllElementsFromFrame",
|
||||
@ -103,7 +104,7 @@ export const actionupdateFrameRendering = register({
|
||||
enabled: !appState.frameRendering.enabled,
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.updateFrameRendering",
|
||||
@ -131,7 +132,7 @@ export const actionSetFrameAsActiveTool = register({
|
||||
type: "frame",
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -27,6 +27,7 @@ import {
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
} from "../frame";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (elements.length >= 2) {
|
||||
@ -69,7 +70,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);
|
||||
@ -89,7 +90,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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -155,7 +156,7 @@ export const actionGroup = register({
|
||||
),
|
||||
},
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.group",
|
||||
@ -182,7 +183,7 @@ export const actionUngroup = register({
|
||||
perform: (elements, appState, _, app) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
if (groupIds.length === 0) {
|
||||
return { appState, elements, commitToHistory: false };
|
||||
return { appState, elements, storeAction: StoreAction.NONE, };
|
||||
}
|
||||
|
||||
let nextElements = [...elements];
|
||||
@ -250,7 +251,7 @@ export const actionUngroup = register({
|
||||
return {
|
||||
appState: { ...appState, ...updateAppState },
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -1,62 +1,51 @@
|
||||
import { Action, ActionResult } from "./types";
|
||||
import { Action, ActionResult, StoreAction } 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 } 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 { ExcalidrawElement } from "../element/types";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { orderByFractionalIndex } from "../fractionalIndex";
|
||||
|
||||
const writeData = (
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
updater: () => HistoryEntry | null,
|
||||
appState: Readonly<AppState>,
|
||||
updater: () => [Map<string, ExcalidrawElement>, 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),
|
||||
// TODO_UNDO: worth detecting z-index deltas or do we just order based on fractional indices?
|
||||
const [nextElementsMap, nextAppState] = result;
|
||||
const nextElements = orderByFractionalIndex(
|
||||
Array.from(nextElementsMap.values()),
|
||||
);
|
||||
const elements = nextElements
|
||||
.map((nextElement) =>
|
||||
newElementWith(
|
||||
prevElementMap.get(nextElement.id) || nextElement,
|
||||
nextElement,
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
deletedElements.map((prevElement) =>
|
||||
newElementWith(prevElement, { isDeleted: true }),
|
||||
),
|
||||
);
|
||||
fixBindingsAfterDeletion(elements, deletedElements);
|
||||
|
||||
// TODO_UNDO: these are all deleted elements, but ideally we should get just those that were delted at this moment
|
||||
const deletedElements = nextElements.filter((element) => element.isDeleted);
|
||||
// TODO_UNDO: this doesn't really work for bound text
|
||||
fixBindingsAfterDeletion(nextElements, deletedElements);
|
||||
|
||||
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;
|
||||
@ -65,7 +54,7 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.undoOnce()),
|
||||
writeData(appState, () => history.undo(arrayToMap(elements), appState)),
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key.toLowerCase() === KEYS.Z &&
|
||||
@ -77,16 +66,16 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
aria-label={t("buttons.undo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={history.isUndoStackEmpty}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
});
|
||||
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
name: "redo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.redoOnce()),
|
||||
writeData(appState, () => history.redo(arrayToMap(elements), appState)),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
@ -99,7 +88,7 @@ export const createRedoAction: ActionCreator = (history) => ({
|
||||
aria-label={t("buttons.redo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={history.isRedoStackEmpty}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
});
|
||||
|
@ -2,6 +2,7 @@ import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { ExcalidrawLinearElement } from "../element/types";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
name: "toggleLinearEditor",
|
||||
@ -30,7 +31,7 @@ export const actionToggleLinearEditor = register({
|
||||
...appState,
|
||||
editingLinearElement,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState, app) => {
|
||||
|
@ -4,6 +4,7 @@ import { t } from "../i18n";
|
||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||
import { register } from "./register";
|
||||
import { KEYS } from "../keys";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
@ -13,7 +14,7 @@ export const actionToggleCanvasMenu = register({
|
||||
...appState,
|
||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
}),
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ToolButton
|
||||
@ -34,7 +35,7 @@ export const actionToggleEditMenu = register({
|
||||
...appState,
|
||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
}),
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
@ -64,7 +65,7 @@ export const actionShortcuts = register({
|
||||
...appState,
|
||||
openDialog: appState.openDialog === "help" ? null : "help",
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||
|
@ -3,6 +3,7 @@ import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { Collaborator } from "../types";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
@ -11,7 +12,7 @@ export const actionGoToCollaborator = register({
|
||||
perform: (_elements, appState, value) => {
|
||||
const point = value as Collaborator["pointer"];
|
||||
if (!point) {
|
||||
return { appState, commitToHistory: false };
|
||||
return { appState, storeAction: StoreAction.NONE };
|
||||
}
|
||||
|
||||
return {
|
||||
@ -28,7 +29,7 @@ export const actionGoToCollaborator = register({
|
||||
// Close mobile menu
|
||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
|
@ -92,6 +92,7 @@ import {
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
@ -222,7 +223,7 @@ const changeFontSize = (
|
||||
? [...newFontSizes][0]
|
||||
: fallbackValue ?? appState.currentItemFontSize,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
};
|
||||
|
||||
@ -251,7 +252,9 @@ export const actionChangeStrokeColor = register({
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
commitToHistory: !!value.currentItemStrokeColor,
|
||||
storeAction: !!value.currentItemStrokeColor
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||
@ -294,7 +297,9 @@ export const actionChangeBackgroundColor = register({
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
commitToHistory: !!value.currentItemBackgroundColor,
|
||||
storeAction: !!value.currentItemBackgroundColor
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||
@ -337,7 +342,7 @@ export const actionChangeFillStyle = register({
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemFillStyle: value },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
@ -409,7 +414,7 @@ export const actionChangeStrokeWidth = register({
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeWidth: value },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@ -463,7 +468,7 @@ export const actionChangeSloppiness = register({
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemRoughness: value },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@ -513,7 +518,7 @@ export const actionChangeStrokeStyle = register({
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeStyle: value },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@ -567,7 +572,7 @@ export const actionChangeOpacity = register({
|
||||
true,
|
||||
),
|
||||
appState: { ...appState, currentItemOpacity: value },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@ -725,7 +730,7 @@ export const actionChangeFontFamily = register({
|
||||
...appState,
|
||||
currentItemFontFamily: value,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
@ -814,7 +819,7 @@ export const actionChangeTextAlign = register({
|
||||
...appState,
|
||||
currentItemTextAlign: value,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
@ -894,7 +899,7 @@ export const actionChangeVerticalAlign = register({
|
||||
appState: {
|
||||
...appState,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
@ -967,7 +972,7 @@ export const actionChangeRoundness = register({
|
||||
...appState,
|
||||
currentItemRoundness: value,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
@ -1047,7 +1052,7 @@ export const actionChangeArrowhead = register({
|
||||
? "currentItemStartArrowhead"
|
||||
: "currentItemEndArrowhead"]: value.type,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
|
@ -6,6 +6,7 @@ import { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
@ -46,7 +47,7 @@ export const actionSelectAll = register({
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAll",
|
||||
|
@ -25,6 +25,7 @@ import {
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
@ -48,7 +49,7 @@ export const actionCopyStyles = register({
|
||||
...appState,
|
||||
toast: { message: t("toast.copyStyles") },
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copyStyles",
|
||||
@ -64,7 +65,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, {
|
||||
@ -149,7 +150,7 @@ export const actionPasteStyles = register({
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.pasteStyles",
|
||||
|
@ -2,6 +2,7 @@ import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionToggleGridMode = register({
|
||||
name: "gridMode",
|
||||
@ -17,7 +18,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 { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionToggleObjectsSnapMode = register({
|
||||
name: "objectsSnapMode",
|
||||
@ -15,7 +16,7 @@ export const actionToggleObjectsSnapMode = register({
|
||||
objectsSnapModeEnabled: !this.checked!(appState),
|
||||
gridSize: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.objectsSnapModeEnabled,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { register } from "./register";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
@ -11,7 +12,7 @@ export const actionToggleStats = register({
|
||||
...appState,
|
||||
showStats: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
@ -14,7 +15,7 @@ export const actionToggleViewMode = register({
|
||||
...appState,
|
||||
viewModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.viewModeEnabled,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
name: "zenMode",
|
||||
@ -14,7 +15,7 @@ export const actionToggleZenMode = register({
|
||||
...appState,
|
||||
zenModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.zenModeEnabled,
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import {
|
||||
moveOneLeft,
|
||||
moveOneRight,
|
||||
@ -16,6 +15,7 @@ import {
|
||||
SendToBackIcon,
|
||||
} from "../components/icons";
|
||||
import { isDarwin } from "../constants";
|
||||
import { StoreAction } from "./types";
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
@ -24,7 +24,7 @@ export const actionSendBackward = register({
|
||||
return {
|
||||
elements: moveOneLeft(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.sendBackward",
|
||||
@ -52,7 +52,7 @@ export const actionBringForward = register({
|
||||
return {
|
||||
elements: moveOneRight(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.bringForward",
|
||||
@ -80,7 +80,7 @@ export const actionSendToBack = register({
|
||||
return {
|
||||
elements: moveAllLeft(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.sendToBack",
|
||||
@ -116,7 +116,7 @@ export const actionBringToFront = register({
|
||||
return {
|
||||
elements: moveAllRight(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.bringToFront",
|
||||
|
@ -10,6 +10,12 @@ import { MarkOptional } from "../utility-types";
|
||||
|
||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
||||
|
||||
export enum StoreAction {
|
||||
NONE = "none",
|
||||
UPDATE = "update", // TODO_UNDO: think about better naming as this one is confusing
|
||||
CAPTURE = "capture",
|
||||
}
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
| {
|
||||
@ -19,8 +25,7 @@ export type ActionResult =
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> | null;
|
||||
files?: BinaryFiles | null;
|
||||
commitToHistory: boolean;
|
||||
syncHistory?: boolean;
|
||||
storeAction: StoreAction;
|
||||
replaceFiles?: boolean;
|
||||
}
|
||||
| false;
|
||||
|
567
src/change.ts
Normal file
567
src/change.ts
Normal file
@ -0,0 +1,567 @@
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { ExcalidrawElement } from "./element/types";
|
||||
import {
|
||||
AppState,
|
||||
ObservedAppState,
|
||||
ObservedElementsAppState,
|
||||
ObservedStandaloneAppState,
|
||||
} from "./types";
|
||||
import { SubtypeOf } from "./utility-types";
|
||||
import { isShallowEqual } from "./utils";
|
||||
|
||||
/**
|
||||
* Represents the difference between two `T` objects.
|
||||
*
|
||||
* Keeping it as pure object (without transient state, side-effects, etc.), so we don't have to instantiate it on load.
|
||||
*/
|
||||
class Delta<T> {
|
||||
private constructor(
|
||||
public readonly from: Partial<T>,
|
||||
public readonly to: Partial<T>,
|
||||
) {}
|
||||
|
||||
public static create<T>(
|
||||
from: Partial<T>,
|
||||
to: Partial<T>,
|
||||
modifier?: (delta: Partial<T>) => Partial<T>,
|
||||
modifierOptions?: "from" | "to",
|
||||
) {
|
||||
const modifiedFrom =
|
||||
modifier && modifierOptions !== "to" ? modifier(from) : from;
|
||||
const modifiedTo =
|
||||
modifier && modifierOptions !== "from" ? modifier(to) : to;
|
||||
|
||||
return new Delta(modifiedFrom, modifiedTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the delta between two objects.
|
||||
*
|
||||
* @param prevObject - The previous state of the object.
|
||||
* @param nextObject - The next state of the object.
|
||||
*
|
||||
* @returns new Delta instance.
|
||||
*/
|
||||
public static calculate<T extends Object>(
|
||||
prevObject: T,
|
||||
nextObject: T,
|
||||
modifier?: (delta: Partial<T>) => Partial<T>,
|
||||
): Delta<T> {
|
||||
if (prevObject === nextObject) {
|
||||
return Delta.empty();
|
||||
}
|
||||
|
||||
const from = {} as Partial<T>;
|
||||
const to = {} as Partial<T>;
|
||||
|
||||
const unionOfKeys = new Set([
|
||||
...Object.keys(prevObject),
|
||||
...Object.keys(nextObject),
|
||||
]);
|
||||
|
||||
for (const key of unionOfKeys) {
|
||||
const prevValue = prevObject[key as keyof T];
|
||||
const nextValue = nextObject[key as keyof T];
|
||||
|
||||
if (prevValue !== nextValue) {
|
||||
from[key as keyof T] = prevValue;
|
||||
to[key as keyof T] = nextValue;
|
||||
}
|
||||
}
|
||||
return Delta.create(from, to, modifier);
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
return new Delta({}, {});
|
||||
}
|
||||
|
||||
public static isEmpty<T>(delta: Delta<T>): boolean {
|
||||
return !Object.keys(delta.from).length && !Object.keys(delta.to).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares if the delta contains any different values compared to the object.
|
||||
*
|
||||
* WARN: it's based on shallow compare performed only on the first level, won't work for objects with deeper props.
|
||||
*/
|
||||
public static containsDifference<T>(delta: Partial<T>, object: T): boolean {
|
||||
const anyDistinctKey = this.distinctKeysIterator(delta, object).next()
|
||||
.value;
|
||||
return !!anyDistinctKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the keys that have distinct values.
|
||||
*
|
||||
* WARN: it's based on shallow compare performed only on the first level, won't work for objects with deeper props.
|
||||
*/
|
||||
public static gatherDifferences<T>(delta: Partial<T>, object: T) {
|
||||
const distinctKeys = new Set<string>();
|
||||
|
||||
for (const key of this.distinctKeysIterator(delta, object)) {
|
||||
distinctKeys.add(key);
|
||||
}
|
||||
|
||||
return Array.from(distinctKeys);
|
||||
}
|
||||
|
||||
private static *distinctKeysIterator<T>(delta: Partial<T>, object: T) {
|
||||
for (const [key, deltaValue] of Object.entries(delta)) {
|
||||
const objectValue = object[key as keyof T];
|
||||
|
||||
if (deltaValue !== objectValue) {
|
||||
// TODO_UNDO: staticly fail (typecheck) on deeper objects?
|
||||
if (
|
||||
typeof deltaValue === "object" &&
|
||||
typeof objectValue === "object" &&
|
||||
deltaValue !== null &&
|
||||
objectValue !== null &&
|
||||
isShallowEqual(
|
||||
deltaValue as Record<string, any>,
|
||||
objectValue as Record<string, any>,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the modifications captured as `Delta`/s.
|
||||
*/
|
||||
interface Change<T> {
|
||||
/**
|
||||
* Inverses the `Delta`s inside while creating a new `Change`.
|
||||
*/
|
||||
inverse(): Change<T>;
|
||||
|
||||
/**
|
||||
* Applies the `Change` to the previous object.
|
||||
*
|
||||
* @returns new object instance and boolean, indicating if there was any visible change made.
|
||||
*/
|
||||
applyTo(previous: Readonly<T>, ...options: unknown[]): [T, boolean];
|
||||
|
||||
/**
|
||||
* Checks whether there are actually `Delta`s.
|
||||
*/
|
||||
isEmpty(): boolean;
|
||||
}
|
||||
|
||||
export class AppStateChange implements Change<AppState> {
|
||||
private constructor(private readonly delta: Delta<ObservedAppState>) {}
|
||||
|
||||
public static calculate<T extends Partial<ObservedAppState>>(
|
||||
prevAppState: T,
|
||||
nextAppState: T,
|
||||
): AppStateChange {
|
||||
const delta = Delta.calculate(prevAppState, nextAppState);
|
||||
return new AppStateChange(delta);
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
return new AppStateChange(Delta.create({}, {}));
|
||||
}
|
||||
|
||||
public inverse(): AppStateChange {
|
||||
const inversedDelta = Delta.create(this.delta.to, this.delta.from);
|
||||
return new AppStateChange(inversedDelta);
|
||||
}
|
||||
|
||||
public applyTo(
|
||||
appState: Readonly<AppState>,
|
||||
elements: Readonly<Map<string, ExcalidrawElement>>,
|
||||
): [AppState, boolean] {
|
||||
const constainsVisibleChanges = this.checkForVisibleChanges(
|
||||
appState,
|
||||
elements,
|
||||
);
|
||||
|
||||
const newAppState = {
|
||||
...appState,
|
||||
...this.delta.to, // TODO_UNDO: probably shouldn't apply element related changes
|
||||
};
|
||||
|
||||
return [newAppState, constainsVisibleChanges];
|
||||
}
|
||||
|
||||
public isEmpty(): boolean {
|
||||
return Delta.isEmpty(this.delta);
|
||||
}
|
||||
|
||||
private checkForVisibleChanges(
|
||||
appState: ObservedAppState,
|
||||
elements: Map<string, ExcalidrawElement>,
|
||||
): boolean {
|
||||
const containsStandaloneDifference = Delta.containsDifference(
|
||||
AppStateChange.stripElementsProps(this.delta.to),
|
||||
appState,
|
||||
);
|
||||
|
||||
if (containsStandaloneDifference) {
|
||||
// We detected a a difference which is unrelated to the elements
|
||||
return true;
|
||||
}
|
||||
|
||||
const containsElementsDifference = Delta.containsDifference(
|
||||
AppStateChange.stripStandaloneProps(this.delta.to),
|
||||
appState,
|
||||
);
|
||||
|
||||
if (!containsStandaloneDifference && !containsElementsDifference) {
|
||||
// There is no difference detected at all
|
||||
return false;
|
||||
}
|
||||
|
||||
// We need to handle elements differences separately,
|
||||
// as they could be related to deleted elements and/or they could on their own result in no visible action
|
||||
const changedDeltaKeys = Delta.gatherDifferences(
|
||||
AppStateChange.stripStandaloneProps(this.delta.to),
|
||||
appState,
|
||||
) as Array<keyof ObservedElementsAppState>;
|
||||
|
||||
// Check whether delta properties are related to the existing non-deleted elements
|
||||
for (const key of changedDeltaKeys) {
|
||||
switch (key) {
|
||||
case "selectedElementIds":
|
||||
if (
|
||||
AppStateChange.checkForSelectedElementsDifferences(
|
||||
this.delta.to[key],
|
||||
appState,
|
||||
elements,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "selectedLinearElement":
|
||||
case "editingLinearElement":
|
||||
if (
|
||||
AppStateChange.checkForLinearElementDifferences(
|
||||
this.delta.to[key],
|
||||
elements,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "editingGroupId":
|
||||
case "selectedGroupIds":
|
||||
return AppStateChange.checkForGroupsDifferences();
|
||||
default: {
|
||||
// WARN: this exhaustive check in the switch statement is here to catch unexpected future changes
|
||||
// TODO_UNDO: use assertNever
|
||||
const exhaustiveCheck: never = key;
|
||||
throw new Error(
|
||||
`Unknown ObservedElementsAppState key '${exhaustiveCheck}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static checkForSelectedElementsDifferences(
|
||||
deltaIds: ObservedElementsAppState["selectedElementIds"] | undefined,
|
||||
appState: Pick<AppState, "selectedElementIds">,
|
||||
elements: Map<string, ExcalidrawElement>,
|
||||
) {
|
||||
if (!deltaIds) {
|
||||
// There are no selectedElementIds in the delta
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO_UNDO: it could have been visible before (and now it's not)
|
||||
// TODO_UNDO: it could have been selected
|
||||
for (const id of Object.keys(deltaIds)) {
|
||||
const element = elements.get(id);
|
||||
|
||||
if (element && !element.isDeleted) {
|
||||
// // TODO_UNDO: breaks multi selection
|
||||
// if (appState.selectedElementIds[id]) {
|
||||
// // Element is already selected
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Found related visible element!
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static checkForLinearElementDifferences(
|
||||
linearElement:
|
||||
| ObservedElementsAppState["editingLinearElement"]
|
||||
| ObservedAppState["selectedLinearElement"]
|
||||
| undefined,
|
||||
elements: Map<string, ExcalidrawElement>,
|
||||
) {
|
||||
if (!linearElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = elements.get(linearElement.elementId);
|
||||
|
||||
if (element && !element.isDeleted) {
|
||||
// Found related visible element!
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Currently we don't have an index of elements by groupIds, which means
|
||||
// the calculation for getting the visible elements based on the groupIds stored in delta
|
||||
// is not worth performing - due to perf. and dev. complexity.
|
||||
//
|
||||
// Therefore we are accepting in these cases empty undos / redos, which should be pretty rare:
|
||||
// - only when one of these (or both) are in delta and the are no non deleted elements containing these group ids
|
||||
private static checkForGroupsDifferences() {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static stripElementsProps(
|
||||
delta: Partial<ObservedAppState>,
|
||||
): Partial<ObservedStandaloneAppState> {
|
||||
// WARN: Do not remove the type-casts as they here for exhaustive type checks
|
||||
const {
|
||||
editingGroupId,
|
||||
selectedGroupIds,
|
||||
selectedElementIds,
|
||||
editingLinearElement,
|
||||
selectedLinearElement,
|
||||
...standaloneProps
|
||||
} = delta as ObservedAppState;
|
||||
|
||||
return standaloneProps as SubtypeOf<
|
||||
typeof standaloneProps,
|
||||
ObservedStandaloneAppState
|
||||
>;
|
||||
}
|
||||
|
||||
private static stripStandaloneProps(
|
||||
delta: Partial<ObservedAppState>,
|
||||
): Partial<ObservedElementsAppState> {
|
||||
// WARN: Do not remove the type-casts as they here for exhaustive type checks
|
||||
const { name, viewBackgroundColor, ...elementsProps } =
|
||||
delta as ObservedAppState;
|
||||
|
||||
return elementsProps as SubtypeOf<
|
||||
typeof elementsProps,
|
||||
ObservedElementsAppState
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elements change is a low level primitive to capture a change between two sets of elements.
|
||||
* It does so by encapsulating forward and backward `Delta`s, which allow to travel in both directions.
|
||||
*
|
||||
* We could be smarter about the change in the future, ideas for improvements are:
|
||||
* - for memory, share the same delta instances between different deltas (flyweight-like)
|
||||
* - for serialization, compress the deltas into a tree-like structures with custom pointers or let one delta instance contain multiple element ids
|
||||
* - for performance, emit the changes directly by the user actions, then apply them in from store into the state (no diffing!)
|
||||
* - for performance, add operations in addition to deltas, which increment (decrement) properties by given value (could be used i.e. for presence-like move)
|
||||
*/
|
||||
export class ElementsChange implements Change<Map<string, ExcalidrawElement>> {
|
||||
private constructor(
|
||||
// TODO_UNDO: re-think the possible need for added/ remove/ updated deltas (possibly for handling edge cases with deletion, fixing bindings for deletion, showing changes added/modified/updated for version end etc.)
|
||||
private readonly deltas: Map<string, Delta<ExcalidrawElement>>,
|
||||
) {}
|
||||
|
||||
public static create(deltas: Map<string, Delta<ExcalidrawElement>>) {
|
||||
return new ElementsChange(deltas);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the `Delta`s between the previous and next set of elements.
|
||||
*
|
||||
* @param prevElements - Map representing the previous state of elements.
|
||||
* @param nextElements - Map representing the next state of elements.
|
||||
*
|
||||
* @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements.
|
||||
*/
|
||||
public static calculate<T extends ExcalidrawElement>(
|
||||
prevElements: Map<string, ExcalidrawElement>,
|
||||
nextElements: Map<string, ExcalidrawElement>,
|
||||
): ElementsChange {
|
||||
if (prevElements === nextElements) {
|
||||
return ElementsChange.empty();
|
||||
}
|
||||
|
||||
const deltas = new Map<string, Delta<T>>();
|
||||
|
||||
// This might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed
|
||||
for (const prevElement of prevElements.values()) {
|
||||
const nextElement = nextElements.get(prevElement.id);
|
||||
|
||||
// Element got removed
|
||||
if (!nextElement) {
|
||||
const from = { ...prevElement, isDeleted: false } as T;
|
||||
const to = { isDeleted: true } as T;
|
||||
|
||||
const delta = Delta.create(
|
||||
from,
|
||||
to,
|
||||
ElementsChange.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
deltas.set(prevElement.id, delta as Delta<T>);
|
||||
}
|
||||
}
|
||||
|
||||
for (const nextElement of nextElements.values()) {
|
||||
const prevElement = prevElements.get(nextElement.id);
|
||||
|
||||
// Element got added
|
||||
if (!prevElement) {
|
||||
if (nextElement.isDeleted) {
|
||||
// Special case when an element is added as deleted (i.e. through the API).
|
||||
// Creating a delta for it wouldn't make sense, as it would go from isDeleted `true` into `true` again.
|
||||
// We are going to skip it for now, later we could be have separate `added` & `removed` entries in the elements change,
|
||||
// so that we would distinguish between actual addition, removal and "soft" (un)deletion.
|
||||
continue;
|
||||
}
|
||||
|
||||
const from = { isDeleted: true } as T;
|
||||
const to = { ...nextElement, isDeleted: false } as T;
|
||||
|
||||
const delta = Delta.create(
|
||||
from,
|
||||
to,
|
||||
ElementsChange.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
deltas.set(nextElement.id, delta as Delta<T>);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Element got updated
|
||||
if (prevElement.versionNonce !== nextElement.versionNonce) {
|
||||
// O(n^2) here, but it's not as bad as it looks:
|
||||
// - we do this only on history recordings, not on every frame
|
||||
// - we do this only on changed elements
|
||||
// - # of element's properties is reasonably small
|
||||
// - otherwise we would have to emit deltas on user actions & apply them on every frame
|
||||
const delta = Delta.calculate<ExcalidrawElement>(
|
||||
prevElement,
|
||||
nextElement,
|
||||
ElementsChange.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
// Make sure there are at least some changes (except changes to irrelevant data)
|
||||
if (!Delta.isEmpty(delta)) {
|
||||
deltas.set(nextElement.id, delta as Delta<T>);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ElementsChange(deltas);
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
return new ElementsChange(new Map());
|
||||
}
|
||||
|
||||
public inverse(): ElementsChange {
|
||||
const deltas = new Map<string, Delta<ExcalidrawElement>>();
|
||||
|
||||
for (const [id, delta] of this.deltas.entries()) {
|
||||
deltas.set(id, Delta.create(delta.to, delta.from));
|
||||
}
|
||||
|
||||
return new ElementsChange(deltas);
|
||||
}
|
||||
|
||||
public applyTo(
|
||||
elements: Readonly<Map<string, ExcalidrawElement>>,
|
||||
): [Map<string, ExcalidrawElement>, boolean] {
|
||||
let containsVisibleDifference = false;
|
||||
|
||||
for (const [id, delta] of this.deltas.entries()) {
|
||||
const existingElement = elements.get(id);
|
||||
|
||||
if (existingElement) {
|
||||
// Check if there was actually any visible change before applying
|
||||
if (!containsVisibleDifference) {
|
||||
// Special case, when delta deletes element, it results in a visible change
|
||||
if (existingElement.isDeleted && delta.to.isDeleted === false) {
|
||||
containsVisibleDifference = true;
|
||||
} else if (!existingElement.isDeleted) {
|
||||
// Check for any difference on a visible element
|
||||
containsVisibleDifference = Delta.containsDifference(
|
||||
delta.to,
|
||||
existingElement,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
elements.set(id, newElementWith(existingElement, delta.to, true));
|
||||
}
|
||||
}
|
||||
|
||||
return [elements, containsVisibleDifference];
|
||||
}
|
||||
|
||||
public isEmpty(): boolean {
|
||||
// TODO_UNDO: might need to go through all deltas and check for emptiness
|
||||
return this.deltas.size === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the delta/s based on the existing elements.
|
||||
*
|
||||
* @param elements current elements
|
||||
* @param modifierOptions defines which of the delta (`from` or `to`) will be updated
|
||||
* @returns new instance with modified delta/s
|
||||
*/
|
||||
public applyLatestChanges(
|
||||
elements: Map<string, ExcalidrawElement>,
|
||||
modifierOptions: "from" | "to",
|
||||
): ElementsChange {
|
||||
const modifier =
|
||||
(element: ExcalidrawElement) => (partial: Partial<ExcalidrawElement>) => {
|
||||
const modifiedPartial: { [key: string]: unknown } = {};
|
||||
|
||||
for (const key of Object.keys(partial)) {
|
||||
modifiedPartial[key] = element[key as keyof ExcalidrawElement];
|
||||
}
|
||||
|
||||
return modifiedPartial;
|
||||
};
|
||||
|
||||
const deltas = new Map<string, Delta<ExcalidrawElement>>();
|
||||
|
||||
for (const [id, delta] of this.deltas.entries()) {
|
||||
const existingElement = elements.get(id);
|
||||
|
||||
if (existingElement) {
|
||||
const modifiedDelta = Delta.create(
|
||||
delta.from,
|
||||
delta.to,
|
||||
modifier(existingElement),
|
||||
modifierOptions,
|
||||
);
|
||||
|
||||
deltas.set(id, modifiedDelta);
|
||||
} else {
|
||||
// Keep whatever we had
|
||||
deltas.set(id, delta);
|
||||
}
|
||||
}
|
||||
|
||||
return ElementsChange.create(deltas);
|
||||
}
|
||||
|
||||
private static stripIrrelevantProps(delta: Partial<ExcalidrawElement>) {
|
||||
// TODO_UNDO: is seed correctly stripped?
|
||||
const { id, updated, version, versionNonce, seed, ...strippedDelta } =
|
||||
delta;
|
||||
|
||||
return strippedDelta;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -40,7 +40,7 @@ import {
|
||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { actions } from "../actions/register";
|
||||
import { Action, ActionResult } from "../actions/types";
|
||||
import { Action, ActionResult, StoreAction } from "../actions/types";
|
||||
import { trackEvent } from "../analytics";
|
||||
import {
|
||||
getDefaultAppState,
|
||||
@ -194,7 +194,7 @@ import {
|
||||
isSelectedViaGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "../groups";
|
||||
import History from "../history";
|
||||
import { History } from "../history";
|
||||
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
|
||||
import {
|
||||
CODES,
|
||||
@ -268,6 +268,8 @@ import {
|
||||
muteFSAbortError,
|
||||
isTestEnv,
|
||||
easeOut,
|
||||
isShallowEqual,
|
||||
arrayToMap,
|
||||
} from "../utils";
|
||||
import {
|
||||
createSrcDoc,
|
||||
@ -398,6 +400,8 @@ import { COLOR_PALETTE } from "../colors";
|
||||
import { ElementCanvasButton } from "./MagicButton";
|
||||
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
||||
import { fixFractionalIndices } from "../fractionalIndex";
|
||||
import { Store } from "../store";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
@ -512,6 +516,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public library: AppClassProperties["library"];
|
||||
public libraryItemsFromStorage: LibraryItems | undefined;
|
||||
public id: string;
|
||||
private store: Store;
|
||||
private history: History;
|
||||
private excalidrawContainerValue: {
|
||||
container: HTMLDivElement | null;
|
||||
@ -595,6 +600,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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,
|
||||
@ -602,6 +610,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
addFiles: this.addFiles,
|
||||
resetScene: this.resetScene,
|
||||
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
||||
store: {
|
||||
clear: this.store.clear,
|
||||
listen: this.store.listen,
|
||||
},
|
||||
history: {
|
||||
clear: this.resetHistory,
|
||||
},
|
||||
@ -641,8 +653,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
onSceneUpdated: this.onSceneUpdated,
|
||||
});
|
||||
this.history = new History();
|
||||
this.actionManager.registerAll(actions);
|
||||
|
||||
this.actionManager = new ActionManager(
|
||||
this.syncActionResult,
|
||||
() => this.state,
|
||||
() => this.scene.getElementsIncludingDeleted(),
|
||||
this,
|
||||
);
|
||||
this.actionManager.registerAll(actions);
|
||||
this.actionManager.registerAction(createUndoAction(this.history));
|
||||
this.actionManager.registerAction(createRedoAction(this.history));
|
||||
}
|
||||
@ -1913,15 +1931,16 @@ 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 {
|
||||
// TODO_UNDO: test if we didn't regress here - shouldn't this commit to store?
|
||||
this.updateScene({
|
||||
elements: this.scene.getElementsIncludingDeleted().map((el) => {
|
||||
if (this.state.selectedElementIds[el.id]) {
|
||||
@ -1957,8 +1976,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
});
|
||||
this.scene.replaceAllElements(actionResult.elements);
|
||||
if (actionResult.commitToHistory) {
|
||||
this.history.resumeRecording();
|
||||
|
||||
if (actionResult.storeAction === StoreAction.UPDATE) {
|
||||
this.store.scheduleSnapshotUpdate();
|
||||
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
|
||||
this.store.resumeCapturing();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1970,8 +1992,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.scheduleSnapshotUpdate();
|
||||
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
|
||||
this.store.resumeCapturing();
|
||||
}
|
||||
|
||||
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
||||
@ -2005,34 +2029,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,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -2056,6 +2070,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 !
|
||||
@ -2068,6 +2086,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isLoading: opts?.resetLoadingState ? false : state.isLoading,
|
||||
theme: this.state.theme,
|
||||
}));
|
||||
this.resetStore();
|
||||
this.resetHistory();
|
||||
},
|
||||
);
|
||||
@ -2152,10 +2171,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, // TODO_UNDO: double-check for regression
|
||||
});
|
||||
};
|
||||
|
||||
@ -2245,9 +2265,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
configurable: true,
|
||||
value: this.history,
|
||||
},
|
||||
store: {
|
||||
configurable: true,
|
||||
value: this.store,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.store.listen((...args) => {
|
||||
this.history.record(...args);
|
||||
});
|
||||
|
||||
this.scene.addCallback(this.onSceneUpdated);
|
||||
this.addEventListeners();
|
||||
|
||||
@ -2303,6 +2331,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.library.destroy();
|
||||
this.laserPathManager.destroy();
|
||||
this.onChangeEmitter.destroy();
|
||||
this.store.destroy();
|
||||
ShapeCache.destroy();
|
||||
SnapCache.destroy();
|
||||
clearTimeout(touchTimeout);
|
||||
@ -2552,7 +2581,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
|
||||
@ -2596,7 +2625,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
),
|
||||
);
|
||||
}
|
||||
this.history.record(this.state, this.scene.getElementsIncludingDeleted());
|
||||
|
||||
this.store.capture(
|
||||
arrayToMap(this.scene.getElementsIncludingDeleted()),
|
||||
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
|
||||
@ -2921,7 +2954,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
...newElements,
|
||||
];
|
||||
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
this.scene.replaceAllElements(nextElements, arrayToMap(newElements));
|
||||
|
||||
newElements.forEach((newElement) => {
|
||||
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
|
||||
@ -2934,7 +2967,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.files = { ...this.files, ...opts.files };
|
||||
}
|
||||
|
||||
this.history.resumeRecording();
|
||||
this.store.resumeCapturing();
|
||||
|
||||
const nextElementsToSelect =
|
||||
excludeElementsInFramesFromSelection(newElements);
|
||||
@ -3147,10 +3180,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getElementIndex(frameId),
|
||||
);
|
||||
} else {
|
||||
this.scene.replaceAllElements([
|
||||
...this.scene.getElementsIncludingDeleted(),
|
||||
...textElements,
|
||||
]);
|
||||
this.scene.replaceAllElements(
|
||||
[...this.scene.getElementsIncludingDeleted(), ...textElements],
|
||||
arrayToMap(textElements),
|
||||
);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
@ -3175,7 +3208,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
PLAIN_PASTE_TOAST_SHOWN = true;
|
||||
}
|
||||
|
||||
this.history.resumeRecording();
|
||||
this.store.resumeCapturing();
|
||||
}
|
||||
|
||||
setAppState: React.Component<any, AppState>["setState"] = (
|
||||
@ -3436,10 +3469,33 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elements?: SceneData["elements"];
|
||||
appState?: Pick<AppState, K> | null;
|
||||
collaborators?: SceneData["collaborators"];
|
||||
commitToHistory?: SceneData["commitToHistory"];
|
||||
commitToStore?: SceneData["commitToStore"];
|
||||
skipSnapshotUpdate?: SceneData["skipSnapshotUpdate"];
|
||||
}) => {
|
||||
if (sceneData.commitToHistory) {
|
||||
this.history.resumeRecording();
|
||||
if (
|
||||
!sceneData.skipSnapshotUpdate &&
|
||||
(sceneData.elements || sceneData.appState)
|
||||
) {
|
||||
this.store.scheduleSnapshotUpdate();
|
||||
|
||||
if (sceneData.commitToStore) {
|
||||
this.store.resumeCapturing();
|
||||
}
|
||||
|
||||
// We need to filter out yet uncomitted local elements
|
||||
// Once we will be exchanging just store increments and updating changes this won't be necessary
|
||||
const localElements = this.scene.getElementsIncludingDeleted();
|
||||
const nextElements = this.store.ignoreUncomittedElements(
|
||||
arrayToMap(localElements),
|
||||
arrayToMap(sceneData.elements || localElements), // Here we expect all next elements
|
||||
);
|
||||
|
||||
const nextAppState: AppState = {
|
||||
...this.state,
|
||||
...(sceneData.appState || {}), // Here we expect just partial appState
|
||||
};
|
||||
|
||||
this.store.capture(nextElements, nextAppState);
|
||||
}
|
||||
|
||||
if (sceneData.appState) {
|
||||
@ -3648,7 +3704,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.editingLinearElement.elementId !==
|
||||
selectedElements[0].id
|
||||
) {
|
||||
this.history.resumeRecording();
|
||||
this.store.resumeCapturing();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElement,
|
||||
@ -4040,7 +4096,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
]);
|
||||
}
|
||||
if (!isDeleted || isExistingElement) {
|
||||
this.history.resumeRecording();
|
||||
this.store.resumeCapturing();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
@ -4332,7 +4388,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(!this.state.editingLinearElement ||
|
||||
this.state.editingLinearElement.elementId !== selectedElements[0].id)
|
||||
) {
|
||||
this.history.resumeRecording();
|
||||
this.store.resumeCapturing();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElements[0],
|
||||
@ -5152,6 +5208,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state,
|
||||
),
|
||||
},
|
||||
skipSnapshotUpdate: true, // TODO_UNDO: test if we didn't regress here
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -5752,7 +5809,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const ret = LinearElementEditor.handlePointerDown(
|
||||
event,
|
||||
this.state,
|
||||
this.history,
|
||||
this.store,
|
||||
pointerDownState.origin,
|
||||
linearElementEditor,
|
||||
);
|
||||
@ -6137,10 +6194,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
height,
|
||||
});
|
||||
|
||||
this.scene.replaceAllElements([
|
||||
...this.scene.getElementsIncludingDeleted(),
|
||||
element,
|
||||
]);
|
||||
this.scene.replaceAllElements(
|
||||
[...this.scene.getElementsIncludingDeleted(), element],
|
||||
arrayToMap([element]),
|
||||
);
|
||||
|
||||
return element;
|
||||
};
|
||||
@ -6192,10 +6249,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
validated: null,
|
||||
});
|
||||
|
||||
this.scene.replaceAllElements([
|
||||
...this.scene.getElementsIncludingDeleted(),
|
||||
element,
|
||||
]);
|
||||
this.scene.replaceAllElements(
|
||||
[...this.scene.getElementsIncludingDeleted(), element],
|
||||
arrayToMap([element]),
|
||||
);
|
||||
|
||||
return element;
|
||||
};
|
||||
@ -6473,10 +6530,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? newMagicFrameElement(constructorOpts)
|
||||
: newFrameElement(constructorOpts);
|
||||
|
||||
this.scene.replaceAllElements([
|
||||
...this.scene.getElementsIncludingDeleted(),
|
||||
frame,
|
||||
]);
|
||||
this.scene.replaceAllElements(
|
||||
[...this.scene.getElementsIncludingDeleted(), frame],
|
||||
arrayToMap([frame]),
|
||||
);
|
||||
|
||||
this.setState({
|
||||
multiElement: null,
|
||||
@ -6844,6 +6901,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
})
|
||||
.map((element) => element.id),
|
||||
);
|
||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||
|
||||
const elements = this.scene.getElementsIncludingDeleted();
|
||||
|
||||
@ -6860,6 +6918,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
groupIdMap,
|
||||
element,
|
||||
);
|
||||
duplicatedElementsMap.set(
|
||||
duplicatedElement.id,
|
||||
duplicatedElement,
|
||||
);
|
||||
const origElement = pointerDownState.originalElements.get(
|
||||
element.id,
|
||||
)!;
|
||||
@ -6882,7 +6944,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
nextElements.push(element);
|
||||
}
|
||||
}
|
||||
const nextSceneElements = [...nextElements, ...elementsToAppend];
|
||||
const nextSceneElements = fixFractionalIndices(
|
||||
[...nextElements, ...elementsToAppend],
|
||||
duplicatedElementsMap,
|
||||
);
|
||||
bindTextToShapeAfterDuplication(
|
||||
nextElements,
|
||||
elementsToAppend,
|
||||
@ -7297,7 +7362,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (isLinearElement(draggingElement)) {
|
||||
if (draggingElement!.points.length > 1) {
|
||||
this.history.resumeRecording();
|
||||
this.store.resumeCapturing();
|
||||
}
|
||||
const pointerCoords = viewportCoordsToSceneCoords(
|
||||
childEvent,
|
||||
@ -7532,7 +7597,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (resizingElement) {
|
||||
this.history.resumeRecording();
|
||||
this.store.resumeCapturing();
|
||||
}
|
||||
|
||||
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
||||
@ -7833,9 +7898,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.resumeCapturing();
|
||||
}
|
||||
|
||||
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
|
||||
@ -7941,7 +8010,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return ele;
|
||||
});
|
||||
|
||||
this.history.resumeRecording();
|
||||
this.store.resumeCapturing();
|
||||
this.scene.replaceAllElements(elements);
|
||||
};
|
||||
|
||||
@ -8489,7 +8558,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isLoading: false,
|
||||
},
|
||||
replaceFiles: true,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
return;
|
||||
} catch (error: any) {
|
||||
@ -8575,15 +8644,21 @@ class App extends React.Component<AppProps, AppState> {
|
||||
fileHandle,
|
||||
);
|
||||
if (ret.type === MIME_TYPES.excalidraw) {
|
||||
// First we need to delete existing elements, so they get recorded in the undo stack
|
||||
const deletedExistingElements = this.scene
|
||||
.getNonDeletedElements()
|
||||
.map((element) => newElementWith(element, { isDeleted: true }));
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
this.syncActionResult({
|
||||
...ret.data,
|
||||
elements: deletedExistingElements.concat(ret.data.elements),
|
||||
appState: {
|
||||
...(ret.data.appState || this.state),
|
||||
isLoading: false,
|
||||
},
|
||||
replaceFiles: true,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
} else if (ret.type === MIME_TYPES.excalidrawlib) {
|
||||
await this.library
|
||||
@ -9199,6 +9274,7 @@ declare global {
|
||||
setState: React.Component<any, AppState>["setState"];
|
||||
app: InstanceType<typeof App>;
|
||||
history: History;
|
||||
store: Store;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ type ToolButtonBaseProps = {
|
||||
hidden?: boolean;
|
||||
visible?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
isLoading?: boolean;
|
||||
@ -123,10 +124,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,8 @@
|
||||
}
|
||||
|
||||
.ToolIcon_type_button,
|
||||
.Modal .ToolIcon_type_button,
|
||||
.ToolIcon_type_button {
|
||||
.Modal .ToolIcon_type_button
|
||||
{
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin: 0;
|
||||
@ -101,6 +101,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;
|
||||
}
|
||||
|
@ -196,6 +196,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
|
||||
@ -302,10 +303,6 @@ export const ROUNDNESS = {
|
||||
ADAPTIVE_RADIUS: 3,
|
||||
} as const;
|
||||
|
||||
/** key containt id of precedeing elemnt id we use in reconciliation during
|
||||
* collaboration */
|
||||
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
|
||||
|
||||
export const ROUGHNESS = {
|
||||
architect: 0,
|
||||
artist: 1,
|
||||
|
@ -97,6 +97,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;
|
||||
|
@ -50,6 +50,15 @@
|
||||
color: var(--color-on-primary-container);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-disabled="true"] {
|
||||
background: initial;
|
||||
border: none;
|
||||
|
||||
svg {
|
||||
color: var(--color-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 300,
|
||||
@ -50,6 +51,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -86,6 +88,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"gap": 1,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 35,
|
||||
@ -139,6 +142,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"gap": 3.834326468444573,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -191,6 +195,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 300,
|
||||
@ -230,6 +235,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -274,6 +280,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -320,6 +327,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"gap": 205,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -371,6 +379,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -417,6 +426,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"gap": 1,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -468,6 +478,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -508,6 +519,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -543,6 +555,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -584,6 +597,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"gap": 1,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -635,6 +649,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -679,6 +694,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -723,6 +739,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -758,6 +775,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 200,
|
||||
@ -790,6 +808,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -835,6 +854,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
|
||||
"endArrowhead": "triangle",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -880,6 +900,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -925,6 +946,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -968,6 +990,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -998,6 +1021,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -1028,6 +1052,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -1058,6 +1083,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
|
||||
"backgroundColor": "#c0eb75",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -1088,6 +1114,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
|
||||
"backgroundColor": "#ffc9c9",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -1118,6 +1145,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"fillStyle": "cross-hatch",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -1152,6 +1180,7 @@ exports[`Test Transform > should transform text element 1`] = `
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -1191,6 +1220,7 @@ exports[`Test Transform > should transform text element 2`] = `
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -1233,6 +1263,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -1283,6 +1314,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -1333,6 +1365,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -1383,6 +1416,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -1430,6 +1464,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -1469,6 +1504,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -1508,6 +1544,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -1548,6 +1585,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -1589,6 +1627,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 35,
|
||||
@ -1624,6 +1663,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 85,
|
||||
@ -1659,6 +1699,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 170,
|
||||
@ -1694,6 +1735,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 120,
|
||||
@ -1729,6 +1771,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 85,
|
||||
@ -1764,6 +1807,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 120,
|
||||
@ -1798,6 +1842,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
@ -1837,6 +1882,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -1877,6 +1923,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 75,
|
||||
@ -1919,6 +1966,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -1959,6 +2007,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 75,
|
||||
@ -2000,6 +2049,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 75,
|
||||
|
@ -26,7 +26,6 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
PRECEDING_ELEMENT_KEY,
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
DEFAULT_SIDEBAR,
|
||||
@ -44,6 +43,7 @@ import {
|
||||
measureBaseline,
|
||||
} from "../element/textElement";
|
||||
import { normalizeLink } from "./url";
|
||||
import { restoreFractionalIndicies } from "../fractionalIndex";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@ -101,8 +101,6 @@ const restoreElementWithProperties = <
|
||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||
/** @deprecated */
|
||||
strokeSharpness?: StrokeRoundness;
|
||||
/** metadata that may be present in elements during collaboration */
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
},
|
||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||
>(
|
||||
@ -115,14 +113,14 @@ const restoreElementWithProperties = <
|
||||
> &
|
||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
|
||||
): T => {
|
||||
const base: Pick<T, keyof ExcalidrawElement> & {
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
} = {
|
||||
const base: Pick<T, keyof ExcalidrawElement> = {
|
||||
type: extra.type || element.type,
|
||||
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||
// newly added elements
|
||||
version: element.version || 1,
|
||||
versionNonce: element.versionNonce ?? 0,
|
||||
// TODO: think about this more
|
||||
fractionalIndex: element.fractionalIndex ?? null,
|
||||
isDeleted: element.isDeleted ?? false,
|
||||
id: element.id || randomId(),
|
||||
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||
@ -166,10 +164,6 @@ const restoreElementWithProperties = <
|
||||
"customData" in extra ? extra.customData : element.customData;
|
||||
}
|
||||
|
||||
if (PRECEDING_ELEMENT_KEY in element) {
|
||||
base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...getNormalizedDimensions(base),
|
||||
@ -467,7 +461,7 @@ export const restoreElements = (
|
||||
}
|
||||
}
|
||||
|
||||
return restoredElements;
|
||||
return restoreFractionalIndicies(restoredElements) as ExcalidrawElement[];
|
||||
};
|
||||
|
||||
const coalesceAppStateValue = <
|
||||
|
@ -40,6 +40,7 @@ import { trackEvent } from "../analytics";
|
||||
import { useAppProps, useExcalidrawAppState } from "../components/App";
|
||||
import { isEmbeddableElement } from "./typeChecks";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { StoreAction } from "../actions/types";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
@ -343,7 +344,7 @@ export const actionLink = register({
|
||||
showHyperlinkPopup: "editor",
|
||||
openMenu: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
trackEvent: { category: "hyperlink", action: "click" },
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
IframeData,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { StoreAction } from "../actions/types";
|
||||
|
||||
const embeddedLinkCache = new Map<string, IframeData>();
|
||||
|
||||
@ -286,7 +287,8 @@ export const actionSetEmbeddableAsActiveTool = register({
|
||||
type: "embeddable",
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -33,7 +33,6 @@ import {
|
||||
InteractiveCanvasAppState,
|
||||
} from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import History from "../history";
|
||||
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
@ -48,6 +47,7 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { DRAGGING_THRESHOLD } from "../constants";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { Store } from "../store";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
@ -602,7 +602,7 @@ export class LinearElementEditor {
|
||||
static handlePointerDown(
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
appState: AppState,
|
||||
history: History,
|
||||
store: Store,
|
||||
scenePointer: { x: number; y: number },
|
||||
linearElementEditor: LinearElementEditor,
|
||||
): {
|
||||
@ -654,7 +654,7 @@ export class LinearElementEditor {
|
||||
});
|
||||
ret.didAddPoint = true;
|
||||
}
|
||||
history.resumeRecording();
|
||||
store.resumeCapturing();
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
pointerDownState: {
|
||||
|
@ -106,24 +106,27 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
forceUpdate: boolean = false,
|
||||
): TElement => {
|
||||
let didChange = false;
|
||||
for (const key in updates) {
|
||||
const value = (updates as any)[key];
|
||||
if (typeof value !== "undefined") {
|
||||
if (
|
||||
(element as any)[key] === value &&
|
||||
// if object, always update because its attrs could have changed
|
||||
(typeof value !== "object" || value === null)
|
||||
) {
|
||||
continue;
|
||||
if (!forceUpdate) {
|
||||
let didChange = false;
|
||||
for (const key in updates) {
|
||||
const value = (updates as any)[key];
|
||||
if (typeof value !== "undefined") {
|
||||
if (
|
||||
(element as any)[key] === value &&
|
||||
// if object, always update because its attrs could have changed
|
||||
(typeof value !== "object" || value === null)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
didChange = true;
|
||||
}
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!didChange) {
|
||||
return element;
|
||||
if (!didChange) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -55,6 +55,7 @@ export type ElementConstructorOpts = MarkOptional<
|
||||
| "angle"
|
||||
| "groupIds"
|
||||
| "frameId"
|
||||
| "fractionalIndex"
|
||||
| "boundElements"
|
||||
| "seed"
|
||||
| "version"
|
||||
@ -88,6 +89,8 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
angle = 0,
|
||||
groupIds = [],
|
||||
frameId = null,
|
||||
// TODO: think about this more
|
||||
fractionalIndex = null,
|
||||
roundness = null,
|
||||
boundElements = null,
|
||||
link = null,
|
||||
@ -113,6 +116,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
opacity,
|
||||
groupIds,
|
||||
frameId,
|
||||
fractionalIndex,
|
||||
roundness,
|
||||
seed: rest.seed ?? randomInteger(),
|
||||
version: rest.version || 1,
|
||||
|
@ -50,6 +50,7 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
Used for deterministic reconciliation of updates during collaboration,
|
||||
in case the versions (see above) are identical. */
|
||||
versionNonce: number;
|
||||
fractionalIndex: string | null;
|
||||
isDeleted: boolean;
|
||||
/** List of groups the element belongs to.
|
||||
Ordered from deepest to shallowest. */
|
||||
|
227
src/fractionalIndex.ts
Normal file
227
src/fractionalIndex.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { ExcalidrawElement } from "./element/types";
|
||||
import {
|
||||
generateKeyBetween,
|
||||
generateJitteredKeyBetween,
|
||||
generateNKeysBetween,
|
||||
generateNJitteredKeysBetween,
|
||||
} from "fractional-indexing-jittered";
|
||||
import { ENV } from "./constants";
|
||||
|
||||
type FractionalIndex = ExcalidrawElement["fractionalIndex"];
|
||||
|
||||
const isValidFractionalIndex = (
|
||||
index: FractionalIndex,
|
||||
predecessor: FractionalIndex,
|
||||
successor: FractionalIndex,
|
||||
) => {
|
||||
if (index) {
|
||||
if (predecessor && successor) {
|
||||
return predecessor < index && index < successor;
|
||||
}
|
||||
|
||||
if (successor && !predecessor) {
|
||||
// first element
|
||||
return index < successor;
|
||||
}
|
||||
|
||||
if (predecessor && !successor) {
|
||||
// last element
|
||||
return predecessor < index;
|
||||
}
|
||||
|
||||
if (!predecessor && !successor) {
|
||||
return index.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getContiguousMovedIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
movedElementsMap: Map<string, ExcalidrawElement>,
|
||||
) => {
|
||||
const result: number[][] = [];
|
||||
const contiguous: number[] = [];
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
if (movedElementsMap.has(element.id)) {
|
||||
if (contiguous.length) {
|
||||
if (contiguous[contiguous.length - 1] + 1 === i) {
|
||||
contiguous.push(i);
|
||||
} else {
|
||||
result.push(contiguous.slice());
|
||||
contiguous.length = 0;
|
||||
contiguous.push(i);
|
||||
}
|
||||
} else {
|
||||
contiguous.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contiguous.length > 0) {
|
||||
result.push(contiguous.slice());
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const fixFractionalIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
movedElementsMap: Map<string, ExcalidrawElement>,
|
||||
) => {
|
||||
const contiguousMovedIndices = getContiguousMovedIndices(
|
||||
elements,
|
||||
movedElementsMap,
|
||||
);
|
||||
|
||||
const generateFn =
|
||||
import.meta.env.MODE === ENV.TEST
|
||||
? generateNKeysBetween
|
||||
: generateNJitteredKeysBetween;
|
||||
|
||||
for (const movedIndices of contiguousMovedIndices) {
|
||||
try {
|
||||
const predecessor =
|
||||
elements[movedIndices[0] - 1]?.fractionalIndex || null;
|
||||
const successor =
|
||||
elements[movedIndices[movedIndices.length - 1] + 1]?.fractionalIndex ||
|
||||
null;
|
||||
|
||||
const newKeys = generateFn(predecessor, successor, movedIndices.length);
|
||||
|
||||
for (let i = 0; i < movedIndices.length; i++) {
|
||||
const element = elements[movedIndices[i]];
|
||||
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
fractionalIndex: newKeys[i],
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("error fixing fractional indices", e);
|
||||
}
|
||||
}
|
||||
|
||||
return elements as ExcalidrawElement[];
|
||||
};
|
||||
|
||||
const compareStrings = (a: string, b: string) => {
|
||||
return a < b ? -1 : 1;
|
||||
};
|
||||
|
||||
export const orderByFractionalIndex = (allElements: ExcalidrawElement[]) => {
|
||||
return allElements.sort((a, b) => {
|
||||
if (a.fractionalIndex && b.fractionalIndex) {
|
||||
if (a.fractionalIndex < b.fractionalIndex) {
|
||||
return -1;
|
||||
} else if (a.fractionalIndex > b.fractionalIndex) {
|
||||
return 1;
|
||||
}
|
||||
return compareStrings(a.id, b.id);
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
const restoreFractionalIndex = (
|
||||
predecessor: FractionalIndex,
|
||||
successor: FractionalIndex,
|
||||
) => {
|
||||
const generateFn =
|
||||
import.meta.env.MODE === ENV.TEST
|
||||
? generateKeyBetween
|
||||
: generateJitteredKeyBetween;
|
||||
|
||||
if (successor && !predecessor) {
|
||||
// first element in the array
|
||||
// insert before successor
|
||||
return generateFn(null, successor);
|
||||
}
|
||||
|
||||
if (predecessor && !successor) {
|
||||
// last element in the array
|
||||
// insert after predecessor
|
||||
return generateFn(predecessor, null);
|
||||
}
|
||||
|
||||
// both predecessor and successor exist (or both do not)
|
||||
// insert after predecessor
|
||||
return generateFn(predecessor, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* restore the fractional indicies of the elements in the given array such that
|
||||
* every element in the array has a fractional index smaller than its successor's
|
||||
*
|
||||
* neighboring indices might be updated as well
|
||||
*
|
||||
* only use this function when restoring or as a fallback to guarantee fractional
|
||||
* indices consistency
|
||||
*/
|
||||
export const restoreFractionalIndicies = (
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
let suc = 1;
|
||||
|
||||
const normalized: ExcalidrawElement[] = [];
|
||||
|
||||
for (const element of allElements) {
|
||||
const predecessor =
|
||||
normalized[normalized.length - 1]?.fractionalIndex || null;
|
||||
const successor = allElements[suc]?.fractionalIndex || null;
|
||||
|
||||
if (
|
||||
!isValidFractionalIndex(element.fractionalIndex, predecessor, successor)
|
||||
) {
|
||||
try {
|
||||
const nextFractionalIndex = restoreFractionalIndex(
|
||||
predecessor,
|
||||
successor,
|
||||
);
|
||||
|
||||
normalized.push({
|
||||
...element,
|
||||
fractionalIndex: nextFractionalIndex,
|
||||
});
|
||||
} catch (e) {
|
||||
normalized.push(element);
|
||||
}
|
||||
} else {
|
||||
normalized.push(element);
|
||||
}
|
||||
suc++;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const validateFractionalIndicies = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
const successor = elements[i + 1];
|
||||
|
||||
if (element.fractionalIndex) {
|
||||
if (
|
||||
successor &&
|
||||
successor.fractionalIndex &&
|
||||
element.fractionalIndex >= successor.fractionalIndex
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
402
src/history.ts
402
src/history.ts
@ -1,265 +1,173 @@
|
||||
import { AppState } from "./types";
|
||||
import { AppStateChange, ElementsChange } from "./change";
|
||||
import { ExcalidrawElement } from "./element/types";
|
||||
import { isLinearElement } from "./element/typeChecks";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import { Mutable } from "./utility-types";
|
||||
import { AppState } from "./types";
|
||||
|
||||
export interface HistoryEntry {
|
||||
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
|
||||
elements: ExcalidrawElement[];
|
||||
}
|
||||
// TODO_UNDO: think about limiting the depth of stack
|
||||
export class History {
|
||||
private readonly undoStack: HistoryEntry[] = [];
|
||||
private readonly redoStack: HistoryEntry[] = [];
|
||||
|
||||
interface DehydratedExcalidrawElement {
|
||||
id: string;
|
||||
versionNonce: number;
|
||||
}
|
||||
|
||||
interface DehydratedHistoryEntry {
|
||||
appState: string;
|
||||
elements: DehydratedExcalidrawElement[];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Suspicious that this is called so many places. Seems error-prone.
|
||||
resumeRecording() {
|
||||
this.recording = true;
|
||||
}
|
||||
if (!entry.isEmpty()) {
|
||||
this.undoStack.push(entry);
|
||||
|
||||
record(state: AppState, elements: readonly ExcalidrawElement[]) {
|
||||
if (this.recording) {
|
||||
this.pushEntry(state, elements);
|
||||
this.recording = false;
|
||||
// As a new entry was pushed, we invalidate the redo stack
|
||||
this.redoStack.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public undo(elements: Map<string, ExcalidrawElement>, appState: AppState) {
|
||||
return this.perform(this.undoOnce.bind(this), elements, appState);
|
||||
}
|
||||
|
||||
public redo(elements: Map<string, ExcalidrawElement>, appState: AppState) {
|
||||
return this.perform(this.redoOnce.bind(this), elements, appState);
|
||||
}
|
||||
|
||||
private perform(
|
||||
action: typeof this.undoOnce | typeof this.redoOnce,
|
||||
elements: Map<string, ExcalidrawElement>,
|
||||
appState: AppState,
|
||||
): [Map<string, ExcalidrawElement>, AppState] | void {
|
||||
let historyEntry = action(elements);
|
||||
|
||||
// Nothing to undo / redo
|
||||
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) {
|
||||
[nextElements, nextAppState, containsVisibleChange] =
|
||||
historyEntry.applyTo(nextElements, nextAppState);
|
||||
|
||||
// TODO_UNDO: Be very carefuly here, as we could accidentaly iterate through the whole stack
|
||||
if (containsVisibleChange) {
|
||||
break;
|
||||
}
|
||||
|
||||
historyEntry = action(elements);
|
||||
}
|
||||
|
||||
return [nextElements, nextAppState];
|
||||
}
|
||||
|
||||
private undoOnce(
|
||||
elements: Map<string, ExcalidrawElement>,
|
||||
): HistoryEntry | null {
|
||||
if (!this.undoStack.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const undoEntry = this.undoStack.pop();
|
||||
|
||||
if (undoEntry !== undefined) {
|
||||
const redoEntry = undoEntry.applyLatestChanges(elements, "to");
|
||||
this.redoStack.push(redoEntry);
|
||||
|
||||
return undoEntry.inverse();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private redoOnce(
|
||||
elements: Map<string, ExcalidrawElement>,
|
||||
): HistoryEntry | null {
|
||||
if (!this.redoStack.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const redoEntry = this.redoStack.pop();
|
||||
|
||||
if (redoEntry !== undefined) {
|
||||
const undoEntry = redoEntry.applyLatestChanges(elements, "from");
|
||||
this.undoStack.push(undoEntry);
|
||||
|
||||
return redoEntry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default History;
|
||||
export class HistoryEntry {
|
||||
private constructor(
|
||||
private readonly appStateChange: AppStateChange,
|
||||
private 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: Map<string, ExcalidrawElement>,
|
||||
appState: AppState,
|
||||
): [Map<string, ExcalidrawElement>, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] =
|
||||
this.elementsChange.applyTo(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: Map<string, ExcalidrawElement>,
|
||||
modifierOptions: "from" | "to",
|
||||
): HistoryEntry {
|
||||
const updatedElementsChange = this.elementsChange.applyLatestChanges(
|
||||
elements,
|
||||
modifierOptions,
|
||||
);
|
||||
|
||||
return HistoryEntry.create(this.appStateChange, updatedElementsChange);
|
||||
}
|
||||
|
||||
public isEmpty(): boolean {
|
||||
return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,17 @@ The change should be grouped under one of the below section and must contain PR
|
||||
Please add the latest change on the top under the correct section.
|
||||
-->
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
|
||||
- Support for multiplayer undo / redo [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
|
||||
- Updates of `elements` or `appState` performed through [`updateScene`](https://github.com/excalidraw/excalidraw/blob/master/src/components/App.tsx#L282) without `commitToStore` set to `true` require a new parameter `skipSnapshotUpdate` to be set to `true`, if the given update should be locally undo-able with the next user action. In other cases such a parameter shouldn't be needed, i.e. as in during multiplayer collab updates, which shouldn't should not be locally undoable.
|
||||
|
||||
## 0.17.0 (2023-11-14)
|
||||
|
||||
### Features
|
||||
|
@ -11,6 +11,11 @@ import { getSelectedElements } from "./selection";
|
||||
import { AppState } from "../types";
|
||||
import { Assert, SameType } from "../utility-types";
|
||||
import { randomInteger } from "../random";
|
||||
import {
|
||||
fixFractionalIndices,
|
||||
validateFractionalIndicies,
|
||||
} from "../fractionalIndex";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
|
||||
type ElementKey = ExcalidrawElement | ElementIdKey;
|
||||
@ -122,6 +127,10 @@ class Scene {
|
||||
return this.elements;
|
||||
}
|
||||
|
||||
getElementsMapIncludingDeleted() {
|
||||
return this.elementsMap;
|
||||
}
|
||||
|
||||
getNonDeletedElements(): readonly NonDeletedExcalidrawElement[] {
|
||||
return this.nonDeletedElements;
|
||||
}
|
||||
@ -229,12 +238,25 @@ class Scene {
|
||||
|
||||
replaceAllElements(
|
||||
nextElements: readonly ExcalidrawElement[],
|
||||
mapElementIds = true,
|
||||
mapOfIndicesToFix?: Map<string, ExcalidrawElement>,
|
||||
) {
|
||||
this.elements = nextElements;
|
||||
let _nextElements;
|
||||
if (mapOfIndicesToFix) {
|
||||
_nextElements = fixFractionalIndices(nextElements, mapOfIndicesToFix);
|
||||
} else {
|
||||
_nextElements = nextElements;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (!validateFractionalIndicies(_nextElements)) {
|
||||
console.error("fractional indices consistency has been compromised");
|
||||
}
|
||||
}
|
||||
|
||||
this.elements = _nextElements;
|
||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
this.elementsMap.clear();
|
||||
nextElements.forEach((element) => {
|
||||
_nextElements.forEach((element) => {
|
||||
if (isFrameLikeElement(element)) {
|
||||
nextFrameLikes.push(element);
|
||||
}
|
||||
@ -303,7 +325,7 @@ class Scene {
|
||||
element,
|
||||
...this.elements.slice(index),
|
||||
];
|
||||
this.replaceAllElements(nextElements);
|
||||
this.replaceAllElements(nextElements, arrayToMap([element]));
|
||||
}
|
||||
|
||||
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
|
||||
@ -318,14 +340,17 @@ class Scene {
|
||||
...this.elements.slice(index),
|
||||
];
|
||||
|
||||
this.replaceAllElements(nextElements);
|
||||
this.replaceAllElements(nextElements, arrayToMap(elements));
|
||||
}
|
||||
|
||||
addNewElement = (element: ExcalidrawElement) => {
|
||||
if (element.frameId) {
|
||||
this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
|
||||
} else {
|
||||
this.replaceAllElements([...this.elements, element]);
|
||||
this.replaceAllElements(
|
||||
[...this.elements, element],
|
||||
arrayToMap([element]),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -59,7 +59,7 @@ const __createSceneForElementsHack__ = (
|
||||
// ids to Scene instances so that we don't override the editor elements
|
||||
// mapping.
|
||||
// We still need to clone the objects themselves to regen references.
|
||||
scene.replaceAllElements(cloneJSON(elements), false);
|
||||
scene.replaceAllElements(cloneJSON(elements));
|
||||
return scene;
|
||||
};
|
||||
|
||||
|
307
src/store.ts
Normal file
307
src/store.ts
Normal file
@ -0,0 +1,307 @@
|
||||
import { getDefaultAppState } from "./appState";
|
||||
import { AppStateChange, ElementsChange } from "./change";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import { ExcalidrawElement } from "./element/types";
|
||||
import { Emitter } from "./emitter";
|
||||
import { AppState, ObservedAppState } from "./types";
|
||||
import { isShallowEqual } from "./utils";
|
||||
|
||||
const getObservedAppState = (appState: AppState): ObservedAppState => {
|
||||
return {
|
||||
name: appState.name,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
selectedGroupIds: appState.selectedGroupIds,
|
||||
editingLinearElement: appState.editingLinearElement,
|
||||
selectedLinearElement: appState.selectedLinearElement, // TODO_UNDO: Think about these two as one level shallow equal is not enough for them (they have new reference even though they shouldn't, sometimes their id does not correspond to selectedElementId)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
|
||||
*
|
||||
* For the future:
|
||||
* - Store should coordinate the changes and maintain its increments cohesive between different instances.
|
||||
* - Store increments should be kept as append-only events log, with additional metadata, such as the logical timestamp for conflict-free resolution of increments.
|
||||
* - Store flow should be bi-directional, not only listening and capturing changes, but mainly receiving increments as commands and applying them to the state.
|
||||
*
|
||||
* @experimental this interface is experimental and subject to change.
|
||||
*/
|
||||
export interface IStore {
|
||||
/**
|
||||
* Capture changes to the @param elements and @param appState by diff calculation and emitting resulting changes as store increment.
|
||||
* In case the property `onlyUpdatingSnapshot` is set, it will only update the store snapshot, without calculating diffs.
|
||||
*
|
||||
* @emits StoreIncrementEvent
|
||||
*/
|
||||
capture(elements: Map<string, ExcalidrawElement>, appState: AppState): void;
|
||||
|
||||
/**
|
||||
* Listens to the store increments, emitted by the capture method.
|
||||
* Suitable for consuming store increments by various system components, such as History, Collab, Storage and etc.
|
||||
*
|
||||
* @listens StoreIncrementEvent
|
||||
*/
|
||||
listen(
|
||||
callback: (
|
||||
elementsChange: ElementsChange,
|
||||
appStateChange: AppStateChange,
|
||||
) => void,
|
||||
): ReturnType<Emitter<StoreIncrementEvent>["on"]>;
|
||||
|
||||
/**
|
||||
* Clears the store instance.
|
||||
*/
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represent an increment to the Store.
|
||||
*/
|
||||
type StoreIncrementEvent = [
|
||||
elementsChange: ElementsChange,
|
||||
appStateChange: AppStateChange,
|
||||
];
|
||||
|
||||
export class Store implements IStore {
|
||||
private readonly onStoreIncrementEmitter = new Emitter<StoreIncrementEvent>();
|
||||
|
||||
private capturingChanges: boolean = false;
|
||||
private updatingSnapshot: boolean = false;
|
||||
|
||||
public snapshot = Snapshot.empty();
|
||||
|
||||
public scheduleSnapshotUpdate() {
|
||||
this.updatingSnapshot = true;
|
||||
}
|
||||
|
||||
// Suspicious that this is called so many places. Seems error-prone.
|
||||
public resumeCapturing() {
|
||||
this.capturingChanges = true;
|
||||
}
|
||||
|
||||
public capture(
|
||||
elements: Map<string, ExcalidrawElement>,
|
||||
appState: AppState,
|
||||
): void {
|
||||
// Quick exit for irrelevant changes
|
||||
if (!this.capturingChanges && !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.capturingChanges) {
|
||||
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(
|
||||
elementsChange,
|
||||
appStateChange,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the snapshot
|
||||
this.snapshot = nextSnapshot;
|
||||
}
|
||||
} finally {
|
||||
// Reset props
|
||||
this.updatingSnapshot = false;
|
||||
this.capturingChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
public ignoreUncomittedElements(
|
||||
prevElements: Map<string, ExcalidrawElement>,
|
||||
nextElements: Map<string, ExcalidrawElement>,
|
||||
) {
|
||||
for (const [id, prevElement] of prevElements.entries()) {
|
||||
const nextElement = nextElements.get(id);
|
||||
|
||||
if (!nextElement) {
|
||||
// Nothing to care about here, elements were forcefully updated
|
||||
continue;
|
||||
}
|
||||
|
||||
const elementSnapshot = this.snapshot.elements.get(id);
|
||||
|
||||
// Uncomitted element's snapshot doesn't exist, or its snapshot has lower version than the local element
|
||||
if (
|
||||
!elementSnapshot ||
|
||||
(elementSnapshot && elementSnapshot.version < prevElement.version)
|
||||
) {
|
||||
if (elementSnapshot) {
|
||||
nextElements.set(id, elementSnapshot);
|
||||
} else {
|
||||
nextElements.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nextElements;
|
||||
}
|
||||
|
||||
public listen(
|
||||
callback: (
|
||||
elementsChange: ElementsChange,
|
||||
appStateChange: AppStateChange,
|
||||
) => void,
|
||||
) {
|
||||
return this.onStoreIncrementEmitter.on(callback);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.snapshot = Snapshot.empty();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.clear();
|
||||
this.onStoreIncrementEmitter.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
class Snapshot {
|
||||
private constructor(
|
||||
public readonly elements: Map<string, ExcalidrawElement>,
|
||||
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 Snapshot instance otherwise.
|
||||
*/
|
||||
public clone(elements: Map<string, ExcalidrawElement>, 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 calls this before the scene updates.
|
||||
*/
|
||||
private detectChangedElements(nextElements: Map<string, ExcalidrawElement>) {
|
||||
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) {
|
||||
// TODO_UNDO: Linear element?
|
||||
return !isShallowEqual(this.appState, observedAppState, {
|
||||
selectedElementIds: isShallowEqual,
|
||||
selectedGroupIds: isShallowEqual,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform structural clone, cloning only elements that changed.
|
||||
*/
|
||||
private createElementsSnapshot(nextElements: Map<string, ExcalidrawElement>) {
|
||||
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 are cleared
|
||||
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
@ -10,6 +10,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -41,8 +42,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"version": 4,
|
||||
"versionNonce": 2019559783,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
@ -57,6 +58,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -75,8 +77,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
@ -91,6 +93,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -109,8 +112,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
@ -125,6 +128,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -156,8 +160,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"version": 4,
|
||||
"versionNonce": 2019559783,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
@ -172,6 +176,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -190,8 +195,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
|
@ -6,6 +6,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 1`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "Zz",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -18,14 +19,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 1`] = `
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1014066025,
|
||||
"seed": 238820263,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 238820263,
|
||||
"version": 6,
|
||||
"versionNonce": 1604849351,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
@ -38,6 +39,40 @@ exports[`duplicate element on move when ALT is clicked > rectangle 2`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 5,
|
||||
"versionNonce": 23633383,
|
||||
"width": 30,
|
||||
"x": -10,
|
||||
"y": 60,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`move element > rectangle 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -57,39 +92,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 2`] = `
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 1604849351,
|
||||
"width": 30,
|
||||
"x": -10,
|
||||
"y": 60,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`move element > rectangle 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 1150084233,
|
||||
"versionNonce": 1116226695,
|
||||
"width": 30,
|
||||
"x": 0,
|
||||
"y": 40,
|
||||
@ -107,6 +110,7 @@ exports[`move element > rectangles with binding arrow 1`] = `
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -125,8 +129,8 @@ exports[`move element > rectangles with binding arrow 1`] = `
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 81784553,
|
||||
"version": 4,
|
||||
"versionNonce": 760410951,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@ -144,6 +148,7 @@ exports[`move element > rectangles with binding arrow 2`] = `
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a1",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 300,
|
||||
@ -156,14 +161,14 @@ exports[`move element > rectangles with binding arrow 2`] = `
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 2019559783,
|
||||
"seed": 1150084233,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"versionNonce": 927333447,
|
||||
"version": 7,
|
||||
"versionNonce": 745419401,
|
||||
"width": 300,
|
||||
"x": 201,
|
||||
"y": 2,
|
||||
@ -182,6 +187,7 @@ exports[`move element > rectangles with binding arrow 3`] = `
|
||||
"gap": 10,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a2",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 81.48231043525051,
|
||||
@ -205,7 +211,7 @@ exports[`move element > rectangles with binding arrow 3`] = `
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 238820263,
|
||||
"seed": 1604849351,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id0",
|
||||
@ -217,8 +223,8 @@ exports[`move element > rectangles with binding arrow 3`] = `
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"versionNonce": 1051383431,
|
||||
"version": 12,
|
||||
"versionNonce": 1984422985,
|
||||
"width": 81,
|
||||
"x": 110,
|
||||
"y": 49.981789081137734,
|
||||
|
@ -8,6 +8,7 @@ exports[`multi point mode in linear elements > arrow 1`] = `
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 110,
|
||||
@ -46,8 +47,8 @@ exports[`multi point mode in linear elements > arrow 1`] = `
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 1505387817,
|
||||
"version": 8,
|
||||
"versionNonce": 23633383,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
@ -62,6 +63,7 @@ exports[`multi point mode in linear elements > line 1`] = `
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 110,
|
||||
@ -100,8 +102,8 @@ exports[`multi point mode in linear elements > line 1`] = `
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 1505387817,
|
||||
"version": 8,
|
||||
"versionNonce": 23633383,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ exports[`select single element on the scene > arrow 1`] = `
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -39,8 +40,8 @@ exports[`select single element on the scene > arrow 1`] = `
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"version": 4,
|
||||
"versionNonce": 2019559783,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
@ -55,6 +56,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -86,8 +88,8 @@ exports[`select single element on the scene > arrow escape 1`] = `
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"version": 4,
|
||||
"versionNonce": 2019559783,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
@ -100,6 +102,7 @@ exports[`select single element on the scene > diamond 1`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -118,8 +121,8 @@ exports[`select single element on the scene > diamond 1`] = `
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
@ -132,6 +135,7 @@ exports[`select single element on the scene > ellipse 1`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -150,8 +154,8 @@ exports[`select single element on the scene > ellipse 1`] = `
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
@ -164,6 +168,7 @@ exports[`select single element on the scene > rectangle 1`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": "a0",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
@ -182,8 +187,8 @@ exports[`select single element on the scene > rectangle 1`] = `
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
|
@ -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}`),
|
||||
@ -423,8 +423,26 @@ describe("contextMenu element", () => {
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
|
||||
expect(h.elements).toHaveLength(2);
|
||||
const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
|
||||
const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...rect2 } = h.elements[1];
|
||||
const {
|
||||
id: _id0,
|
||||
seed: _seed0,
|
||||
x: _x0,
|
||||
y: _y0,
|
||||
fractionalIndex: _fractionalIndex0,
|
||||
version: _version0,
|
||||
versionNonce: _versionNonce0,
|
||||
...rect1
|
||||
} = h.elements[0];
|
||||
const {
|
||||
id: _id1,
|
||||
seed: _seed1,
|
||||
x: _x1,
|
||||
y: _y1,
|
||||
fractionalIndex: _fractionalIndex1,
|
||||
version: _version1,
|
||||
versionNonce: _versionNonce1,
|
||||
...rect2
|
||||
} = h.elements[1];
|
||||
expect(rect1).toEqual(rect2);
|
||||
});
|
||||
|
||||
|
@ -8,6 +8,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -53,6 +54,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
||||
"backgroundColor": "blue",
|
||||
"boundElements": [],
|
||||
"fillStyle": "cross-hatch",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"1",
|
||||
@ -89,6 +91,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
||||
"backgroundColor": "blue",
|
||||
"boundElements": [],
|
||||
"fillStyle": "cross-hatch",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"1",
|
||||
@ -125,6 +128,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
||||
"backgroundColor": "blue",
|
||||
"boundElements": [],
|
||||
"fillStyle": "cross-hatch",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"1",
|
||||
@ -161,6 +165,7 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
@ -199,6 +204,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -246,6 +252,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -295,6 +302,7 @@ exports[`restoreElements > should restore text element correctly passing value f
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 14,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
@ -336,6 +344,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 10,
|
||||
"fractionalIndex": null,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
|
1
src/tests/fixtures/elementFixture.ts
vendored
1
src/tests/fixtures/elementFixture.ts
vendored
@ -17,6 +17,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: null,
|
||||
fractionalIndex: null,
|
||||
seed: 1041657908,
|
||||
version: 120,
|
||||
versionNonce: 1188004276,
|
||||
|
329
src/tests/fractionalIndex.test.ts
Normal file
329
src/tests/fractionalIndex.test.ts
Normal file
@ -0,0 +1,329 @@
|
||||
import {
|
||||
fixFractionalIndices,
|
||||
restoreFractionalIndicies,
|
||||
validateFractionalIndicies,
|
||||
} from "../fractionalIndex";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { API } from "./helpers/api";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { moveAllLeft, moveOneLeft, moveOneRight } from "../zindex";
|
||||
import { AppState } from "../types";
|
||||
|
||||
const createElementWithIndex = (
|
||||
fractionalIndex: string | null = null,
|
||||
): ExcalidrawElement => {
|
||||
return API.createElement({
|
||||
type: "rectangle",
|
||||
fractionalIndex,
|
||||
});
|
||||
};
|
||||
|
||||
const testLengthAndOrder = (
|
||||
before: ExcalidrawElement[],
|
||||
after: ExcalidrawElement[],
|
||||
) => {
|
||||
// length is not changed
|
||||
expect(after.length).toBe(before.length);
|
||||
// order is not changed
|
||||
expect(after.map((e) => e.id)).deep.equal(before.map((e) => e.id));
|
||||
};
|
||||
|
||||
const testValidity = (elements: ExcalidrawElement[]) => {
|
||||
expect(validateFractionalIndicies(elements)).toBe(true);
|
||||
};
|
||||
|
||||
const genrateElementsAtLength = (length: number) => {
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
elements.push(createElementWithIndex());
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
describe("restoring fractional indicies", () => {
|
||||
it("restore all null fractional indices", () => {
|
||||
const randomNumOfElements = Math.floor(Math.random() * 100);
|
||||
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < randomNumOfElements) {
|
||||
elements.push(createElementWithIndex());
|
||||
i++;
|
||||
}
|
||||
|
||||
const restoredElements = restoreFractionalIndicies(elements);
|
||||
|
||||
testLengthAndOrder(elements, restoredElements);
|
||||
testValidity(restoredElements);
|
||||
});
|
||||
|
||||
it("restore out of order fractional indices", () => {
|
||||
const elements = [
|
||||
createElementWithIndex("a0"),
|
||||
createElementWithIndex("c0"),
|
||||
createElementWithIndex("b0"),
|
||||
createElementWithIndex("d0"),
|
||||
];
|
||||
|
||||
const restoredElements = restoreFractionalIndicies(elements);
|
||||
|
||||
testLengthAndOrder(elements, restoredElements);
|
||||
testValidity(restoredElements);
|
||||
// should only fix the second element's fractional index
|
||||
expect(elements[1].fractionalIndex).not.toEqual(
|
||||
restoredElements[1].fractionalIndex,
|
||||
);
|
||||
expect(elements.filter((value, index) => index !== 1)).deep.equal(
|
||||
restoredElements.filter((value, index) => index !== 1),
|
||||
);
|
||||
});
|
||||
|
||||
it("restore same fractional indices", () => {
|
||||
const randomNumOfElements = Math.floor(Math.random() * 100);
|
||||
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < randomNumOfElements) {
|
||||
elements.push(createElementWithIndex("a0"));
|
||||
i++;
|
||||
}
|
||||
|
||||
const restoredElements = restoreFractionalIndicies(elements);
|
||||
|
||||
testLengthAndOrder(elements, restoredElements);
|
||||
testValidity(restoredElements);
|
||||
expect(new Set(restoredElements.map((e) => e.fractionalIndex)).size).toBe(
|
||||
randomNumOfElements,
|
||||
);
|
||||
});
|
||||
|
||||
it("restore a mix of bad fractional indices", () => {
|
||||
const elements = [
|
||||
createElementWithIndex("a0"),
|
||||
createElementWithIndex("a0"),
|
||||
createElementWithIndex("a1"),
|
||||
createElementWithIndex(),
|
||||
createElementWithIndex("a3"),
|
||||
createElementWithIndex("a2"),
|
||||
createElementWithIndex(),
|
||||
createElementWithIndex(),
|
||||
];
|
||||
|
||||
const restoredElements = restoreFractionalIndicies(elements);
|
||||
|
||||
testLengthAndOrder(elements, restoredElements);
|
||||
testValidity(restoredElements);
|
||||
expect(new Set(restoredElements.map((e) => e.fractionalIndex)).size).toBe(
|
||||
elements.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fix fractional indices", () => {
|
||||
it("add each new element properly", () => {
|
||||
const elements = [
|
||||
createElementWithIndex(),
|
||||
createElementWithIndex(),
|
||||
createElementWithIndex(),
|
||||
createElementWithIndex(),
|
||||
];
|
||||
|
||||
const fixedElements = elements.reduce((acc, el) => {
|
||||
return fixFractionalIndices([...acc, el], arrayToMap([el]));
|
||||
}, [] as ExcalidrawElement[]);
|
||||
|
||||
testLengthAndOrder(elements, fixedElements);
|
||||
testValidity(fixedElements);
|
||||
});
|
||||
|
||||
it("add multiple new elements properly", () => {
|
||||
const elements = genrateElementsAtLength(Math.floor(Math.random() * 100));
|
||||
|
||||
const fixedElements = fixFractionalIndices(elements, arrayToMap(elements));
|
||||
|
||||
testLengthAndOrder(elements, fixedElements);
|
||||
testValidity(fixedElements);
|
||||
|
||||
const elements2 = genrateElementsAtLength(Math.floor(Math.random() * 100));
|
||||
|
||||
const allElements2 = [...elements, ...elements2];
|
||||
|
||||
const fixedElements2 = fixFractionalIndices(
|
||||
allElements2,
|
||||
arrayToMap(elements2),
|
||||
);
|
||||
|
||||
testLengthAndOrder(allElements2, fixedElements2);
|
||||
testValidity(fixedElements2);
|
||||
});
|
||||
|
||||
it("fix properly after z-index changes", () => {
|
||||
const elements = genrateElementsAtLength(Math.random() * 100);
|
||||
|
||||
const fixedElements = fixFractionalIndices(elements, arrayToMap(elements));
|
||||
|
||||
let randomlySelected = [
|
||||
...new Set([
|
||||
fixedElements[Math.floor(Math.random() * fixedElements.length)],
|
||||
fixedElements[Math.floor(Math.random() * fixedElements.length)],
|
||||
fixedElements[Math.floor(Math.random() * fixedElements.length)],
|
||||
fixedElements[Math.floor(Math.random() * fixedElements.length)],
|
||||
fixedElements[Math.floor(Math.random() * fixedElements.length)],
|
||||
fixedElements[Math.floor(Math.random() * fixedElements.length)],
|
||||
fixedElements[Math.floor(Math.random() * fixedElements.length)],
|
||||
]),
|
||||
];
|
||||
|
||||
const movedOneLeftFixedElements = moveOneLeft(
|
||||
fixedElements,
|
||||
randomlySelected.reduce(
|
||||
(acc, el) => {
|
||||
acc.selectedElementIds[el.id] = true;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
selectedElementIds: {},
|
||||
} as {
|
||||
selectedElementIds: Record<string, boolean>;
|
||||
},
|
||||
) as any as AppState,
|
||||
);
|
||||
|
||||
testValidity(movedOneLeftFixedElements);
|
||||
|
||||
randomlySelected = [
|
||||
...new Set([
|
||||
movedOneLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
]),
|
||||
];
|
||||
|
||||
const movedOneRightFixedElements = moveOneRight(
|
||||
movedOneLeftFixedElements,
|
||||
randomlySelected.reduce(
|
||||
(acc, el) => {
|
||||
acc.selectedElementIds[el.id] = true;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
selectedElementIds: {},
|
||||
} as {
|
||||
selectedElementIds: Record<string, boolean>;
|
||||
},
|
||||
) as any as AppState,
|
||||
);
|
||||
|
||||
testValidity(movedOneRightFixedElements);
|
||||
|
||||
randomlySelected = [
|
||||
...new Set([
|
||||
movedOneRightFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneRightFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneRightFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneRightFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneRightFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneRightFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedOneRightFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
]),
|
||||
];
|
||||
|
||||
const movedAllLeftFixedElements = moveAllLeft(
|
||||
movedOneRightFixedElements,
|
||||
randomlySelected.reduce(
|
||||
(acc, el) => {
|
||||
acc.selectedElementIds[el.id] = true;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
selectedElementIds: {},
|
||||
} as {
|
||||
selectedElementIds: Record<string, boolean>;
|
||||
},
|
||||
) as any as AppState,
|
||||
) as ExcalidrawElement[];
|
||||
|
||||
testValidity(movedAllLeftFixedElements);
|
||||
|
||||
randomlySelected = [
|
||||
...new Set([
|
||||
movedAllLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedAllLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedAllLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedAllLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedAllLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedAllLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
movedAllLeftFixedElements[
|
||||
Math.floor(Math.random() * fixedElements.length)
|
||||
],
|
||||
]),
|
||||
];
|
||||
|
||||
const movedAllRightFixedElements = moveAllLeft(
|
||||
movedAllLeftFixedElements,
|
||||
randomlySelected.reduce(
|
||||
(acc, el) => {
|
||||
acc.selectedElementIds[el.id] = true;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
selectedElementIds: {},
|
||||
} as {
|
||||
selectedElementIds: Record<string, boolean>;
|
||||
},
|
||||
) as any as AppState,
|
||||
) as ExcalidrawElement[];
|
||||
|
||||
testValidity(movedAllRightFixedElements);
|
||||
});
|
||||
});
|
@ -66,9 +66,14 @@ 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 clearSelection = () => {
|
||||
@ -100,6 +105,7 @@ export class API {
|
||||
id?: string;
|
||||
isDeleted?: boolean;
|
||||
frameId?: ExcalidrawElement["id"] | null;
|
||||
fractionalIndex?: ExcalidrawElement["fractionalIndex"];
|
||||
groupIds?: string[];
|
||||
// generic element props
|
||||
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
||||
@ -167,6 +173,7 @@ export class API {
|
||||
x,
|
||||
y,
|
||||
frameId: rest.frameId ?? null,
|
||||
fractionalIndex: rest.fractionalIndex || null,
|
||||
angle: rest.angle ?? 0,
|
||||
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
|
||||
backgroundColor:
|
||||
|
@ -108,6 +108,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 => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { assertSelectedElements, render } from "./test-utils";
|
||||
import { assertSelectedElements, render, togglePopover } from "./test-utils";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { API } from "./helpers/api";
|
||||
@ -6,13 +6,18 @@ import { getDefaultAppState } from "../appState";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
import { ExcalidrawImperativeAPI } from "../types";
|
||||
import { resolvablePromise } from "../utils";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
import { KEYS } from "../keys";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("history", () => {
|
||||
it("initializing scene should end up with single history entry", async () => {
|
||||
it("initializing scene should end up with no history entry", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
@ -24,12 +29,15 @@ describe("history", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(h.state.zenModeEnabled).toBe(true));
|
||||
await waitFor(() =>
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(h.state.zenModeEnabled).toBe(true);
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
||||
});
|
||||
|
||||
const undoAction = createUndoAction(h.history);
|
||||
const redoAction = createRedoAction(h.history);
|
||||
// noop
|
||||
h.app.actionManager.executeAction(undoAction);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
@ -51,14 +59,14 @@ describe("history", () => {
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: rectangle.id, isDeleted: true }),
|
||||
]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
|
||||
h.app.actionManager.executeAction(redoAction);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: rectangle.id, isDeleted: false }),
|
||||
]);
|
||||
expect(API.getStateHistory().length).toBe(2);
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
});
|
||||
|
||||
it("scene import via drag&drop should create new history entry", async () => {
|
||||
@ -94,9 +102,10 @@ describe("history", () => {
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(API.getStateHistory().length).toBe(2));
|
||||
await waitFor(() => expect(API.getUndoStack().length).toBe(1));
|
||||
expect(h.state.viewBackgroundColor).toBe("#000");
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: true }),
|
||||
expect.objectContaining({ id: "B", isDeleted: false }),
|
||||
]);
|
||||
|
||||
@ -111,8 +120,8 @@ describe("history", () => {
|
||||
h.app.actionManager.executeAction(redoAction);
|
||||
expect(h.state.viewBackgroundColor).toBe("#000");
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "B", isDeleted: false }),
|
||||
expect.objectContaining({ id: "A", isDeleted: true }),
|
||||
expect.objectContaining({ id: "B", isDeleted: false }),
|
||||
]);
|
||||
});
|
||||
|
||||
@ -189,4 +198,652 @@ describe("history", () => {
|
||||
expect.objectContaining({ A: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("undo/redo should support basic element creation, selection and deletion", async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
const rect1 = UI.createElement("rectangle", { x: 10 });
|
||||
const rect2 = UI.createElement("rectangle", { x: 20, y: 20 });
|
||||
const rect3 = UI.createElement("rectangle", { x: 40, y: 40 });
|
||||
|
||||
mouse.select([rect2, rect3]);
|
||||
Keyboard.keyDown(KEYS.DELETE);
|
||||
|
||||
expect(API.getUndoStack().length).toBe(6);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements(rect2, rect3);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: rect3.id, isDeleted: false }),
|
||||
]);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements(rect2);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements(rect3);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements(rect2);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id }),
|
||||
expect.objectContaining({ id: rect2.id }),
|
||||
expect.objectContaining({ id: rect3.id, isDeleted: true }),
|
||||
]);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements(rect1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: true }),
|
||||
expect.objectContaining({ id: rect3.id, isDeleted: true }),
|
||||
]);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id, isDeleted: true }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: true }),
|
||||
expect.objectContaining({ id: rect3.id, isDeleted: true }),
|
||||
]);
|
||||
|
||||
// no-op
|
||||
Keyboard.undo();
|
||||
assertSelectedElements();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id, isDeleted: true }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: true }),
|
||||
expect.objectContaining({ id: rect3.id, isDeleted: true }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
assertSelectedElements(rect1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: true }),
|
||||
expect.objectContaining({ id: rect3.id, isDeleted: true }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
assertSelectedElements(rect2);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id }),
|
||||
expect.objectContaining({ id: rect2.id }),
|
||||
expect.objectContaining({ id: rect3.id, isDeleted: true }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
assertSelectedElements(rect3);
|
||||
|
||||
Keyboard.redo();
|
||||
assertSelectedElements(rect2);
|
||||
|
||||
Keyboard.redo();
|
||||
assertSelectedElements(rect2, rect3);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: rect3.id, isDeleted: false }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(6);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
assertSelectedElements();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: true }),
|
||||
expect.objectContaining({ id: rect3.id, isDeleted: true }),
|
||||
]);
|
||||
|
||||
// no-op
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(6);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
assertSelectedElements();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: true }),
|
||||
expect.objectContaining({ id: rect3.id, isDeleted: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("multiplayer undo/redo", () => {
|
||||
const transparent = COLOR_PALETTE.transparent;
|
||||
const red = COLOR_PALETTE.red[1];
|
||||
const blue = COLOR_PALETTE.blue[1];
|
||||
const yellow = COLOR_PALETTE.yellow[1];
|
||||
const violet = COLOR_PALETTE.violet[1];
|
||||
|
||||
let excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
|
||||
beforeEach(async () => {
|
||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||
await render(
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||
handleKeyboardGlobally={true}
|
||||
/>,
|
||||
);
|
||||
excalidrawAPI = await excalidrawAPIPromise;
|
||||
});
|
||||
|
||||
it("applying history entries should not override remote changes on different elements", () => {
|
||||
UI.createElement("rectangle", { x: 10 });
|
||||
togglePopover("Background");
|
||||
UI.clickOnTestId("color-red");
|
||||
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
|
||||
// Simulate remote update
|
||||
excalidrawAPI.updateScene({
|
||||
elements: [
|
||||
...h.elements,
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
strokeColor: blue,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ backgroundColor: transparent }),
|
||||
expect.objectContaining({ strokeColor: blue }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ backgroundColor: red }),
|
||||
expect.objectContaining({ strokeColor: blue }),
|
||||
]);
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ backgroundColor: transparent }),
|
||||
expect.objectContaining({ strokeColor: blue }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("applying history entries should not override remote changes on different props", () => {
|
||||
UI.createElement("rectangle", { x: 10 });
|
||||
togglePopover("Background");
|
||||
UI.clickOnTestId("color-red");
|
||||
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
|
||||
// Simulate remote update
|
||||
excalidrawAPI.updateScene({
|
||||
elements: [
|
||||
newElementWith(h.elements[0], {
|
||||
strokeColor: yellow,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
backgroundColor: transparent,
|
||||
strokeColor: yellow,
|
||||
}),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
backgroundColor: red,
|
||||
strokeColor: yellow,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
// https://www.figma.com/blog/how-figmas-multiplayer-technology-works/#implementing-undo
|
||||
it("history entries should get updated after remote changes on same props", async () => {
|
||||
UI.createElement("rectangle", { x: 10 });
|
||||
togglePopover("Background");
|
||||
UI.clickOnTestId("color-red");
|
||||
UI.clickOnTestId("color-blue");
|
||||
|
||||
// At this point we have all the history entries created, no new entries will be created, only existing entries will get inversed and updated
|
||||
expect(API.getUndoStack().length).toBe(3);
|
||||
|
||||
Keyboard.undo();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ backgroundColor: red }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ backgroundColor: blue }),
|
||||
]);
|
||||
|
||||
// Simulate remote update
|
||||
excalidrawAPI.updateScene({
|
||||
elements: [
|
||||
newElementWith(h.elements[0], {
|
||||
backgroundColor: yellow,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// At this point our entry gets updated from `red` -> `blue` into `red` -> `yellow`
|
||||
Keyboard.undo();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ backgroundColor: red }),
|
||||
]);
|
||||
|
||||
// Simulate remote update
|
||||
excalidrawAPI.updateScene({
|
||||
elements: [
|
||||
newElementWith(h.elements[0], {
|
||||
backgroundColor: violet,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// At this point our (inversed) entry gets updated from `red` -> `yellow` into `violet` -> `yellow`
|
||||
Keyboard.redo();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ backgroundColor: yellow }),
|
||||
]);
|
||||
|
||||
Keyboard.undo();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ backgroundColor: violet }),
|
||||
]);
|
||||
|
||||
Keyboard.undo();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ backgroundColor: transparent }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("")
|
||||
|
||||
it("should iterate through the history when element changes relate only to remotely deleted elements", async () => {
|
||||
const rect1 = UI.createElement("rectangle", { x: 10 });
|
||||
|
||||
const rect2 = UI.createElement("rectangle", { x: 20 });
|
||||
togglePopover("Background");
|
||||
UI.clickOnTestId("color-red");
|
||||
|
||||
const rect3 = UI.createElement("rectangle", { x: 30, y: 30 });
|
||||
|
||||
mouse.downAt(35, 35);
|
||||
mouse.moveTo(55, 55);
|
||||
mouse.upAt(55, 55);
|
||||
|
||||
expect(API.getUndoStack().length).toBe(5);
|
||||
|
||||
// Simulate remote update
|
||||
excalidrawAPI.updateScene({
|
||||
elements: [
|
||||
h.elements[0],
|
||||
newElementWith(h.elements[1], {
|
||||
isDeleted: true,
|
||||
}),
|
||||
newElementWith(h.elements[2], {
|
||||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(4);
|
||||
expect(API.getSelectedElements()).toEqual([
|
||||
expect.objectContaining({ id: rect1.id }),
|
||||
]);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: rect1.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: rect2.id,
|
||||
isDeleted: true,
|
||||
backgroundColor: transparent,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: rect3.id,
|
||||
isDeleted: true,
|
||||
x: 30,
|
||||
y: 30,
|
||||
}),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(5);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(API.getSelectedElements()).toEqual([
|
||||
expect.objectContaining({ id: rect3.id }),
|
||||
]);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: rect1.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: rect2.id,
|
||||
isDeleted: true,
|
||||
backgroundColor: red,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: rect3.id,
|
||||
isDeleted: true,
|
||||
x: 50,
|
||||
y: 50,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should iterate through the history when selection changes relate only to remotely deleted elements", async () => {
|
||||
const rect1 = API.createElement({ type: "rectangle", x: 10, y: 10 });
|
||||
const rect2 = API.createElement({ type: "rectangle", x: 20, y: 20 });
|
||||
const rect3 = API.createElement({ type: "rectangle", x: 30, y: 30 });
|
||||
|
||||
h.elements = [rect1, rect2, rect3];
|
||||
mouse.select(rect1);
|
||||
mouse.select([rect2, rect3]);
|
||||
|
||||
expect(API.getUndoStack().length).toBe(3);
|
||||
|
||||
// Simulate remote update
|
||||
excalidrawAPI.updateScene({
|
||||
elements: [
|
||||
h.elements[0],
|
||||
newElementWith(h.elements[1], {
|
||||
isDeleted: true,
|
||||
}),
|
||||
newElementWith(h.elements[2], {
|
||||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(2);
|
||||
expect(API.getSelectedElements()).toEqual([
|
||||
expect.objectContaining({ id: rect1.id }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(3);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(API.getSelectedElements()).toEqual([
|
||||
expect.objectContaining({ id: rect2.id }),
|
||||
expect.objectContaining({ id: rect3.id }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should iterate through the history when selection changes relate only to remotely deleted elements", async () => {
|
||||
const rect1 = API.createElement({ type: "rectangle", x: 10, y: 10 });
|
||||
const rect2 = API.createElement({ type: "rectangle", x: 20, y: 20 });
|
||||
const rect3 = API.createElement({ type: "rectangle", x: 30, y: 30 });
|
||||
|
||||
h.elements = [rect1, rect2, rect3];
|
||||
mouse.select(rect1);
|
||||
mouse.select([rect2, rect3]);
|
||||
|
||||
expect(API.getUndoStack().length).toBe(3);
|
||||
|
||||
// Simulate remote update
|
||||
excalidrawAPI.updateScene({
|
||||
elements: [
|
||||
h.elements[0],
|
||||
newElementWith(h.elements[1], {
|
||||
isDeleted: true,
|
||||
}),
|
||||
newElementWith(h.elements[2], {
|
||||
isDeleted: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(2);
|
||||
expect(API.getSelectedElements()).toEqual([
|
||||
expect.objectContaining({ id: rect1.id }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(3);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(API.getSelectedElements()).toEqual([
|
||||
expect.objectContaining({ id: rect2.id }),
|
||||
expect.objectContaining({ id: rect3.id }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("remote update should not interfere with in progress freedraw", async () => {
|
||||
UI.clickTool("freedraw");
|
||||
mouse.down(10, 10);
|
||||
mouse.moveTo(30, 30);
|
||||
|
||||
// Simulate remote update
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeColor: blue,
|
||||
});
|
||||
|
||||
excalidrawAPI.updateScene({
|
||||
elements: [...h.elements, rect],
|
||||
});
|
||||
|
||||
mouse.moveTo(60, 60);
|
||||
mouse.up();
|
||||
|
||||
Keyboard.undo();
|
||||
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: h.elements[0].id,
|
||||
type: "freedraw",
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({ ...rect }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: h.elements[0].id,
|
||||
type: "freedraw",
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({ ...rect }),
|
||||
]);
|
||||
});
|
||||
|
||||
// TODO_UNDO: tests like this might need to go through some util, as expectations in redo / undo are duplicated
|
||||
it("remote update should not interfere with in progress dragging", async () => {
|
||||
const rect1 = UI.createElement("rectangle", { x: 10, y: 10 });
|
||||
const rect2 = UI.createElement("rectangle", { x: 30, y: 30 });
|
||||
|
||||
mouse.select([rect1, rect2]);
|
||||
mouse.downAt(20, 20);
|
||||
mouse.moveTo(50, 50);
|
||||
|
||||
assertSelectedElements(rect1, rect2);
|
||||
expect(API.getUndoStack().length).toBe(4);
|
||||
|
||||
const rect3 = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeColor: blue,
|
||||
});
|
||||
|
||||
// Simulate remote update
|
||||
excalidrawAPI.updateScene({
|
||||
elements: [...h.elements, rect3],
|
||||
});
|
||||
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.up();
|
||||
|
||||
expect(API.getUndoStack().length).toBe(5);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
assertSelectedElements(rect1, rect2);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: rect1.id,
|
||||
x: 90,
|
||||
y: 90,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: rect2.id,
|
||||
x: 110,
|
||||
y: 110,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({ ...rect3 }),
|
||||
]);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements(rect1, rect2);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: rect1.id,
|
||||
x: 10,
|
||||
y: 10,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: rect2.id,
|
||||
x: 30,
|
||||
y: 30,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({ ...rect3 }),
|
||||
]);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements(rect1);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements(rect2);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements(rect1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: rect1.id,
|
||||
x: 10,
|
||||
y: 10,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: rect2.id,
|
||||
x: 30,
|
||||
y: 30,
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({ ...rect3 }),
|
||||
]);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: rect1.id,
|
||||
x: 10,
|
||||
y: 10,
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: rect2.id,
|
||||
x: 30,
|
||||
y: 30,
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({ ...rect3 }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
assertSelectedElements(rect1);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: rect1.id,
|
||||
x: 10,
|
||||
y: 10,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: rect2.id,
|
||||
x: 30,
|
||||
y: 30,
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({ ...rect3 }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
assertSelectedElements(rect2);
|
||||
|
||||
Keyboard.redo();
|
||||
assertSelectedElements(rect1);
|
||||
|
||||
Keyboard.redo();
|
||||
assertSelectedElements(rect1, rect2);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: rect1.id,
|
||||
x: 10,
|
||||
y: 10,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: rect2.id,
|
||||
x: 30,
|
||||
y: 30,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({ ...rect3 }),
|
||||
]);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(5);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
assertSelectedElements(rect1, rect2);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: rect1.id,
|
||||
x: 90,
|
||||
y: 90,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: rect2.id,
|
||||
x: 110,
|
||||
y: 110,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({ ...rect3 }),
|
||||
]);
|
||||
});
|
||||
|
||||
// TODO_UNDO: testing testing David' concurrency issues (dragging, image, etc.)
|
||||
// TODO_UNDO: test "UPDATE" actions as they become undoable (), but are necessary for the diff calculation and unexpect during undo / redo (also test change CAPTURE)
|
||||
// TODO_UNDO: testing edge cases - bound elements get often messed up (i.e. when client 1 adds it to client2 element and client 2 undos)
|
||||
// TODO_UNDO: testing edge cases - empty undos - when item are already selected
|
||||
// TODO_UNDO: testing z-index actions (after Ryans PR)
|
||||
// TODO_UNDO: testing linear element + editor (multiple, single clients / empty undo / redos / selection)
|
||||
// TODO_UNDO: testing edge cases - align actions bugs
|
||||
// TODO_UNDO: testing edge cases - unit testing quick quick reference checks and exits
|
||||
// TODO_UNDO: testing edge cases - add what elements should not contain (notEqual)
|
||||
// TODO_UNDO: testing edge cases - clearing of redo stack
|
||||
// TODO_UNDO: testing edge cases - expected reference values in deltas
|
||||
// TODO_UNDO: testing edge cases - caching / cloning of snapshot and its disposal
|
||||
// TODO_UNDO: testing edge cases - state of the stored increments / changes and their deltas
|
||||
// TODO_UNDO: test out number of store calls in collab
|
||||
});
|
||||
});
|
||||
|
@ -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}`),
|
||||
@ -372,7 +372,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 +386,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);
|
||||
});
|
||||
|
@ -107,7 +107,7 @@ exports[`exportToSvg > with elements that have a link 1`] = `
|
||||
exports[`exportToSvg > with exportEmbedScene 1`] = `
|
||||
"
|
||||
<!-- svg-source:excalidraw -->
|
||||
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1SPW/CMFx1MDAxMN35XHUwMDE1UbpcIuFAIJSNlqpCqtqBXHUwMDAxqVVcdTAwMDdcdTAwMTNfiFx1MDAxNcdcdTAwMGW2w4dcdTAwMTD/vbaBuETMnerBkt+9d3e+e8dOXHUwMDEwhPpQQThcdELYp5hRXCLxLuxafFx1MDAwYlJRwU2o795K1DJ1zFxc62rS6zFhXHUwMDA0uVB6MkBcYp1FwKBcdTAwMDSulaF9mXdcdTAwMTBcdTAwMWPdbVwilFjpdik3XHUwMDFm06ygnPQ3aZm8zaavn07qSHvDiaO4eVx1MDAxZmz1QdK8d5To3GBcdTAwMTFCXHKWXHUwMDAzXee6XHUwMDA1Yr5mtlePKC1FXHUwMDAxz4JcdGlcdTAwMWJ5QO740iucXHUwMDE2aylqTjwnXHUwMDFhYrzKPCejjC30gZ2ngNO8llx1MDAxMLYqLK8ttvBGp4SZsleZkuucg1I3XHUwMDFhUeGU6kPrV7a/ak7cdL99V1x1MDAxMpcwt+PlNWO/XHUwMDEzc3JJfFx1MDAxM1BcdTAwMDDEJY6j0TB5ROMm4ldcdTAwMWX1UVx1MDAxYn1cdTAwMTfcrT+KxmOE4n4yalx1MDAxOFTNzOK1S5thpsBP1Tbx4k1x00hdXHUwMDExfFx1MDAxNvmPM8qLNs9cdTAwMTituJP7alxcQnEpOFx0XHUwMDFkfur+2+7fdn9hO2CMVlxuLrYzt1x1MDAxYk2Iq2qhTX5DOZsw3FLYPd1Zc+aO1TvT2jWDbfZ46px+XHUwMDAwcU5t0CJ9<!-- payload-end -->
|
||||
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1Sy27CMFx1MDAxMLzzXHUwMDE1UXpFwoFAKDdaqlxuqWpcdTAwMGZcdTAwMWOQWvVg4lxyseLYwXZ4XGLx77VccsQl4lx1MDAwM3qoXHUwMDBmljw7szvZzLFcdTAwMTNcdTAwMDShPlRcdTAwMTBOglx1MDAxMPYpZpRIvFx1MDAwYrtcdTAwMTbfglRUcFPqu7dcdTAwMTK1TFx1MDAxZDPXupr0ekxcdTAwMThBLpSeXGZcdTAwMTBCZ1x1MDAxMTAogWtlaF/mXHUwMDFkXHUwMDA0R3ebXG4lVrpdys3HNCsoJ/1NWiZvs+nrp5M60t5w4ihu3lx1MDAwNzt9kDTvXHUwMDFkJTo3WIRQg+VA17lugZivmfXqXHUwMDExpaUo4FkwIa2RXHUwMDA35I5cdTAwMWa9wmmxlqLmxHOiIcarzHMyythCXHUwMDFm2HlcdTAwMGI4zWtcdGFrwvJqsYU3OiXMlr3KjFxc51x1MDAxY5S60YhcbqdUXHUwMDFmWl9l/VVz4rb77V1JXFzC3K6X14z9bszJpfFNwfBTbf4sZnNOYN8uK1x1MDAwMOLmxtFomDyicVPxiYj6qI2+XHUwMDBi7tJcdTAwMTFF4zFCcT9cdTAwMTk1XGaqZiZcdTAwMTfatc0wU+CXbj2++MzcXHUwMDE4qSuCz1wiv1x1MDAxN0Z50eaZXHUwMDFjXHUwMDE2d3pfc00oLlx1MDAwNSehw0/d/1T+p/JcdTAwMGakXHUwMDEyXHUwMDE4o5WCSyrN7TZcdTAwMTfiqlpo099Qzlx1MDAxOVxyt1x1MDAxNHZPd1KQuWP1LtM2XHUwMDA1YM1cdTAwMWVPndNcdTAwMGaLVICkIn0=<!-- payload-end -->
|
||||
<defs>
|
||||
<style class=\\"style-fonts\\">
|
||||
@font-face {
|
||||
|
@ -46,6 +46,7 @@ const populateElements = (
|
||||
height?: number;
|
||||
containerId?: string;
|
||||
frameId?: ExcalidrawFrameElement["id"];
|
||||
fractionalIndex?: ExcalidrawElement["fractionalIndex"];
|
||||
}[],
|
||||
appState?: Partial<AppState>,
|
||||
) => {
|
||||
|
29
src/types.ts
29
src/types.ts
@ -38,6 +38,7 @@ import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
||||
import { ContextMenuItems } from "./components/ContextMenu";
|
||||
import { SnapLine } from "./snapping";
|
||||
import { Merge, ValueOf } from "./utility-types";
|
||||
import { IStore } from "./store";
|
||||
|
||||
export type Point = Readonly<RoughPoint>;
|
||||
|
||||
@ -172,7 +173,23 @@ export type InteractiveCanvasAppState = Readonly<
|
||||
}
|
||||
>;
|
||||
|
||||
export interface AppState {
|
||||
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"];
|
||||
editingLinearElement: AppState["editingLinearElement"];
|
||||
selectedLinearElement: AppState["selectedLinearElement"];
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
contextMenu: {
|
||||
items: ContextMenuItems;
|
||||
top: number;
|
||||
@ -452,7 +469,8 @@ export type SceneData = {
|
||||
elements?: ImportedDataState["elements"];
|
||||
appState?: ImportedDataState["appState"];
|
||||
collaborators?: Map<string, Collaborator>;
|
||||
commitToHistory?: boolean;
|
||||
commitToStore?: boolean;
|
||||
skipSnapshotUpdate?: boolean; // TODO_UNDO: this flag is weird & causing breaking change, think about inverse (might cause less isues)
|
||||
};
|
||||
|
||||
export enum UserIdleState {
|
||||
@ -630,6 +648,13 @@ export type ExcalidrawImperativeAPI = {
|
||||
history: {
|
||||
clear: InstanceType<typeof App>["resetHistory"];
|
||||
};
|
||||
/**
|
||||
* @experimental this API is experimental and subject to change
|
||||
*/
|
||||
store: {
|
||||
clear: IStore["clear"];
|
||||
listen: IStore["listen"];
|
||||
};
|
||||
scrollToContent: InstanceType<typeof App>["scrollToContent"];
|
||||
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||||
getAppState: () => InstanceType<typeof App>["state"];
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { bumpVersion } from "./element/mutateElement";
|
||||
import { isFrameLikeElement } from "./element/typeChecks";
|
||||
import { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./element/types";
|
||||
import { fixFractionalIndices } from "./fractionalIndex";
|
||||
import { getElementsInGroup } from "./groups";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import Scene from "./scene/Scene";
|
||||
@ -234,9 +234,9 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
|
||||
) => {
|
||||
return indices.reduce((acc, index) => {
|
||||
const element = elements[index];
|
||||
acc[element.id] = element;
|
||||
acc.set(element.id, element);
|
||||
return acc;
|
||||
}, {} as Record<string, ExcalidrawElement>);
|
||||
}, new Map<string, ExcalidrawElement>());
|
||||
};
|
||||
|
||||
const shiftElementsByOne = (
|
||||
@ -312,12 +312,7 @@ const shiftElementsByOne = (
|
||||
];
|
||||
});
|
||||
|
||||
return elements.map((element) => {
|
||||
if (targetElementsMap[element.id]) {
|
||||
return bumpVersion(element);
|
||||
}
|
||||
return element;
|
||||
});
|
||||
return fixFractionalIndices(elements, targetElementsMap);
|
||||
};
|
||||
|
||||
const shiftElementsToEnd = (
|
||||
@ -383,26 +378,27 @@ const shiftElementsToEnd = (
|
||||
}
|
||||
}
|
||||
|
||||
const targetElements = Object.values(targetElementsMap).map((element) => {
|
||||
return bumpVersion(element);
|
||||
});
|
||||
const targetElements = Array.from(targetElementsMap.values());
|
||||
|
||||
const leadingElements = elements.slice(0, leadingIndex);
|
||||
const trailingElements = elements.slice(trailingIndex + 1);
|
||||
|
||||
return direction === "left"
|
||||
? [
|
||||
...leadingElements,
|
||||
...targetElements,
|
||||
...displacedElements,
|
||||
...trailingElements,
|
||||
]
|
||||
: [
|
||||
...leadingElements,
|
||||
...displacedElements,
|
||||
...targetElements,
|
||||
...trailingElements,
|
||||
];
|
||||
return fixFractionalIndices(
|
||||
direction === "left"
|
||||
? [
|
||||
...leadingElements,
|
||||
...targetElements,
|
||||
...displacedElements,
|
||||
...trailingElements,
|
||||
]
|
||||
: [
|
||||
...leadingElements,
|
||||
...displacedElements,
|
||||
...targetElements,
|
||||
...trailingElements,
|
||||
],
|
||||
targetElementsMap,
|
||||
);
|
||||
};
|
||||
|
||||
function shiftElementsAccountingForFrames(
|
||||
|
@ -4891,6 +4891,11 @@ form-data@^4.0.0:
|
||||
combined-stream "^1.0.8"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
fractional-indexing-jittered@0.9.0:
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/fractional-indexing-jittered/-/fractional-indexing-jittered-0.9.0.tgz#53a5f05acc4a8f8ceb5cb5a326122deb2cf8105c"
|
||||
integrity sha512-3XIGQbmuEIg1j/qdHLDVyCH1vNMUyeBhM+5d6Su03fTiHzmQLE5nOqvDXLDgg240lLBIsLyJa3xZIUqO57yrAQ==
|
||||
|
||||
fs-extra@^11.1.0:
|
||||
version "11.1.1"
|
||||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d"
|
||||
|
Loading…
x
Reference in New Issue
Block a user